Files
gogobee/internal/plugin/wotd.go
prosolis 2c191ec464 Fix scheduled posts only broadcasting to first room
All scheduled post dedup keys (wotd, holidays, gaming, anime, movies,
concerts, birthdays) were global — after posting to the first room,
the job was marked complete and the second room was skipped. Fixed by
including the room ID in each dedup key so posts go to all broadcast
rooms. Birthday XP grants and DMs remain once-per-user-per-year.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:09:11 -07:00

385 lines
11 KiB
Go

package plugin
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// wordnikWOTDResponse is the top-level Wordnik Word of the Day response.
type wordnikWOTDResponse struct {
Word string `json:"word"`
Definitions []wordnikWOTDDef `json:"definitions"`
Examples []wordnikWOTDEx `json:"examples"`
}
type wordnikWOTDDef struct {
Text string `json:"text"`
PartOfSpeech string `json:"partOfSpeech"`
}
type wordnikWOTDEx struct {
Text string `json:"text"`
}
// WOTDPlugin provides a Word of the Day feature using the Wordnik API.
type WOTDPlugin struct {
Base
apiKey string
httpClient *http.Client
}
// NewWOTDPlugin creates a new WOTDPlugin.
func NewWOTDPlugin(client *mautrix.Client) *WOTDPlugin {
return &WOTDPlugin{
Base: NewBase(client),
apiKey: os.Getenv("WORDNIK_API_KEY"),
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (p *WOTDPlugin) Name() string { return "wotd" }
func (p *WOTDPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "wotd", Description: "Show today's Word of the Day", Usage: "!wotd", Category: "Lookup & Reference"},
}
}
func (p *WOTDPlugin) Init() error { return nil }
func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "wotd") {
return p.handleWOTD(ctx)
}
// Passive: track WOTD usage in messages
if !ctx.IsCommand {
p.trackUsage(ctx)
}
return nil
}
// Prefetch fetches today's Word of the Day from Wordnik and stores it in the database.
func (p *WOTDPlugin) Prefetch() error {
if p.apiKey == "" {
slog.Warn("wotd: WORDNIK_API_KEY not set, skipping prefetch")
return nil
}
today := time.Now().UTC().Format("2006-01-02")
// Check if already fetched
d := db.Get()
var exists int
err := d.QueryRow(`SELECT 1 FROM wotd_log WHERE date = ?`, today).Scan(&exists)
if err == nil {
slog.Info("wotd: already fetched for today", "date", today)
return nil
}
url := fmt.Sprintf("https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=%s", p.apiKey)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("wotd: create request: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("wotd: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("wotd: API returned status %d", resp.StatusCode)
}
var wotd wordnikWOTDResponse
if err := json.NewDecoder(resp.Body).Decode(&wotd); err != nil {
return fmt.Errorf("wotd: decode response: %w", err)
}
definition := ""
partOfSpeech := ""
if len(wotd.Definitions) > 0 {
definition = wotd.Definitions[0].Text
partOfSpeech = wotd.Definitions[0].PartOfSpeech
}
example := ""
if len(wotd.Examples) > 0 {
example = wotd.Examples[0].Text
}
_, err = d.Exec(
`INSERT INTO wotd_log (date, word, definition, part_of_speech, example, posted)
VALUES (?, ?, ?, ?, ?, 0)
ON CONFLICT(date) DO NOTHING`,
today, wotd.Word, definition, partOfSpeech, example,
)
if err != nil {
return fmt.Errorf("wotd: store: %w", err)
}
slog.Info("wotd: prefetched", "date", today, "word", wotd.Word)
return nil
}
// PostWOTD posts today's Word of the Day to the given room and marks it as posted.
func (p *WOTDPlugin) PostWOTD(roomID id.RoomID) error {
today := time.Now().UTC().Format("2006-01-02")
d := db.Get()
// Per-room dedup
roomKey := fmt.Sprintf("%s:%s", today, roomID)
if db.JobCompleted("wotd", roomKey) {
slog.Info("wotd: already posted today", "date", today, "room", roomID)
return nil
}
var word, definition, partOfSpeech, example string
err := d.QueryRow(
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
).Scan(&word, &definition, &partOfSpeech, &example)
if err == sql.ErrNoRows {
slog.Warn("wotd: no entry for today, attempting prefetch", "date", today)
if err := p.Prefetch(); err != nil {
return fmt.Errorf("wotd: prefetch failed: %w", err)
}
err = d.QueryRow(
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
).Scan(&word, &definition, &partOfSpeech, &example)
if err != nil {
return fmt.Errorf("wotd: still no entry after prefetch: %w", err)
}
} else if err != nil {
return fmt.Errorf("wotd: query: %w", err)
}
msg := p.formatWOTD(word, definition, partOfSpeech, example)
if err := p.SendMessage(roomID, msg); err != nil {
return fmt.Errorf("wotd: send message: %w", err)
}
db.MarkJobCompleted("wotd", roomKey)
return nil
}
func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error {
today := time.Now().UTC().Format("2006-01-02")
d := db.Get()
var word, definition, partOfSpeech, example string
err := d.QueryRow(
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
).Scan(&word, &definition, &partOfSpeech, &example)
if err == sql.ErrNoRows {
// Prefetch on demand
if pfErr := p.Prefetch(); pfErr != nil {
slog.Error("wotd: on-demand prefetch failed", "err", pfErr)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day. Try again later.")
}
err = d.QueryRow(
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
).Scan(&word, &definition, &partOfSpeech, &example)
}
if err != nil {
slog.Error("wotd: query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day.")
}
msg := p.formatWOTD(word, definition, partOfSpeech, example)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, example string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Word of the Day: %s", word))
if partOfSpeech != "" {
sb.WriteString(fmt.Sprintf(" (%s)", partOfSpeech))
}
if definition != "" {
sb.WriteString(fmt.Sprintf("\n\nDefinition: %s", definition))
}
if example != "" {
sb.WriteString(fmt.Sprintf("\n\nExample: \"%s\"", example))
}
sb.WriteString("\n\nUse this word in a message today to earn 25 XP!")
return sb.String()
}
// trackUsage checks if the user used the WOTD in their message and rewards them.
func (p *WOTDPlugin) trackUsage(ctx MessageContext) {
today := time.Now().UTC().Format("2006-01-02")
d := db.Get()
// Get today's word
var word string
err := d.QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&word)
if err != nil {
return // No word today or error; silently skip
}
// Check if the message contains the word (case-insensitive)
if !strings.Contains(strings.ToLower(ctx.Body), strings.ToLower(word)) {
return
}
// Atomically insert/increment usage and try to claim reward in one step
// This avoids the TOCTOU race where two messages could both grant XP
_, err = d.Exec(
`INSERT INTO wotd_usage (user_id, date, count, rewarded) VALUES (?, ?, 1, 0)
ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1`,
string(ctx.Sender), today,
)
if err != nil {
slog.Error("wotd: track usage", "err", err)
return
}
// Check if already rewarded before doing LLM verification
var rewarded int
err = d.QueryRow(
`SELECT rewarded FROM wotd_usage WHERE user_id = ? AND date = ?`,
string(ctx.Sender), today,
).Scan(&rewarded)
if err != nil || rewarded == 1 {
return // Already rewarded or error
}
// Verify correct usage with LLM
if !p.verifyUsage(word, ctx.Body) {
slog.Debug("wotd: LLM rejected usage", "user", ctx.Sender, "word", word)
return
}
// Atomically claim the reward: only update if not yet rewarded
res, err := d.Exec(
`UPDATE wotd_usage SET rewarded = 1 WHERE user_id = ? AND date = ? AND rewarded = 0`,
string(ctx.Sender), today,
)
if err != nil {
slog.Error("wotd: claim reward", "err", err)
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
return // Already rewarded
}
// Grant 25 XP via direct SQL
p.grantWOTDXP(ctx.Sender, 25)
if err := p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Nice! You used today's word \"%s\" correctly and earned 25 XP!", word)); err != nil {
slog.Error("wotd: send reward message", "err", err)
}
}
// verifyUsage asks the LLM whether the word was used correctly in context.
// Returns false if LLM is not configured or on any error.
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
host := os.Getenv("OLLAMA_HOST")
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return false
}
prompt := fmt.Sprintf(`A user sent this chat message: "%s"
The Word of the Day is "%s". Was this word used correctly and meaningfully in the message? The user must demonstrate understanding of the word by using it naturally in a sentence — not just mentioning it, quoting it, or saying "the word is X".
Respond with ONLY "yes" or "no".`, message, word)
payload := map[string]interface{}{
"model": model,
"prompt": prompt,
"stream": false,
}
data, err := json.Marshal(payload)
if err != nil {
return false
}
apiURL := strings.TrimRight(host, "/") + "/api/generate"
slog.Debug("wotd: sending LLM verification request", "url", apiURL, "word", word)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil {
slog.Error("wotd: LLM verify request failed", "err", err)
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return false
}
var result struct {
Response string `json:"response"`
}
if json.Unmarshal(body, &result) != nil {
return false
}
answer := strings.ToLower(strings.TrimSpace(result.Response))
accepted := strings.HasPrefix(answer, "yes")
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
return accepted
}
// grantWOTDXP inserts XP directly via SQL to avoid cross-plugin dependency.
func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
d := db.Get()
// Ensure user exists
_, err := d.Exec(
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
ON CONFLICT(user_id) DO NOTHING`, string(userID))
if err != nil {
slog.Error("wotd: ensure user", "err", err)
return
}
// Update XP
_, err = d.Exec(
`UPDATE users SET xp = xp + ?, last_xp_at = ? WHERE user_id = ?`,
amount, time.Now().UTC().Unix(), string(userID))
if err != nil {
slog.Error("wotd: grant xp", "err", err)
return
}
// Log XP grant
_, err = d.Exec(
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
string(userID), amount, "wotd_usage")
if err != nil {
slog.Error("wotd: log xp", "err", err)
}
}