managarten/services/mana-matrix-bot/internal/matrix/client.go
Till JS 819568c3df feat(infra): consolidate 21 Matrix bots into Go binary + add Go API gateway
Replace 21 separate NestJS Matrix bot processes (~2.1 GB RAM, ~4.2 GB Docker images)
with a single Go binary using plugin architecture (8.6 MB binary, ~30 MB RAM).

New services:
- services/mana-matrix-bot/ — Go Matrix bot with 21 plugins (mautrix-go, Redis sessions)
- services/mana-api-gateway-go/ — Go API gateway (rate limiting, API keys, credit billing)

Deleted:
- 21 services/matrix-*-bot/ directories
- packages/bot-services/ and packages/matrix-bot-common/
- Legacy deploy scripts and CI build jobs

Updated:
- docker-compose.macmini.yml: new Go services, legacy bots removed
- CI/CD: change detection + build jobs for Go services
- Root package.json: new dev:matrix, build:matrix, test:matrix scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:03:00 +01:00

241 lines
6.8 KiB
Go

package matrix
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
var mxcRegex = regexp.MustCompile(`^mxc://([^/]+)/(.+)$`)
// Client wraps mautrix.Client and implements the plugin.MatrixClient interface.
type Client struct {
inner *mautrix.Client
homeserver string
accessToken string
storagePath string
logger *slog.Logger
}
// ClientConfig holds configuration for creating a Matrix client.
type ClientConfig struct {
HomeserverURL string
AccessToken string
StoragePath string // path for sync state file
PluginName string
}
// NewClient creates a new Matrix client wrapper.
func NewClient(cfg ClientConfig) (*Client, error) {
userID := id.UserID("") // will be resolved via whoami
client, err := mautrix.NewClient(cfg.HomeserverURL, userID, cfg.AccessToken)
if err != nil {
return nil, fmt.Errorf("create mautrix client: %w", err)
}
// Ensure storage directory exists
if cfg.StoragePath != "" {
dir := filepath.Dir(cfg.StoragePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create storage dir: %w", err)
}
}
logger := slog.With("plugin", cfg.PluginName)
return &Client{
inner: client,
homeserver: cfg.HomeserverURL,
accessToken: cfg.AccessToken,
storagePath: cfg.StoragePath,
logger: logger,
}, nil
}
// Inner returns the underlying mautrix.Client for advanced operations.
func (c *Client) Inner() *mautrix.Client {
return c.inner
}
// Login resolves the bot's user ID via /whoami.
func (c *Client) Login(ctx context.Context) (id.UserID, error) {
resp, err := c.inner.Whoami(ctx)
if err != nil {
return "", fmt.Errorf("whoami: %w", err)
}
c.inner.UserID = resp.UserID
c.logger.Info("authenticated", "user_id", resp.UserID)
return resp.UserID, nil
}
// GetUserID returns the bot's Matrix user ID.
func (c *Client) GetUserID() string {
return c.inner.UserID.String()
}
// SendMessage sends a text message with markdown formatting to a room.
func (c *Client) SendMessage(ctx context.Context, roomID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SendReply sends a reply to a specific event.
func (c *Client) SendReply(ctx context.Context, roomID string, eventID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
content.SetReply(&event.Event{
RoomID: id.RoomID(roomID),
ID: id.EventID(eventID),
})
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SendNotice sends a notice (non-highlighted message).
func (c *Client) SendNotice(ctx context.Context, roomID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// EditMessage edits an existing message.
func (c *Client) EditMessage(ctx context.Context, roomID string, eventID string, text string) (string, error) {
content := map[string]any{
"msgtype": "m.text",
"body": "* " + text,
"format": "org.matrix.custom.html",
"formatted_body": "* " + MarkdownToHTML(text),
"m.relates_to": map[string]any{
"rel_type": "m.replace",
"event_id": eventID,
},
"m.new_content": map[string]any{
"msgtype": "m.text",
"body": text,
"format": "org.matrix.custom.html",
"formatted_body": MarkdownToHTML(text),
},
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SetTyping sets the typing indicator for the bot in a room.
func (c *Client) SetTyping(ctx context.Context, roomID string, typing bool) error {
timeout := time.Duration(0)
if typing {
timeout = 30 * time.Second
}
_, err := c.inner.UserTyping(ctx, id.RoomID(roomID), typing, timeout)
return err
}
// DownloadMedia downloads media from a mxc:// URL.
func (c *Client) DownloadMedia(ctx context.Context, mxcURL string) ([]byte, error) {
matches := mxcRegex.FindStringSubmatch(mxcURL)
if len(matches) != 3 {
return nil, fmt.Errorf("invalid mxc URL: %s", mxcURL)
}
serverName := matches[1]
mediaID := matches[2]
// Try authenticated media API (Matrix spec v1.11+)
url := fmt.Sprintf("%s/_matrix/client/v1/media/download/%s/%s", c.homeserver, serverName, mediaID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Fallback to legacy API
url = fmt.Sprintf("%s/_matrix/media/v3/download/%s/%s", c.homeserver, serverName, mediaID)
req2, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
return nil, fmt.Errorf("download media (legacy): %w", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download media failed: %d", resp2.StatusCode)
}
return io.ReadAll(resp2.Body)
}
return io.ReadAll(resp.Body)
}
// UploadMedia uploads media and returns the mxc:// URL.
func (c *Client) UploadMedia(ctx context.Context, data []byte, contentType string, filename string) (string, error) {
resp, err := c.inner.UploadBytes(ctx, data, contentType)
if err != nil {
return "", fmt.Errorf("upload media: %w", err)
}
return resp.ContentURI.String(), nil
}
// SendAudio sends an audio message to a room.
func (c *Client) SendAudio(ctx context.Context, roomID string, mxcURL string, filename string, size int) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgAudio,
Body: filename,
URL: id.ContentURIString(mxcURL),
Info: &event.FileInfo{
MimeType: "audio/mpeg",
Size: size,
},
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}