Files
mattermore/server/hooks.go
T

301 lines
9.5 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."
// 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
// "custom" → CustomPersona textarea (falls back to defaultCustomPersona if empty)
// otherwise → resolve from personaLibrary
persona := p.GetPersonaOverride(post.UserId, post.ChannelId)
if persona == "" {
persona = cfg.Persona
}
var personaDesc string
if persona == "custom" {
personaDesc = cfg.CustomPersona
} else {
personaDesc = resolvePersona(persona)
}
systemPrompt := ""
if personaDesc != "" {
systemPrompt = "You are " + personaDesc + ". 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())
}
}