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 ") } 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()) }