Files
mattermore/server/hooks.go
T

256 lines
7.9 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)
}
// 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)
}
// 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.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.
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 {
p.postReply(post, "Failed to build conversation context.")
return
}
// Prepend the persona system prompt to set the bot's character.
persona := cfg.CustomPersona
if persona == "" {
persona = cfg.Persona
}
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...)
}
p.API.LogDebug("Chat request", "model", cfg.DefaultModel, "persona", persona, "brevity", cfg.Brevity)
// 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: 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())
}
}