mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
GetDMRoom's lookup chain could never hit. The in-memory cache died with the process, and the m.direct account data it fell back on was never written by the bot -- room creation only ever populated the map. As an appservice user there is no /sync to populate it either, so after each restart the first DM to a user fell through both checks and created a fresh room. Persist the mapping in a new dm_rooms table and read it before anything else. Creation now also publishes m.direct, so clients label the room as a DM. A stored room is checked for liveness before reuse, counting the user as present while merely invited -- they often never accept, and treating that as gone would recreate the room on every send, which is the original bug wearing a different hat. The same check now guards the m.direct path, which previously took the last entry on faith. Two adoption paths keep existing rooms from being orphaned: user-initiated DM invites are claimed on join, and a room the user is already talking in is adopted on first message (negative-cached so group rooms cost one member lookup, not one per message). For users predating the table, a one-shot sweep of joined rooms runs only on the path that would otherwise create a duplicate. The sweep cannot rank duplicates -- without /sync there are no timestamps, so it takes the newest by room-list order. First message from the user corrects any wrong pick.
1117 lines
33 KiB
Go
1117 lines
33 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.
|
|
// It fronts the dm_rooms table; notDMCache is the negative half, marking rooms
|
|
// LearnDMRoom has already ruled out so group rooms cost one member lookup ever.
|
|
var (
|
|
dmCache = make(map[id.UserID]id.RoomID)
|
|
dmMapped = make(map[id.UserID]bool)
|
|
notDMCache = make(map[id.RoomID]bool)
|
|
dmCacheMu sync.Mutex
|
|
|
|
// One-shot index of two-person rooms, built by findExistingDMRoom.
|
|
dmSweepOnce sync.Once
|
|
dmSweepIndex = make(map[id.UserID]id.RoomID)
|
|
)
|
|
|
|
// Base provides common helpers for plugin implementations.
|
|
type Base struct {
|
|
Client *mautrix.Client
|
|
Prefix string
|
|
|
|
// Sink, when non-nil, receives every outbound message (DM or room) the
|
|
// plugin would otherwise hand to the Matrix client, and the client is
|
|
// never touched. It is the test seam for handlers whose only observable
|
|
// output is a DM — beginCombatTurn's terminal close-out,
|
|
// expeditionCmdLeave, the arena season announcement. Production leaves it
|
|
// nil, so behaviour is unchanged. See captureSink in the test files.
|
|
Sink MessageSink
|
|
}
|
|
|
|
// outboundMessage is one message a handler tried to send. Exactly one of
|
|
// ToUser (a DM) or ToRoom (a room message) is set.
|
|
type outboundMessage struct {
|
|
ToUser id.UserID
|
|
ToRoom id.RoomID
|
|
Text string
|
|
}
|
|
|
|
// MessageSink captures a plugin's outbound messages in place of the live
|
|
// client. Capture returns the event ID to report back to the caller, so the
|
|
// *ID send variants keep working. Unexported param by design: only in-package
|
|
// tests implement it.
|
|
type MessageSink interface {
|
|
Capture(outboundMessage) (id.EventID, error)
|
|
}
|
|
|
|
// 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 {
|
|
localpart := func() string {
|
|
s := string(userID)
|
|
if idx := strings.Index(s, ":"); idx > 0 {
|
|
s = s[1:idx]
|
|
}
|
|
return s
|
|
}
|
|
// The headless sim and unit tests construct plugins without a client.
|
|
if b == nil || b.Client == nil {
|
|
return localpart()
|
|
}
|
|
resp, err := b.Client.GetDisplayName(context.Background(), userID)
|
|
if err != nil || resp.DisplayName == "" {
|
|
return localpart()
|
|
}
|
|
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 {
|
|
if b != nil && b.Sink != nil {
|
|
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
|
return err
|
|
}
|
|
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) {
|
|
if b != nil && b.Sink != nil {
|
|
return b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
|
}
|
|
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 {
|
|
// The sink is the headless capture seam: a reply is an outbound room
|
|
// message, so — like SendMessage/SendDM — it diverts here when installed and
|
|
// the live client is never touched. Without this branch, reply-only handlers
|
|
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
|
|
if b != nil && b.Sink != nil {
|
|
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
|
|
// rememberDMRoom pins a user's DM room in both the in-process cache and the
|
|
// database, so a restart doesn't send the bot off creating a duplicate room.
|
|
func rememberDMRoom(userID id.UserID, roomID id.RoomID) {
|
|
dmCacheMu.Lock()
|
|
dmCache[userID] = roomID
|
|
dmMapped[userID] = true
|
|
dmCacheMu.Unlock()
|
|
|
|
if d := db.Get(); d != nil {
|
|
_, err := d.Exec(`INSERT INTO dm_rooms (user_id, room_id, updated_at)
|
|
VALUES (?, ?, unixepoch())
|
|
ON CONFLICT(user_id) DO UPDATE SET room_id = excluded.room_id, updated_at = excluded.updated_at`,
|
|
string(userID), string(roomID))
|
|
if err != nil {
|
|
slog.Error("persist dm room", "user", userID, "room", roomID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// forgetDMRoom drops a mapping that no longer resolves to a live shared room.
|
|
func forgetDMRoom(userID id.UserID) {
|
|
dmCacheMu.Lock()
|
|
delete(dmCache, userID)
|
|
delete(dmMapped, userID)
|
|
dmCacheMu.Unlock()
|
|
|
|
if d := db.Get(); d != nil {
|
|
if _, err := d.Exec(`DELETE FROM dm_rooms WHERE user_id = ?`, string(userID)); err != nil {
|
|
slog.Error("forget dm room", "user", userID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// storedDMRoom reads the persisted DM room for a user, if any.
|
|
func storedDMRoom(userID id.UserID) (id.RoomID, bool) {
|
|
d := db.Get()
|
|
if d == nil {
|
|
return "", false
|
|
}
|
|
var roomID string
|
|
err := d.QueryRow(`SELECT room_id FROM dm_rooms WHERE user_id = ?`, string(userID)).Scan(&roomID)
|
|
if err != nil || roomID == "" {
|
|
return "", false
|
|
}
|
|
return id.RoomID(roomID), true
|
|
}
|
|
|
|
// dmRoomUsable reports whether the bot and the user still share the room. The
|
|
// user counts as present while merely invited — they often never accept, and
|
|
// treating that as "gone" is what would recreate the room on every send.
|
|
func (b *Base) dmRoomUsable(roomID id.RoomID, userID id.UserID) bool {
|
|
ctx := context.Background()
|
|
|
|
var self event.MemberEventContent
|
|
if err := b.Client.StateEvent(ctx, roomID, event.StateMember, string(b.Client.UserID), &self); err != nil {
|
|
return false
|
|
}
|
|
if self.Membership != event.MembershipJoin {
|
|
return false
|
|
}
|
|
|
|
var other event.MemberEventContent
|
|
if err := b.Client.StateEvent(ctx, roomID, event.StateMember, string(userID), &other); err != nil {
|
|
return false
|
|
}
|
|
return other.Membership == event.MembershipJoin || other.Membership == event.MembershipInvite
|
|
}
|
|
|
|
// publishDirect appends the room to the bot's m.direct account data, so the
|
|
// room is labelled as a DM rather than a nameless private room.
|
|
func (b *Base) publishDirect(userID id.UserID, roomID id.RoomID) {
|
|
ctx := context.Background()
|
|
|
|
dmRooms := map[id.UserID][]id.RoomID{}
|
|
// A missing m.direct is a 404 — start from empty rather than bailing.
|
|
_ = b.Client.GetAccountData(ctx, "m.direct", &dmRooms)
|
|
|
|
for _, existing := range dmRooms[userID] {
|
|
if existing == roomID {
|
|
return
|
|
}
|
|
}
|
|
dmRooms[userID] = append(dmRooms[userID], roomID)
|
|
|
|
if err := b.Client.SetAccountData(ctx, "m.direct", dmRooms); err != nil {
|
|
slog.Warn("publish m.direct", "user", userID, "room", roomID, "err", err)
|
|
}
|
|
}
|
|
|
|
// RecordDMRoom claims a room as the user's DM room. Used for user-initiated
|
|
// DM invites, where the invite itself is the intent — it overwrites any older
|
|
// mapping, since the user just told us which room they want to talk in.
|
|
func (b *Base) RecordDMRoom(userID id.UserID, roomID id.RoomID) {
|
|
slog.Info("recorded user-initiated DM room", "user", userID, "room", roomID)
|
|
rememberDMRoom(userID, roomID)
|
|
b.publishDirect(userID, roomID)
|
|
}
|
|
|
|
// LearnDMRoom records a room the user messaged the bot in as their DM room,
|
|
// when it really is a two-person room. This adopts DM rooms that predate the
|
|
// database mapping instead of leaving them orphaned beside a freshly created one.
|
|
func (b *Base) LearnDMRoom(userID id.UserID, roomID id.RoomID) {
|
|
if b == nil || b.Client == nil {
|
|
return
|
|
}
|
|
|
|
dmCacheMu.Lock()
|
|
_, known := dmCache[userID]
|
|
mapped := dmMapped[userID]
|
|
notDM := notDMCache[roomID]
|
|
dmCacheMu.Unlock()
|
|
if known || mapped || notDM {
|
|
return
|
|
}
|
|
if _, ok := storedDMRoom(userID); ok {
|
|
// Note it as mapped, not as resolved: caching the room here would let
|
|
// GetDMRoom skip its liveness check on a possibly-stale room.
|
|
dmCacheMu.Lock()
|
|
dmMapped[userID] = true
|
|
dmCacheMu.Unlock()
|
|
return
|
|
}
|
|
|
|
members, err := b.Client.JoinedMembers(context.Background(), roomID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, botIn := members.Joined[b.Client.UserID]
|
|
_, userIn := members.Joined[userID]
|
|
if len(members.Joined) != 2 || !botIn || !userIn {
|
|
// Group room — remember that so every later message here is free.
|
|
dmCacheMu.Lock()
|
|
notDMCache[roomID] = true
|
|
dmCacheMu.Unlock()
|
|
return
|
|
}
|
|
|
|
slog.Info("adopted existing DM room", "user", userID, "room", roomID)
|
|
rememberDMRoom(userID, roomID)
|
|
}
|
|
|
|
// findExistingDMRoom scans the bot's joined rooms for a two-person room shared
|
|
// with userID. The scan is expensive (one member lookup per room), so it runs
|
|
// at most once per process and only on the path that would otherwise create a
|
|
// duplicate room.
|
|
func (b *Base) findExistingDMRoom(userID id.UserID) (id.RoomID, bool) {
|
|
dmSweepOnce.Do(func() {
|
|
ctx := context.Background()
|
|
joined, err := b.Client.JoinedRooms(ctx)
|
|
if err != nil {
|
|
slog.Warn("DM sweep: list joined rooms", "err", err)
|
|
return
|
|
}
|
|
for _, roomID := range joined.JoinedRooms {
|
|
members, err := b.Client.JoinedMembers(ctx, roomID)
|
|
if err != nil || len(members.Joined) != 2 {
|
|
continue
|
|
}
|
|
if _, ok := members.Joined[b.Client.UserID]; !ok {
|
|
continue
|
|
}
|
|
for member := range members.Joined {
|
|
if member != b.Client.UserID {
|
|
dmSweepIndex[member] = roomID
|
|
}
|
|
}
|
|
}
|
|
slog.Info("DM sweep complete", "rooms", len(joined.JoinedRooms), "dms", len(dmSweepIndex))
|
|
})
|
|
|
|
roomID, ok := dmSweepIndex[userID]
|
|
return roomID, ok
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Persisted mapping — the authoritative store across restarts.
|
|
if roomID, ok := storedDMRoom(userID); ok {
|
|
if b.dmRoomUsable(roomID, userID) {
|
|
dmCacheMu.Lock()
|
|
dmCache[userID] = roomID
|
|
dmCacheMu.Unlock()
|
|
return roomID, nil
|
|
}
|
|
slog.Info("stored DM room no longer usable, recreating", "user", userID, "room", roomID)
|
|
forgetDMRoom(userID)
|
|
}
|
|
|
|
// 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 {
|
|
for i := len(dmRooms[userID]) - 1; i >= 0; i-- {
|
|
roomID := dmRooms[userID][i] // most recent first
|
|
if b.dmRoomUsable(roomID, userID) {
|
|
rememberDMRoom(userID, roomID)
|
|
return roomID, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last resort before creating: sweep the rooms the bot is already in for a
|
|
// two-person room shared with this user. Users who predate the dm_rooms
|
|
// table have such a room and nothing pointing at it — without this sweep
|
|
// their first post-upgrade DM would open yet another duplicate. If several
|
|
// duplicates exist we cannot tell which is liveliest (no /sync, so no
|
|
// timestamps), so the newest by room-list order wins.
|
|
if roomID, ok := b.findExistingDMRoom(userID); ok {
|
|
slog.Info("recovered pre-existing DM room by member sweep", "user", userID, "room", roomID)
|
|
rememberDMRoom(userID, roomID)
|
|
b.publishDirect(userID, roomID)
|
|
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)
|
|
}
|
|
|
|
rememberDMRoom(userID, resp.RoomID)
|
|
b.publishDirect(userID, resp.RoomID)
|
|
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.Sink != nil {
|
|
_, err := b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
|
|
return err
|
|
}
|
|
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) {
|
|
if b != nil && b.Sink != nil {
|
|
return b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
|
|
}
|
|
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()
|
|
}()
|
|
}
|