From ed1cc8a6a66f373bf8a511b65fb622eaffb1c6d9 Mon Sep 17 00:00:00 2001 From: Fork Less Date: Tue, 16 Jun 2026 17:46:53 +0200 Subject: [PATCH] Add MessageHasBeenPosted hook with @-mention, thread reply, bot account --- server/hooks.go | 177 ++++++++++++++++++++++++++++++++++++++++++++++++ server/store.go | 10 +-- 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 server/hooks.go diff --git a/server/hooks.go b/server/hooks.go new file mode 100644 index 0000000..999573c --- /dev/null +++ b/server/hooks.go @@ -0,0 +1,177 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" +) + +const botUsername = "mattermore" +const botDisplayName = "Mattermore 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: "Mattermore", + Description: "AI chat agent powered by Ollama.", + AutoComplete: true, + AutoCompleteDesc: "Chat with the AI. Usage: /ai ", + AutoCompleteHint: "", + }); err != nil { + return fmt.Errorf("register command: %w", err) + } + + // Ensure the bot account exists. + bot := &model.Bot{ + Username: botUsername, + DisplayName: botDisplayName, + Description: botDescription, + } + createdBot, appErr := p.API.CreateBot(bot) + if appErr != nil { + return fmt.Errorf("create bot: %w", appErr) + } + p.botUserID = createdBot.UserId + + // 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) + + return 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 + } + + // Check if this is a thread reply to a post we made. + threadOwned := false + if post.RootId != "" { + rootPost, appErr := p.API.GetPost(post.RootId) + if appErr == nil && rootPost.UserId == p.botUserID { + threadOwned = true + } + } + + // Check if the bot is @-mentioned in the message. + mentioned := strings.Contains( + strings.ToLower(post.Message), + "@"+botUsername, + ) + + // If not mentioned and not a thread reply, ignore. + if !mentioned && !threadOwned { + return + } + + // Build the prompt by stripping the @-mention. + prompt := post.Message + if mentioned { + prompt = strings.TrimSpace(strings.Replace( + strings.ToLower(prompt), + "@"+botUsername, + "", + 1, + )) + } + + // Handle stop words before sending to Ollama. + cfg := p.getConfiguration() + if stopWordMatch(prompt, cfg.stopWordSet()) { + p.postReply(post, "Goodbye! Feel free to @mention me again anytime.") + 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 + } + + // 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 + } + + // Send to Ollama. + resp, err := p.ollamaClient.ChatCompletion(&ChatRequest{ + Model: cfg.DefaultModel, + Messages: messages, + Options: map[string]any{}, + }) + if err != nil { + p.postReply(post, fmt.Sprintf("Failed to get response from Ollama: %v", err)) + return + } + + p.postReply(post, resp.Message.Content) +} + +// postReply creates a reply post in the same thread as the given post. +func (p *Plugin) postReply(replyTo *model.Post, text string) { + replyPost := &model.Post{ + UserId: p.botUserID, + ChannelId: replyTo.ChannelId, + Message: text, + RootId: replyTo.RootId, + } + + // If the original post has no root, use the original post ID as root. + if replyPost.RootId == "" { + replyPost.RootId = replyTo.Id + } + + 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()) + } +} diff --git a/server/store.go b/server/store.go index 236b0a4..1de320d 100644 --- a/server/store.go +++ b/server/store.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "time" + + "github.com/mattermost/mattermost/server/public/model" ) // ConversationStore manages conversation context in the Mattermost KV store. @@ -16,9 +18,9 @@ type ConversationStore struct { // pluginKV is the subset of the plugin API needed for KV operations. type pluginKV interface { - KVGet(key string) ([]byte, error) - KVSet(key string, value []byte) (bool, error) - KVDelete(key string) error + KVGet(key string) ([]byte, *model.AppError) + KVSet(key string, value []byte) *model.AppError + KVDelete(key string) *model.AppError } // NewConversationStore creates a new store backed by the plugin API. @@ -104,7 +106,7 @@ func (s *ConversationStore) saveSession(session *conversationSession) error { if err != nil { return fmt.Errorf("marshal session: %w", err) } - _, err = s.api.KVSet(sessionKey(session.UserID, session.ChannelID), data) + err = s.api.KVSet(sessionKey(session.UserID, session.ChannelID), data) return err }