Files
gogobee/internal/plugin/vibe.go
prosolis ddb196aaad 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>
2026-03-08 18:25:18 -07:00

222 lines
5.9 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
const (
vibeBufferSize = 50
vibeCooldownMins = 5
vibeMinMessages = 10
)
type bufferedMessage struct {
Sender string
Body string
Time time.Time
}
// VibePlugin uses LLM to describe room energy or summarize conversation.
type VibePlugin struct {
Base
mu sync.Mutex
buffers map[id.RoomID][]bufferedMessage
cooldowns map[id.RoomID]time.Time
}
// NewVibePlugin creates a new vibe plugin.
func NewVibePlugin(client *mautrix.Client) *VibePlugin {
return &VibePlugin{
Base: NewBase(client),
buffers: make(map[id.RoomID][]bufferedMessage),
cooldowns: make(map[id.RoomID]time.Time),
}
}
func (p *VibePlugin) Name() string { return "vibe" }
func (p *VibePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "vibe", Description: "LLM describes the current room energy", Usage: "!vibe", Category: "LLM & Sentiment"},
{Name: "tldr", Description: "LLM summarizes recent conversation", Usage: "!tldr", Category: "LLM & Sentiment"},
}
}
func (p *VibePlugin) Init() error { return nil }
func (p *VibePlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *VibePlugin) OnMessage(ctx MessageContext) error {
// Always buffer messages (except commands)
if !ctx.IsCommand {
p.addToBuffer(ctx)
}
if p.IsCommand(ctx.Body, "vibe") {
return p.handleVibe(ctx)
}
if p.IsCommand(ctx.Body, "tldr") {
return p.handleTLDR(ctx)
}
return nil
}
func (p *VibePlugin) addToBuffer(ctx MessageContext) {
p.mu.Lock()
defer p.mu.Unlock()
buf := p.buffers[ctx.RoomID]
buf = append(buf, bufferedMessage{
Sender: string(ctx.Sender),
Body: ctx.Body,
Time: time.Now().UTC(),
})
// Keep only the last N messages
if len(buf) > vibeBufferSize {
buf = buf[len(buf)-vibeBufferSize:]
}
p.buffers[ctx.RoomID] = buf
}
func (p *VibePlugin) getBuffer(roomID id.RoomID) []bufferedMessage {
p.mu.Lock()
defer p.mu.Unlock()
buf := p.buffers[roomID]
// Return a copy
result := make([]bufferedMessage, len(buf))
copy(result, buf)
return result
}
func (p *VibePlugin) checkCooldown(roomID id.RoomID) bool {
p.mu.Lock()
defer p.mu.Unlock()
last, ok := p.cooldowns[roomID]
if ok && time.Since(last) < vibeCooldownMins*time.Minute {
return false
}
p.cooldowns[roomID] = time.Now()
return true
}
func (p *VibePlugin) resetCooldown(roomID id.RoomID) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.cooldowns, roomID)
}
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
ollamaHost := os.Getenv("OLLAMA_HOST")
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
}
buf := p.getBuffer(ctx.RoomID)
if len(buf) < vibeMinMessages {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Need at least %d messages to read the vibe. Currently have %d.", vibeMinMessages, len(buf)))
}
if !p.checkCooldown(ctx.RoomID) {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Vibe check is on cooldown. Try again in %d minutes.", vibeCooldownMins))
}
botName := os.Getenv("BOT_DISPLAY_NAME")
if botName == "" {
botName = "GogoBee"
}
transcript := formatTranscript(buf)
prompt := fmt.Sprintf(
`You are %s, a fun community bot. Based on the following recent chat messages, describe the current "vibe" or energy of the room in 2-3 sentences. Be creative, playful, and use colorful language. Reference specific topics or dynamics you notice.
Recent messages:
%s
Describe the room's current vibe:`, botName, transcript)
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Reading the room..."); err != nil {
slog.Error("vibe: send thinking", "err", err)
}
response, err := callOllama(ollamaHost, ollamaModel, prompt)
if err != nil {
slog.Error("vibe: ollama call", "err", err)
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read the vibe. LLM might be offline.")
}
return p.SendMessage(ctx.RoomID, response)
}
func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
ollamaHost := os.Getenv("OLLAMA_HOST")
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
}
buf := p.getBuffer(ctx.RoomID)
if len(buf) < vibeMinMessages {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Need at least %d messages for a summary. Currently have %d.", vibeMinMessages, len(buf)))
}
if !p.checkCooldown(ctx.RoomID) {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("TLDR is on cooldown. Try again in %d minutes.", vibeCooldownMins))
}
tldrBotName := os.Getenv("BOT_DISPLAY_NAME")
if tldrBotName == "" {
tldrBotName = "GogoBee"
}
transcript := formatTranscript(buf)
prompt := fmt.Sprintf(
`You are %s, a community bot. Summarize the following recent chat conversation in 3-5 concise bullet points. Focus on the main topics discussed, any decisions made, and key moments. Be brief and informative.
Recent messages:
%s
Summary:`, tldrBotName, transcript)
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Summarizing the conversation..."); err != nil {
slog.Error("vibe: send thinking", "err", err)
}
response, err := callOllama(ollamaHost, ollamaModel, prompt)
if err != nil {
slog.Error("vibe: ollama call", "err", err)
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to summarize. LLM might be offline.")
}
return p.SendMessage(ctx.RoomID, response)
}
func formatTranscript(messages []bufferedMessage) string {
var sb strings.Builder
for _, m := range messages {
// Truncate long messages
body := m.Body
if len(body) > 300 {
body = body[:300] + "..."
}
sb.WriteString(fmt.Sprintf("[%s] %s: %s\n", m.Time.Format("15:04"), m.Sender, body))
}
return sb.String()
}