mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 06:59:40 +02:00
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>
241 lines
6.8 KiB
Go
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
|
|
}
|