mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
239 lines
6.4 KiB
Go
239 lines
6.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/expr-lang/expr"
|
|
"github.com/skip2/go-qrcode"
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ToolsPlugin provides calculator and QR code generation.
|
|
type ToolsPlugin struct {
|
|
Base
|
|
}
|
|
|
|
// NewToolsPlugin creates a new ToolsPlugin.
|
|
func NewToolsPlugin(client *mautrix.Client) *ToolsPlugin {
|
|
return &ToolsPlugin{
|
|
Base: NewBase(client),
|
|
}
|
|
}
|
|
|
|
func (p *ToolsPlugin) Name() string { return "tools" }
|
|
|
|
func (p *ToolsPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "calc", Description: "Math calculator", Usage: "!calc <expression>", Category: "Lookup & Reference"},
|
|
{Name: "qr", Description: "Generate a QR code", Usage: "!qr <text>", Category: "Lookup & Reference"},
|
|
}
|
|
}
|
|
|
|
func (p *ToolsPlugin) Init() error { return nil }
|
|
|
|
func (p *ToolsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *ToolsPlugin) OnMessage(ctx MessageContext) error {
|
|
switch {
|
|
case p.IsCommand(ctx.Body, "calc"):
|
|
return p.handleCalc(ctx)
|
|
case p.IsCommand(ctx.Body, "qr"):
|
|
return p.handleQR(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *ToolsPlugin) handleCalc(ctx MessageContext) error {
|
|
expression := strings.TrimSpace(p.GetArgs(ctx.Body, "calc"))
|
|
if expression == "" {
|
|
return p.SendMessage(ctx.RoomID, "Usage: !calc <expression>\nExamples: !calc 2+2, !calc sqrt(144), !calc 5 times 3")
|
|
}
|
|
|
|
// Normalize natural language
|
|
normalized := normalizeExpression(expression)
|
|
|
|
// Evaluate using expr
|
|
program, err := expr.Compile(normalized)
|
|
if err != nil {
|
|
slog.Debug("calc: compile error", "expr", normalized, "err", err)
|
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Could not parse expression: %s", expression))
|
|
}
|
|
|
|
output, err := expr.Run(program, nil)
|
|
if err != nil {
|
|
slog.Debug("calc: eval error", "expr", normalized, "err", err)
|
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Error evaluating: %s", expression))
|
|
}
|
|
|
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🧮 %s = **%s**", expression, formatCalcResult(output)))
|
|
}
|
|
|
|
// commaNumberRe matches numbers with commas like 40,000 or 1,234,567.89
|
|
var commaNumberRe = regexp.MustCompile(`\d{1,3}(,\d{3})+(\.\d+)?`)
|
|
|
|
// percentOfRe matches patterns like "8% of 40000" or "15.5% of 200"
|
|
var percentOfRe = regexp.MustCompile(`(?i)([\d.]+)\s*%\s*of\s+([\d,.]+)`)
|
|
|
|
// normalizeExpression converts natural language math to operators.
|
|
func normalizeExpression(s string) string {
|
|
// Remove common prefixes
|
|
s = strings.TrimSpace(s)
|
|
lower := strings.ToLower(s)
|
|
for _, prefix := range []string{"what is ", "what's ", "calculate ", "compute ", "evaluate "} {
|
|
if strings.HasPrefix(lower, prefix) {
|
|
s = s[len(prefix):]
|
|
lower = strings.ToLower(s)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Strip commas from numbers (e.g. 40,000 -> 40000)
|
|
s = commaNumberRe.ReplaceAllStringFunc(s, func(m string) string {
|
|
return strings.ReplaceAll(m, ",", "")
|
|
})
|
|
|
|
// Handle "X% of Y" -> (X / 100) * Y
|
|
s = percentOfRe.ReplaceAllString(s, "($1 / 100) * $2")
|
|
|
|
// Replace natural language operators (order matters: longer phrases first)
|
|
replacements := []struct{ from, to string }{
|
|
{"divided by", "/"},
|
|
{"to the power of", "**"},
|
|
{"raised to", "**"},
|
|
{"times", "*"},
|
|
{"multiplied by", "*"},
|
|
{"plus", "+"},
|
|
{"minus", "-"},
|
|
{"mod", "%"},
|
|
}
|
|
|
|
result := s
|
|
for _, r := range replacements {
|
|
result = strings.ReplaceAll(strings.ToLower(result), r.from, r.to)
|
|
}
|
|
|
|
return strings.TrimSpace(result)
|
|
}
|
|
|
|
// formatCalcResult formats a calculation result, adding commas for large numbers.
|
|
func formatCalcResult(v interface{}) string {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return formatNumber(n)
|
|
case int64:
|
|
return formatNumberInt64(n)
|
|
case float64:
|
|
if n == math.Trunc(n) && !math.IsInf(n, 0) && !math.IsNaN(n) && math.Abs(n) < 1e15 {
|
|
// Whole number - format without decimals
|
|
return formatNumberInt64(int64(n))
|
|
}
|
|
// Format with decimals, then add commas to the integer part
|
|
formatted := fmt.Sprintf("%g", n)
|
|
parts := strings.SplitN(formatted, ".", 2)
|
|
// Try to parse the integer part for comma formatting
|
|
if len(parts[0]) > 3 {
|
|
negative := strings.HasPrefix(parts[0], "-")
|
|
digits := parts[0]
|
|
if negative {
|
|
digits = digits[1:]
|
|
}
|
|
var result strings.Builder
|
|
remainder := len(digits) % 3
|
|
if remainder > 0 {
|
|
result.WriteString(digits[:remainder])
|
|
}
|
|
for i := remainder; i < len(digits); i += 3 {
|
|
if result.Len() > 0 {
|
|
result.WriteByte(',')
|
|
}
|
|
result.WriteString(digits[i : i+3])
|
|
}
|
|
prefix := ""
|
|
if negative {
|
|
prefix = "-"
|
|
}
|
|
if len(parts) == 2 {
|
|
return prefix + result.String() + "." + parts[1]
|
|
}
|
|
return prefix + result.String()
|
|
}
|
|
return formatted
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// formatNumberInt64 adds commas to an int64 for display.
|
|
func formatNumberInt64(n int64) string {
|
|
if n < 0 {
|
|
return "-" + formatNumberInt64(-n)
|
|
}
|
|
s := fmt.Sprintf("%d", n)
|
|
if len(s) <= 3 {
|
|
return s
|
|
}
|
|
var result strings.Builder
|
|
remainder := len(s) % 3
|
|
if remainder > 0 {
|
|
result.WriteString(s[:remainder])
|
|
}
|
|
for i := remainder; i < len(s); i += 3 {
|
|
if result.Len() > 0 {
|
|
result.WriteByte(',')
|
|
}
|
|
result.WriteString(s[i : i+3])
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
func (p *ToolsPlugin) handleQR(ctx MessageContext) error {
|
|
text := strings.TrimSpace(p.GetArgs(ctx.Body, "qr"))
|
|
if text == "" {
|
|
return p.SendMessage(ctx.RoomID, "Usage: !qr <text or URL>")
|
|
}
|
|
|
|
if len(text) > 2048 {
|
|
return p.SendMessage(ctx.RoomID, "Text is too long for a QR code (max 2048 characters).")
|
|
}
|
|
|
|
// Generate QR code PNG
|
|
pngData, err := qrcode.Encode(text, qrcode.Medium, 256)
|
|
if err != nil {
|
|
slog.Error("qr: generate failed", "err", err)
|
|
return p.SendMessage(ctx.RoomID, "Failed to generate QR code.")
|
|
}
|
|
|
|
// Upload to Matrix
|
|
mxcURI, err := p.UploadContent(pngData, "image/png", "qrcode.png")
|
|
if err != nil {
|
|
slog.Error("qr: upload failed", "err", err)
|
|
return p.SendMessage(ctx.RoomID, "Failed to upload QR code image.")
|
|
}
|
|
|
|
// Send as image message
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgImage,
|
|
Body: "qrcode.png",
|
|
URL: id.ContentURIString(mxcURI.String()),
|
|
Info: &event.FileInfo{
|
|
MimeType: "image/png",
|
|
Size: len(pngData),
|
|
},
|
|
}
|
|
|
|
_, err = p.Client.SendMessageEvent(context.Background(), ctx.RoomID, event.EventMessage, content)
|
|
if err != nil {
|
|
slog.Error("qr: send image failed", "err", err)
|
|
return p.SendMessage(ctx.RoomID, "Failed to send QR code image.")
|
|
}
|
|
|
|
return nil
|
|
}
|