mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
86
internal/util/auth.go
Normal file
86
internal/util/auth.go
Normal 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
24
internal/util/logger.go
Normal 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
160
internal/util/parser.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user