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 ", AutoCompleteHint: "", }); 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 // If dropdown = "custom" → use CustomPersona textarea field // Otherwise → resolve from personaLibrary persona := p.GetPersonaOverride(post.UserId, post.ChannelId) persona = resolvePersona(persona) if persona == "" { if cfg.Persona == "custom" { persona = cfg.CustomPersona } 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()) } }