- Fix SQLite connection string (drop query param, use explicit PRAGMA) - Register sqlite3-fk-wal driver for mautrix cryptohelper (pure Go, no CGO) - Fetch DeviceID via /whoami when using bare access token - Log cross-signing results at Warn/Info instead of Debug - Post deals into Matrix threads: Game Deals, DLC Deals, Epic Free Games - Add numbered watchlist; !extend and !unwatch accept list numbers - URL-encode search queries for multi-word !search commands - Add GetConfig/SetConfig helpers for persistent thread event IDs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
507 lines
14 KiB
Go
507 lines
14 KiB
Go
package matrix
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/crypto/cryptohelper"
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// DeviceInfo holds persisted device credentials for stable device identity
|
|
// across restarts.
|
|
type DeviceInfo struct {
|
|
AccessToken string `json:"access_token"`
|
|
DeviceID string `json:"device_id"`
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
// ClientConfig holds the parameters needed to create a Matrix client.
|
|
type ClientConfig struct {
|
|
HomeserverURL string
|
|
UserID string
|
|
AccessToken string
|
|
Password string // optional: enables auto-refresh and cross-signing
|
|
CryptoDBPath string // e.g. "crypto.db", empty to skip E2EE
|
|
DevicePath string // e.g. "device.json", empty to skip persistence
|
|
}
|
|
|
|
type Client struct {
|
|
client *mautrix.Client
|
|
crypto *cryptohelper.CryptoHelper
|
|
cancel context.CancelFunc
|
|
syncCancel context.CancelFunc
|
|
startTime time.Time
|
|
cfg ClientConfig
|
|
|
|
dmMu sync.Mutex
|
|
dmCache map[id.UserID]id.RoomID
|
|
}
|
|
|
|
// New creates a new Matrix client. If a password is provided, the client will:
|
|
// - Persist device credentials to DevicePath for stable device identity
|
|
// - Validate existing tokens on startup and re-login if expired
|
|
// - Set LoginAs on the cryptohelper so it can auto-refresh mid-operation
|
|
// - Bootstrap cross-signing so the bot's device is automatically verified
|
|
//
|
|
// If only an access token is provided (no password), the client works but
|
|
// cannot auto-refresh — the token must be manually replaced if it expires.
|
|
func New(cfg ClientConfig) (*Client, error) {
|
|
uid := id.UserID(cfg.UserID)
|
|
accessToken := cfg.AccessToken
|
|
|
|
c := &Client{
|
|
cfg: cfg,
|
|
dmCache: make(map[id.UserID]id.RoomID),
|
|
startTime: time.Now(),
|
|
}
|
|
|
|
// If we have a device file and password, try to load persisted credentials
|
|
if cfg.DevicePath != "" && cfg.Password != "" {
|
|
device, err := loadDevice(cfg.DevicePath)
|
|
if err == nil && device.AccessToken != "" {
|
|
if isTokenValid(cfg.HomeserverURL, device.AccessToken) {
|
|
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
|
|
accessToken = device.AccessToken
|
|
|
|
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(device.UserID), device.AccessToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create client with existing token: %w", err)
|
|
}
|
|
client.DeviceID = id.DeviceID(device.DeviceID)
|
|
c.client = client
|
|
} else {
|
|
slog.Warn("existing device credentials expired, logging in again")
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we don't have a valid client yet, try password login or fall back to token
|
|
if c.client == nil && cfg.Password != "" {
|
|
loginResp, err := loginWithPassword(cfg.HomeserverURL, cfg.UserID, cfg.Password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login: %w", err)
|
|
}
|
|
|
|
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(loginResp.UserID), loginResp.AccessToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create client after login: %w", err)
|
|
}
|
|
client.DeviceID = id.DeviceID(loginResp.DeviceID)
|
|
c.client = client
|
|
accessToken = loginResp.AccessToken
|
|
|
|
// Save device credentials for next restart
|
|
if cfg.DevicePath != "" {
|
|
if err := saveDevice(cfg.DevicePath, &DeviceInfo{
|
|
AccessToken: loginResp.AccessToken,
|
|
DeviceID: loginResp.DeviceID,
|
|
UserID: loginResp.UserID,
|
|
}); err != nil {
|
|
slog.Warn("failed to save device info", "error", err)
|
|
}
|
|
}
|
|
|
|
slog.Info("logged in with password", "user_id", loginResp.UserID, "device_id", loginResp.DeviceID)
|
|
}
|
|
|
|
// Fall back to bare access token (no password, no auto-refresh)
|
|
if c.client == nil {
|
|
client, err := mautrix.NewClient(cfg.HomeserverURL, uid, accessToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create client: %w", err)
|
|
}
|
|
c.client = client
|
|
}
|
|
|
|
// Ensure DeviceID is set (required by cryptohelper)
|
|
if c.client.DeviceID == "" {
|
|
resp, err := c.client.Whoami(context.Background())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("whoami failed (needed for device ID): %w", err)
|
|
}
|
|
c.client.DeviceID = resp.DeviceID
|
|
}
|
|
|
|
// Set up E2EE
|
|
if cfg.CryptoDBPath != "" {
|
|
ch, err := cryptohelper.NewCryptoHelper(c.client, []byte("pastel_pickle_key"), cfg.CryptoDBPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("init crypto helper: %w", err)
|
|
}
|
|
|
|
// If password is available, enable auto-refresh on token expiry
|
|
if cfg.Password != "" {
|
|
ch.LoginAs = &mautrix.ReqLogin{
|
|
Type: mautrix.AuthTypePassword,
|
|
Identifier: mautrix.UserIdentifier{
|
|
Type: mautrix.IdentifierTypeUser,
|
|
User: cfg.UserID,
|
|
},
|
|
Password: cfg.Password,
|
|
}
|
|
}
|
|
|
|
if err := ch.Init(context.Background()); err != nil {
|
|
return nil, fmt.Errorf("crypto helper init: %w", err)
|
|
}
|
|
|
|
c.client.Crypto = ch
|
|
c.crypto = ch
|
|
|
|
// Bootstrap cross-signing if password is available
|
|
if cfg.Password != "" {
|
|
c.bootstrapCrossSigning()
|
|
}
|
|
|
|
slog.Info("E2EE initialized", "device_id", c.client.DeviceID)
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Client) bootstrapCrossSigning() {
|
|
mach := c.crypto.Machine()
|
|
|
|
_, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
|
|
return map[string]interface{}{
|
|
"type": mautrix.AuthTypePassword,
|
|
"identifier": map[string]interface{}{
|
|
"type": mautrix.IdentifierTypeUser,
|
|
"user": c.cfg.UserID,
|
|
},
|
|
"password": c.cfg.Password,
|
|
"session": ui.Session,
|
|
}
|
|
}, "")
|
|
if err != nil {
|
|
slog.Warn("cross-signing: key upload failed (may already exist)", "error", err)
|
|
} else {
|
|
slog.Info("cross-signing: keys uploaded")
|
|
}
|
|
|
|
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
|
|
slog.Warn("cross-signing: sign own device failed", "error", err)
|
|
} else {
|
|
slog.Info("cross-signing: own device signed")
|
|
}
|
|
|
|
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
|
|
slog.Warn("cross-signing: sign master key failed", "error", err)
|
|
} else {
|
|
slog.Info("cross-signing: master key signed")
|
|
}
|
|
}
|
|
|
|
// RegisterMessageHandler registers a callback for incoming messages.
|
|
// Must be called before StartSync. Ignores the bot's own messages and
|
|
// messages from before the bot started (avoids replaying history).
|
|
func (c *Client) RegisterMessageHandler(fn func(senderID id.UserID, roomID id.RoomID, body string)) {
|
|
syncer := c.client.Syncer.(*mautrix.DefaultSyncer)
|
|
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
|
if evt.Sender == c.client.UserID {
|
|
return
|
|
}
|
|
evtTime := time.UnixMilli(evt.Timestamp)
|
|
if evtTime.Before(c.startTime) {
|
|
return
|
|
}
|
|
msg := evt.Content.AsMessage()
|
|
if msg == nil || msg.Body == "" {
|
|
return
|
|
}
|
|
fn(evt.Sender, evt.RoomID, msg.Body)
|
|
})
|
|
}
|
|
|
|
// StartSync launches the background sync loop. Must be called after handler
|
|
// registration so events are dispatched to registered callbacks.
|
|
func (c *Client) StartSync() {
|
|
syncCtx, syncCancel := context.WithCancel(context.Background())
|
|
c.syncCancel = syncCancel
|
|
go func() {
|
|
for {
|
|
if err := c.client.SyncWithContext(syncCtx); err != nil {
|
|
if syncCtx.Err() != nil {
|
|
return
|
|
}
|
|
slog.Warn("sync error, retrying in 10s", "error", err)
|
|
time.Sleep(10 * time.Second)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Whoami validates the access token by calling /whoami.
|
|
func (c *Client) Whoami() (string, error) {
|
|
resp, err := c.client.Whoami(context.Background())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return resp.UserID.String(), nil
|
|
}
|
|
|
|
// JoinedRooms returns the list of rooms the bot has joined.
|
|
func (c *Client) JoinedRooms() ([]string, error) {
|
|
resp, err := c.client.JoinedRooms(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rooms []string
|
|
for _, r := range resp.JoinedRooms {
|
|
rooms = append(rooms, r.String())
|
|
}
|
|
return rooms, nil
|
|
}
|
|
|
|
// SendDeal sends a formatted deal message to a room (top-level, no thread).
|
|
func (c *Client) SendDeal(roomID, plainText, html string) error {
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgText,
|
|
Body: plainText,
|
|
Format: event.FormatHTML,
|
|
FormattedBody: html,
|
|
}
|
|
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send deal message: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateThread sends a root message and returns its event ID for use as a thread root.
|
|
func (c *Client) CreateThread(roomID, text string) (string, error) {
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgText,
|
|
Body: text,
|
|
}
|
|
resp, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create thread: %w", err)
|
|
}
|
|
return resp.EventID.String(), nil
|
|
}
|
|
|
|
// SendDealInThread sends a formatted deal message as a reply in a thread.
|
|
func (c *Client) SendDealInThread(roomID, threadEventID, plainText, html string) error {
|
|
evtID := id.EventID(threadEventID)
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgText,
|
|
Body: plainText,
|
|
Format: event.FormatHTML,
|
|
FormattedBody: html,
|
|
RelatesTo: &event.RelatesTo{
|
|
Type: event.RelThread,
|
|
EventID: evtID,
|
|
InReplyTo: &event.InReplyTo{
|
|
EventID: evtID,
|
|
},
|
|
IsFallingBack: true,
|
|
},
|
|
}
|
|
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send threaded deal: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendNotice sends a notice message to a room (non-highlighting).
|
|
func (c *Client) SendNotice(roomID, text string) error {
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: text,
|
|
}
|
|
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send notice: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetDMRoom returns the DM room for a user, reusing an existing one if available.
|
|
func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
|
c.dmMu.Lock()
|
|
if roomID, ok := c.dmCache[userID]; ok {
|
|
c.dmMu.Unlock()
|
|
return roomID, nil
|
|
}
|
|
c.dmMu.Unlock()
|
|
|
|
var dmRooms map[id.UserID][]id.RoomID
|
|
err := c.client.GetAccountData(context.Background(), "m.direct", &dmRooms)
|
|
if err == nil {
|
|
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
|
|
roomID := rooms[len(rooms)-1]
|
|
c.dmMu.Lock()
|
|
c.dmCache[userID] = roomID
|
|
c.dmMu.Unlock()
|
|
return roomID, nil
|
|
}
|
|
}
|
|
|
|
resp, err := c.client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
|
|
Preset: "trusted_private_chat",
|
|
Invite: []id.UserID{userID},
|
|
IsDirect: true,
|
|
InitialState: []*event.Event{
|
|
{
|
|
Type: event.StateEncryption,
|
|
Content: event.Content{
|
|
Parsed: &event.EncryptionEventContent{
|
|
Algorithm: id.AlgorithmMegolmV1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create DM room: %w", err)
|
|
}
|
|
|
|
c.dmMu.Lock()
|
|
c.dmCache[userID] = resp.RoomID
|
|
c.dmMu.Unlock()
|
|
|
|
slog.Info("created DM room", "user", userID, "room", resp.RoomID)
|
|
return resp.RoomID, nil
|
|
}
|
|
|
|
// SendDM sends a direct message to a user. Reuses existing DM room if available.
|
|
func (c *Client) SendDM(userID id.UserID, text string) error {
|
|
roomID, err := c.GetDMRoom(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgText,
|
|
Body: text,
|
|
}
|
|
_, err = c.client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
|
return err
|
|
}
|
|
|
|
// StartPresenceHeartbeat starts a goroutine that sends online presence every 60 seconds.
|
|
func (c *Client) StartPresenceHeartbeat() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.cancel = cancel
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(60 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
c.sendPresence()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
c.sendPresence()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (c *Client) sendPresence() {
|
|
err := c.client.SetPresence(context.Background(), mautrix.ReqPresence{Presence: event.PresenceOnline})
|
|
if err != nil {
|
|
slog.Warn("failed to set presence", "error", err)
|
|
}
|
|
}
|
|
|
|
// Stop cancels the presence heartbeat and sync loop.
|
|
func (c *Client) Stop() {
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
}
|
|
if c.syncCancel != nil {
|
|
c.syncCancel()
|
|
}
|
|
if c.crypto != nil {
|
|
c.crypto.Close()
|
|
}
|
|
}
|
|
|
|
// --- auth helpers ---
|
|
|
|
type loginResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
DeviceID string `json:"device_id"`
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
func loginWithPassword(homeserver, user, password string) (*loginResponse, error) {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]string{
|
|
"type": "m.id.user",
|
|
"user": user,
|
|
},
|
|
"password": password,
|
|
})
|
|
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Post(
|
|
homeserver+"/_matrix/client/v3/login", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
var result loginResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("parse login response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func isTokenValid(homeserver, accessToken string) bool {
|
|
req, err := http.NewRequest("GET", homeserver+"/_matrix/client/v3/account/whoami", nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode == 200
|
|
}
|
|
|
|
func loadDevice(path string) (*DeviceInfo, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var info DeviceInfo
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return nil, err
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
func saveDevice(path string, info *DeviceInfo) error {
|
|
data, err := json.MarshalIndent(info, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0o600)
|
|
}
|