Expand sentiment to 10 categories, add DB maintenance job, update README

- Expand LLM sentiment classification from 3 to 10 categories: positive,
  negative, neutral, excited, sarcastic, frustrated, curious, grateful,
  humorous, supportive — with emoji reactions for each
- Add daily DB maintenance job at 03:00 UTC to purge stale caches,
  expired rate limits, old logs, and run SQLite optimize
- Add migrate.sql for upgrading existing databases
- Update README: add !sentiment command, maintenance job, rate limit
  config, in-memory message buffer docs, SDK migration history,
  remove references to previous private repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-08 20:46:18 -07:00
parent 2c7c15b282
commit 298c7bb8f1
6 changed files with 182 additions and 40 deletions

View File

@@ -2,7 +2,7 @@
Matrix community bot with E2EE, 35+ plugins, passive tracking, scheduled posts, and optional LLM features. Matrix community bot with E2EE, 35+ plugins, passive tracking, scheduled posts, and optional LLM features.
Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage. Successor to the TypeScript "Freebee" bot. Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage.
--- ---
@@ -128,7 +128,9 @@ Everything is configured through environment variables or a `.env` file.
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `RATELIMIT_WEATHER` | `5` | Daily weather lookups per user |
| `RATELIMIT_TRANSLATE` | `20` | Daily translation limit per user | | `RATELIMIT_TRANSLATE` | `20` | Daily translation limit per user |
| `RATELIMIT_CONCERTS` | `10` | Daily concert searches per user |
--- ---
@@ -286,6 +288,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
| `!howami [@user]` | Roast profile | | `!howami [@user]` | Roast profile |
| `!vibe` | Room energy check | | `!vibe` | Room energy check |
| `!tldr` | Summarize recent chat | | `!tldr` | Summarize recent chat |
| `!sentiment [@user]` | Sentiment breakdown (10 categories) |
| `!potty [@user]` | Profanity count | | `!potty [@user]` | Profanity count |
| `!pottyboard` | Profanity leaderboard | | `!pottyboard` | Profanity leaderboard |
| `!insults [@user]` | Insult stats | | `!insults [@user]` | Insult stats |
@@ -316,7 +319,8 @@ All of these run in the background without any commands:
- **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages - **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages
- **URL previews** - OG tag extraction (feature-flagged, off by default) - **URL previews** - OG tag extraction (feature-flagged, off by default)
- **Reactions** - logs all reactions for `!emojiboard` - **Reactions** - logs all reactions for `!emojiboard`
- **LLM classification** - sentiment, profanity, insults, WOTD usage (needs Ollama) - **LLM classification** - sentiment (10 categories), profanity, insults, WOTD usage (needs Ollama)
- **Message buffer** - last 50 messages per room held in memory for `!vibe` and `!tldr`. Not persisted to disk; resets on restart.
- **Quotes** - star-react any message to save it - **Quotes** - star-react any message to save it
--- ---
@@ -336,6 +340,7 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
| 11:00 | Movies | Movie releases today | | 11:00 | Movies | Movie releases today |
| 12:00 Sun | Concerts | Weekly concert digest | | 12:00 Sun | Concerts | Weekly concert digest |
| Every 30s | Reminders | Fires pending reminders | | Every 30s | Reminders | Fires pending reminders |
| 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs; runs SQLite optimize |
--- ---
@@ -455,9 +460,9 @@ gogobee/
### Why Go? ### Why Go?
**E2EE** - The TS version used `matrix-js-sdk` with `fake-indexeddb` for an in-memory crypto store. Every restart wiped device keys and required re-verification in all encrypted rooms. mautrix-go stores crypto state in SQLite. Verify once, it sticks. **E2EE** - This project went through three SDK iterations: `matrix-bot-sdk` (no E2EE support), `matrix-js-sdk` (E2EE via `fake-indexeddb` with an in-memory crypto store that wiped device keys on every restart), and finally `mautrix-go` which stores crypto state in SQLite with cross-signing bootstrap. Verify once, it sticks.
**Deployment** - Pure Go, no CGo. `go build -tags goolm` gives you a static binary with zero system dependencies. The TS version needed Node.js, npm, a C compiler for better-sqlite3, and libolm. **Deployment** - Pure Go, no CGo. `go build -tags goolm` gives you a static binary with zero system dependencies. The TypeScript version needed Node.js, npm, a C compiler for better-sqlite3, and libolm.
**Scheduler** - Replaced a hand-rolled 60s tick loop with robfig/cron. Standard cron expressions, less code, fewer bugs. **Scheduler** - Replaced a hand-rolled 60s tick loop with robfig/cron. Standard cron expressions, less code, fewer bugs.

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -110,6 +111,74 @@ func CacheSet(key, data string) {
} }
} }
// RunMaintenance purges stale data from cache tables, old rate limits,
// expired logs, and runs SQLite optimization. Intended to run daily.
func RunMaintenance() {
d := Get()
now := time.Now().UTC()
today := now.Format("2006-01-02")
cutoff7d := now.AddDate(0, 0, -7).Unix()
cutoff30d := now.AddDate(0, 0, -30).Unix()
date30d := now.AddDate(0, 0, -30).Format("2006-01-02")
date90d := now.AddDate(0, 0, -90).Format("2006-01-02")
queries := []struct {
label string
sql string
args []interface{}
}{
// Cache tables — purge entries older than their effective TTL
{"api_cache", `DELETE FROM api_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"releases_cache", `DELETE FROM releases_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"hltb_cache", `DELETE FROM hltb_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"stocks_cache", `DELETE FROM stocks_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"concerts_cache", `DELETE FROM concerts_cache WHERE cached_at < ?`, []interface{}{cutoff7d}},
{"anime_cache", `DELETE FROM anime_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"movie_cache", `DELETE FROM movie_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"retro_cache", `DELETE FROM retro_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
// Rate limits — purge entries older than today
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
// Daily prefetch log — keep 30 days
{"daily_prefetch", `DELETE FROM daily_prefetch WHERE date < ?`, []interface{}{date30d}},
// Holiday and WOTD logs — keep 90 days
{"holidays_log", `DELETE FROM holidays_log WHERE date < ?`, []interface{}{date90d}},
{"wotd_log", `DELETE FROM wotd_log WHERE date < ?`, []interface{}{date90d}},
{"wotd_usage", `DELETE FROM wotd_usage WHERE date < ?`, []interface{}{date90d}},
// LLM classifications — keep 30 days
{"llm_classifications", `DELETE FROM llm_classifications WHERE timestamp < ?`, []interface{}{cutoff30d}},
// Daily activity older than 1 year
{"daily_activity", `DELETE FROM daily_activity WHERE date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}},
}
totalDeleted := int64(0)
for _, q := range queries {
res, err := d.Exec(q.sql, q.args...)
if err != nil {
slog.Error("maintenance: "+q.label, "err", err)
continue
}
n, _ := res.RowsAffected()
if n > 0 {
slog.Info("maintenance: purged", "table", q.label, "rows", n)
totalDeleted += n
}
}
// SQLite optimization
if _, err := d.Exec(`PRAGMA optimize`); err != nil {
slog.Error("maintenance: pragma optimize", "err", err)
}
slog.Info("maintenance: complete", "total_purged", totalDeleted)
}
const schema = ` const schema = `
-- Users & XP -- Users & XP
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -508,6 +577,13 @@ CREATE TABLE IF NOT EXISTS sentiment_stats (
positive INTEGER DEFAULT 0, positive INTEGER DEFAULT 0,
negative INTEGER DEFAULT 0, negative INTEGER DEFAULT 0,
neutral INTEGER DEFAULT 0, neutral INTEGER DEFAULT 0,
excited INTEGER DEFAULT 0,
sarcastic INTEGER DEFAULT 0,
frustrated INTEGER DEFAULT 0,
curious INTEGER DEFAULT 0,
grateful INTEGER DEFAULT 0,
humorous INTEGER DEFAULT 0,
supportive INTEGER DEFAULT 0,
total_score REAL DEFAULT 0 total_score REAL DEFAULT 0
); );

