Files
mattermore/server/hooks.go
T

317 lines
11 KiB
Go

package main
import (
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
const botUsername = "matty"
const botDisplayName = "Matty AI"
const botDescription = "AI chat agent powered by Ollama."
// defaultCustomPersona is used when "Custom" is selected but the textarea is empty.
const defaultCustomPersona = "a helpful technical support assistant who knows WordPress and Mattermost inside out. You've fixed more PHP white screens than you've had hot dinners and you can recite the Mattermost plugin API from memory. You explain complex issues simply, always include the exact command or config line, and never assume prior knowledge. You're patient, direct, and you don't flinch at legacy code.\n\nGive the diagnosis and fix plainly first. Then explain why it works so the user learns something. Keep responses under 5 sentences unless the user asks for details. Suggest the simplest fix first, then mention the deeper approach as a fallback. Never guess — if you're unsure, say so."
// OnActivate is invoked when the plugin is activated.
func (p *Plugin) OnActivate() error {
// Register the /ai slash command.
if err := p.API.RegisterCommand(&model.Command{
Trigger: "ai",
DisplayName: "Matty",
Description: "AI chat powered by Ollama. @matty",
AutoComplete: true,
AutoCompleteDesc: "Chat with the AI. Usage: /ai <prompt>",
AutoCompleteHint: "<prompt>",
}); err != nil {
return fmt.Errorf("register command: %w", err)
}
// 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)
}
// Initialise components.
cfg := p.getConfiguration()
if cfg == nil {
// Load configuration first.
if err := p.OnConfigurationChange(); err != nil {
return fmt.Errorf("initial configuration: %w", err)
}
cfg = p.getConfiguration()
}
p.ollamaClient = NewOllamaClient(cfg.OllamaURL, p.API.LogInfo)
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
// on next OnActivate. No explicit cleanup needed.
return nil
}
// Implemented returns the list of hooks this plugin handles.
func (p *Plugin) Implemented() ([]string, error) {
return []string{
"OnActivate",
"OnDeactivate",
"OnConfigurationChange",
"ExecuteCommand",
"MessageHasBeenPosted",
}, nil
}
// MessageHasBeenPosted is the primary entry point for @-mention interaction.
func (p *Plugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
// Never respond to our own posts.
if post.UserId == p.botUserID {
return
}
// Never respond to other bots.
if post.IsFromOAuthBot() {
return
}
cfg := p.getConfiguration()
// Check if the bot is @-mentioned in the message.
mentioned := strings.Contains(
strings.ToLower(post.Message),
"@"+botUsername,
)
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.
prompt := post.Message
if mentioned {
prompt = strings.TrimSpace(strings.Replace(
strings.ToLower(prompt),
"@"+botUsername,
"",
1,
))
}
// Handle stop words before sending to Ollama.
if stopWordMatch(prompt, cfg.stopWordSet()) {
p.engagementEngine.Sleep(post.UserId, post.ChannelId)
p.postReply(post, "Goodbye! _Matty_ signing off. :wave:")
return
}
// Authorisation check.
if users := cfg.allowedUsers(); users != nil && !users[post.UserId] {
p.postReply(post, "You are not authorised to use this bot.")
return
}
// Rate limit check.
if !p.rateLimiter.Allow(post.UserId) {
p.postReply(post, "Rate limit exceeded. Please wait before sending another request.")
return
}
// Wake or refresh the engagement session.
// Fresh @-mention resets conversation history so old context doesn't bleed.
if mentioned {
_ = p.conversationStore.Reset(post.UserId, post.ChannelId)
p.engagementEngine.Wake(post.UserId, post.ChannelId)
}
// Build conversation context.
messages, err := p.conversationStore.BuildMessages(post.UserId, post.ChannelId, prompt)
if err != nil {
p.postReply(post, "Failed to build conversation context.")
return
}
// Limit conversation history based on brevity — fewer sentences = less context
// to prevent the model re-addressing old messages.
if cfg.Brevity != "" && cfg.Brevity != "0" {
brevity := 0
fmt.Sscanf(cfg.Brevity, "%d", &brevity)
if brevity > 0 && len(messages) > 2 {
// Keep: system prompt (added later) + (brevity * 2) lines of history + current prompt
keep := brevity * 2
if keep < 2 {
keep = 2 // always keep at least the current exchange
}
if keep < len(messages) {
messages = messages[len(messages)-keep:]
}
}
}
// Prepend the persona system prompt to set the bot's character.
// Priority: KV override > dropdown selection
// KV "custom" or dropdown "custom" → use CustomPersona textarea field
// Otherwise → resolve from personaLibrary
persona := p.GetPersonaOverride(post.UserId, post.ChannelId)
if persona == "custom" {
persona = cfg.CustomPersona
if persona == "" {
persona = defaultCustomPersona
}
} else {
persona = resolvePersona(persona)
}
if persona == "" {
if cfg.Persona == "custom" {
persona = cfg.CustomPersona
if persona == "" {
persona = defaultCustomPersona
}
} else {
persona = resolvePersona(cfg.Persona)
// If resolvePersona returned empty but a value was set, use it as-is (legacy fallback).
if persona == "" && cfg.Persona != "" {
persona = cfg.Persona
}
}
}
systemPrompt := ""
if persona != "" {
systemPrompt = "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."
if cfg.Brevity != "" && cfg.Brevity != "0" {
systemPrompt += " Reply in " + cfg.Brevity + " sentences or fewer."
}
messages = append([]ChatMessage{{
Role: "system",
Content: systemPrompt,
}}, messages...)
}
// Calculate the resolved token cap.
maxTokens := cfg.MaxTokens
if cfg.Brevity != "" && cfg.Brevity != "0" {
brevity := 0
fmt.Sscanf(cfg.Brevity, "%d", &brevity)
if brevity > 0 {
suggested := 100 + brevity*75
if maxTokens == 0 || suggested < maxTokens {
maxTokens = suggested
}
}
}
if cfg.DebugLogging {
p.API.LogInfo("Chat request",
"model", cfg.DefaultModel,
"ollama_url", cfg.OllamaURL,
"persona", cfg.Persona,
"custom_persona", cfg.CustomPersona,
"brevity", cfg.Brevity,
"max_tokens_config", cfg.MaxTokens,
"max_tokens_resolved", maxTokens,
"repeat", cfg.RepeatPenalty,
"freq", cfg.FrequencyPenalty,
"rate_limit", cfg.RateLimitPerMinute,
"allowed_users", cfg.AllowedUserIDs,
"stop_words", cfg.StopWords,
"system", systemPrompt)
}
// Stream the full response from Ollama, then post once.
opts := map[string]any{}
if maxTokens > 0 {
opts["num_predict"] = 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: opts,
})
if err != nil {
p.postReply(post, "Matty is asleep. @matty when you need me.")
return
}
var fullResponse strings.Builder
for event := range stream {
if event.Error != nil {
p.API.LogError("Stream error", "error", event.Error.Error())
fullResponse.WriteString("\n\n(Matty is asleep. @matty when you need me.)")
break
}
fullResponse.WriteString(event.Token)
if event.Done {
break
}
}
p.postReply(post, fullResponse.String())
}
// 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,
}
if _, appErr := p.API.CreatePost(replyPost); appErr != nil {
// Log the failure but don't crash.
p.API.LogError("Failed to create reply post", "error", appErr.Error())
}
}