mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add space groups, fix HLTB scraper, fix quote wall E2EE, improve tarot prompts
Space groups: automatic room grouping via membership overlap percentage. Rooms with sufficient shared members are grouped together so leaderboards, trivia scores, and stats aggregate across the community. Uses strict clique-based algorithm (every room must meet threshold with every other room in group). Configurable via SPACE_GROUP_THRESHOLD env var. Persisted to SQLite, recomputed hourly and on startup. HLTB scraper: rewrote for new howlongtobeat.com API (two-step token auth via /api/finder/init + /api/finder endpoint). Quote wall: fix E2EE decrypt flow for reply-to-save (ParseRaw before Decrypt, skip ParseRaw after Decrypt returns pre-parsed event). Fix subcommand matching for delete/search. Remove broken star-reaction handler and old quote handler from user.go. Other fixes: - Strip Matrix reply fallback before command detection (main.go) - Increase Ollama context window to 8192 - Improve tarot spread prompts (~4x longer, narrative arc) - Add "cards are props" instruction to tarot LLM prompt - Fix movies.go CommandDef name mismatch - Add Community category to !help - Remove daily horoscope broadcast cron - Update README with all changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -110,7 +110,7 @@ func (p *MoviesPlugin) Commands() []CommandDef {
|
||||
{Name: "movie watch", Description: "Add a movie to your watchlist", Usage: "!movie watch <title>", 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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": "🔧",
|
||||
|
||||
Reference in New Issue
Block a user