Add EngagementEngine, persona system, dropdowns, penalties, /resetmatty, debug logging

This commit is contained in:
2026-06-16 19:45:50 +02:00
parent 83da1698c0
commit 31a81fbfaa
10 changed files with 411 additions and 129 deletions
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -1
View File
@@ -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
+83
View File
@@ -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)
}
+104 -81
View File
@@ -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
}
}
// 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())
}
p.postReply(post, fullResponse.String())
}
// 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 {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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