commit 7d1c046145e3e3530a90090b354a138ea5143280 Author: phlux Date: Fri May 9 04:43:15 2025 +0000 First commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e0143ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Dockerfile for Matrix GPT Bot + +# --- Build Stage --- +FROM golang:1.24-alpine AS builder + +# Install git for module downloads +RUN apk add --no-cache git + +WORKDIR /app + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the rest of the source +COPY . . + +# Build the binary +RUN go build -o matrix-gpt-bot ./main.go + +# --- Final Stage --- +FROM alpine:3.18 + +# Install CA certificates +RUN apk add --no-cache ca-certificates + +WORKDIR /root/ + +# Copy the built binary from the builder stage +COPY --from=builder /app/matrix-gpt-bot . + +# Define the entrypoint +ENTRYPOINT ["./matrix-gpt-bot"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..783ef6f --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Matrix GPT Bot + +A Matrix chat bot written in Go that leverages OpenAI to provide GPT-powered conversational responses. The bot listens for messages prefixed with `gpt:` or mentions (`@gpt:`) and maintains per-user conversation history. + +## Features + +- **GPT Conversations**: Respond to `gpt: ` commands. +- **Matrix Mentions**: Also triggers on full Matrix mention (`@gpt:domain`). +- **Contextual History**: Keeps a conversation history per user. +- **HTML Formatting**: Sends replies as plain text (no additional markup). +- **Docker Support**: Easily containerize for deployment. + +## Prerequisites + +- Go 1.20+ installed +- Access to a Matrix homeserver (e.g., Synapse) +- A bot user created on your Matrix server +- An OpenAI API key + +## Configuration + +Edit `config/config.go` to set your credentials: + +```go +func Load() *Config { + return &Config{ + Homeserver: "https://matrix.your-server.net", + BotUserID: "@gpt:your-server.net", // Your bot's user ID + AccessToken: "YOUR_MATRIX_ACCESS_TOKEN", // Your Matrix access token + OpenAIKey: "YOUR_OPENAI_API_KEY", // Your OpenAI API key + } +} +``` + +## Local Development + +```bash +# Clone the repository +git clone https://git.ewnix.net/phlux/matrix-gpt-bot.git +cd matrix-gpt-bot + +# Initialize modules and download dependencies +go mod init matrix-gpt-bot +go mod tidy + +# Run the bot +go run . +``` + +Invite your bot into a Matrix room and test: + +``` +gpt: Hello, bot! +``` + +## Docker Deployment + +To build a Docker image: + +```bash +docker build -t matrix-gpt-bot:latest . +``` + +Run the container: + +```bash +docker run -d --name matrix-gpt-bot matrix-gpt-bot:latest +``` + +## Kubernetes (K3s) + +1. Create a namespace and pull-secret for private registries if needed. +2. Deploy with a standard Deployment manifest, mounting environment variables for credentials. + +## Command Reference + +- `gpt: ` – Sends `` to OpenAI and returns the response. +- `@gpt:domain ` – Alternate trigger via mention. + +## License + +MIT © Your Name + diff --git a/bot/bot.go b/bot/bot.go new file mode 100644 index 0000000..7b80876 --- /dev/null +++ b/bot/bot.go @@ -0,0 +1,67 @@ +package bot + +import ( + "context" + "strings" + + "matrix-gpt-bot/openai" + + mautrix "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" +) + +// RegisterHandler sets up the GPT message handler on the Matrix client. +func RegisterHandler(client *mautrix.Client) { + syncer := client.Syncer.(*mautrix.DefaultSyncer) + + // Compute the bot's local name (without '@' or domain) for prefix matching + fullID := string(client.UserID) // e.g. "@gpt:ewnix.net" + local := strings.SplitN(fullID, ":", 2)[0] // "@gpt" + local = strings.TrimPrefix(local, "@") // "gpt" + + syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + msg := evt.Content.AsMessage() + if msg.MsgType != event.MsgText { + return + } + + body := msg.Body + lcBody := strings.ToLower(body) + var content string + + // Check for "gpt: message" prefix + if strings.HasPrefix(lcBody, local+":") { + content = strings.TrimSpace(body[len(local)+1:]) + + // Or for full Matrix mention + } else if strings.Contains(body, fullID) { + content = strings.TrimSpace(strings.ReplaceAll(body, fullID, "")) + } else { + // Not addressed to bot + return + } + + // Nothing to send + if content == "" { + return + } + + // Retrieve or initialize conversation history for this user + userID := string(evt.Sender) + history := GetHistory(userID) + history = append(history, openai.Message{Role: "user", Content: content}) + + // Ask OpenAI + resp, err := openai.Ask(history) + if err != nil { + client.SendText(context.Background(), evt.RoomID, "Error talking to ChatGPT: "+err.Error()) + return + } + + history = append(history, openai.Message{Role: "assistant", Content: resp}) + SetHistory(userID, history) + + // Send reply + client.SendText(context.Background(), evt.RoomID, resp) + }) +} diff --git a/bot/memory.go b/bot/memory.go new file mode 100644 index 0000000..7e13c8d --- /dev/null +++ b/bot/memory.go @@ -0,0 +1,18 @@ +package bot + +import ( + "matrix-gpt-bot/openai" +) + +// conversations stores per-user message history. +var conversations = map[string][]openai.Message{} + +// GetHistory retrieves the conversation history for a given user. +func GetHistory(userID string) []openai.Message { + return conversations[userID] +} + +// SetHistory saves the conversation history for a given user. +func SetHistory(userID string, history []openai.Message) { + conversations[userID] = history +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..aaa6ab4 --- /dev/null +++ b/config/config.go @@ -0,0 +1,19 @@ +package config + +// Config holds Matrix and OpenAI credentials for the GPT bot. +type Config struct { + Homeserver string + BotUserID string + AccessToken string + OpenAIKey string +} + +// Load returns hardcoded credentials; replace with your own values. +func Load() *Config { + return &Config{ + Homeserver: "", + BotUserID: "", // replace with your bot's user ID + AccessToken: "", // replace with bot access token + OpenAIKey: "", // replace with your OpenAI key + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a80a0fb --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module matrix-gpt-bot + +replace matrix-gpt-bot => . + +go 1.24.2 + +require ( + github.com/sashabaranov/go-openai v1.39.1 + maunium.net/go/mautrix v0.23.3 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.8.6 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..67b015e --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sashabaranov/go-openai v1.39.1 h1:TMD4w77Iy9WTFlgnjNaxbAASdsCJ9R/rMdzL+SN14oU= +github.com/sashabaranov/go-openai v1.39.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= +go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.23.3 h1:U+fzdcLhFKLUm5gf2+Q0hEUqWkwDMRfvE+paUH9ogSk= +maunium.net/go/mautrix v0.23.3/go.mod h1:LX+3evXVKSvh/b43BVC3rkvN2qV7b0bkIV4fY7Snn/4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ebafd34 --- /dev/null +++ b/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "matrix-gpt-bot/bot" + "matrix-gpt-bot/config" + + mautrix "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +func main() { + // Load configuration + cfg := config.Load() + + // Initialize Matrix client + client, err := mautrix.NewClient(cfg.Homeserver, id.UserID(cfg.BotUserID), cfg.AccessToken) + if err != nil { + log.Fatalf("Failed to create Matrix client: %v", err) + } + + // Register the GPT message handler + bot.RegisterHandler(client) + + log.Println("Matrix GPT bot started. Press CTRL+C to exit.") + + // Start syncing in a background goroutine + go func() { + if err := client.Sync(); err != nil { + log.Fatalf("Sync failed: %v", err) + } + }() + + // Wait for termination signal + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + log.Println("Shutting down bot.") +} diff --git a/openai/client.go b/openai/client.go new file mode 100644 index 0000000..f492918 --- /dev/null +++ b/openai/client.go @@ -0,0 +1,47 @@ +package openai + +import ( + "context" + + "matrix-gpt-bot/config" + + goai "github.com/sashabaranov/go-openai" +) + +// Message represents a single message in the conversation. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// Ask sends the provided messages to the OpenAI API and returns the assistant's reply. +func Ask(messages []Message) (string, error) { + // Load OpenAI key from configuration + conf := config.Load() + client := goai.NewClient(conf.OpenAIKey) + + // Build the chat history in the format expected by the API + var chatHistory []goai.ChatCompletionMessage + for _, m := range messages { + chatHistory = append(chatHistory, goai.ChatCompletionMessage{ + Role: m.Role, + Content: m.Content, + }) + } + + // Create the chat completion request + resp, err := client.CreateChatCompletion( + context.Background(), + goai.ChatCompletionRequest{ + Model: "gpt-4.1", + Messages: chatHistory, + MaxTokens: 600, + }, + ) + if err != nil { + return "", err + } + + // Return the content of the first choice + return resp.Choices[0].Message.Content, nil +}