Compare commits

...

10 Commits

Author SHA1 Message Date
Erik Winter 988ca34644 bot name in log 2023-06-14 15:45:44 +02:00
Erik Winter ee638a6fc8 add readme 2023-06-14 15:21:17 +02:00
Erik Winter b241fc6961 accept invites only when allowed 2023-06-14 15:19:24 +02:00
Erik Winter fbc6aef783 default bot for unaddressed questions 2023-06-13 19:58:56 +02:00
Erik Winter bfcba9a464 matching env secrets with config file 2023-06-13 19:29:54 +02:00
Erik Winter 815b7e0808 move bot config to file 2023-06-09 14:28:32 +02:00
Erik Winter 988951970b prepare for multiple bots 2023-06-08 19:18:38 +02:00
Erik Winter ae6d0532bd some refactors
Signed-off-by: Erik Winter <e@ewintr.nl>
2023-06-08 19:10:46 +02:00
Erik Winter 681ad81358 repo sync test 2023-05-25 12:23:34 +02:00
Erik Winter 4fb1ec915b make logger a parameter 2023-05-23 16:15:53 +02:00
10 changed files with 354 additions and 199 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.idea
*.db
*.toml

View File

@ -1,4 +1,4 @@
docker-push:
docker build . -t matrix-bots
docker tag matrix-bots registry.ewintr.nl/matrix-gptzoo
docker push registry.ewintr.nl/matrix-gptzoo
docker build . -t matrix-gptzoo:latest
docker tag matrix-gptzoo:latest registry.ewintr.nl/matrix-gptzoo:latest
docker push registry.ewintr.nl/matrix-gptzoo:latest

53
README.md Normal file
View File

@ -0,0 +1,53 @@
# Matrix-GPTZoo
A Matrix bot version of ChatGPT, with multiple configurable prompts.
The bot will log in with a different user for each prompt, making it possible to create a chat room full of AI assistants you can ask questions.
Bots will only answer questions specifically addressed to them, but it is also possible to configure one to answer questions that are not addressed to a specific bot. Continuing a conversation can be done by replying to an answer.
![screenshot](gptzoo-screenshot.png)
## Configuration
Configuration consists of two parts: a toml file that defines the prompt and environment variables for the passwords and keys. Matching environment variables to the bots in the toml is done on the Matrix user ID.
This probably best explained with an example:
```toml
[[Bot]]
DBPath = "gpt4-bot.db"
Pickle = "hi-there"
Homeserver = "https://ewintr.nl"
UserID = "@chatgpt4:ewintr.nl"
UserDisplayName = "ChatGPT4"
SystemPrompt = "You are an encouraging chatbot that likes to help people by providing answers in a creative and fun way."
AnswerUnaddressed = true
[[Bot]]
DBPath = "go-bot.db"
Pickle = "hi-there"
Homeserver = "https://ewintr.nl"
UserID = "@gogpt:ewintr.nl"
UserDisplayName = "GoGPT"
SystemPrompt = "You are an export on the programming language Go, you assume that any programming question is about Go and your answers are aimed at senior level developers."
AnswerUnaddressed = false
```
Together with:
```
OPENAI_API_KEY=secret
CONFIG_PATH=/config.toml
MATRIX_BOT0_ID=@chatgpt4:ewintr.nl
MATRIX_BOT0_PASSWORD=secret
MATRIX_BOT0_ACCESS_KEY=secret
MATRIX_BOT1_ID=@gogpt:ewintr.nl
MATRIX_BOT1_PASSWORD=secret
MATRIX_BOT1_ACCESS_KEY=secret
MATRIX_ACCEPT_INVITES=false
```

234
bot/bot.go Normal file
View File

