Files
gogobee/internal/plugin/vibe.go
prosolis 91af4f1e83 Thanks, Ollama.
Replace flat 'LLM might be offline' error strings in vibe and howami with
the deadpan 'Thanks, Ollama.' callback. Skipped tarot — its 'union-mandated
vacation' line was already the right register and shouldn't be displaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:09:32 -07:00

242 lines
6.3 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
startedAt 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),
startedAt: time.Now().UTC(),
}
}
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. (uptime: %s)", vibeMinMessages, len(buf), p.uptime()))
}
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. Thanks, Ollama.")
}
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. (uptime: %s)", vibeMinMessages, len(buf), p.uptime()))
}
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, "Couldn't summarize. Thanks, Ollama.")
}
return p.SendMessage(ctx.RoomID, response)
}
func (p *VibePlugin) uptime() string {
d := time.Since(p.startedAt)
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h >= 24 {
days := h / 24
h = h % 24
return fmt.Sprintf("%dd %dh", days, h)
}
return fmt.Sprintf("%dh %dm", h, m)
}
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()
}