Files
gogobee/internal/plugin/wotd.go
2026-03-14 11:57:21 -07:00

386 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,
"think": 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)
}
}