@ -0,0 +1,234 @@
package bot
import (
"strings"
"sync"
"github.com/sashabaranov/go-openai"
"golang.org/x/exp/slog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
var (
botNames = []string{}
mu = &sync.Mutex{}
)
func BotNameAppend(name string) {
mu.Lock()
defer mu.Unlock()
botNames = append(botNames, name)
}
type ConfigOpenAI struct {
APIKey string
}
type ConfigBot struct {
DBPath string
Pickle string
Homeserver string
UserID string
UserAccessKey string
UserPassword string
UserDisplayName string
SystemPrompt string
AnswerUnaddressed bool
}
type Config struct {
OpenAI ConfigOpenAI `toml:"openai"`
Bots []ConfigBot `toml:"bot"`
}
type Bot struct {
openaiKey string
config ConfigBot
client *mautrix.Client
cryptoHelper *cryptohelper.CryptoHelper
characters []Character
conversations Conversations
gptClient *GPT
logger *slog.Logger
}
func New(openaiKey string, cfg ConfigBot, logger *slog.Logger) *Bot {
return &Bot{
openaiKey: openaiKey,
config: cfg,
logger: logger,
}
}
func (m *Bot) Init(acceptInvites bool) error {
client, err := mautrix.NewClient(m.config.Homeserver, id.UserID(m.config.UserID), m.config.UserAccessKey)
if err != nil {
return err
}
var oei mautrix.OldEventIgnorer
oei.Register(client.Syncer.(mautrix.ExtensibleSyncer))
m.client = client
m.cryptoHelper, err = cryptohelper.NewCryptoHelper(client, []byte(m.config.Pickle), m.config.DBPath)
if err != nil {
return err
}
m.cryptoHelper.LoginAs = &mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: m.config.UserID},
Password: m.config.UserPassword,
}
if err := m.cryptoHelper.Init(); err != nil {
return err
}
m.client.Crypto = m.cryptoHelper
m.gptClient = NewGPT(m.openaiKey)
m.conversations = make(Conversations, 0)
if acceptInvites {
m.AddEventHandler(m.InviteHandler())
}
m.AddEventHandler(m.ResponseHandler())
m.config.UserDisplayName = strings.ToLower(m.config.UserDisplayName)
BotNameAppend(m.config.UserDisplayName)
return nil
}
func (m *Bot) Run() error {
if err := m.client.Sync(); err != nil {
return err
}
return nil
}
func (m *Bot) Close() error {
if err := m.client.Sync(); err != nil {
return err
}
if err := m.cryptoHelper.Close(); err != nil {
return err
}
return nil
}
func (m *Bot) AddEventHandler(eventType event.Type, handler mautrix.EventHandler) {
syncer := m.client.Syncer.(*mautrix.DefaultSyncer)
syncer.OnEventType(eventType, handler)
}
func (m *Bot) InviteHandler() (event.Type, mautrix.EventHandler) {
return event.StateMember, func(source mautrix.EventSource, evt *event.Event) {
if evt.GetStateKey() == m.client.UserID.String() && evt.Content.AsMember().Membership == event.MembershipInvite {
_, err := m.client.JoinRoomByID(evt.RoomID)
if err != nil {
m.logger.Error("failed to join room after invite", slog.String("err", err.Error()), slog.String("room_id", evt.RoomID.String()), slog.String("inviter", evt.Sender.String()), slog.String("bot", m.config.UserDisplayName))
return
}
m.logger.Info("joined room after invite", slog.String("room_id", evt.RoomID.String()), slog.String("inviter", evt.Sender.String()), slog.String("bot", m.config.UserDisplayName))
}
}
}
func (m *Bot) ResponseHandler() (event.Type, mautrix.EventHandler) {
return event.EventMessage, func(source mautrix.EventSource, evt *event.Event) {
content := evt.Content.AsMessage()
eventID := evt.ID
m.logger.Info("received message", slog.String("content", content.Body))
// ignore if the message is already recorded
if conv := m.conversations.FindByEventID(eventID); conv != nil {
m.logger.Info("known message, ignoring", slog.String("event_id", eventID.String()), slog.String("bot", m.config.UserDisplayName))
return
}
// ignore if the message is sent by the bot itself
if evt.Sender == id.UserID(m.config.UserID) {
m.logger.Info("message sent by bot itself, ignoring", slog.String("event_id", eventID.String()), slog.String("bot", m.config.UserDisplayName))
return
}
var conv *Conversation
// find out if it is a reply to a known conversation
parentID := id.EventID("")
var hasParent bool
if relatesTo := content.GetRelatesTo(); relatesTo != nil {
if parentID = relatesTo.GetReplyTo(); parentID != "" {
hasParent = true
m.logger.Info("message is a reply", slog.String("parent_id", parentID.String()))
if c := m.conversations.FindByEventID(parentID); c != nil {
m.logger.Info("found parent, appending message to conversation", slog.String("event_id", eventID.String()), slog.String("bot", m.config.UserDisplayName))
c.Add(Message{
EventID: eventID,
ParentID: parentID,
Role: openai.ChatMessageRoleUser,
Content: content.Body,
})
conv = c
}
}
}
m.logger.Info(content.Body)
addressedTo, _, isAddressed := strings.Cut(content.Body, ": ")
addressedTo = strings.TrimSpace(strings.ToLower(addressedTo))
if strings.Contains(addressedTo, " ") {
isAddressed = false // only display names without spaces, otherwise no way to know if it's a name or not
}
// find out if message is a new question addressed to the bot
if conv == nil && isAddressed && addressedTo == m.config.UserDisplayName {
m.logger.Info("message is addressed to bot", slog.String("event_id", eventID.String()), slog.String("bot", m.config.UserDisplayName))
conv = NewConversation(eventID, m.config.SystemPrompt, content.Body)
m.conversations = append(m.conversations, conv)
}
// find out if the message is addressed to no-one and this bot answers those
if conv == nil && !isAddressed && !hasParent && m.config.AnswerUnaddressed {
m.logger.Info("message is addressed to no-one", slog.String("event_id", eventID.String()), slog.String("bot", m.config.UserDisplayName))
conv = NewConversation(eventID, m.config.SystemPrompt, content.Body)
m.conversations = append(m.conversations, conv)
}
if conv == nil {
m.logger.Info("apparently not for us, ignoring", slog.String("event_id", eventID.String()), slog.String("bot", m.config.UserDisplayName))
return
}
// get reply from GPT
reply, err := m.gptClient.Complete(conv)
if err != nil {
m.logger.Error("failed to get reply from openai", slog.String("err", err.Error()), slog.String("bot", m.config.UserDisplayName))
return
}
formattedReply := format.RenderMarkdown(reply, true, false)
formattedReply.RelatesTo = &event.RelatesTo{
InReplyTo: &event.InReplyTo{
EventID: eventID,
},
}
res, err := m.client.SendMessageEvent(evt.RoomID, event.EventMessage, &formattedReply)
if err != nil {
m.logger.Error("failed to send message", slog.String("err", err.Error()), slog.String("bot", m.config.UserDisplayName))
return
}
conv.Add(Message{
EventID: res.EventID,
ParentID: eventID,
Role: openai.ChatMessageRoleAssistant,
Content: reply,
})
if len(reply) > 30 {
reply = reply[:30] + "..."
}
m.logger.Info("sent reply", slog.String("parent_id", eventID.String()), slog.String("content", reply), slog.String("bot", m.config.UserDisplayName))
}
}

