Add MessageHasBeenPosted hook with @-mention, thread reply, bot account
This commit is contained in:
+177
@@ -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 <prompt>",
|
||||||
|
AutoCompleteHint: "<prompt>",
|
||||||
|
}); 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConversationStore manages conversation context in the Mattermost KV store.
|
// 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.
|
// pluginKV is the subset of the plugin API needed for KV operations.
|
||||||
type pluginKV interface {
|
type pluginKV interface {
|
||||||
KVGet(key string) ([]byte, error)
|
KVGet(key string) ([]byte, *model.AppError)
|
||||||
KVSet(key string, value []byte) (bool, error)
|
KVSet(key string, value []byte) *model.AppError
|
||||||
KVDelete(key string) error
|
KVDelete(key string) *model.AppError
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConversationStore creates a new store backed by the plugin API.
|
// NewConversationStore creates a new store backed by the plugin API.
|
||||||
@@ -104,7 +106,7 @@ func (s *ConversationStore) saveSession(session *conversationSession) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal session: %w", err)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user