Files
gogobee/internal/plugin/countdown.go
prosolis ddb196aaad Rewrite from TypeScript to Go
Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go.

- E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap
- 35+ plugins with dependency injection and ordered registration
- SQLite storage via modernc.org/sqlite (no CGo)
- Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc.
- Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes
- Threaded trivia, three-tier profanity tracking, WOTD usage verification
- Multi-country holiday support with deduplication
- Quadratic XP curve, configurable bot display name, encrypted DMs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:25:18 -07:00

273 lines
7.3 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
)
var countdownLabelRe = regexp.MustCompile(`"([^"]+)"\s+(\d{4}-\d{2}-\d{2})`)
// CountdownPlugin manages countdown timers to specific dates.
type CountdownPlugin struct {
Base
}
// NewCountdownPlugin creates a new countdown plugin.
func NewCountdownPlugin(client *mautrix.Client) *CountdownPlugin {
return &CountdownPlugin{
Base: NewBase(client),
}
}
func (p *CountdownPlugin) Name() string { return "countdown" }
func (p *CountdownPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "countdown", Description: "Manage countdowns to dates", Usage: "!countdown [add/private/mine/remove/id]", Category: "Personal"},
}
}
func (p *CountdownPlugin) Init() error {
// Auto-complete old countdowns
p.completeOldCountdowns()
return nil
}
func (p *CountdownPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *CountdownPlugin) OnMessage(ctx MessageContext) error {
if !p.IsCommand(ctx.Body, "countdown") {
return nil
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "countdown"))
switch {
case strings.HasPrefix(strings.ToLower(args), "add "):
return p.handleAdd(ctx, args[4:], true)
case strings.HasPrefix(strings.ToLower(args), "private "):
return p.handleAdd(ctx, args[8:], false)
case strings.ToLower(args) == "mine":
return p.handleMine(ctx)
case strings.HasPrefix(strings.ToLower(args), "remove "):
return p.handleRemove(ctx, strings.TrimSpace(args[7:]))
case args == "":
return p.handleList(ctx)
default:
// Try to parse as an ID
if _, err := strconv.Atoi(args); err == nil {
return p.handleShow(ctx, args)
}
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: !countdown add \"<label>\" <YYYY-MM-DD> | private \"<label>\" <YYYY-MM-DD> | mine | remove <id> | [id]")
}
}
func (p *CountdownPlugin) handleAdd(ctx MessageContext, input string, public bool) error {
matches := countdownLabelRe.FindStringSubmatch(input)
if matches == nil {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: !countdown add \"<label>\" <YYYY-MM-DD>")
}
label := matches[1]
dateStr := matches[2]
_, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid date format. Use YYYY-MM-DD.")
}
d := db.Get()
isPublic := 1
if !public {
isPublic = 0
}
_, err = d.Exec(
`INSERT INTO countdowns (user_id, room_id, label, target_date, public) VALUES (?, ?, ?, ?, ?)`,
string(ctx.Sender), string(ctx.RoomID), label, dateStr, isPublic,
)
if err != nil {
slog.Error("countdown: add", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add countdown.")
}
visibility := "public"
if !public {
visibility = "private"
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Countdown added (%s): \"%s\" on %s", visibility, label, dateStr))
}
func (p *CountdownPlugin) handleMine(ctx MessageContext) error {
d := db.Get()
rows, err := d.Query(
`SELECT id, label, target_date, public FROM countdowns
WHERE user_id = ? AND completed = 0
ORDER BY target_date ASC`,
string(ctx.Sender),
)
if err != nil {
slog.Error("countdown: mine", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your countdowns.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("Your countdowns:\n\n")
count := 0
now := time.Now().UTC()
for rows.Next() {
var cdID int
var label, targetDate string
var public int
if err := rows.Scan(&cdID, &label, &targetDate, &public); err != nil {
continue
}
t, _ := time.Parse("2006-01-02", targetDate)
days := int(t.Sub(now).Hours() / 24)
vis := ""
if public == 0 {
vis = " (private)"
}
status := fmt.Sprintf("%d days", days)
if days < 0 {
status = fmt.Sprintf("%d days ago", -days)
} else if days == 0 {
status = "TODAY!"
}
sb.WriteString(fmt.Sprintf(" [%d] %s — %s (%s)%s\n", cdID, label, targetDate, status, vis))
count++
}
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no active countdowns.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *CountdownPlugin) handleRemove(ctx MessageContext, idStr string) error {
d := db.Get()
result, err := d.Exec(
`DELETE FROM countdowns WHERE id = ? AND user_id = ?`,
idStr, string(ctx.Sender),
)
if err != nil {
slog.Error("countdown: remove", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove countdown.")
}
affected, _ := result.RowsAffected()
if affected == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found or not yours.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown removed.")
}
func (p *CountdownPlugin) handleList(ctx MessageContext) error {
d := db.Get()
rows, err := d.Query(
`SELECT id, label, target_date, user_id FROM countdowns
WHERE public = 1 AND completed = 0
ORDER BY target_date ASC LIMIT 20`,
)
if err != nil {
slog.Error("countdown: list", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load countdowns.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("Active countdowns:\n\n")
count := 0
now := time.Now().UTC()
for rows.Next() {
var cdID int
var label, targetDate, userID string
if err := rows.Scan(&cdID, &label, &targetDate, &userID); err != nil {
continue
}
t, _ := time.Parse("2006-01-02", targetDate)
days := int(t.Sub(now).Hours() / 24)
status := fmt.Sprintf("%d days", days)
if days < 0 {
status = fmt.Sprintf("%d days ago", -days)
} else if days == 0 {
status = "TODAY!"
}
sb.WriteString(fmt.Sprintf(" [%d] %s — %s (%s) by %s\n", cdID, label, targetDate, status, userID))
count++
}
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active countdowns. Add one with !countdown add \"<label>\" <date>")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *CountdownPlugin) handleShow(ctx MessageContext, idStr string) error {
d := db.Get()
var label, targetDate, userID string
var public int
err := d.QueryRow(
`SELECT label, target_date, user_id, public FROM countdowns WHERE id = ? AND completed = 0`,
idStr,
).Scan(&label, &targetDate, &userID, &public)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found.")
}
// Private countdowns only visible to owner
if public == 0 && userID != string(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found.")
}
t, _ := time.Parse("2006-01-02", targetDate)
now := time.Now().UTC()
diff := t.Sub(now)
var status string
if diff < 0 {
absDays := int(-diff.Hours()) / 24
status = fmt.Sprintf("%d days ago", absDays)
} else {
days := int(diff.Hours()) / 24
hours := int(diff.Hours()) % 24
if days == 0 {
status = fmt.Sprintf("%d hours remaining!", hours)
} else {
status = fmt.Sprintf("%d days and %d hours", days, hours)
}
}
msg := fmt.Sprintf("\"%s\" — %s\n%s (by %s)", label, targetDate, status, userID)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *CountdownPlugin) completeOldCountdowns() {
d := db.Get()
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
_, err := d.Exec(
`UPDATE countdowns SET completed = 1 WHERE target_date < ? AND completed = 0`,
cutoff,
)
if err != nil {
slog.Error("countdown: auto-complete", "err", err)
}
}