Files
gogobee/internal/plugin/plugin.go
prosolis a46b773750 J3 D8-f #1: route prod autopilot boss/elite through the turn engine
Prod autopilot resolved boss/elite fights inline via SimulateCombat, which
swings the enemy once per round (Combatant has no ID to look up the SRD
multiattack profile). Manual !fight uses the turn engine, which loops the
full profile — so autopilot players faced strictly weaker bosses than
manual. D8-e confirmed this is the gap, not a turn-engine artifact.

- Promote the sim's autoResolveCombat/simPickCombatAction to shared plugin
  methods autoDriveCombat/pickAutoCombatAction (single source of truth; the
  sim now calls the same code prod does).
- Add MessageContext.Silent + a replyDM helper; the turn-engine combat
  handlers route their DMs through it so the background autopilot can drive
  the real !fight/!attack engine without spamming a DM per round (the EoD
  digest summarizes the outcome).
- tryAutoRun now calls runAutopilotWalkDriven (inlineBossCombat flipped
  true->false): walk->fight->walk loop so one tick still covers ~autoRunRoomCap
  rooms, but boss AND elite now face the player's full kit against the
  enemy's full multiattack. Loss surfaces as stopEnded (run already
  force-extracted by finishCombatSession).

Trash mobs stay on the fast inline path. GOGOBEE_SIM_INLINE_BOSS=1 A/B
toggle preserved. Build + plugin tests green; sim smoke-run unchanged.
2026-05-28 15:30:48 -07:00

858 lines
24 KiB
Go

