Rewrite Pastel from Python to Go with watchlist feature
Full rewrite of the gaming deals Matrix bot in Go for better performance and a single static binary. Includes all original functionality (CheapShark, ITAD, Epic free games, multi-currency pricing, dedup, preflight checks, presence heartbeat) plus a new watchlist feature where users can DM the bot to watch for specific game deals with 180-day auto-expiry. New: systemd service file, DB migration script for Python-to-Go transition. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
263
internal/matrix/client.go
Normal file
263
internal/matrix/client.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *mautrix.Client
|
||||
crypto *cryptohelper.CryptoHelper
|
||||
cancel context.CancelFunc
|
||||
syncCancel context.CancelFunc
|
||||
hasE2EE bool
|
||||
startTime time.Time
|
||||
|
||||
dmMu sync.Mutex
|
||||
dmCache map[id.UserID]id.RoomID // userID -> DM room
|
||||
}
|
||||
|
||||
// New creates a new Matrix client with E2EE support via cryptohelper.
|
||||
// The cryptoDBPath is the path to the SQLite database for the crypto store
|
||||
// (e.g. "crypto.db"). Call RegisterMessageHandler before StartSync to
|
||||
// receive DM events.
|
||||
func New(homeserverURL, userID, accessToken, cryptoDBPath string) (*Client, error) {
|
||||
uid := id.UserID(userID)
|
||||
client, err := mautrix.NewClient(homeserverURL, uid, accessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create matrix client: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
client: client,
|
||||
dmCache: make(map[id.UserID]id.RoomID),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
|
||||
if cryptoDBPath != "" {
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("pastel_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
if err := ch.Init(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
|
||||
client.Crypto = ch
|
||||
c.crypto = ch
|
||||
|
||||
slog.Info("E2EE initialized", "device_id", client.DeviceID)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Ignore own messages
|
||||
if evt.Sender == c.client.UserID {
|
||||
return
|
||||
}
|
||||
// Ignore messages from before bot startup (history replay)
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Checks the in-memory cache first, then m.direct account data, and only creates
|
||||
// a new encrypted room as a last resort. Follows gogobee's pattern to avoid
|
||||
// opening duplicate DM rooms against the same user.
|
||||
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()
|
||||
|
||||
// Check m.direct account data for existing DM rooms
|
||||
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] // use most recent
|
||||
c.dmMu.Lock()
|
||||
c.dmCache[userID] = roomID
|
||||
c.dmMu.Unlock()
|
||||
return roomID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No existing DM room — create an encrypted one
|
||||
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()
|
||||
|
||||
// Send immediately
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user