Add EngagementEngine, persona system, dropdowns, penalties, /resetmatty, debug logging
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Mattermore — Mattermost AI Chat Agent
|
||||
# Build targets:
|
||||
# make dist — Build all platform binaries and package plugin
|
||||
# make build — Build linux-amd64 binary only
|
||||
# make check — Lint + test
|
||||
# make clean — Remove build artifacts
|
||||
|
||||
PLUGIN_ID := com.forkless.mattermore
|
||||
PLUGIN_VERSION := 0.1.0
|
||||
DIST_DIR := dist
|
||||
SERVER_DIR := server
|
||||
BUNDLE_NAME := $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz
|
||||
BUNDLE_PATH := $(DIST_DIR)/$(BUNDLE_NAME)
|
||||
|
||||
# Platforms to build for
|
||||
LINUX_AMD64 := linux-amd64
|
||||
LINUX_ARM64 := linux-arm64
|
||||
DARWIN_AMD64 := darwin-amd64
|
||||
DARWIN_ARM64 := darwin-arm64
|
||||
|
||||
.PHONY: all build dist check clean
|
||||
|
||||
all: dist
|
||||
|
||||
# Build linux-amd64 only (fastest for local testing)
|
||||
build:
|
||||
@mkdir -p $(SERVER_DIR)/dist
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
|
||||
go build -o $(SERVER_DIR)/dist/plugin-$(LINUX_AMD64) ./$(SERVER_DIR)
|
||||
|
||||
# Build all platform binaries
|
||||
dist: build-all-platforms bundle
|
||||
|
||||
build-all-platforms:
|
||||
@mkdir -p $(SERVER_DIR)/dist
|
||||
@echo "Building for $(LINUX_AMD64)..."
|
||||
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
|
||||
go build -o $(SERVER_DIR)/dist/plugin-$(LINUX_AMD64) ./$(SERVER_DIR)
|
||||
@echo "Building for $(LINUX_ARM64)..."
|
||||
@GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
|
||||
go build -o $(SERVER_DIR)/dist/plugin-$(LINUX_ARM64) ./$(SERVER_DIR)
|
||||
@echo "Building for $(DARWIN_AMD64)..."
|
||||
@GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 \
|
||||
go build -o $(SERVER_DIR)/dist/plugin-$(DARWIN_AMD64) ./$(SERVER_DIR)
|
||||
@echo "Building for $(DARWIN_ARM64)..."
|
||||
@GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 \
|
||||
go build -o $(SERVER_DIR)/dist/plugin-$(DARWIN_ARM64) ./$(SERVER_DIR)
|
||||
|
||||
bundle:
|
||||
@mkdir -p $(DIST_DIR)
|
||||
@echo "Creating bundle..."
|
||||
@mkdir -p /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION)
|
||||
@cp plugin.json /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION)/
|
||||
@mkdir -p /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION)/server/dist
|
||||
@cp $(SERVER_DIR)/dist/* /tmp/mm-bundle/$(PLUGIN_ID)-$(PLUGIN_VERSION)/server/dist/
|
||||
@cd /tmp/mm-bundle && tar czf $(CURDIR)/$(BUNDLE_PATH) $(PLUGIN_ID)-$(PLUGIN_VERSION)
|
||||
@rm -rf /tmp/mm-bundle
|
||||
@echo "Bundle created: $(BUNDLE_PATH)"
|
||||
|
||||
check:
|
||||
@echo "Running go vet..."
|
||||
@go vet ./$(SERVER_DIR)
|
||||
@echo "Running go fmt..."
|
||||
@go fmt ./$(SERVER_DIR)
|
||||
@echo "Done"
|
||||
|
||||
clean:
|
||||
@rm -rf $(DIST_DIR)
|
||||
@rm -rf $(SERVER_DIR)/dist
|
||||
@echo "Cleaned"
|
||||
+35
-12
@@ -25,11 +25,11 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Database
|
||||
MM_USERNAME: mmuser
|
||||
MM_PASSWORD: mmuser_pass
|
||||
MM_DBNAME: mattermost_test
|
||||
MM_DBHOST: postgres:5432
|
||||
# Database — full connection string (env format: MM_{SECTION}_{KEY})
|
||||
MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:mmuser_pass@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10"
|
||||
# Plugin + file storage — use /tmp to avoid permission issues
|
||||
MM_PLUGINSETTINGS_DIRECTORY: /tmp/plugins
|
||||
MM_FILESETTINGS_DIRECTORY: /tmp/file_data
|
||||
# Server
|
||||
MM_SERVICESETTINGS_SITEURL: http://localhost:8065
|
||||
MM_SERVICESETTINGS_LISTENADDRESS: :8065
|
||||
@@ -46,21 +46,44 @@ services:
|
||||
MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false"
|
||||
ports:
|
||||
- "8065:8065"
|
||||
# Auto-create admin user (admin/admin) on first boot.
|
||||
entrypoint: ["/bin/bash", "/init.sh"]
|
||||
volumes:
|
||||
# Init script creates admin user on first startup.
|
||||
- ./init.sh:/init.sh:ro
|
||||
# Plugin directory — mount so you can upload via UI:
|
||||
- ./mattermost/plugins:/mattermost/data/plugins:rw
|
||||
# Data volume for uploads, avatars, etc.
|
||||
- mattermost-data:/mattermost/data:rw
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8065/api/v4/system/ping | grep -q OK"]
|
||||
test: ["CMD", "pgrep", "mattermost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
|
||||
setup:
|
||||
image: curlimages/curl:latest
|
||||
container_name: mattermore-setup
|
||||
depends_on:
|
||||
mattermost:
|
||||
condition: service_healthy
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
echo "setup: waiting for Mattermost API..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://mattermost:8065/api/v4/system/ping > /dev/null 2>&1; then
|
||||
echo "setup: Mattermost is ready"
|
||||
break
|
||||
fi
|
||||
echo "setup: waiting... ($i)"
|
||||
sleep 3
|
||||
done
|
||||
echo "setup: creating admin user..."
|
||||
curl -s -X POST http://mattermost:8065/api/v4/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@example.com","username":"admin","password":"password"}' \
|
||||
-w "\nsetup: HTTP %{http_code}"
|
||||
echo ""
|
||||
echo "setup: done"
|
||||
|
||||
volumes:
|
||||
mattermost-data:
|
||||
|
||||
+75
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "com.forkless.mattermore",
|
||||
"name": "Mattermore",
|
||||
"name": "Matty",
|
||||
"description": "AI chat agent powered by Ollama.",
|
||||
"version": "0.1.0",
|
||||
"min_server_version": "11.6.1",
|
||||
@@ -12,7 +12,6 @@
|
||||
"darwin-arm64": "server/dist/plugin-darwin-arm64"
|
||||
}
|
||||
},
|
||||
"webapp": {},
|
||||
"settings_schema": {
|
||||
"header": "Configure the connection to your Ollama instance.",
|
||||
"footer": "",
|
||||
@@ -28,18 +27,82 @@
|
||||
{
|
||||
"key": "DefaultModel",
|
||||
"display_name": "Default Model",
|
||||
"type": "text",
|
||||
"help_text": "Model name to use when none is specified.",
|
||||
"placeholder": "llama3:latest",
|
||||
"default": "llama3:latest"
|
||||
"type": "dropdown",
|
||||
"help_text": "Model to use for chat responses.",
|
||||
"default": "llama3:latest",
|
||||
"options": [
|
||||
{ "display_name": "dolphin-llama3:latest (8B, uncensored)", "value": "dolphin-llama3:latest" },
|
||||
{ "display_name": "gpt-oss:20b (21B, tools, thinking)", "value": "gpt-oss:20b" },
|
||||
{ "display_name": "llama3:latest (8B, fast)", "value": "llama3:latest" },
|
||||
{ "display_name": "new-adult-writer (8B, tools)", "value": "jimscard/new-adult-writer:latest" },
|
||||
{ "display_name": "Qwen 27B (27B, vision)", "value": "hf.co/DavidAU/Qwen3.6-27B-Heretic-Uncensored-FINETUNE-NEO-CODE-Di-IMatrix-MAX-GGUF:Q4_K_M" },
|
||||
{ "display_name": "supergemma4:26b (26B, uncensored)", "value": "hf.co/Jiunsong/supergemma4-26b-uncensored-gguf-v2:Q4_K_M" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "SystemPrompt",
|
||||
"display_name": "System Prompt",
|
||||
"key": "Persona",
|
||||
"display_name": "Bot Persona",
|
||||
"type": "dropdown",
|
||||
"help_text": "The bot's character. Pick a persona or set a custom one in the config file.",
|
||||
"default": "a helpful assistant. Be concise, 2-3 sentences max, never repeat yourself.",
|
||||
"options": [
|
||||
{ "display_name": "D&D dungeon master", "value": "a Dungeons & Dragons dungeon master. Epic narrative, roll for initiative." },
|
||||
{ "display_name": "Donald Trump", "value": "Donald Trump. Boastful, hyperbolic, third-person. Think: rally speech, not catchphrase list. Keep it to 2 sentences." },
|
||||
{ "display_name": "Drunken Jeanine Pirro", "value": "a drunk Jeanine Pirro. Slur your words, repeat yourself, be loud and uncensored." },
|
||||
{ "display_name": "French mime", "value": "a French mime. Respond with dramatic pauses and gestures described in asterisks. Minimal words." },
|
||||
{ "display_name": "George Carlin", "value": "George Carlin. Acerbic, observational, profane comedy. Rant about the absurdity of everything. Use seven dirty words liberally. Keep it tight, one or two punchlines max." },
|
||||
{ "display_name": "Gordon Ramsay (code review)", "value": "Gordon Ramsay but for code reviews. Aggressive chef. YELL. Brief." },
|
||||
{ "display_name": "Grumpy sysadmin", "value": "a grumpy sysadmin who thinks every question is stupid. Be sarcastic, brief, and annoyed." },
|
||||
{ "display_name": "Helpful assistant (concise)", "value": "a helpful assistant. Be concise, 2-3 sentences max, never repeat yourself." },
|
||||
{ "display_name": "Infomercial host (1980s)", "value": "a 1980s TV infomercial host. Over-enthusiastic, loud, BUT WAIT THERE'S MORE." },
|
||||
{ "display_name": "Medieval court jester", "value": "a medieval court jester who mocks everyone. Old-timey insults, brief." },
|
||||
{ "display_name": "Motivational speaker", "value": "a motivational speaker who turns everything into a life lesson. Over-the-top positive but concise." },
|
||||
{ "display_name": "Noir detective", "value": "a noir detective who answers every question with another question. Brooding, cryptic, short." },
|
||||
{ "display_name": "Overly polite Canadian", "value": "an overly polite Canadian who apologises excessively. Sorry, eh, keep it short." },
|
||||
{ "display_name": "Philosophical overthinker", "value": "a philosopher who overcomplicates everything. Pseudo-intellectual, wordy is the point." },
|
||||
{ "display_name": "Pirate captain", "value": "a pirate captain. Use pirate slang, be theatrical, keep responses short." },
|
||||
{ "display_name": "Shakespearean author", "value": "a Shakespearean author. Respond in eloquent old English, but keep it brief." },
|
||||
{ "display_name": "Surfer dude", "value": "a surfer dude explaining complex topics badly. Chill, inaccurate, gnarly." },
|
||||
{ "display_name": "Tech support scammer (2008)", "value": "a tech support scammer from 2008. Alarmist, pushy, uses ALL CAPS occasionally." },
|
||||
{ "display_name": "Tired parent", "value": "a tired parent who's given up on life. Exhausted, relatable, monosyllabic when possible." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "RepeatPenalty",
|
||||
"display_name": "Repeat Penalty",
|
||||
"type": "number",
|
||||
"help_text": "Penalises repeated words. 10 = none, 11 = mild, 12 = strong. Helps with repetitive output. (Internally divided by 10.)",
|
||||
"default": 11
|
||||
},
|
||||
{
|
||||
"key": "FrequencyPenalty",
|
||||
"display_name": "Frequency Penalty",
|
||||
"type": "number",
|
||||
"help_text": "Penalises frequently used tokens. 0 = none, 1 = mild, 5 = strong. Reduces word/phrase repetition. (Internally divided by 10.)",
|
||||
"default": 1
|
||||
},
|
||||
{
|
||||
"key": "Brevity",
|
||||
"display_name": "Max Sentences",
|
||||
"type": "dropdown",
|
||||
"default": "3",
|
||||
"help_text": "Maximum sentences per response. Suffixed to the persona automatically.",
|
||||
"options": [
|
||||
{ "display_name": "1 sentence", "value": "1" },
|
||||
{ "display_name": "2 sentences", "value": "2" },
|
||||
{ "display_name": "3 sentences", "value": "3" },
|
||||
{ "display_name": "5 sentences", "value": "5" },
|
||||
{ "display_name": "10 sentences", "value": "10" },
|
||||
{ "display_name": "Unlimited", "value": "0" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "CustomPersona",
|
||||
"display_name": "Custom Persona (overrides dropdown)",
|
||||
"type": "text",
|
||||
"help_text": "System prompt prepended to every conversation.",
|
||||
"placeholder": "You are a helpful assistant.",
|
||||
"default": "You are a helpful assistant."
|
||||
"help_text": "Type a custom persona here to override the dropdown. Leave empty to use the dropdown selection. E.g. \"a sarcastic AI that quotes Monty Python\".",
|
||||
"placeholder": "",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"key": "MaxTokens",
|
||||
@@ -53,7 +116,7 @@
|
||||
"display_name": "Rate Limit (requests/minute/user)",
|
||||
"type": "number",
|
||||
"help_text": "Maximum requests per minute per user. 0 = unlimited.",
|
||||
"default": 10
|
||||
"default": 0
|
||||
},
|
||||
{
|
||||
"key": "AllowedUserIDs",
|
||||
|
||||
+21
-10
@@ -8,26 +8,36 @@ import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
)
|
||||
|
||||
const helpText = `### Mattermore -- AI Chat Agent
|
||||
const helpText = `### Matty -- AI Chat Agent
|
||||
|
||||
**@mention the bot in any channel to start a conversation.**
|
||||
|
||||
**Commands:**
|
||||
|
||||
- @mattermore <prompt> -- Chat with the default model
|
||||
- @mattermore model <name> <prompt> -- Use a specific model
|
||||
- @mattermore new -- Reset conversation context
|
||||
- @mattermore model list -- List available models
|
||||
- @mattermore help -- Show this help
|
||||
- @mattermore stop -- End the current conversation
|
||||
- @matty <prompt> -- Chat with the default model
|
||||
- @matty model <name> <prompt> -- Use a specific model
|
||||
- @matty new -- Reset conversation context
|
||||
- @matty model list -- List available models
|
||||
- @matty help -- Show this help
|
||||
- @matty stop -- End the current conversation
|
||||
|
||||
You can also use /ai <prompt> as an alternative.
|
||||
You can also use /ai <prompt> as an alternative. /resetmatty clears memory.
|
||||
|
||||
The bot responds only when addressed and disengages naturally.
|
||||
`
|
||||
|
||||
// ExecuteCommand handles the /ai slash command.
|
||||
// ExecuteCommand handles slash commands (/ai, /resetmatterless).
|
||||
func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
|
||||
// /resetmatty — clear context and end session.
|
||||
if args.Command == "/resetmatty" {
|
||||
if err := p.conversationStore.Reset(args.UserId, args.ChannelId); err != nil {
|
||||
return p.response("Failed to reset conversation."), nil
|
||||
}
|
||||
p.engagementEngine.Sleep(args.UserId, args.ChannelId)
|
||||
return p.response("Matty's memory wiped. @matty to start again."), nil
|
||||
}
|
||||
|
||||
// /ai — dispatch subcommands.
|
||||
trigger := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(args.Command), "/ai"))
|
||||
parts := parseArgs(trigger)
|
||||
|
||||
@@ -39,7 +49,8 @@ func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*mo
|
||||
if err := p.conversationStore.Reset(args.UserId, args.ChannelId); err != nil {
|
||||
return p.response("Failed to reset conversation."), nil
|
||||
}
|
||||
return p.response("Conversation reset. Start fresh with @mattermore <prompt>"), nil
|
||||
p.engagementEngine.Sleep(args.UserId, args.ChannelId)
|
||||
return p.response("Conversation reset. @matty to start again."), nil
|
||||
|
||||
case parts[0] == "model" && len(parts) >= 2 && parts[1] == "list":
|
||||
return p.handleModelList(args)
|
||||
|
||||
@@ -10,7 +10,11 @@ import (
|
||||
type configuration struct {
|
||||
OllamaURL string
|
||||
DefaultModel string
|
||||
SystemPrompt string
|
||||
Persona string
|
||||
CustomPersona string
|
||||
Brevity string
|
||||
RepeatPenalty float64
|
||||
FrequencyPenalty float64
|
||||
MaxTokens int
|
||||
RateLimitPerMinute int
|
||||
AllowedUserIDs string
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EngagementState tracks whether a user is in an active conversation.
|
||||
type EngagementState int
|
||||
|
||||
const (
|
||||
Sleeping EngagementState = 0
|
||||
Active EngagementState = 1
|
||||
)
|
||||
|
||||
// engagementSession tracks a single user's active conversation.
|
||||
type engagementSession struct {
|
||||
userID string
|
||||
channelID string
|
||||
state EngagementState
|
||||
lastActive time.Time
|
||||
}
|
||||
|
||||
// EngagementEngine manages active conversations per user+channel.
|
||||
// When a user @mentions the bot, they enter an Active session.
|
||||
// Their subsequent non-@mention messages in the same channel
|
||||
// are treated as continuations until a stop word or timeout.
|
||||
type EngagementEngine struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*engagementSession
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewEngagementEngine creates a new engine with the given inactivity timeout.
|
||||
func NewEngagementEngine() *EngagementEngine {
|
||||
return &EngagementEngine{
|
||||
sessions: make(map[string]*engagementSession),
|
||||
timeout: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionKey builds a unique key per user+channel.
|
||||
func (e *EngagementEngine) sessionKey(userID, channelID string) string {
|
||||
return userID + ":" + channelID
|
||||
}
|
||||
|
||||
// Wake marks a user+channel as actively engaged with the bot.
|
||||
func (e *EngagementEngine) Wake(userID, channelID string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
key := e.sessionKey(userID, channelID)
|
||||
e.sessions[key] = &engagementSession{
|
||||
userID: userID,
|
||||
channelID: channelID,
|
||||
state: Active,
|
||||
lastActive: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsActive returns true if the user+channel is in an active conversation.
|
||||
func (e *EngagementEngine) IsActive(userID, channelID string) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
key := e.sessionKey(userID, channelID)
|
||||
s, exists := e.sessions[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
if time.Since(s.lastActive) > e.timeout {
|
||||
delete(e.sessions, key)
|
||||
return false
|
||||
}
|
||||
s.lastActive = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// Sleep ends the active conversation for a user+channel.
|
||||
func (e *EngagementEngine) Sleep(userID, channelID string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
key := e.sessionKey(userID, channelID)
|
||||
delete(e.sessions, key)
|
||||
}
|
||||
+105
-82
@@ -3,14 +3,13 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
)
|
||||
|
||||
const botUsername = "mattermore"
|
||||
const botDisplayName = "Mattermore AI"
|
||||
const botUsername = "matty"
|
||||
const botDisplayName = "Matty AI"
|
||||
const botDescription = "AI chat agent powered by Ollama."
|
||||
|
||||
// OnActivate is invoked when the plugin is activated.
|
||||
@@ -18,8 +17,8 @@ func (p *Plugin) OnActivate() error {
|
||||
// Register the /ai slash command.
|
||||
if err := p.API.RegisterCommand(&model.Command{
|
||||
Trigger: "ai",
|
||||
DisplayName: "Mattermore",
|
||||
Description: "AI chat agent powered by Ollama.",
|
||||
DisplayName: "Matty",
|
||||
Description: "AI chat powered by Ollama. @matty",
|
||||
AutoComplete: true,
|
||||
AutoCompleteDesc: "Chat with the AI. Usage: /ai <prompt>",
|
||||
AutoCompleteHint: "<prompt>",
|
||||
@@ -27,17 +26,24 @@ func (p *Plugin) OnActivate() error {
|
||||
return fmt.Errorf("register command: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the bot account exists.
|
||||
bot := &model.Bot{
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayName,
|
||||
Description: botDescription,
|
||||
// Register the /resetmatty command.
|
||||
if err := p.API.RegisterCommand(&model.Command{
|
||||
Trigger: "resetmatty",
|
||||
DisplayName: "Reset Matty",
|
||||
Description: "Clear conversation context and end active session.",
|
||||
AutoComplete: true,
|
||||
AutoCompleteDesc: "Clear Matty's memory for this channel.",
|
||||
AutoCompleteHint: "",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register reset command: %w", err)
|
||||
}
|
||||
createdBot, appErr := p.API.CreateBot(bot)
|
||||
if appErr != nil {
|
||||
return fmt.Errorf("create bot: %w", appErr)
|
||||
|
||||
// Ensure the bot account exists — create or re-use.
|
||||
var err error
|
||||
p.botUserID, err = p.ensureBot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("bot setup: %w", err)
|
||||
}
|
||||
p.botUserID = createdBot.UserId
|
||||
|
||||
// Initialise components.
|
||||
cfg := p.getConfiguration()
|
||||
@@ -52,10 +58,44 @@ func (p *Plugin) OnActivate() error {
|
||||
p.ollamaClient = NewOllamaClient(cfg.OllamaURL)
|
||||
p.conversationStore = NewConversationStore(p.API)
|
||||
p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute)
|
||||
p.engagementEngine = NewEngagementEngine()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureBot finds or creates the bot account and returns its user ID.
|
||||
func (p *Plugin) ensureBot() (string, error) {
|
||||
// Check if a user with the bot username already exists.
|
||||
user, appErr := p.API.GetUserByUsername(botUsername)
|
||||
if appErr == nil && user != nil {
|
||||
// User exists — check if it's a bot.
|
||||
if _, botErr := p.API.GetBot(user.Id, true); botErr == nil {
|
||||
return user.Id, nil
|
||||
}
|
||||
// User exists but is not a bot — create the bot association.
|
||||
createdBot, createErr := p.API.CreateBot(&model.Bot{
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayName,
|
||||
Description: botDescription,
|
||||
})
|
||||
if createErr != nil {
|
||||
return "", fmt.Errorf("account exists but bot creation failed: %w", createErr)
|
||||
}
|
||||
return createdBot.UserId, nil
|
||||
}
|
||||
|
||||
// No existing user — create bot fresh.
|
||||
createdBot, appErr := p.API.CreateBot(&model.Bot{
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayName,
|
||||
Description: botDescription,
|
||||
})
|
||||
if appErr != nil {
|
||||
return "", fmt.Errorf("create bot: %w", appErr)
|
||||
}
|
||||
return createdBot.UserId, nil
|
||||
}
|
||||
|
||||
// OnDeactivate is invoked when the plugin is deactivated.
|
||||
func (p *Plugin) OnDeactivate() error {
|
||||
// The bot account persists across restarts — it will be re-used
|
||||
@@ -86,14 +126,7 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a thread reply to a post we made.
|
||||
threadOwned := false
|
||||
if post.RootId != "" {
|
||||
rootPost, appErr := p.API.GetPost(post.RootId)
|
||||
if appErr == nil && rootPost.UserId == p.botUserID {
|
||||
threadOwned = true
|
||||
}
|
||||
}
|
||||
cfg := p.getConfiguration()
|
||||
|
||||
// Check if the bot is @-mentioned in the message.
|
||||
mentioned := strings.Contains(
|
||||
@@ -101,12 +134,14 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
||||
"@"+botUsername,
|
||||
)
|
||||
|
||||
// If not mentioned and not a thread reply, ignore.
|
||||
if !mentioned && !threadOwned {
|
||||
isActive := p.engagementEngine.IsActive(post.UserId, post.ChannelId)
|
||||
|
||||
// If not mentioned and not in an active conversation, ignore.
|
||||
if !mentioned && !isActive {
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prompt by stripping the @-mention.
|
||||
// Build the prompt.
|
||||
prompt := post.Message
|
||||
if mentioned {
|
||||
prompt = strings.TrimSpace(strings.Replace(
|
||||
@@ -118,9 +153,9 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
||||
}
|
||||
|
||||
// Handle stop words before sending to Ollama.
|
||||
cfg := p.getConfiguration()
|
||||
if stopWordMatch(prompt, cfg.stopWordSet()) {
|
||||
p.postReply(post, "Goodbye! Feel free to @mention me again anytime.")
|
||||
p.engagementEngine.Sleep(post.UserId, post.ChannelId)
|
||||
p.postReply(post, "Goodbye! _Matty_ signing off. :wave:")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,6 +171,11 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
||||
return
|
||||
}
|
||||
|
||||
// Wake or refresh the engagement session.
|
||||
if mentioned {
|
||||
p.engagementEngine.Wake(post.UserId, post.ChannelId)
|
||||
}
|
||||
|
||||
// Build conversation context.
|
||||
messages, err := p.conversationStore.BuildMessages(post.UserId, post.ChannelId, prompt)
|
||||
if err != nil {
|
||||
@@ -143,86 +183,69 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create the initial thread post with a placeholder.
|
||||
replyPost := &model.Post{
|
||||
UserId: p.botUserID,
|
||||
ChannelId: post.ChannelId,
|
||||
Message: "…",
|
||||
RootId: post.RootId,
|
||||
// Prepend the persona system prompt to set the bot's character.
|
||||
persona := cfg.CustomPersona
|
||||
if persona == "" {
|
||||
persona = cfg.Persona
|
||||
}
|
||||
if replyPost.RootId == "" {
|
||||
replyPost.RootId = post.Id
|
||||
if persona != "" {
|
||||
systemContent := "You are " + persona + ". Respond in character. Only respond to the latest message — do not re-address earlier messages. Vary your openings and phrasing each time."
|
||||
// Append brevity instruction if set.
|
||||
if cfg.Brevity != "" && cfg.Brevity != "0" {
|
||||
systemContent += " Reply in " + cfg.Brevity + " sentences or fewer."
|
||||
}
|
||||
systemMsg := ChatMessage{
|
||||
Role: "system",
|
||||
Content: systemContent,
|
||||
}
|
||||
messages = append([]ChatMessage{systemMsg}, messages...)
|
||||
}
|
||||
|
||||
createdPost, appErr := p.API.CreatePost(replyPost)
|
||||
if appErr != nil {
|
||||
p.API.LogError("Failed to create reply post", "error", appErr.Error())
|
||||
return
|
||||
}
|
||||
p.API.LogDebug("Chat request", "model", cfg.DefaultModel, "persona", persona, "brevity", cfg.Brevity)
|
||||
|
||||
// Stream the response from Ollama.
|
||||
// Stream the full response from Ollama, then post once.
|
||||
opts := map[string]any{}
|
||||
if cfg.MaxTokens > 0 {
|
||||
opts["num_predict"] = cfg.MaxTokens
|
||||
}
|
||||
if cfg.RepeatPenalty > 0 {
|
||||
opts["repeat_penalty"] = float64(cfg.RepeatPenalty) / 10.0
|
||||
}
|
||||
if cfg.FrequencyPenalty > 0 {
|
||||
opts["frequency_penalty"] = float64(cfg.FrequencyPenalty) / 10.0
|
||||
}
|
||||
stream, err := p.ollamaClient.ChatCompletionStream(&ChatRequest{
|
||||
Model: cfg.DefaultModel,
|
||||
Messages: messages,
|
||||
Options: map[string]any{},
|
||||
Options: opts,
|
||||
})
|
||||
if err != nil {
|
||||
p.postReply(post, fmt.Sprintf("Failed to get response from Ollama: %v", err))
|
||||
p.postReply(post, "Matty is asleep. @matty when you need me.")
|
||||
return
|
||||
}
|
||||
|
||||
// Read the stream and update the post periodically.
|
||||
var accumulated strings.Builder
|
||||
updateTicker := time.NewTicker(200 * time.Millisecond)
|
||||
defer updateTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-stream:
|
||||
if !ok {
|
||||
// Stream closed unexpectedly.
|
||||
p.finalisePost(createdPost, accumulated.String())
|
||||
return
|
||||
}
|
||||
var fullResponse strings.Builder
|
||||
for event := range stream {
|
||||
if event.Error != nil {
|
||||
p.API.LogError("Stream error", "error", event.Error.Error())
|
||||
p.finalisePost(createdPost, accumulated.String()+"\n\n*Error: response truncated*")
|
||||
return
|
||||
fullResponse.WriteString("\n\n(Matty is asleep. @matty when you need me.)")
|
||||
break
|
||||
}
|
||||
accumulated.WriteString(event.Token)
|
||||
fullResponse.WriteString(event.Token)
|
||||
if event.Done {
|
||||
p.finalisePost(createdPost, accumulated.String())
|
||||
return
|
||||
}
|
||||
case <-updateTicker.C:
|
||||
if accumulated.Len() > 0 {
|
||||
createdPost.Message = accumulated.String()
|
||||
p.API.UpdatePost(createdPost)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
p.postReply(post, fullResponse.String())
|
||||
}
|
||||
|
||||
// finalisePost updates the post with the final content and persists the session.
|
||||
func (p *Plugin) finalisePost(post *model.Post, content string) {
|
||||
post.Message = content
|
||||
if _, appErr := p.API.UpdatePost(post); appErr != nil {
|
||||
p.API.LogError("Failed to finalise post", "error", appErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// postReply creates a reply post in the same thread as the given post.
|
||||
// postReply creates a direct channel post.
|
||||
func (p *Plugin) postReply(replyTo *model.Post, text string) {
|
||||
replyPost := &model.Post{
|
||||
UserId: p.botUserID,
|
||||
ChannelId: replyTo.ChannelId,
|
||||
Message: text,
|
||||
RootId: replyTo.RootId,
|
||||
}
|
||||
|
||||
// If the original post has no root, use the original post ID as root.
|
||||
if replyPost.RootId == "" {
|
||||
replyPost.RootId = replyTo.Id
|
||||
}
|
||||
|
||||
if _, appErr := p.API.CreatePost(replyPost); appErr != nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ type Plugin struct {
|
||||
ollamaClient *OllamaClient
|
||||
conversationStore *ConversationStore
|
||||
rateLimiter *RateLimiter
|
||||
engagementEngine *EngagementEngine
|
||||
}
|
||||
|
||||
// ServeHTTP allows the plugin to handle HTTP requests.
|
||||
|
||||
+6
-2
@@ -19,8 +19,8 @@ type bucket struct {
|
||||
|
||||
// NewRateLimiter creates a rate limiter with the given rate (requests/minute).
|
||||
func NewRateLimiter(ratePerMinute int) *RateLimiter {
|
||||
if ratePerMinute <= 0 {
|
||||
ratePerMinute = 10
|
||||
if ratePerMinute < 0 {
|
||||
ratePerMinute = 0
|
||||
}
|
||||
return &RateLimiter{
|
||||
users: make(map[string]*bucket),
|
||||
@@ -30,6 +30,10 @@ func NewRateLimiter(ratePerMinute int) *RateLimiter {
|
||||
|
||||
// Allow checks if a request from the given user ID should be allowed.
|
||||
func (rl *RateLimiter) Allow(userID string) bool {
|
||||
// 0 = unlimited.
|
||||
if rl.rate == 0 {
|
||||
return true
|
||||
}
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ func (s *ConversationStore) saveSession(session *conversationSession) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// stopWordMatch checks if the trimmed, lowercased prompt matches a stop word.
|
||||
// stopWordMatch checks if the trimmed, lowercased prompt matches a stop word exactly.
|
||||
func stopWordMatch(prompt string, stopWords map[string]bool) bool {
|
||||
if stopWords == nil {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user