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:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# Database
|
# Database — full connection string (env format: MM_{SECTION}_{KEY})
|
||||||
MM_USERNAME: mmuser
|
MM_SQLSETTINGS_DATASOURCE: "postgres://mmuser:mmuser_pass@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10"
|
||||||
MM_PASSWORD: mmuser_pass
|
# Plugin + file storage — use /tmp to avoid permission issues
|
||||||
MM_DBNAME: mattermost_test
|
MM_PLUGINSETTINGS_DIRECTORY: /tmp/plugins
|
||||||
MM_DBHOST: postgres:5432
|
MM_FILESETTINGS_DIRECTORY: /tmp/file_data
|
||||||
# Server
|
# Server
|
||||||
MM_SERVICESETTINGS_SITEURL: http://localhost:8065
|
MM_SERVICESETTINGS_SITEURL: http://localhost:8065
|
||||||
MM_SERVICESETTINGS_LISTENADDRESS: :8065
|
MM_SERVICESETTINGS_LISTENADDRESS: :8065
|
||||||
@@ -46,21 +46,44 @@ services:
|
|||||||
MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false"
|
MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false"
|
||||||
ports:
|
ports:
|
||||||
- "8065:8065"
|
- "8065:8065"
|
||||||
# Auto-create admin user (admin/admin) on first boot.
|
|
||||||
entrypoint: ["/bin/bash", "/init.sh"]
|
|
||||||
volumes:
|
volumes:
|
||||||
# Init script creates admin user on first startup.
|
|
||||||
- ./init.sh:/init.sh:ro
|
|
||||||
# Plugin directory — mount so you can upload via UI:
|
# Plugin directory — mount so you can upload via UI:
|
||||||
- ./mattermost/plugins:/mattermost/data/plugins:rw
|
- ./mattermost/plugins:/mattermost/data/plugins:rw
|
||||||
# Data volume for uploads, avatars, etc.
|
# Data volume for uploads, avatars, etc.
|
||||||
- mattermost-data:/mattermost/data:rw
|
- mattermost-data:/mattermost/data:rw
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:8065/api/v4/system/ping | grep -q OK"]
|
test: ["CMD", "pgrep", "mattermost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 30
|
||||||
start_period: 30s
|
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:
|
volumes:
|
||||||
mattermost-data:
|
mattermost-data:
|
||||||
|
|||||||
+75
-12
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "com.forkless.mattermore",
|
"id": "com.forkless.mattermore",
|
||||||
"name": "Mattermore",
|
"name": "Matty",
|
||||||
"description": "AI chat agent powered by Ollama.",
|
"description": "AI chat agent powered by Ollama.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"min_server_version": "11.6.1",
|
"min_server_version": "11.6.1",
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
"darwin-arm64": "server/dist/plugin-darwin-arm64"
|
"darwin-arm64": "server/dist/plugin-darwin-arm64"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"webapp": {},
|
|
||||||
"settings_schema": {
|
"settings_schema": {
|
||||||
"header": "Configure the connection to your Ollama instance.",
|
"header": "Configure the connection to your Ollama instance.",
|
||||||
"footer": "",
|
"footer": "",
|
||||||
@@ -28,18 +27,82 @@
|
|||||||
{
|
{
|
||||||
"key": "DefaultModel",
|
"key": "DefaultModel",
|
||||||
"display_name": "Default Model",
|
"display_name": "Default Model",
|
||||||
"type": "text",
|
"type": "dropdown",
|
||||||
"help_text": "Model name to use when none is specified.",
|
"help_text": "Model to use for chat responses.",
|
||||||
"placeholder": "llama3:latest",
|
"default": "llama3:latest",
|
||||||
"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",
|
"key": "Persona",
|
||||||
"display_name": "System Prompt",
|
"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",
|
"type": "text",
|
||||||
"help_text": "System prompt prepended to every conversation.",
|
"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": "You are a helpful assistant.",
|
"placeholder": "",
|
||||||
"default": "You are a helpful assistant."
|
"default": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "MaxTokens",
|
"key": "MaxTokens",
|
||||||
@@ -53,7 +116,7 @@
|
|||||||
"display_name": "Rate Limit (requests/minute/user)",
|
"display_name": "Rate Limit (requests/minute/user)",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"help_text": "Maximum requests per minute per user. 0 = unlimited.",
|
"help_text": "Maximum requests per minute per user. 0 = unlimited.",
|
||||||
"default": 10
|
"default": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "AllowedUserIDs",
|
"key": "AllowedUserIDs",
|
||||||
|
|||||||
+21
-10
@@ -8,26 +8,36 @@ import (
|
|||||||
"github.com/mattermost/mattermost/server/public/plugin"
|
"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.**
|
**@mention the bot in any channel to start a conversation.**
|
||||||
|
|
||||||
**Commands:**
|
**Commands:**
|
||||||
|
|
||||||
- @mattermore <prompt> -- Chat with the default model
|
- @matty <prompt> -- Chat with the default model
|
||||||
- @mattermore model <name> <prompt> -- Use a specific model
|
- @matty model <name> <prompt> -- Use a specific model
|
||||||
- @mattermore new -- Reset conversation context
|
- @matty new -- Reset conversation context
|
||||||
- @mattermore model list -- List available models
|
- @matty model list -- List available models
|
||||||
- @mattermore help -- Show this help
|
- @matty help -- Show this help
|
||||||
- @mattermore stop -- End the current conversation
|
- @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.
|
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) {
|
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"))
|
trigger := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(args.Command), "/ai"))
|
||||||
parts := parseArgs(trigger)
|
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 {
|
if err := p.conversationStore.Reset(args.UserId, args.ChannelId); err != nil {
|
||||||
return p.response("Failed to reset conversation."), 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":
|
case parts[0] == "model" && len(parts) >= 2 && parts[1] == "list":
|
||||||
return p.handleModelList(args)
|
return p.handleModelList(args)
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import (
|
|||||||
type configuration struct {
|
type configuration struct {
|
||||||
OllamaURL string
|
OllamaURL string
|
||||||
DefaultModel string
|
DefaultModel string
|
||||||
SystemPrompt string
|
Persona string
|
||||||
|
CustomPersona string
|
||||||
|
Brevity string
|
||||||
|
RepeatPenalty float64
|
||||||
|
FrequencyPenalty float64
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
RateLimitPerMinute int
|
RateLimitPerMinute int
|
||||||
AllowedUserIDs string
|
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)
|
||||||
|
}
|
||||||
+109
-86
@@ -3,14 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
"github.com/mattermost/mattermost/server/public/plugin"
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const botUsername = "mattermore"
|
const botUsername = "matty"
|
||||||
const botDisplayName = "Mattermore AI"
|
const botDisplayName = "Matty AI"
|
||||||
const botDescription = "AI chat agent powered by Ollama."
|
const botDescription = "AI chat agent powered by Ollama."
|
||||||
|
|
||||||
// OnActivate is invoked when the plugin is activated.
|
// OnActivate is invoked when the plugin is activated.
|
||||||
@@ -18,8 +17,8 @@ func (p *Plugin) OnActivate() error {
|
|||||||
// Register the /ai slash command.
|
// Register the /ai slash command.
|
||||||
if err := p.API.RegisterCommand(&model.Command{
|
if err := p.API.RegisterCommand(&model.Command{
|
||||||
Trigger: "ai",
|
Trigger: "ai",
|
||||||
DisplayName: "Mattermore",
|
DisplayName: "Matty",
|
||||||
Description: "AI chat agent powered by Ollama.",
|
Description: "AI chat powered by Ollama. @matty",
|
||||||
AutoComplete: true,
|
AutoComplete: true,
|
||||||
AutoCompleteDesc: "Chat with the AI. Usage: /ai <prompt>",
|
AutoCompleteDesc: "Chat with the AI. Usage: /ai <prompt>",
|
||||||
AutoCompleteHint: "<prompt>",
|
AutoCompleteHint: "<prompt>",
|
||||||
@@ -27,17 +26,24 @@ func (p *Plugin) OnActivate() error {
|
|||||||
return fmt.Errorf("register command: %w", err)
|
return fmt.Errorf("register command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the bot account exists.
|
// Register the /resetmatty command.
|
||||||
bot := &model.Bot{
|
if err := p.API.RegisterCommand(&model.Command{
|
||||||
Username: botUsername,
|
Trigger: "resetmatty",
|
||||||
DisplayName: botDisplayName,
|
DisplayName: "Reset Matty",
|
||||||
Description: botDescription,
|
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 {
|
// Ensure the bot account exists — create or re-use.
|
||||||
return fmt.Errorf("create bot: %w", appErr)
|
var err error
|
||||||
|
p.botUserID, err = p.ensureBot()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bot setup: %w", err)
|
||||||
}
|
}
|
||||||
p.botUserID = createdBot.UserId
|
|
||||||
|
|
||||||
// Initialise components.
|
// Initialise components.
|
||||||
cfg := p.getConfiguration()
|
cfg := p.getConfiguration()
|
||||||
@@ -52,10 +58,44 @@ func (p *Plugin) OnActivate() error {
|
|||||||
p.ollamaClient = NewOllamaClient(cfg.OllamaURL)
|
p.ollamaClient = NewOllamaClient(cfg.OllamaURL)
|
||||||
p.conversationStore = NewConversationStore(p.API)
|
p.conversationStore = NewConversationStore(p.API)
|
||||||
p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute)
|
p.rateLimiter = NewRateLimiter(cfg.RateLimitPerMinute)
|
||||||
|
p.engagementEngine = NewEngagementEngine()
|
||||||
|
|
||||||
return nil
|
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.
|
// OnDeactivate is invoked when the plugin is deactivated.
|
||||||
func (p *Plugin) OnDeactivate() error {
|
func (p *Plugin) OnDeactivate() error {
|
||||||
// The bot account persists across restarts — it will be re-used
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a thread reply to a post we made.
|
cfg := p.getConfiguration()
|
||||||
threadOwned := false
|
|
||||||
if post.RootId != "" {
|
|
||||||
rootPost, appErr := p.API.GetPost(post.RootId)
|
|
||||||
if appErr == nil && rootPost.UserId == p.botUserID {
|
|
||||||
threadOwned = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the bot is @-mentioned in the message.
|
// Check if the bot is @-mentioned in the message.
|
||||||
mentioned := strings.Contains(
|
mentioned := strings.Contains(
|
||||||
@@ -101,12 +134,14 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
|||||||
"@"+botUsername,
|
"@"+botUsername,
|
||||||
)
|
)
|
||||||
|
|
||||||
// If not mentioned and not a thread reply, ignore.
|
isActive := p.engagementEngine.IsActive(post.UserId, post.ChannelId)
|
||||||
if !mentioned && !threadOwned {
|
|
||||||
|
// If not mentioned and not in an active conversation, ignore.
|
||||||
|
if !mentioned && !isActive {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the prompt by stripping the @-mention.
|
// Build the prompt.
|
||||||
prompt := post.Message
|
prompt := post.Message
|
||||||
if mentioned {
|
if mentioned {
|
||||||
prompt = strings.TrimSpace(strings.Replace(
|
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.
|
// Handle stop words before sending to Ollama.
|
||||||
cfg := p.getConfiguration()
|
|
||||||
if stopWordMatch(prompt, cfg.stopWordSet()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +171,11 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wake or refresh the engagement session.
|
||||||
|
if mentioned {
|
||||||
|
p.engagementEngine.Wake(post.UserId, post.ChannelId)
|
||||||
|
}
|
||||||
|
|
||||||
// Build conversation context.
|
// Build conversation context.
|
||||||
messages, err := p.conversationStore.BuildMessages(post.UserId, post.ChannelId, prompt)
|
messages, err := p.conversationStore.BuildMessages(post.UserId, post.ChannelId, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,86 +183,69 @@ func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the initial thread post with a placeholder.
|
// Prepend the persona system prompt to set the bot's character.
|
||||||
replyPost := &model.Post{
|
persona := cfg.CustomPersona
|
||||||
UserId: p.botUserID,
|
if persona == "" {
|
||||||
ChannelId: post.ChannelId,
|
persona = cfg.Persona
|
||||||
Message: "…",
|
|
||||||
RootId: post.RootId,
|
|
||||||
}
|
}
|
||||||
if replyPost.RootId == "" {
|
if persona != "" {
|
||||||
replyPost.RootId = post.Id
|
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)
|
p.API.LogDebug("Chat request", "model", cfg.DefaultModel, "persona", persona, "brevity", cfg.Brevity)
|
||||||
if appErr != nil {
|
|
||||||
p.API.LogError("Failed to create reply post", "error", appErr.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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{
|
stream, err := p.ollamaClient.ChatCompletionStream(&ChatRequest{
|
||||||
Model: cfg.DefaultModel,
|
Model: cfg.DefaultModel,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Options: map[string]any{},
|
Options: opts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the stream and update the post periodically.
|
var fullResponse strings.Builder
|
||||||
var accumulated strings.Builder
|
for event := range stream {
|
||||||
updateTicker := time.NewTicker(200 * time.Millisecond)
|
if event.Error != nil {
|
||||||
defer updateTicker.Stop()
|
p.API.LogError("Stream error", "error", event.Error.Error())
|
||||||
|
fullResponse.WriteString("\n\n(Matty is asleep. @matty when you need me.)")
|
||||||
for {
|
break
|
||||||
select {
|
}
|
||||||
case event, ok := <-stream:
|
fullResponse.WriteString(event.Token)
|
||||||
if !ok {
|
if event.Done {
|
||||||
// Stream closed unexpectedly.
|
break
|
||||||
p.finalisePost(createdPost, accumulated.String())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if event.Error != nil {
|
|
||||||
p.API.LogError("Stream error", "error", event.Error.Error())
|
|
||||||
p.finalisePost(createdPost, accumulated.String()+"\n\n*Error: response truncated*")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accumulated.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.postReply(post, fullResponse.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalisePost updates the post with the final content and persists the session.
|
// postReply creates a direct channel post.
|
||||||
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.
|
|
||||||
func (p *Plugin) postReply(replyTo *model.Post, text string) {
|
func (p *Plugin) postReply(replyTo *model.Post, text string) {
|
||||||
replyPost := &model.Post{
|
replyPost := &model.Post{
|
||||||
UserId: p.botUserID,
|
UserId: p.botUserID,
|
||||||
ChannelId: replyTo.ChannelId,
|
ChannelId: replyTo.ChannelId,
|
||||||
Message: text,
|
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 {
|
if _, appErr := p.API.CreatePost(replyPost); appErr != nil {
|
||||||
|
|||||||
+6
-5
@@ -11,12 +11,13 @@ import (
|
|||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
plugin.MattermostPlugin
|
plugin.MattermostPlugin
|
||||||
|
|
||||||
configuration *configuration
|
configuration *configuration
|
||||||
configLock sync.RWMutex
|
configLock sync.RWMutex
|
||||||
botUserID string
|
botUserID string
|
||||||
ollamaClient *OllamaClient
|
ollamaClient *OllamaClient
|
||||||
conversationStore *ConversationStore
|
conversationStore *ConversationStore
|
||||||
rateLimiter *RateLimiter
|
rateLimiter *RateLimiter
|
||||||
|
engagementEngine *EngagementEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP allows the plugin to handle HTTP requests.
|
// 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).
|
// NewRateLimiter creates a rate limiter with the given rate (requests/minute).
|
||||||
func NewRateLimiter(ratePerMinute int) *RateLimiter {
|
func NewRateLimiter(ratePerMinute int) *RateLimiter {
|
||||||
if ratePerMinute <= 0 {
|
if ratePerMinute < 0 {
|
||||||
ratePerMinute = 10
|
ratePerMinute = 0
|
||||||
}
|
}
|
||||||
return &RateLimiter{
|
return &RateLimiter{
|
||||||
users: make(map[string]*bucket),
|
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.
|
// Allow checks if a request from the given user ID should be allowed.
|
||||||
func (rl *RateLimiter) Allow(userID string) bool {
|
func (rl *RateLimiter) Allow(userID string) bool {
|
||||||
|
// 0 = unlimited.
|
||||||
|
if rl.rate == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
defer rl.mu.Unlock()
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ func (s *ConversationStore) saveSession(session *conversationSession) error {
|
|||||||
return err
|
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 {
|
func stopWordMatch(prompt string, stopWords map[string]bool) bool {
|
||||||
if stopWords == nil {
|
if stopWords == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user