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 } // 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, "$1") // 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] + "" + m[start+1:end] + "" + m[end+1:] }) h = mdCodeRe.ReplaceAllString(h, "$1") h = strings.ReplaceAll(h, "\n", "
\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. func (b *Base) SendDM(userID id.UserID, text string) error { 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() }() }