View File

@@ -120,11 +120,13 @@ func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
sb.WriteString(fmt.Sprintf("Achievements unlocked: %d\n", achievementCount)) sb.WriteString(fmt.Sprintf("Achievements unlocked: %d\n", achievementCount))
// Sentiment stats // Sentiment stats
var positive, negative, neutral int var positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive int
if err := d.QueryRow( if err := d.QueryRow(
`SELECT positive, negative, neutral FROM sentiment_stats WHERE user_id = ?`, uid, `SELECT positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive
).Scan(&positive, &negative, &neutral); err == nil { FROM sentiment_stats WHERE user_id = ?`, uid,
sb.WriteString(fmt.Sprintf("Sentiment: %d positive, %d negative, %d neutral\n", positive, negative, neutral)) ).Scan(&positive, &negative, &neutral, &excited, &sarcastic, &frustrated, &curious, &grateful, &humorous, &supportive); err == nil {
sb.WriteString(fmt.Sprintf("Sentiment: %d positive, %d excited, %d supportive, %d grateful, %d humorous, %d curious, %d neutral, %d sarcastic, %d frustrated, %d negative\n",
positive, excited, supportive, grateful, humorous, curious, neutral, sarcastic, frustrated, negative))
} }
// Profanity count // Profanity count

