Files
gogobee/internal/plugin/presence.go
prosolis 9aca6b202d Phase D: presence intent comment + sendZoneCombatMessages contract
D1: single block comment above the presence.go best-effort QueryRow
cluster (lines 149-198). Zero-value fallbacks are correct for new users
with no activity rows yet.

D2: contract comment on sendZoneCombatMessages — returns a done channel,
callers chaining post-flush work MUST consume it; in-flow handlers with
nothing to chain MUST explicitly discard. Blocking on flush would stall
command handlers behind the 2-3s/message pacing, defeating streaming.
Two fire-and-forget sites (adventure_arena.go round-advance,
dnd_zone_cmd.go streamFlow) now use `_ =` + comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:03:42 -07:00

251 lines
7.2 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
)
// PresencePlugin handles away status and user profile lookups.
type PresencePlugin struct {
Base
}
// NewPresencePlugin creates a new PresencePlugin.
func NewPresencePlugin(client *mautrix.Client) *PresencePlugin {
return &PresencePlugin{
Base: NewBase(client),
}
}
func (p *PresencePlugin) Name() string { return "presence" }
func (p *PresencePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "away", Description: "Set away status", Usage: "!away [message]", Category: "Personal"},
{Name: "afk", Description: "Set away status (alias)", Usage: "!afk [message]", Category: "Personal"},
{Name: "back", Description: "Clear away status", Usage: "!back", Category: "Personal"},
{Name: "whois", Description: "Show user profile card", Usage: "!whois @user", Category: "Personal"},
}
}
func (p *PresencePlugin) Init() error { return nil }
func (p *PresencePlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *PresencePlugin) OnMessage(ctx MessageContext) error {
switch {
case p.IsCommand(ctx.Body, "away"):
return p.handleAway(ctx, p.GetArgs(ctx.Body, "away"))
case p.IsCommand(ctx.Body, "afk"):
return p.handleAway(ctx, p.GetArgs(ctx.Body, "afk"))
case p.IsCommand(ctx.Body, "back"):
return p.handleBack(ctx)
case p.IsCommand(ctx.Body, "whois"):
return p.handleWhois(ctx)
default:
// Auto-clear away status on non-command messages
return p.autoClearAway(ctx)
}
}
func (p *PresencePlugin) handleAway(ctx MessageContext, message string) error {
if message == "" {
message = "Away"
}
now := time.Now().Unix()
_, err := db.Get().Exec(
`INSERT INTO presence (user_id, status, message, updated_at)
VALUES (?, 'away', ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
status = 'away',
message = ?,
updated_at = ?`,
string(ctx.Sender), message, now, message, now,
)
if err != nil {
slog.Error("presence: set away", "err", err)
return p.SendMessage(ctx.RoomID, "Failed to set away status.")
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("💤 %s is now away: %s", ctx.Sender, message))
}
func (p *PresencePlugin) handleBack(ctx MessageContext) error {
result, err := db.Get().Exec(
`UPDATE presence SET status = 'online', message = '', updated_at = ?
WHERE user_id = ? AND status = 'away'`,
time.Now().Unix(), string(ctx.Sender),
)
if err != nil {
slog.Error("presence: set back", "err", err)
return p.SendMessage(ctx.RoomID, "Failed to clear away status.")
}
affected, _ := result.RowsAffected()
if affected == 0 {
return p.SendMessage(ctx.RoomID, "You weren't marked as away.")
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("👋 Welcome back, %s!", ctx.Sender))
}
func (p *PresencePlugin) autoClearAway(ctx MessageContext) error {
// Check if user is away
var status string
err := db.Get().QueryRow(
`SELECT status FROM presence WHERE user_id = ?`,
string(ctx.Sender),
).Scan(&status)
if err != nil || status != "away" {
return nil
}
// Clear away status
_, err = db.Get().Exec(
`UPDATE presence SET status = 'online', message = '', updated_at = ?
WHERE user_id = ?`,
time.Now().Unix(), string(ctx.Sender),
)
if err != nil {
slog.Error("presence: auto-clear away", "err", err)
return nil
}
return p.SendMessage(ctx.RoomID, fmt.Sprintf("👋 Welcome back, %s! (auto-cleared away status)", ctx.Sender))
}
func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "whois"))
if args == "" {
return p.SendMessage(ctx.RoomID, "Usage: !whois <user>")
}
targetUser, ok := p.ResolveUser(args, ctx.RoomID)
if !ok {
return p.SendMessage(ctx.RoomID, "Could not find a user matching that name.")
}
// Gather profile data
var displayName string
var xp, level int
err := db.Get().QueryRow(
`SELECT display_name, xp, level FROM users WHERE user_id = ?`,
string(targetUser),
).Scan(&displayName, &xp, &level)
if err != nil {
displayName = string(targetUser)
}
// best-effort: activity tracking is non-critical; missing rows are
// expected for new users. The cluster of `_ = QueryRow(...).Scan(...)`
// calls below all share this rationale — zero values are the correct
// fallback (no messages yet, no streak, no rep, no presence row).
// Get stats
var totalMessages, totalWords int
_ = db.Get().QueryRow(
`SELECT total_messages, total_words FROM user_stats WHERE user_id = ?`,
string(targetUser),
).Scan(&totalMessages, &totalWords)
// Get streak
var currentStreak int
today := time.Now().Format("2006-01-02")
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
var countToday, countYesterday int
_ = db.Get().QueryRow(
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
string(targetUser), today,
).Scan(&countToday)
_ = db.Get().QueryRow(
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
string(targetUser), yesterday,
).Scan(&countYesterday)
if countToday > 0 || countYesterday > 0 {
// Count consecutive days backward
currentStreak = 0
checkDate := time.Now()
for {
dateStr := checkDate.Format("2006-01-02")
var cnt int
err := db.Get().QueryRow(
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
string(targetUser), dateStr,
).Scan(&cnt)
if err != nil || cnt == 0 {
break
}
currentStreak++
checkDate = checkDate.AddDate(0, 0, -1)
}
}
// Get reputation (from XP log with reason = 'reputation')
var repXP int
_ = db.Get().QueryRow(
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
string(targetUser),
).Scan(&repXP)
repCount := repXP / 5
// Get presence status
var status, statusMsg string
_ = db.Get().QueryRow(
`SELECT status, message FROM presence WHERE user_id = ?`,
string(targetUser),
).Scan(&status, &statusMsg)
// Get birthday
var bdayMonth, bdayDay int
var timezone string
hasBirthday := false
err = db.Get().QueryRow(
`SELECT month, day, timezone FROM birthdays WHERE user_id = ?`,
string(targetUser),
).Scan(&bdayMonth, &bdayDay, &timezone)
if err == nil {
hasBirthday = true
}
if timezone == "" {
timezone = "Not set"
}
// Build profile card
var sb strings.Builder
sb.WriteString(fmt.Sprintf("👤 **Profile: %s**\n", displayName))
sb.WriteString(fmt.Sprintf(" ID: %s\n", targetUser))
sb.WriteString(fmt.Sprintf(" Level: %d (XP: %d)\n", level, xp))
sb.WriteString(fmt.Sprintf(" Messages: %d | Words: %d\n", totalMessages, totalWords))
sb.WriteString(fmt.Sprintf(" Rep: +%d\n", repCount))
sb.WriteString(fmt.Sprintf(" Streak: %d days\n", currentStreak))
sb.WriteString(fmt.Sprintf(" Timezone: %s\n", timezone))
if hasBirthday {
sb.WriteString(fmt.Sprintf(" Birthday: %s %d\n", time.Month(bdayMonth).String(), bdayDay))
}
if status == "away" {
sb.WriteString(fmt.Sprintf(" Status: 💤 Away — %s\n", statusMsg))
} else {
sb.WriteString(" Status: 🟢 Online\n")
}
// Archetypes
archetypes := GetUserArchetypesLimited(string(targetUser))
if len(archetypes) > 0 && archetypes[0].Name != "Regular" {
sb.WriteString(fmt.Sprintf("\n🎭 %s\n", FormatArchetypeNames(archetypes)))
}
return p.SendMessage(ctx.RoomID, sb.String())
}