304 lines
9.6 KiB
Go
304 lines
9.6 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
|
|
// 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())
|
|
}
|
|
}
|