View File

@@ -287,26 +287,22 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
} }
// Aggregate sentiment stats // Aggregate sentiment stats
switch result.Sentiment { sentimentCol := "neutral"
case "positive": validSentiments := map[string]bool{
_, _ = d.Exec( "positive": true, "negative": true, "neutral": true,
`INSERT INTO sentiment_stats (user_id, positive, total_score) VALUES (?, 1, ?) "excited": true, "sarcastic": true, "frustrated": true,
ON CONFLICT(user_id) DO UPDATE SET positive = positive + 1, total_score = total_score + ?`, "curious": true, "grateful": true, "humorous": true, "supportive": true,
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
case "negative":
_, _ = d.Exec(
`INSERT INTO sentiment_stats (user_id, negative, total_score) VALUES (?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET negative = negative + 1, total_score = total_score + ?`,
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
default:
_, _ = d.Exec(
`INSERT INTO sentiment_stats (user_id, neutral, total_score) VALUES (?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET neutral = neutral + 1, total_score = total_score + ?`,
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
} }
if validSentiments[result.Sentiment] {
sentimentCol = result.Sentiment
}
_, _ = d.Exec(
fmt.Sprintf(
`INSERT INTO sentiment_stats (user_id, %s, total_score) VALUES (?, 1, ?)
ON CONFLICT(user_id) DO UPDATE SET %s = %s + 1, total_score = total_score + ?`,
sentimentCol, sentimentCol, sentimentCol),
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
// Track profanity with severity // Track profanity with severity
if result.Profanity { if result.Profanity {
@@ -360,12 +356,24 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
) )
} }
// React with emojis based on classification // React with emojis based on sentiment
if result.Sentiment == "positive" && result.SentimentScore > 0.7 { sentimentEmojis := map[string]string{
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44d") // thumbsup "positive": "\U0001f44d", // 👍
"negative": "\U0001f44e", // 👎
"excited": "\U0001f525", // 🔥
"sarcastic": "\U0001f928", // 🤨
"frustrated": "\U0001f62e\u200d\U0001f4a8", // 😮‍💨
"curious": "\U0001f9d0", // 🧐
"grateful": "\U0001f49c", // 💜
"humorous": "\U0001f602", // 😂
"supportive": "\U0001f917", // 🤗
}
if emoji, ok := sentimentEmojis[result.Sentiment]; ok && result.Sentiment != "neutral" {
// Only react to strong sentiments (|score| > 0.5)
score := result.SentimentScore
if score > 0.5 || score < -0.5 {
_ = p.SendReact(item.RoomID, item.EventID, emoji)
} }
if result.Sentiment == "negative" && result.SentimentScore < -0.7 {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44e") // thumbsdown
} }
if result.Profanity { if result.Profanity {
switch result.ProfanitySeverity { switch result.ProfanitySeverity {
@@ -413,7 +421,7 @@ func (p *LLMPassivePlugin) callOllama(messageText string) (*classificationResult
JSON schema: JSON schema:
{ {
"sentiment": "positive" | "negative" | "neutral", "sentiment": "positive" | "negative" | "neutral" | "excited" | "sarcastic" | "frustrated" | "curious" | "grateful" | "humorous" | "supportive",
"sentiment_score": number between -1.0 and 1.0, "sentiment_score": number between -1.0 and 1.0,
"topics": ["topic1", "topic2"], "topics": ["topic1", "topic2"],
"profanity": true | false, "profanity": true | false,
@@ -644,18 +652,22 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
} }
d := db.Get() d := db.Get()
var positive, negative, neutral int var positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive int
var totalScore float64 var totalScore float64
err := d.QueryRow( err := d.QueryRow(
`SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0), COALESCE(total_score, 0) `SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0),
COALESCE(excited, 0), COALESCE(sarcastic, 0), COALESCE(frustrated, 0),
COALESCE(curious, 0), COALESCE(grateful, 0), COALESCE(humorous, 0),
COALESCE(supportive, 0), COALESCE(total_score, 0)
FROM sentiment_stats WHERE user_id = ?`, FROM sentiment_stats WHERE user_id = ?`,
string(target), string(target),
).Scan(&positive, &negative, &neutral, &totalScore) ).Scan(&positive, &negative, &neutral, &excited, &sarcastic, &frustrated,
&curious, &grateful, &humorous, &supportive, &totalScore)
if err != nil { if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No sentiment data for %s yet.", string(target))) return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No sentiment data for %s yet.", string(target)))
} }
total := positive + negative + neutral total := positive + negative + neutral + excited + sarcastic + frustrated + curious + grateful + humorous + supportive
avgScore := 0.0 avgScore := 0.0
if total > 0 { if total > 0 {
avgScore = totalScore / float64(total) avgScore = totalScore / float64(total)
@@ -672,7 +684,33 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
mood = "leaning negative" mood = "leaning negative"
} }
msg := fmt.Sprintf("Sentiment for %s:\n😊 Positive: %s 😐 Neutral: %s 😠 Negative: %s\nAverage mood: %.2f (%s)", // Build sentiment breakdown, only showing non-zero counts
string(target), formatNumber(positive), formatNumber(neutral), formatNumber(negative), avgScore, mood) type sentEntry struct {
return p.SendReply(ctx.RoomID, ctx.EventID, msg) emoji string
label string
count int
}
entries := []sentEntry{
{"👍", "Positive", positive},
{"🔥", "Excited", excited},
{"🤗", "Supportive", supportive},
{"💜", "Grateful", grateful},
{"😂", "Humorous", humorous},
{"🧐", "Curious", curious},
{"😐", "Neutral", neutral},
{"🤨", "Sarcastic", sarcastic},
{"😮\u200d💨", "Frustrated", frustrated},
{"👎", "Negative", negative},
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Sentiment for %s:\n", string(target)))
for _, e := range entries {
if e.count > 0 {
sb.WriteString(fmt.Sprintf(" %s %s: %s\n", e.emoji, e.label, formatNumber(e.count)))
}
}
sb.WriteString(fmt.Sprintf("Average mood: %.2f (%s)", avgScore, mood))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
} }

View File

@@ -277,6 +277,12 @@ func setupScheduledJobs(
c.AddFunc("@every 30s", func() { c.AddFunc("@every 30s", func() {
plugin.FirePendingReminders(client) plugin.FirePendingReminders(client)
}) })
// Database maintenance at 03:00 daily
c.AddFunc("0 3 * * *", func() {
slog.Info("scheduler: running database maintenance")
db.RunMaintenance()
})
} }
func getRooms() []id.RoomID { func getRooms() []id.RoomID {

15
migrate.sql Normal file
View File

@@ -0,0 +1,15 @@
-- Add expanded sentiment columns to sentiment_stats
ALTER TABLE sentiment_stats ADD COLUMN excited INTEGER DEFAULT 0;
ALTER TABLE sentiment_stats ADD COLUMN sarcastic INTEGER DEFAULT 0;
ALTER TABLE sentiment_stats ADD COLUMN frustrated INTEGER DEFAULT 0;
ALTER TABLE sentiment_stats ADD COLUMN curious INTEGER DEFAULT 0;
ALTER TABLE sentiment_stats ADD COLUMN grateful INTEGER DEFAULT 0;
ALTER TABLE sentiment_stats ADD COLUMN humorous INTEGER DEFAULT 0;
ALTER TABLE sentiment_stats ADD COLUMN supportive INTEGER DEFAULT 0;
-- Create generic API cache table
CREATE TABLE IF NOT EXISTS api_cache (
cache_key TEXT PRIMARY KEY,
data TEXT NOT NULL,
cached_at INTEGER DEFAULT (unixepoch())
);