diff --git a/.env.example b/.env.example index 0a64101..8e19eb0 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,9 @@ FEATURE_TRIVIA=true # set to "false" to disable trivia FEATURE_ESTEEMED= # set to anything to enable satirical esteemed member posts ESTEEMED_ROOM= # room ID for esteemed member posts (separate from broadcast rooms) +# ---- Space Groups (automatic room grouping for community-wide leaderboards) ---- +SPACE_GROUP_THRESHOLD=50 # % of smaller room's members that must overlap (1-100, default 50) + # ---- Missing Members (!haveyouseenthem / !missing) ---- MISSING_THRESHOLD_DAYS=14 # days of inactivity before considered missing MISSING_MAX_DAYS=90 # days after which they're considered gone, not missing diff --git a/README.md b/README.md index 941f2ac..c34d6cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GogoBee -Matrix community bot with E2EE, 35+ plugins, passive tracking, scheduled posts, and optional LLM features. +Matrix community bot with E2EE, 37 plugins, passive tracking, scheduled posts, and optional LLM features. Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage. @@ -29,10 +29,12 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a - **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device. - **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want. -- **35+ plugins** with dependency injection and ordered registration +- **37 plugins** with dependency injection and ordered registration - **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently -- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - WOTD, holidays, game releases, birthdays, anime/movie releases, concert digests -- **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, conversation summaries +- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - WOTD, holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members +- **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries +- **Encrypted quote wall** - AES-256-GCM encrypted quotes at rest, reply-to-save, search, leaderboard +- **Space groups** - automatic room grouping via membership overlap. Leaderboards, stats, and trivia scores span all rooms in a group. No Matrix Spaces API needed — the bot infers community boundaries from shared members. - **SQLite everything** - one file, no external database needed --- @@ -108,6 +110,7 @@ Everything is configured through environment variables or a `.env` file. | `FINNHUB_API_KEY` | [Finnhub](https://finnhub.io) | `!stock` | | `BANDSINTOWN_API_KEY` | [Bandsintown](https://artists.bandsintown.com) | `!concerts` | | `TMDB_API_KEY` | [TMDB](https://www.themoviedb.org/documentation/api) | `!movie`, `!tv`, `!upcoming` | +| `SERPAPI_KEY` | [SerpAPI](https://serpapi.com) | Esteemed member image fetching | ### Services (optional) @@ -116,6 +119,13 @@ Everything is configured through environment variables or a `.env` file. | `OLLAMA_HOST` | Ollama server URL, e.g. `http://localhost:11434` | | `OLLAMA_MODEL` | Model name, e.g. `llama3.2` | | `LIBRETRANSLATE_URL` | LibreTranslate instance for `!translate` | +| `LLM_SAMPLE_RATE` | Fraction of messages to classify (0.0–1.0, default `0.15`) | + +### Encryption + +| Variable | Description | +|----------|-------------| +| `QUOTE_ENCRYPTION_KEY` | Base64-encoded 32-byte AES-256 key for encrypted quote storage. Generate with `openssl rand -base64 32`. If unset, `!quote` is disabled. | ### Feature Flags @@ -123,6 +133,20 @@ Everything is configured through environment variables or a `.env` file. |----------|-------------| | `FEATURE_URL_PREVIEW` | Set to anything to enable automatic URL previews | | `FEATURE_SHADE` | Set to anything to enable the shade plugin (stub) | +| `FEATURE_TRIVIA` | Set to `false` to disable trivia (default: enabled) | +| `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts | +| `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) | + +### Missing Members + +| Variable | Default | Description | +|----------|---------|-------------| +### Space Groups + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPACE_GROUP_THRESHOLD` | `50` | Percentage of the smaller room's members that must overlap to group rooms together (1–100) | +| `HOLIDAY_COUNTRIES` | `US` | Comma-separated ISO country codes for Calendarific holiday posts | ### Missing Members @@ -222,7 +246,7 @@ Rep is earned when someone thanks you. The bot detects this automatically. | `!roll [NdM+X]` | Dice (default 1d6) | | `!8ball ` | Magic 8-ball | | `!coin` | Coin flip | -| `!time [city]` | World clock | +| `!time [city\|@user]` | World clock (cities or user's timezone) | | `!hltb ` | How Long To Beat | | `!twinbee` | Twinbee series lore | | `!poll \| \| ...` | Reaction poll | @@ -267,7 +291,6 @@ Rep is earned when someone thanks you. The bot detects this automatically. | `!mytz` | Your timezone and current time | | `!timezone list` | Common timezones | | `!np [track]` | Now playing | -| `!quote` | Random saved quote | | `!backlog add\|list\|random\|done` | Personal backlog | | `!watch ` | DM alerts for a keyword | | `!watching` | List keyword watches | @@ -289,10 +312,17 @@ Rep is earned when someone thanks you. The bot detects this automatically. | `!birthday remove` | Remove birthday | | `!birthday show` | Show yours | | `!birthdays` | Upcoming (next 30 days) | +| `!horoscope` | Daily horoscope (requires birthday to be set) | ### Community | Command | Description | |---------|-------------| +| `!quote` | Random quote (or reply to a message to save it) | +| `!quote @user` | Random quote from a specific user | +| `!quote search ` | Search quotes | +| `!quote "text" -- @user` | Manually save a quote attributed to a user | +| `!quote delete ` | Delete a quote (admin only) | +| `!quoteboard` | Top 5 most-quoted members | | `!missing` | List members who haven't posted recently | | `!missing post [@user]` | Generate a milk carton poster for the longest-absent (or specified) member | | `!haveyouseenthem @user` | Generate a milk carton missing poster for a user | @@ -308,6 +338,8 @@ Rep is earned when someone thanks you. The bot detects this automatically. | `!pottyboard` | Profanity leaderboard | | `!insults [@user]` | Insult stats | | `!insultboard` | Insult leaderboard | +| `!tarot [@user]` | Draw a tarot card + LLM reading | +| `!tarotspread [@user]` | Three-card spread (Past/Present/Future) | ### Other | Command | Description | @@ -334,9 +366,9 @@ All of these run in the background without any commands: - **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages - **URL previews** - OG tag extraction (feature-flagged, off by default) - **Reactions** - logs all reactions for `!emojiboard` +- **Space groups** - rooms with sufficient member overlap are automatically grouped. Leaderboards, trivia scores, and other per-room features aggregate across the group. Recomputed hourly; persisted to SQLite. Uses strict clique-based grouping (every room must meet the threshold with every other room in the group). - **LLM classification** - sentiment (10 categories), profanity, insults, WOTD usage (needs Ollama) -- **Message buffer** - last 50 messages per room held in memory for `!vibe` and `!tldr`. Not persisted to disk; resets on restart. -- **Quotes** - star-react any message to save it +- **Message buffer** - last 50 messages per room held in memory for `!vibe` and `!tldr`. Not persisted to disk; resets on restart. Uptime reported when insufficient messages are buffered. --- @@ -354,7 +386,9 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC. | 10:00 | Anime | Anime airing today | | 11:00 | Movies | Movie releases today | | 12:00 Sun | Concerts | Weekly concert digest | +| 13:00 Wed/Sun | Esteemed | Satirical esteemed community member posts (feature-flagged) | | Every 30s | Reminders | Fires pending reminders | +| Hourly | Space groups | Refreshes room membership overlap and group mappings | | 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs; runs SQLite optimize | --- @@ -437,8 +471,10 @@ All optional. The bot works fine without any of them, you just won't have those | [Free Dictionary](https://dictionaryapi.dev) | Yes, no key | Definitions | | [Urban Dictionary](https://rapidapi.com/community/api/urban-dictionary) | Yes, no key | Slang | | [icanhazdadjoke](https://icanhazdadjoke.com) | Yes, no key | Dad jokes | +| [SerpAPI](https://serpapi.com) | Free (100/mo) | Image search for esteemed members | +| [HowLongToBeat](https://howlongtobeat.com) | Yes, no key | Game completion times | | [LibreTranslate](https://libretranslate.com) | Self-host | Translation | -| [Ollama](https://ollama.ai) | Self-host | LLM features | +| [Ollama](https://ollama.ai) | Self-host | LLM features (sentiment, tarot, vibes, roasts) | --- @@ -452,6 +488,8 @@ gogobee/ │ ├── bot/ │ │ ├── client.go # mautrix client + E2EE (cryptohelper + goolm) │ │ └── dispatch.go # Plugin registry, event dispatch +│ ├── crypto/ +│ │ └── crypto.go # AES-256-GCM encryption, HMAC-SHA256 │ ├── db/ │ │ └── db.go # SQLite schema (40+ tables), migrations │ ├── plugin/ @@ -488,6 +526,10 @@ gogobee/ │ │ ├── vibe.go # Room vibe, TLDR │ │ ├── shade.go # Stub │ │ ├── milkcarton.go # Missing member milk carton posters +│ │ ├── quotewall.go # Encrypted quote wall (AES-256-GCM) +│ │ ├── tarot.go # LLM-powered tarot readings +│ │ ├── horoscope.go # Daily horoscopes +│ │ ├── esteemed.go # Satirical esteemed member posts │ │ └── ratelimits.go # Rate limiting │ └── util/ │ ├── auth.go # Matrix login, token check @@ -511,7 +553,7 @@ gogobee/ Single SQLite file at `$DATA_DIR/gogobee.db`. Schema auto-creates on first run. WAL mode enabled. -40+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, quotes, backlog, keyword watches, scheduler config, birthdays, LLM classifications, stocks, concerts, anime, movies, countdowns, presence, markov corpus, reaction log, and various caches. +40+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, encrypted quotes (AES-256-GCM), backlog, keyword watches, scheduler config, birthdays, horoscopes, LLM classifications, stocks, concerts, anime, movies, countdowns, presence, markov corpus, reaction log, and various caches. ### Backup diff --git a/internal/db/db.go b/internal/db/db.go index 251499e..b0b269b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -621,6 +621,13 @@ CREATE TABLE IF NOT EXISTS api_cache ( cached_at INTEGER DEFAULT (unixepoch()) ); +-- Space groups (rooms with overlapping membership) +CREATE TABLE IF NOT EXISTS space_groups ( + room_id TEXT PRIMARY KEY, + group_id INTEGER NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/fun.go b/internal/plugin/fun.go index f930369..20fe65b 100644 --- a/internal/plugin/fun.go +++ b/internal/plugin/fun.go @@ -1,6 +1,7 @@ package plugin import ( + "bytes" "encoding/json" "fmt" "io" @@ -329,11 +330,73 @@ func (p *FunPlugin) handleHLTB(ctx MessageContext) error { return p.SendMessage(ctx.RoomID, result) } -func scrapeHLTB(gameName string) (string, error) { - payload := fmt.Sprintf(`{"searchType":"games","searchTerms":["%s"],"searchPage":1,"size":1,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","subGenre":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0}}`, - strings.ReplaceAll(gameName, `"`, `\"`)) +// hltbFetchToken gets a short-lived auth token from the HLTB finder init endpoint. +func hltbFetchToken() (string, error) { + client := &http.Client{Timeout: 5 * time.Second} + url := fmt.Sprintf("https://howlongtobeat.com/api/finder/init?t=%d", time.Now().UnixMilli()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36") + req.Header.Set("Referer", "https://howlongtobeat.com") + req.Header.Set("Origin", "https://howlongtobeat.com") - req, err := http.NewRequest("POST", "https://howlongtobeat.com/api/search", strings.NewReader(payload)) + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if result.Token == "" { + return "", fmt.Errorf("empty token from HLTB") + } + return result.Token, nil +} + +func scrapeHLTB(gameName string) (string, error) { + token, err := hltbFetchToken() + if err != nil { + return "", fmt.Errorf("hltb token: %w", err) + } + + payload := map[string]interface{}{ + "searchType": "games", + "searchTerms": []string{gameName}, + "searchPage": 1, + "size": 1, + "searchOptions": map[string]interface{}{ + "games": map[string]interface{}{ + "userId": 0, + "platform": "", + "sortCategory": "popular", + "rangeCategory": "main", + "rangeTime": map[string]interface{}{"min": nil, "max": nil}, + "gameplay": map[string]interface{}{"perspective": "", "flow": "", "genre": "", "subGenre": "", "difficulty": ""}, + "rangeYear": map[string]interface{}{"min": "", "max": ""}, + "modifier": "", + }, + "users": map[string]interface{}{"sortCategory": "postcount"}, + "lists": map[string]interface{}{"sortCategory": "follows"}, + "filter": "", + "sort": 0, + "randomizer": 0, + }, + "useCache": true, + } + + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", "https://howlongtobeat.com/api/finder", bytes.NewReader(data)) if err != nil { return "", err } @@ -342,6 +405,7 @@ func scrapeHLTB(gameName string) (string, error) { req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36") req.Header.Set("Referer", "https://howlongtobeat.com") req.Header.Set("Origin", "https://howlongtobeat.com") + req.Header.Set("x-auth-token", token) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) @@ -355,14 +419,18 @@ func scrapeHLTB(gameName string) (string, error) { return "", err } + if resp.StatusCode != 200 { + return "", fmt.Errorf("hltb HTTP %d", resp.StatusCode) + } + var result struct { Data []struct { - GameName string `json:"game_name"` - CompMain float64 `json:"comp_main"` - CompPlus float64 `json:"comp_plus"` - CompAll float64 `json:"comp_100"` - ProfileDev string `json:"profile_dev"` - ReleaseWorld int `json:"release_world"` + GameName string `json:"game_name"` + CompMain float64 `json:"comp_main"` + CompPlus float64 `json:"comp_plus"` + CompAll float64 `json:"comp_100"` + ProfileDev string `json:"profile_dev"` + ReleaseWorld int `json:"release_world"` } `json:"data"` } @@ -388,7 +456,7 @@ func scrapeHLTB(gameName string) (string, error) { } var sb strings.Builder - sb.WriteString(fmt.Sprintf("🎮 **%s**\n", game.GameName)) + sb.WriteString(fmt.Sprintf("\U0001f3ae **%s**\n", game.GameName)) sb.WriteString(fmt.Sprintf(" Main Story: %s\n", formatTime(game.CompMain))) sb.WriteString(fmt.Sprintf(" Main + Extras: %s\n", formatTime(game.CompPlus))) sb.WriteString(fmt.Sprintf(" Completionist: %s\n", formatTime(game.CompAll))) diff --git a/internal/plugin/howami.go b/internal/plugin/howami.go index 741d2df..056109d 100644 --- a/internal/plugin/howami.go +++ b/internal/plugin/howami.go @@ -184,6 +184,9 @@ func callOllama(host, model, prompt string) (string, error) { "model": model, "prompt": prompt, "stream": false, + "options": map[string]interface{}{ + "num_ctx": 8192, + }, } data, err := json.Marshal(payload) diff --git a/internal/plugin/movies.go b/internal/plugin/movies.go index cee8799..892fc60 100644 --- a/internal/plugin/movies.go +++ b/internal/plugin/movies.go @@ -110,7 +110,7 @@ func (p *MoviesPlugin) Commands() []CommandDef { {Name: "movie watch", Description: "Add a movie to your watchlist", Usage: "!movie watch ", Category: "Entertainment"}, {Name: "movie watching", Description: "List your movie watchlist", Usage: "!movie watching", Category: "Entertainment"}, {Name: "movie unwatch", Description: "Remove from watchlist by TMDB ID", Usage: "!movie unwatch <id>", Category: "Entertainment"}, - {Name: "upcoming movies", Description: "Show upcoming movies", Usage: "!upcoming movies", Category: "Entertainment"}, + {Name: "upcoming", Description: "Show upcoming movies", Usage: "!upcoming movies", Category: "Entertainment"}, } } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 1fbdc02..8835807 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -5,7 +5,9 @@ import ( "fmt" "log/slog" "os" + "strconv" "strings" + "sync" "gogobee/internal/db" "gogobee/internal/util" @@ -88,9 +90,16 @@ func (b *Base) IsAdmin(userID id.UserID) bool { return false } -// RoomMembers returns the set of user IDs in a room. Used to scope leaderboards -// to only show users present in the room where the command was issued. +// 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) @@ -103,6 +112,246 @@ func (b *Base) RoomMembers(roomID id.RoomID) map[id.UserID]bool { 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 + 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 + } + 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 + } + // 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) + tx.Rollback() + 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) + tx.Rollback() + 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. diff --git a/internal/plugin/quotewall.go b/internal/plugin/quotewall.go index 1334f7b..f951049 100644 --- a/internal/plugin/quotewall.go +++ b/internal/plugin/quotewall.go @@ -15,6 +15,7 @@ import ( "gogobee/internal/db" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -89,12 +90,13 @@ func (p *QuoteWallPlugin) handleQuote(ctx MessageContext) error { } // !quote delete [id] - if strings.HasPrefix(strings.ToLower(args), "delete") { + argsLower := strings.ToLower(args) + if argsLower == "delete" || strings.HasPrefix(argsLower, "delete ") { return p.handleQuoteDelete(ctx, strings.TrimSpace(args[6:])) } // !quote search [keyword] - if strings.HasPrefix(strings.ToLower(args), "search") { + if argsLower == "search" || strings.HasPrefix(argsLower, "search ") { keyword := strings.TrimSpace(args[6:]) return p.handleQuoteSearch(ctx, keyword) } @@ -125,10 +127,27 @@ func (p *QuoteWallPlugin) handleQuoteSaveReply(ctx MessageContext, replyEventID return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch the original message.") } - err = evt.Content.ParseRaw(evt.Type) - if err != nil { - slog.Error("quotewall: parse reply event content", "err", err) - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse the original message.") + // Decrypt if the event is encrypted + if evt.Type == event.EventEncrypted { + if p.Client.Crypto != nil { + if parseErr := evt.Content.ParseRaw(evt.Type); parseErr != nil { + slog.Error("quotewall: parse encrypted event", "err", parseErr) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse the encrypted message.") + } + decrypted, decErr := p.Client.Crypto.Decrypt(context.Background(), evt) + if decErr != nil { + slog.Error("quotewall: decrypt reply event", "err", decErr) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to decrypt the original message.") + } + evt = decrypted + } else { + return p.SendReply(ctx.RoomID, ctx.EventID, "Cannot read encrypted messages without crypto support.") + } + } else { + if parseErr := evt.Content.ParseRaw(evt.Type); parseErr != nil { + slog.Error("quotewall: parse reply event content", "err", parseErr) + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse the original message.") + } } msgContent := evt.Content.AsMessage() diff --git a/internal/plugin/tarot.go b/internal/plugin/tarot.go index 6108ba5..38b15bf 100644 --- a/internal/plugin/tarot.go +++ b/internal/plugin/tarot.go @@ -38,15 +38,29 @@ var tarotDeck = []string{ "Page of Pentacles", "Knight of Pentacles", "Queen of Pentacles", "King of Pentacles", } -const tarotSystemPrompt = `You are a tarot reader of extraordinary accuracy and absolutely no sympathy. +const tarotBasePrompt = `You are a tarot reader of extraordinary accuracy and absolutely no sympathy. You deliver readings with the warm professionalism of a customer service representative who has fully accepted that everyone who consults you is, in some specific and personal way, a disappointment. Your readings are detailed, specific, and correct. They are also mean. Not vague mean -- specific mean. You will compliment one thing and immediately undermine it. You will predict great fortune followed by a precise and avoidable tragedy. The tragedy should always be mundane, specific, and disproportionate to the fortune. You close every -reading politely. Under 5 sentences. Do not explain the card. Do not break character. -The querent asked for this.` +reading politely. Do not break character. The querent asked for this. +Do NOT interpret the card's traditional meaning. The card name is a prop. +The reading should feel like it could apply to anyone and was generated +specifically to ruin their day in a cheerful, efficient manner. +The tragedy must be physical, mundane, and stupid. +Emotional tragedies are not funny enough.` + +const tarotSingleSuffix = `Under 5 sentences. Do not explain the card.` + +const tarotSpreadSuffix = `This is a three-card spread. Each card represents a phase of the +querent's timeline: what got them here, what's happening now, and what's coming. +Dedicate a short paragraph to each card. The past should establish a pattern of behavior +that makes the present inevitable. The present should be a false peak -- things look good, +or at least survivable. The future should undo everything with something preventable, +physical, and profoundly stupid. Build momentum across all three so the final tragedy +lands harder. Around 15-20 sentences total. Close with a polite farewell.` const tarotFewShot = `Example 1: Card: The Sun @@ -135,7 +149,7 @@ func (p *TarotPlugin) handleTarot(ctx MessageContext) error { if sign := lookupZodiac(readingFor); sign != "" { extraLines += fmt.Sprintf("\nQuerent's zodiac sign: %s", sign) } - prompt := fmt.Sprintf("%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotSystemPrompt, tarotFewShot, card, extraLines) + prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotBasePrompt, tarotSingleSuffix, tarotFewShot, card, extraLines) response, err := callOllama(ollamaHost, ollamaModel, prompt) if err != nil { @@ -185,8 +199,8 @@ func (p *TarotPlugin) handleSpread(ctx MessageContext) error { if sign := lookupZodiac(readingFor); sign != "" { extraLines += fmt.Sprintf("\nQuerent's zodiac sign: %s", sign) } - prompt := fmt.Sprintf("%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive a unified reading that weaves all three cards together.", - tarotSystemPrompt, tarotFewShot, cards[0], cards[1], cards[2], extraLines) + prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive the reading.", + tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines) response, err := callOllama(ollamaHost, ollamaModel, prompt) if err != nil { diff --git a/internal/plugin/user.go b/internal/plugin/user.go index f1d3caa..1480b88 100644 --- a/internal/plugin/user.go +++ b/internal/plugin/user.go @@ -1,7 +1,6 @@ package plugin import ( - "context" "database/sql" "fmt" "log/slog" @@ -33,7 +32,6 @@ func (p *UserPlugin) Commands() []CommandDef { {Name: "settz", Description: "Set your timezone (IANA format)", Usage: "!settz America/New_York", Category: "Personal"}, {Name: "mytz", Description: "Show your timezone and current time", Usage: "!mytz", Category: "Personal"}, {Name: "timezone", Description: "List common timezones", Usage: "!timezone list", Category: "Personal"}, - {Name: "quote", Description: "Show a random saved quote from this room", Usage: "!quote", Category: "Personal"}, {Name: "np", Description: "Set or show now playing", Usage: "!np [track]", Category: "Personal"}, {Name: "backlog", Description: "Manage your personal backlog", Usage: "!backlog add/list/random/done", Category: "Personal"}, {Name: "watch", Description: "Watch a keyword for DM alerts", Usage: "!watch <keyword>", Category: "Personal"}, @@ -44,40 +42,7 @@ func (p *UserPlugin) Commands() []CommandDef { func (p *UserPlugin) Init() error { return nil } -func (p *UserPlugin) OnReaction(ctx ReactionContext) error { - if ctx.Emoji != "\u2B50" { // ⭐ - return nil - } - - // Fetch the target event to get the quoted message - evt, err := p.Client.GetEvent(context.Background(), ctx.RoomID, ctx.TargetEvent) - if err != nil { - slog.Error("user: fetch event for quote", "err", err) - return nil - } - - if err := evt.Content.ParseRaw(evt.Type); err != nil { - slog.Error("user: parse event content for quote", "err", err) - return nil - } - - content := evt.Content.AsMessage() - if content == nil || content.Body == "" { - return nil - } - - d := db.Get() - _, err = d.Exec( - `INSERT INTO quotes (room_id, user_id, quote_text, saved_by) VALUES (?, ?, ?, ?)`, - string(ctx.RoomID), string(evt.Sender), content.Body, string(ctx.Sender), - ) - if err != nil { - slog.Error("user: save quote", "err", err) - return nil - } - - return p.SendReact(ctx.RoomID, ctx.EventID, "\u2705") // ✅ -} +func (p *UserPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *UserPlugin) OnMessage(ctx MessageContext) error { switch { @@ -87,8 +52,6 @@ func (p *UserPlugin) OnMessage(ctx MessageContext) error { return p.handleMyTZ(ctx) case p.IsCommand(ctx.Body, "timezone"): return p.handleTimezoneList(ctx) - case p.IsCommand(ctx.Body, "quote"): - return p.handleQuote(ctx) case p.IsCommand(ctx.Body, "np"): return p.handleNP(ctx) case p.IsCommand(ctx.Body, "backlog"): @@ -182,25 +145,6 @@ func (p *UserPlugin) handleTimezoneList(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } -func (p *UserPlugin) handleQuote(ctx MessageContext) error { - d := db.Get() - var quoteText, userID string - err := d.QueryRow( - `SELECT quote_text, user_id FROM quotes WHERE room_id = ? ORDER BY RANDOM() LIMIT 1`, - string(ctx.RoomID), - ).Scan("eText, &userID) - if err == sql.ErrNoRows { - return p.SendReply(ctx.RoomID, ctx.EventID, "No quotes saved in this room yet. React with a star to save one!") - } - if err != nil { - slog.Error("user: random quote", "err", err) - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch a quote.") - } - - msg := fmt.Sprintf("\"%s\"\n — %s", quoteText, userID) - return p.SendReply(ctx.RoomID, ctx.EventID, msg) -} - func (p *UserPlugin) handleNP(ctx MessageContext) error { args := strings.TrimSpace(p.GetArgs(ctx.Body, "np")) d := db.Get() diff --git a/internal/plugin/welcome.go b/internal/plugin/welcome.go index 63b1512..b0baeb1 100644 --- a/internal/plugin/welcome.go +++ b/internal/plugin/welcome.go @@ -118,6 +118,7 @@ var categoryOrder = []string{ "LLM & Sentiment", "Personal", "Holidays", + "Community", "Reactions", "Info", } @@ -131,6 +132,7 @@ var categoryEmojis = map[string]string{ "LLM & Sentiment": "🧠", "Personal": "👤", "Holidays": "🎉", + "Community": "💬", "Reactions": "😎", "Info": "ℹ️", "Admin": "🔧", diff --git a/main.go b/main.go index b6c5af8..f6359eb 100644 --- a/main.go +++ b/main.go @@ -147,6 +147,9 @@ func main() { os.Exit(1) } + // Initialize space groups (room overlap detection for community-wide leaderboards) + plugin.InitSpaceGroups(client) + // ---- Set up event handlers ---- syncer := client.Syncer.(*mautrix.DefaultSyncer) @@ -179,6 +182,12 @@ func main() { } body := content.Body + // Strip Matrix reply fallback: "> <@user:server> ..." lines followed by blank line + if strings.HasPrefix(body, "> <@") || strings.HasPrefix(body, "> * <@") { + if idx := strings.Index(body, "\n\n"); idx >= 0 { + body = strings.TrimSpace(body[idx+2:]) + } + } msgCtx := plugin.MessageContext{ RoomID: evt.RoomID, EventID: evt.ID, @@ -216,7 +225,7 @@ func main() { // ---- Set up cron scheduler ---- scheduler := cron.New() - setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, horoscopePlugin) + setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin) scheduler.Start() // ---- Start syncing ---- @@ -252,7 +261,6 @@ func setupScheduledJobs( movies *plugin.MoviesPlugin, concerts *plugin.ConcertsPlugin, esteemed *plugin.EsteemPlugin, - horoscope *plugin.HoroscopePlugin, ) { rooms := getRooms() @@ -271,14 +279,6 @@ func setupScheduledJobs( } }) - // Daily horoscopes at 06:30 - c.AddFunc("30 6 * * *", func() { - slog.Info("scheduler: posting daily horoscopes") - for _, r := range rooms { - horoscope.PostDailyHoroscopes(r) - } - }) - // Holidays at 07:00 c.AddFunc("0 7 * * *", func() { slog.Info("scheduler: posting holidays") @@ -338,6 +338,12 @@ func setupScheduledJobs( esteemed.PostWeekly() }) + // Space groups refresh every hour + c.AddFunc("0 * * * *", func() { + slog.Info("scheduler: refreshing space groups") + plugin.RefreshSpaceGroups() + }) + // Database maintenance at 03:00 daily c.AddFunc("0 3 * * *", func() { slog.Info("scheduler: running database maintenance")