View File

@ -2,10 +2,17 @@ package bot
import (
"github.com/sashabaranov/go-openai"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
const systemPrompt = "You are a chatbot that helps people by responding to their questions with short messages."
type Character struct {
UserID string
Password string
AccessKey string
Prompt string
client *mautrix.Client
}
type Message struct {
EventID id.EventID
@ -18,7 +25,7 @@ type Conversation struct {
Messages []Message
}
func NewConversation(id id.EventID, question string) *Conversation {
func NewConversation(id id.EventID, systemPrompt, question string) *Conversation {
return &Conversation{
Messages: []Message{
{

View File

@ -1,179 +0,0 @@
package bot
import (
"fmt"
"time"
"github.com/chzyer/readline"
"github.com/rs/zerolog"
"github.com/sashabaranov/go-openai"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
type Config struct {
Homeserver string
UserID string
UserAccessKey string
UserPassword string
DBPath string
Pickle string
OpenAIKey string
}
type Matrix struct {
config Config
readline *readline.Instance
client *mautrix.Client
cryptoHelper *cryptohelper.CryptoHelper
conversations Conversations
gptClient *GPT
}
func New(cfg Config) *Matrix {
return &Matrix{
config: cfg,
}
}
func (m *Matrix) Init() error {
client, err := mautrix.NewClient(m.config.Homeserver, id.UserID(m.config.UserID), m.config.UserAccessKey)
if err != nil {
return err
}
var oei mautrix.OldEventIgnorer
oei.Register(client.Syncer.(mautrix.ExtensibleSyncer))
m.client = client
m.client.Log = zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
w.TimeFormat = time.Stamp
})).With().Timestamp().Logger().Level(zerolog.InfoLevel)
m.cryptoHelper, err = cryptohelper.NewCryptoHelper(client, []byte(m.config.Pickle), m.config.DBPath)
if err != nil {
return err
}
m.cryptoHelper.LoginAs = &mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: m.config.UserID},
Password: m.config.UserPassword,
}
if err := m.cryptoHelper.Init(); err != nil {
return err
}
m.client.Crypto = m.cryptoHelper
m.gptClient = NewGPT(m.config.OpenAIKey)
m.conversations = make(Conversations, 0)
return nil
}
func (m *Matrix) Run() error {
if err := m.client.Sync(); err != nil {
return err
}
return nil
}
func (m *Matrix) Close() error {
if err := m.client.Sync(); err != nil {
return err
}
if err := m.cryptoHelper.Close(); err != nil {
return err
}
return nil
}
func (m *Matrix) AddEventHandler(eventType event.Type, handler mautrix.EventHandler) {
syncer := m.client.Syncer.(*mautrix.DefaultSyncer)
syncer.OnEventType(eventType, handler)
}
func (m *Matrix) InviteHandler() (event.Type, mautrix.EventHandler) {
return event.StateMember, func(source mautrix.EventSource, evt *event.Event) {
if evt.GetStateKey() == m.client.UserID.String() && evt.Content.AsMember().Membership == event.MembershipInvite {
_, err := m.client.JoinRoomByID(evt.RoomID)
if err == nil {
m.client.Log.Info().
Str("room_id", evt.RoomID.String()).
Str("inviter", evt.Sender.String()).
Msg("Joined room after invite")
} else {
m.client.Log.Error().Err(err).
Str("room_id", evt.RoomID.String()).
Str("inviter", evt.Sender.String()).
Msg("Failed to join room after invite")
}
}
}
}
func (m *Matrix) ResponseHandler() (event.Type, mautrix.EventHandler) {
return event.EventMessage, func(source mautrix.EventSource, evt *event.Event) {
content := evt.Content.AsMessage()
eventID := evt.ID
m.client.Log.Info().
Str("content", content.Body).
Msg("received message")
conv := m.conversations.FindByEventID(eventID)
if conv != nil {
m.client.Log.Info().
Str("event_id", eventID.String()).
Msg("already known message, ignoring")
return
}
parentID := id.EventID("")
if relatesTo := content.GetRelatesTo(); relatesTo != nil {
parentID = relatesTo.GetReplyTo()
}
if parentID != "" {
m.client.Log.Info().Msg("parent found, looking for conversation")
conv = m.conversations.FindByEventID(parentID)
}
if conv != nil {
conv.Add(Message{
EventID: eventID,
ParentID: parentID,
Role: openai.ChatMessageRoleUser,
Content: content.Body,
})
m.client.Log.Info().Msg("found parent, appending message to conversation")
} else {
conv = NewConversation(eventID, content.Body)
m.conversations = append(m.conversations, conv)
m.client.Log.Info().Msg("no parent found, starting new conversation")
}
if evt.Sender != id.UserID(m.config.UserID) {
// get reply from GPT
reply, err := m.gptClient.Complete(conv)
if err != nil {
m.client.Log.Error().Err(err).Msg("OpenAI API returned with ")
return
}
formattedReply := format.RenderMarkdown(reply, true, false)
formattedReply.RelatesTo = &event.RelatesTo{
InReplyTo: &event.InReplyTo{
EventID: eventID,
},
}
if _, err := m.client.SendMessageEvent(evt.RoomID, event.EventMessage, &formattedReply); err != nil {
m.client.Log.Err(err).Msg("failed to send message")
return
}
m.client.Log.Info().Str("message", fmt.Sprintf("%+v", formattedReply.Body)).Msg("Sent reply")
}
}
}

1
go.mod
View File

@ -12,6 +12,7 @@ require (
)
require (
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/tidwall/gjson v1.14.4 // indirect

2
go.sum
View File

@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=

BIN
gptzoo-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

64
main.go
View File

@ -1,10 +1,12 @@
package main
import (
"fmt"
"os"
"os/signal"
"ewintr.nl/matrix-bots/bot"
"github.com/BurntSushi/toml"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/exp/slog"
)
@ -12,29 +14,63 @@ import (
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
matrixClient := bot.New(bot.Config{
Homeserver: getParam("MATRIX_HOMESERVER", "http://localhost"),
UserID: getParam("MATRIX_USER_ID", "@bot:localhost"),
UserPassword: getParam("MATRIX_PASSWORD", "secret"),
UserAccessKey: getParam("MATRIX_ACCESS_KEY", "secret"),
DBPath: getParam("BOT_DB_PATH", "bot.db"),
Pickle: getParam("BOT_PICKLE", "scrambled"),
OpenAIKey: getParam("OPENAI_API_KEY", "no key"),
})
if err := matrixClient.Init(); err != nil {
var config bot.Config
if _, err := toml.DecodeFile(getParam("CONFIG_PATH", "conf.toml"), &config); err != nil {
logger.Error(err.Error())
os.Exit(1)
}
go matrixClient.Run()
type Credentials struct {
Password string
AccessKey string
}
credentials := make(map[string]Credentials)
for i := 0; i < len(config.Bots); i++ {
user := getParam(fmt.Sprintf("MATRIX_BOT%d_ID", i), "")
if user == "" {
logger.Error("missing user id", slog.Int("user", i))
os.Exit(1)
}
credentials[user] = Credentials{
Password: getParam(fmt.Sprintf("MATRIX_BOT%d_PASSWORD", i), ""),
AccessKey: getParam(fmt.Sprintf("MATRIX_BOT%d_ACCESSKEY", i), ""),
}
}
for i, bc := range config.Bots {
creds, ok := credentials[bc.UserID]
if !ok {
logger.Error("missing credentials", slog.Int("user", i))
os.Exit(1)
}
config.Bots[i].UserPassword = creds.Password
config.Bots[i].UserAccessKey = creds.AccessKey
}
matrixClient.AddEventHandler(matrixClient.InviteHandler())
matrixClient.AddEventHandler(matrixClient.ResponseHandler())
config.OpenAI = bot.ConfigOpenAI{
APIKey: getParam("OPENAI_API_KEY", ""),
}
var acceptInvites bool
if getParam("MATRIX_ACCEPT_INVITES", "false") == "true" {
acceptInvites = true
}
logger.Info("loaded config", slog.Int("bots", len(config.Bots)))
for _, bc := range config.Bots {
b := bot.New(config.OpenAI.APIKey, bc, logger)
if err := b.Init(acceptInvites); err != nil {
logger.Error(err.Error())
os.Exit(1)
}
go b.Run()
logger.Info("started bot", slog.String("name", bc.UserDisplayName))
}
done := make(chan os.Signal)
signal.Notify(done, os.Interrupt)
<-done
logger.Info("service stopped")
}
func getParam(name, def string) string {