package plugin
import (
"context"
"fmt"
"html"
"log/slog"
"os"
"regexp"
"strconv"
"strings"
"sync"
"gogobee/internal/db"
"gogobee/internal/util"
"gogobee/internal/version"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// CommandDef describes a bot command for help/discovery.
type CommandDef struct {
Name string
Description string
Usage string
Category string
AdminOnly bool
}
// MessageContext holds the context for a message event.
type MessageContext struct {
RoomID id.RoomID
EventID id.EventID
Sender id.UserID
Body string
IsCommand bool // true if the message starts with the command prefix
Event *event.Event
// OriginRoomID is set by plugins that rewrite RoomID for dispatch (e.g. holdem
// routes DM commands to the player's game room). When set, sender-private
// replies should go here instead of the rewritten RoomID.
OriginRoomID id.RoomID
// Silent suppresses player-facing replies for handlers that honor it
// (currently the turn-engine combat commands via replyDM). Set by the
// background autopilot when it drives a boss/elite fight through the
// real !fight/!attack engine for manual parity (long-expedition D8-f) —
// the day digest summarizes the outcome, so the per-round narration is
// dropped rather than DM'd round-by-round.
Silent bool
}
// ReactionContext holds the context for a reaction event.
type ReactionContext struct {
RoomID id.RoomID
EventID id.EventID
Sender id.UserID
TargetEvent id.EventID
Emoji string
Event *event.Event
}
// Plugin is the interface all plugins must implement.
type Plugin interface {
Name() string
Commands() []CommandDef
OnMessage(ctx MessageContext) error
OnReaction(ctx ReactionContext) error
Init() error
}
// Versioned is an optional interface plugins can implement to declare their version.
type Versioned interface {
Version() string
}
// PluginVersion returns the version for a plugin, or "1.0.0" if it doesn't implement Versioned.
func PluginVersion(p Plugin) string {
if v, ok := p.(Versioned); ok {
return v.Version()
}
return "1.0.0"
}
// dmCache maps user IDs to their DM room IDs to avoid creating duplicate rooms.
var (
dmCache = make(map[id.UserID]id.RoomID)
dmCacheMu sync.Mutex
)
// Base provides common helpers for plugin implementations.
type Base struct {
Client *mautrix.Client
Prefix string
}
// NewBase creates a Base with default prefix "!".
func NewBase(client *mautrix.Client) Base {
return Base{Client: client, Prefix: "!"}
}
// IsCommand checks if body matches prefix+command.
func (b *Base) IsCommand(body, command string) bool {
return util.IsCommand(body, b.Prefix, command)
}
// GetArgs returns the argument string after the command.
func (b *Base) GetArgs(body, command string) string {
return util.GetArgs(body, b.Prefix, command)
}
// IsAdmin checks if a user ID is in the admin list.
func (b *Base) IsAdmin(userID id.UserID) bool {
admins := os.Getenv("ADMIN_USERS")
if admins == "" {
return false
}
for _, a := range strings.Split(admins, ",") {
if strings.TrimSpace(a) == string(userID) {
return true
}
}
return false
}
// DisplayName returns the Matrix display name for a user, falling back
// to the localpart extracted from the user ID (e.g., "@alice:server" -> "alice").
func (b *Base) DisplayName(userID id.UserID) string {
resp, err := b.Client.GetDisplayName(context.Background(), userID)
if err != nil || resp.DisplayName == "" {
s := string(userID)
if idx := strings.Index(s, ":"); idx > 0 {
s = s[1:idx]
}
return s
}
return resp.DisplayName
}
// RoomMembers returns the set of user IDs visible from a room. If space groups
// are enabled, this returns the union of all members across rooms in the same
// space group. Otherwise falls back to the single room's membership.
func (b *Base) RoomMembers(roomID id.RoomID) map[id.UserID]bool {
if spaceGroupMgr != nil {
if members := spaceGroupMgr.GetGroupMembers(roomID); members != nil {
return members
}
}
// Fallback: direct API call
resp, err := b.Client.JoinedMembers(context.Background(), roomID)
if err != nil {
slog.Error("failed to get room members", "room", roomID, "err", err)
return nil
}
members := make(map[id.UserID]bool, len(resp.Joined))
for uid := range resp.Joined {
members[uid] = true
}
return members
}
// ---- Space Group Manager ----
var spaceGroupMgr *SpaceGroupManager
// SpaceGroupManager automatically groups rooms with overlapping membership
// so that leaderboards and other scoped queries show the full community.
type SpaceGroupManager struct {
mu sync.RWMutex
client *mautrix.Client
threshold int // overlap percentage (0-100)
roomToGroup map[id.RoomID]int
groupMembers map[int]map[id.UserID]bool
}
// InitSpaceGroups creates and initializes the space group manager.
func InitSpaceGroups(client *mautrix.Client) {
threshold := 50
if v := os.Getenv("SPACE_GROUP_THRESHOLD"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
threshold = n
}
}
sg := &SpaceGroupManager{
client: client,
threshold: threshold,
roomToGroup: make(map[id.RoomID]int),
groupMembers: make(map[int]map[id.UserID]bool),
}
// Always compute fresh groups on startup to pick up threshold changes
sg.Refresh()
spaceGroupMgr = sg
slog.Info("space_groups: initialized", "threshold", threshold)
}
// RefreshSpaceGroups triggers a refresh of space group mappings.
func RefreshSpaceGroups() {
if spaceGroupMgr != nil {
spaceGroupMgr.Refresh()
}
}
// GetGroupMembers returns the union of all members across rooms in the same
// space group as roomID. Returns nil if the room is not tracked.
func (sg *SpaceGroupManager) GetGroupMembers(roomID id.RoomID) map[id.UserID]bool {
sg.mu.RLock()
defer sg.mu.RUnlock()
gid, ok := sg.roomToGroup[roomID]
if !ok {
return nil
}
return sg.groupMembers[gid]
}
// Refresh recomputes space groups from live room membership data.
func (sg *SpaceGroupManager) Refresh() {
ctx := context.Background()
// Get all rooms the bot is in
joinedResp, err := sg.client.JoinedRooms(ctx)
if err != nil {
slog.Error("space_groups: failed to get joined rooms", "err", err)
return
}
rooms := joinedResp.JoinedRooms
if len(rooms) == 0 {
slog.Warn("space_groups: bot is not in any rooms")
return
}
// Fetch members for each room, skipping DM rooms (≤2 members)
roomMembers := make(map[id.RoomID]map[id.UserID]bool, len(rooms))
for _, roomID := range rooms {
resp, err := sg.client.JoinedMembers(ctx, roomID)
if err != nil {
slog.Warn("space_groups: failed to get members", "room", roomID, "err", err)
continue
}
if len(resp.Joined) <= 2 {
continue // skip DM rooms
}
members := make(map[id.UserID]bool, len(resp.Joined))
for uid := range resp.Joined {
members[uid] = true
}
roomMembers[roomID] = members
}
// Strict grouping: a room only joins a group if it meets the overlap
// threshold with EVERY room already in that group (no transitive chaining).
roomList := make([]id.RoomID, 0, len(roomMembers))
for r := range roomMembers {
roomList = append(roomList, r)
}
// Precompute pairwise overlap pass/fail
meetsThreshold := func(a, b id.RoomID) bool {
membersA, membersB := roomMembers[a], roomMembers[b]
smallerSize := len(membersA)
if len(membersB) < smallerSize {
smallerSize = len(membersB)
}
if smallerSize == 0 {
return false
}
overlap := 0
if len(membersA) <= len(membersB) {
for uid := range membersA {
if membersB[uid] {
overlap++
}
}
} else {
for uid := range membersB {
if membersA[uid] {
overlap++
}
}
}
return overlap*100 >= smallerSize*sg.threshold
}
// Build groups: try to add each room to an existing group where it
// meets the threshold with every member. Otherwise start a new group.
var groups [][]id.RoomID
for _, r := range roomList {
placed := false
for gi, group := range groups {
fitsAll := true
for _, member := range group {
if !meetsThreshold(r, member) {
fitsAll = false
break
}
}
if fitsAll {
groups[gi] = append(groups[gi], r)
placed = true
break
}
}
if !placed {
groups = append(groups, []id.RoomID{r})
}
}
// Assign group IDs
newRoomToGroup := make(map[id.RoomID]int, len(roomList))
for gid, group := range groups {
for _, r := range group {
newRoomToGroup[r] = gid + 1
}
}
// Build group member unions
newGroupMembers := make(map[int]map[id.UserID]bool)
for r, gid := range newRoomToGroup {
if newGroupMembers[gid] == nil {
newGroupMembers[gid] = make(map[id.UserID]bool)
}
for uid := range roomMembers[r] {
newGroupMembers[gid][uid] = true
}
}
// Persist to DB — only replace rows for rooms we successfully fetched
d := db.Get()
tx, err := d.Begin()
if err != nil {
slog.Error("space_groups: begin tx", "err", err)
return
}
defer tx.Rollback()
// Delete only rooms we have fresh data for (preserve entries for rooms that failed to fetch)
for r := range newRoomToGroup {
if _, err := tx.Exec(`DELETE FROM space_groups WHERE room_id = ?`, string(r)); err != nil {
slog.Error("space_groups: delete row", "room", r, "err", err)
return
}
}
for r, gid := range newRoomToGroup {
if _, err := tx.Exec(`INSERT INTO space_groups (room_id, group_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
string(r), gid); err != nil {
slog.Error("space_groups: insert row", "room", r, "err", err)
return
}
}
if err := tx.Commit(); err != nil {
slog.Error("space_groups: commit", "err", err)
return
}
// Swap cache under write lock
sg.mu.Lock()
sg.roomToGroup = newRoomToGroup
sg.groupMembers = newGroupMembers
sg.mu.Unlock()
// Log summary
groupCounts := make(map[int]int)
for _, gid := range newRoomToGroup {
groupCounts[gid]++
}
multiRoom := 0
for _, count := range groupCounts {
if count > 1 {
multiRoom++
}
}
slog.Info("space_groups: refresh complete",
"rooms", len(newRoomToGroup),
"groups", len(groupCounts),
"multi_room_groups", multiRoom,
"threshold", sg.threshold)
}
// rebuildMemberCache fetches live membership for rooms in stored groups.
func (sg *SpaceGroupManager) rebuildMemberCache() {
ctx := context.Background()
newGroupMembers := make(map[int]map[id.UserID]bool)
for roomID, gid := range sg.roomToGroup {
resp, err := sg.client.JoinedMembers(ctx, roomID)
if err != nil {
slog.Warn("space_groups: rebuild cache failed for room", "room", roomID, "err", err)
continue
}
if newGroupMembers[gid] == nil {
newGroupMembers[gid] = make(map[id.UserID]bool)
}
for uid := range resp.Joined {
newGroupMembers[gid][uid] = true
}
}
sg.mu.Lock()
sg.groupMembers = newGroupMembers
sg.mu.Unlock()
}
// ResolveUser resolves a partial username or display name to a full Matrix user ID.
// Accepts any of: "@user:server", "user:server", "user", display name, or partial match.
// Uses room membership to match against display names when a localpart match isn't found.
// Returns the matched user ID and true, or empty and false if no unique match.
func (b *Base) ResolveUser(input string, roomIDs ...id.RoomID) (id.UserID, bool) {
input = strings.TrimSpace(input)
if input == "" {
return "", false
}
// If it already looks like a full Matrix ID, use it directly
if strings.Contains(input, ":") {
if !strings.HasPrefix(input, "@") {
input = "@" + input
}
return id.UserID(input), true
}
// Strip leading @ for the search
query := strings.TrimPrefix(input, "@")
queryLower := strings.ToLower(query)
d := db.Get()
rows, err := d.Query(`SELECT user_id FROM user_stats`)
if err != nil {
return "", false
}
defer rows.Close()
var exact []string
var partial []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
// Extract localpart: @localpart:server -> localpart
localpart := uid
if strings.HasPrefix(localpart, "@") {
localpart = localpart[1:]
}
if idx := strings.Index(localpart, ":"); idx > 0 {
localpart = localpart[:idx]
}
lower := strings.ToLower(localpart)
if lower == queryLower {
exact = append(exact, uid)
} else if strings.Contains(lower, queryLower) {
partial = append(partial, uid)
}
}
// Exact localpart match — return it (even if multiple servers, prefer first)
if len(exact) >= 1 {
return id.UserID(exact[0]), true
}
// Single partial localpart match
if len(partial) == 1 {
return id.UserID(partial[0]), true
}
// Fall back to display name matching via room membership
if len(roomIDs) > 0 {
resp, err := b.Client.JoinedMembers(context.Background(), roomIDs[0])
if err == nil {
var dnExact []id.UserID
var dnPartial []id.UserID
for uid, member := range resp.Joined {
if member.DisplayName == "" {
continue
}
dn := strings.ToLower(member.DisplayName)
if dn == queryLower {
dnExact = append(dnExact, uid)
} else if strings.Contains(dn, queryLower) {
dnPartial = append(dnPartial, uid)
}
}
if len(dnExact) >= 1 {
return dnExact[0], true
}
if len(dnPartial) == 1 {
return dnPartial[0], true
}
}
}
return "", false
}
// simpleMarkdownToHTML converts the limited Markdown subset used in bot messages
// (**bold**, _italic_, `code`, newlines) to Matrix-compatible HTML.
var (
mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`)
mdItalicRe = regexp.MustCompile(`(?:^|[\n (])_([^_]+?)_(?:$|[\n ).,!?])`)
mdCodeRe = regexp.MustCompile("`([^`]+)`")
mdHasFmt = regexp.MustCompile(`\*\*|(?:^|[\n (])_[^_]+_(?:$|[\n ).,!?])|` + "`")
)
func simpleMarkdownToHTML(text string) string {
h := html.EscapeString(text)
h = mdBoldRe.ReplaceAllString(h, "<strong>$1</strong>")
// Italic needs careful handling to not match snake_case
h = mdItalicRe.ReplaceAllStringFunc(h, func(m string) string {
// Preserve leading/trailing non-underscore chars
start := 0
for start < len(m) && m[start] != '_' {
start++
}
end := len(m) - 1
for end > start && m[end] != '_' {
end--
}
return m[:start] + "<em>" + m[start+1:end] + "</em>" + m[end+1:]
})
h = mdCodeRe.ReplaceAllString(h, "<code>$1</code>")
h = strings.ReplaceAll(h, "\n", "<br>\n")
return h
}
var mdStripItalicRe = regexp.MustCompile(`_([^_]+?)_`)
func stripMarkdown(text string) string {
s := mdBoldRe.ReplaceAllString(text, "$1")
s = mdStripItalicRe.ReplaceAllString(s, "$1")
s = mdCodeRe.ReplaceAllString(s, "$1")
return s
}
// textContent builds a MessageEventContent, adding HTML formatting when Markdown is detected.
func textContent(text string) *event.MessageEventContent {
if mdHasFmt.MatchString(text) {
return &event.MessageEventContent{
MsgType: event.MsgText,
Body: stripMarkdown(text),
Format: event.FormatHTML,
FormattedBody: simpleMarkdownToHTML(text),
}
}
return &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
}
}
// SendMessage sends a message to a room, auto-formatting Markdown as HTML.
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
content := textContent(text)
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {
slog.Error("failed to send message", "room", roomID, "err", err)
}
return err
}
// SendMessageID sends a message and returns the event ID.
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
content := textContent(text)
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {
slog.Error("failed to send message", "room", roomID, "err", err)
return "", err
}
return resp.EventID, nil
}
// SendThread sends a message in a thread rooted at threadID.
func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) error {
content := textContent(text)
content.RelatesTo = &event.RelatesTo{
Type: event.RelThread,
EventID: threadID,
InReplyTo: &event.InReplyTo{
EventID: threadID,
},
IsFallingBack: true,
}
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {
slog.Error("failed to send thread message", "room", roomID, "err", err)
}
return err
}
// SendNotice sends an m.notice message to a room.
func (b *Base) SendNotice(roomID id.RoomID, text string) error {
content := textContent(text)
content.MsgType = event.MsgNotice
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}
// SendReply sends a reply to a specific event.
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
content := textContent(text)
if eventID != "" {
content.RelatesTo = &event.RelatesTo{
InReplyTo: &event.InReplyTo{
EventID: eventID,
},
}
}
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {
slog.Error("failed to send reply", "room", roomID, "err", err)
}
return err
}
// SendHTML sends an HTML-formatted message.
func (b *Base) SendHTML(roomID id.RoomID, plain, htmlBody string) error {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: plain,
Format: event.FormatHTML,
FormattedBody: htmlBody,
}
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}
// SendReact sends a reaction emoji to an event.
func (b *Base) SendReact(roomID id.RoomID, eventID id.EventID, emoji string) error {
content := &event.ReactionEventContent{
RelatesTo: event.RelatesTo{
Type: event.RelAnnotation,
EventID: eventID,
Key: emoji,
},
}
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventReaction, content)
return err
}
// GetDMRoom returns the DM room for a user, creating one if needed.
func (b *Base) GetDMRoom(userID id.UserID) (id.RoomID, error) {
dmCacheMu.Lock()
if roomID, ok := dmCache[userID]; ok {
dmCacheMu.Unlock()
return roomID, nil
}
dmCacheMu.Unlock()
// Check account data for existing DM rooms
var dmRooms map[id.UserID][]id.RoomID
err := b.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
dmCacheMu.Lock()
dmCache[userID] = roomID
dmCacheMu.Unlock()
return roomID, nil
}
}
// No existing DM room — create one
resp, err := b.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)
}
dmCacheMu.Lock()
dmCache[userID] = resp.RoomID
dmCacheMu.Unlock()
return resp.RoomID, nil
}
// IsDMRoom checks if the given room is a known DM room for the given user.
func IsDMRoom(roomID id.RoomID, userID id.UserID) bool {
dmCacheMu.Lock()
defer dmCacheMu.Unlock()
cached, ok := dmCache[userID]
return ok && cached == roomID
}
// SendDM sends a direct message to a user. Reuses existing DM room if available.
// No-op when the Matrix client is nil (which happens in unit tests that
// construct plugins without a real client).
func (b *Base) SendDM(userID id.UserID, text string) error {
if b == nil || b.Client == nil {
return nil
}
roomID, err := b.GetDMRoom(userID)
if err != nil {
return err
}
return b.SendMessage(roomID, text)
}
// SendDMID sends a direct message and returns the event ID (for later editing).
func (b *Base) SendDMID(userID id.UserID, text string) (id.EventID, error) {
roomID, err := b.GetDMRoom(userID)
if err != nil {
return "", err
}
return b.SendMessageID(roomID, text)
}
// PinEvent appends the given event ID to the room's m.room.pinned_events
// state. No-op if already pinned. Requires the bot to have power level for
// state events; failures are logged and returned without retry.
func (b *Base) PinEvent(roomID id.RoomID, eventID id.EventID) error {
var content event.PinnedEventsEventContent
err := b.Client.StateEvent(context.Background(), roomID, event.StatePinnedEvents, "", &content)
if err != nil && !strings.Contains(err.Error(), "M_NOT_FOUND") {
slog.Error("pin: read state", "room", roomID, "err", err)
return err
}
for _, id := range content.Pinned {
if id == eventID {
return nil // already pinned
}
}
content.Pinned = append(content.Pinned, eventID)
_, err = b.Client.SendStateEvent(context.Background(), roomID, event.StatePinnedEvents, "", content)
if err != nil {
slog.Error("pin: write state", "room", roomID, "event", eventID, "err", err)
}
return err
}
// UnpinEvent removes the given event ID from the room's pinned events.
// No-op if not currently pinned.
func (b *Base) UnpinEvent(roomID id.RoomID, eventID id.EventID) error {
var content event.PinnedEventsEventContent
err := b.Client.StateEvent(context.Background(), roomID, event.StatePinnedEvents, "", &content)
if err != nil {
if strings.Contains(err.Error(), "M_NOT_FOUND") {
return nil
}
slog.Error("unpin: read state", "room", roomID, "err", err)
return err
}
out := content.Pinned[:0]
found := false
for _, id := range content.Pinned {
if id == eventID {
found = true
continue
}
out = append(out, id)
}
if !found {
return nil
}
content.Pinned = out
_, err = b.Client.SendStateEvent(context.Background(), roomID, event.StatePinnedEvents, "", content)
if err != nil {
slog.Error("unpin: write state", "room", roomID, "event", eventID, "err", err)
}
return err
}
// EditMessage edits an existing message using Matrix m.replace relation.
func (b *Base) EditMessage(roomID id.RoomID, eventID id.EventID, newText string) error {
newContent := textContent(newText)
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: "* " + newContent.Body,
NewContent: &event.MessageEventContent{
MsgType: newContent.MsgType,
Body: newContent.Body,
Format: newContent.Format,
FormattedBody: newContent.FormattedBody,
},
RelatesTo: &event.RelatesTo{
Type: event.RelReplace,
EventID: eventID,
},
}
if newContent.Format == event.FormatHTML {
content.Format = event.FormatHTML
content.FormattedBody = "* " + newContent.FormattedBody
}
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {
slog.Error("failed to edit message", "room", roomID, "event", eventID, "err", err)
}
return err
}
// EditDM edits a message in a user's DM room.
func (b *Base) EditDM(userID id.UserID, eventID id.EventID, newText string) error {
roomID, err := b.GetDMRoom(userID)
if err != nil {
return err
}
return b.EditMessage(roomID, eventID, newText)
}
// UploadContent uploads data to the Matrix content repository and returns the MXC URI.
func (b *Base) UploadContent(data []byte, contentType, filename string) (id.ContentURI, error) {
resp, err := b.Client.UploadBytesWithName(context.Background(), data, contentType, filename)
if err != nil {
return id.ContentURI{}, err
}
return resp.ContentURI, nil
}
// SendImage uploads image data and sends it as an m.image message with a caption.
func (b *Base) SendImage(roomID id.RoomID, imgData []byte, filename, caption string, width, height int) error {
uri, err := b.UploadContent(imgData, "image/png", filename)
if err != nil {
return fmt.Errorf("upload image: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: caption,
FileName: filename,
URL: uri.CUString(),
Info: &event.FileInfo{
MimeType: "image/png",
Size: len(imgData),
Width: width,
Height: height,
},
}
_, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}
// safeGo runs fn in a goroutine with panic recovery.
// Use instead of bare `go func()` to prevent daemon crashes.
func safeGo(label string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("goroutine panic recovered", "label", label, "panic", r)
db.RecordCrash(version.Short(), label, fmt.Sprintf("%v", r))
}
}()
fn()
}()
}