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>
This commit is contained in:
prosolis
2026-03-08 18:25:18 -07:00
parent 07c8674fe0
commit ddb196aaad
91 changed files with 12865 additions and 13909 deletions

86
internal/util/auth.go Normal file
View File

@@ -0,0 +1,86 @@
package util
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// LoginResponse holds the Matrix login response fields we care about.
type LoginResponse struct {
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
}
// LoginWithPassword performs a Matrix password login.
func LoginWithPassword(homeserver, user, password, deviceDisplayName string) (*LoginResponse, error) {
url := homeserver + "/_matrix/client/v3/login"
body := map[string]any{
"type": "m.login.password",
"identifier": map[string]string{
"type": "m.id.user",
"user": user,
},
"password": password,
"initial_device_display_name": deviceDisplayName,
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal login body: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("login request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, string(respBody))
}
var result LoginResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse login response: %w", err)
}
return &result, nil
}
// IsTokenValid checks if an access token is still valid via /account/whoami.
func IsTokenValid(homeserver, accessToken string) (bool, string) {
url := homeserver + "/_matrix/client/v3/account/whoami"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return false, ""
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return false, ""
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false, ""
}
var result struct {
UserID string `json:"user_id"`
}
body, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(body, &result); err != nil {
return false, ""
}
return true, result.UserID
}

24
internal/util/logger.go Normal file
View File

@@ -0,0 +1,24 @@
package util
import (
"log/slog"
"os"
)
// InitLogger sets up a structured slog logger writing to stderr.
func InitLogger(level string) {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
slog.SetDefault(slog.New(handler))
}

160
internal/util/parser.go Normal file
View File

@@ -0,0 +1,160 @@
package util
import (
"math"
"regexp"
"strconv"
"strings"
"unicode"
)
// MessageStats holds parsed message metrics.
type MessageStats struct {
Words int
Chars int
Links int
Images int
Questions int
Exclamations int
Emojis int
}
var (
linkRe = regexp.MustCompile(`https?://\S+`)
imageRe = regexp.MustCompile(`\.(png|jpg|jpeg|gif|webp|svg|bmp)(\?[^\s]*)?$`)
emojiRe = regexp.MustCompile(`[\x{1F600}-\x{1F64F}]|[\x{1F300}-\x{1F5FF}]|[\x{1F680}-\x{1F6FF}]|[\x{1F1E0}-\x{1F1FF}]|[\x{2600}-\x{26FF}]|[\x{2700}-\x{27BF}]`)
)
// ParseMessage extracts stats from a chat message body.
func ParseMessage(body string) MessageStats {
words := strings.Fields(body)
links := linkRe.FindAllString(body, -1)
images := 0
for _, l := range links {
if imageRe.MatchString(strings.ToLower(l)) {
images++
}
}
questions := strings.Count(body, "?")
exclamations := strings.Count(body, "!")
emojis := len(emojiRe.FindAllString(body, -1))
return MessageStats{
Words: len(words),
Chars: len([]rune(body)),
Links: len(links),
Images: images,
Questions: questions,
Exclamations: exclamations,
Emojis: emojis,
}
}
// XPForLevel returns the cumulative XP needed to reach level n.
// Uses quadratic curve: level² × 100.
func XPForLevel(level int) int {
return level * level * 100
}
// LevelFromXP returns the current level for a given XP total.
// Inverse of level² × 100: level = floor(sqrt(xp / 100)).
func LevelFromXP(xp int) int {
if xp <= 0 {
return 0
}
level := 0
for (level+1)*(level+1)*100 <= xp {
level++
}
return level
}
// ProgressBar returns a text progress bar like [#####-----] 50%.
func ProgressBar(current, max, width int) string {
if max <= 0 {
return strings.Repeat("-", width)
}
ratio := float64(current) / float64(max)
if ratio > 1 {
ratio = 1
}
filled := int(math.Round(ratio * float64(width)))
if filled > width {
filled = width
}
empty := width - filled
pct := int(ratio * 100)
return "[" + strings.Repeat("#", filled) + strings.Repeat("-", empty) + "] " + strconv.Itoa(pct) + "%"
}
// Archetype definitions matching the TS version.
type Archetype struct {
Name string
Description string
}
var archetypes = []struct {
name string
desc string
check func(s MessageStats, totalMessages int) bool
}{
{"Chatterbox", "You talk a LOT", func(s MessageStats, t int) bool { return t > 500 && s.Words > 0 }},
{"Novelist", "Long-form writer", func(s MessageStats, t int) bool { return s.Words > 0 && s.Chars/max1(s.Words) > 8 }},
{"Inquisitor", "Always asking questions", func(s MessageStats, t int) bool { return s.Questions > t/5 && s.Questions > 10 }},
{"Linkmaster", "Shares lots of links", func(s MessageStats, t int) bool { return s.Links > t/10 && s.Links > 5 }},
{"Shutterbug", "Posts lots of images", func(s MessageStats, t int) bool { return s.Images > t/10 && s.Images > 5 }},
{"Enthusiast", "Lots of exclamation marks!", func(s MessageStats, t int) bool { return s.Exclamations > t/4 && s.Exclamations > 10 }},
{"Regular", "A steady community member", func(_ MessageStats, _ int) bool { return true }},
}
// DeriveArchetype picks the best-fitting archetype based on aggregate stats.
func DeriveArchetype(stats MessageStats, totalMessages int) Archetype {
for _, a := range archetypes {
if a.check(stats, totalMessages) {
return Archetype{Name: a.name, Description: a.desc}
}
}
return Archetype{Name: "Regular", Description: "A steady community member"}
}
func max1(n int) int {
if n < 1 {
return 1
}
return n
}
// IsCommand checks if body starts with prefix+command (case-insensitive).
func IsCommand(body, prefix, command string) bool {
cmd := prefix + command
lower := strings.ToLower(strings.TrimSpace(body))
if lower == cmd {
return true
}
return strings.HasPrefix(lower, cmd+" ")
}
// GetArgs returns everything after the command prefix.
func GetArgs(body, prefix, command string) string {
cmd := prefix + command
trimmed := strings.TrimSpace(body)
lower := strings.ToLower(trimmed)
if !strings.HasPrefix(lower, cmd) {
return ""
}
rest := trimmed[len(cmd):]
return strings.TrimSpace(rest)
}
// HasNonASCII checks if string contains non-ASCII characters.
func HasNonASCII(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII {
return true
}
}
return false
}