Scaffold Go plugin skeleton with Ollama client, conversation store, rate limiter

This commit is contained in:
2026-06-16 17:45:53 +02:00
parent 2709120cf3
commit f749b3dd02
14 changed files with 1141 additions and 3 deletions
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
const helpText = `### Mattermore -- AI Chat Agent
**@mention the bot in any channel to start a conversation.**
**Commands:**
- @mattermore <prompt> -- Chat with the default model
- @mattermore model <name> <prompt> -- Use a specific model
- @mattermore new -- Reset conversation context
- @mattermore model list -- List available models
- @mattermore help -- Show this help
- @mattermore stop -- End the current conversation
You can also use /ai <prompt> as an alternative.
The bot responds only when addressed and disengages naturally.
`
// ExecuteCommand handles the /ai slash command.
func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
trigger := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(args.Command), "/ai"))
parts := parseArgs(trigger)
switch {
case len(parts) == 0 || parts[0] == "help":
return p.response(helpText), nil
case parts[0] == "new":
if err := p.conversationStore.Reset(args.UserId, args.ChannelId); err != nil {
return p.response("Failed to reset conversation."), nil
}
return p.response("Conversation reset. Start fresh with @mattermore <prompt>"), nil
case parts[0] == "model" && len(parts) >= 2 && parts[1] == "list":
return p.handleModelList(args)
case parts[0] == "model" && len(parts) >= 3:
modelName := parts[1]
prompt := strings.Join(parts[2:], " ")
return p.handlePrompt(args, modelName, prompt)
default:
return p.handlePrompt(args, "", strings.Join(parts, " "))
}
}
// handlePrompt processes a user prompt and returns the AI response.
func (p *Plugin) handlePrompt(args *model.CommandArgs, modelName, prompt string) (*model.CommandResponse, *model.AppError) {
cfg := p.getConfiguration()
// Check rate limit.
if !p.rateLimiter.Allow(args.UserId) {
return p.response("Rate limit exceeded. Please wait before sending another request."), nil
}
// Check authorization.
if users := cfg.allowedUsers(); users != nil && !users[args.UserId] {
return p.response("You are not authorized to use this bot."), nil
}
// Check stop words before sending to Ollama.
if stopWordMatch(prompt, cfg.stopWordSet()) {
return p.response("Goodbye! Feel free to @mention me again anytime."), nil
}
// Build the conversation context.
var messages []ChatMessage
messages, err := p.conversationStore.BuildMessages(args.UserId, args.ChannelId, prompt)
if err != nil {
return p.response("Failed to build conversation context."), nil
}
// Determine model to use.
activeModel := cfg.DefaultModel
if modelName != "" {
activeModel = modelName
}
// Send to Ollama.
resp, err := p.ollamaClient.ChatCompletion(&ChatRequest{
Model: activeModel,
Messages: messages,
Options: map[string]any{},
})
if err != nil {
return p.response(fmt.Sprintf("Failed to get response from Ollama: %v", err)), nil
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: resp.Message.Content,
}, nil
}
// handleModelList queries Ollama for available models and returns them.
func (p *Plugin) handleModelList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
models, err := p.ollamaClient.ListModels()
if err != nil {
return p.response("Failed to fetch models from Ollama."), nil
}
var b strings.Builder
b.WriteString("### Available Models\n\n")
for _, m := range models {
b.WriteString(fmt.Sprintf("- %s\n", m))
}
return p.response(b.String()), nil
}
// parseArgs splits a command string into tokens.
func parseArgs(input string) []string {
return strings.Fields(input)
}
// response creates a standard ephemeral command response.
func (p *Plugin) response(text string) *model.CommandResponse {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: text,
}
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"net/url"
"strings"
)
// configuration holds the plugin settings from the System Console.
type configuration struct {
OllamaURL string
DefaultModel string
SystemPrompt string
MaxTokens int
RateLimitPerMinute int
AllowedUserIDs string
StopWords string
}
// getConfiguration returns the current configuration, safe for concurrent use.
func (p *Plugin) getConfiguration() *configuration {
p.configLock.RLock()
defer p.configLock.RUnlock()
return p.configuration
}
// setConfiguration updates the configuration and validates it.
func (p *Plugin) setConfiguration(c *configuration) error {
p.configLock.Lock()
defer p.configLock.Unlock()
if c == nil {
return fmt.Errorf("nil configuration")
}
if err := c.validate(); err != nil {
return fmt.Errorf("invalid configuration: %w", err)
}
p.configuration = c
return nil
}
// validate checks that the configuration values are usable.
func (c *configuration) validate() error {
if c.OllamaURL == "" {
return fmt.Errorf("OllamaURL must not be empty")
}
u, err := url.Parse(c.OllamaURL)
if err != nil {
return fmt.Errorf("OllamaURL is not a valid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("OllamaURL must use http or https scheme, got %q", u.Scheme)
}
if c.DefaultModel == "" {
return fmt.Errorf("DefaultModel must not be empty")
}
if c.MaxTokens < 0 {
return fmt.Errorf("MaxTokens must not be negative")
}
if c.RateLimitPerMinute < 0 {
return fmt.Errorf("RateLimitPerMinute must not be negative")
}
return nil
}
// OnConfigurationChange is called by the Mattermost server when the plugin
// configuration is changed in the System Console.
func (p *Plugin) OnConfigurationChange() error {
var c configuration
// Load from the System Console settings.
if err := p.API.LoadPluginConfiguration(&c); err != nil {
return fmt.Errorf("failed to load plugin configuration: %w", err)
}
if err := p.setConfiguration(&c); err != nil {
return fmt.Errorf("failed to set configuration: %w", err)
}
return nil
}
// allowedUsers returns the set of user IDs allowed to use the plugin.
// An empty set means all users are allowed.
func (c *configuration) allowedUsers() map[string]bool {
if strings.TrimSpace(c.AllowedUserIDs) == "" {
return nil
}
users := make(map[string]bool)
for _, id := range strings.Split(c.AllowedUserIDs, ",") {
users[strings.TrimSpace(id)] = true
}
return users
}
// stopWordSet returns the set of stop words for the engagement engine.
func (c *configuration) stopWordSet() map[string]bool {
if strings.TrimSpace(c.StopWords) == "" {
return map[string]bool{
"thanks": true, "thank you": true, "bye": true,
"goodbye": true, "that's all": true, "done": true,
"stop": true, "quit": true, "end": true,
}
}
words := make(map[string]bool)
for _, w := range strings.Split(c.StopWords, ",") {
words[strings.TrimSpace(strings.ToLower(w))] = true
}
return words
}
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
func main() {
plugin.ClientMain(&Plugin{})
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// OllamaClient communicates with the Ollama API.
type OllamaClient struct {
baseURL string
httpClient *http.Client
}
// NewOllamaClient creates a new client for the given Ollama server URL.
func NewOllamaClient(baseURL string) *OllamaClient {
return &OllamaClient{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// ChatMessage represents a message in the chat conversation.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatRequest is sent to Ollama's /api/chat endpoint.
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
Options map[string]any `json:"options,omitempty"`
}
// ChatResponse is the non-streaming response from Ollama.
type ChatResponse struct {
Message ChatMessage `json:"message"`
Done bool `json:"done"`
EvalCount int `json:"eval_count"`
PromptEvalCount int `json:"prompt_eval_count"`
}
// ListModelsResponse is the response from /api/tags.
type ListModelsResponse struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
// ChatCompletion sends a chat request and returns the full response.
func (c *OllamaClient) ChatCompletion(req *ChatRequest) (*ChatResponse, error) {
req.Stream = false
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequest("POST", c.baseURL+"/api/chat", strings.NewReader(string(body)))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var chatResp ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &chatResp, nil
}
// ListModels fetches the list of available models from Ollama.
func (c *OllamaClient) ListModels() ([]string, error) {
httpReq, err := http.NewRequest("GET", c.baseURL+"/api/tags", nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var listResp ListModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
models := make([]string, len(listResp.Models))
for i, m := range listResp.Models {
models[i] = m.Name
}
return models, nil
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"net/http"
"sync"
"github.com/mattermost/mattermost/server/public/plugin"
)
// Plugin implements the Mattermost plugin interface.
type Plugin struct {
plugin.MattermostPlugin
configuration *configuration
configLock sync.RWMutex
botUserID string
ollamaClient *OllamaClient
conversationStore *ConversationStore
rateLimiter *RateLimiter
}
// ServeHTTP allows the plugin to handle HTTP requests.
// Not used in v0.1.0 — reserved for future metrics endpoint.
func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}
// Note: Plugin does not compile-check against plugin.Hooks because that
// interface has ~60 methods. Instead, Implemented() tells the server which
// hooks this plugin handles, and plugin.ClientMain handles the rest.
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"sync"
"time"
)
// RateLimiter implements a per-user token bucket rate limiter.
type RateLimiter struct {
mu sync.Mutex
users map[string]*bucket
rate int // requests per minute
}
type bucket struct {
tokens float64
lastRefill time.Time
}
// NewRateLimiter creates a rate limiter with the given rate (requests/minute).
func NewRateLimiter(ratePerMinute int) *RateLimiter {
if ratePerMinute <= 0 {
ratePerMinute = 10
}
return &RateLimiter{
users: make(map[string]*bucket),
rate: ratePerMinute,
}
}
// Allow checks if a request from the given user ID should be allowed.
func (rl *RateLimiter) Allow(userID string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
b, exists := rl.users[userID]
now := time.Now()
if !exists {
b = &bucket{tokens: 1, lastRefill: now}
rl.users[userID] = b
}
elapsed := now.Sub(b.lastRefill).Seconds()
refill := elapsed * float64(rl.rate) / 60.0
b.tokens += refill
if b.tokens > 1 {
b.tokens = 1
}
b.lastRefill = now
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}
// Reset clears all rate limiter state.
func (rl *RateLimiter) Reset() {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.users = make(map[string]*bucket)
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// ConversationStore manages conversation context in the Mattermost KV store.
type ConversationStore struct {
api pluginKV
mu sync.RWMutex
}
// 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
}
// NewConversationStore creates a new store backed by the plugin API.
func NewConversationStore(api pluginKV) *ConversationStore {
return &ConversationStore{api: api}
}
// conversationSession holds the messages for a single user+channel session.
type conversationSession struct {
UserID string `json:"user_id"`
ChannelID string `json:"channel_id"`
Messages []chatMsg `json:"messages"`
UpdatedAt time.Time `json:"updated_at"`
}
type chatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
const (
maxTurns = 10
sessionTTL = 1 * time.Hour
)
func sessionKey(userID, channelID string) string {
return fmt.Sprintf("ctx_%s_%s", userID, channelID)
}
// BuildMessages retrieves session history and appends the user prompt,
// returning the full message array for the Ollama API.
func (s *ConversationStore) BuildMessages(userID, channelID, prompt string) ([]ChatMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
session := s.loadSession(userID, channelID)
if session == nil {
session = &conversationSession{
UserID: userID,
ChannelID: channelID,
Messages: []chatMsg{},
}
}
session.Messages = append(session.Messages, chatMsg{Role: "user", Content: prompt})
// Trim to max turns (keep newest).
if len(session.Messages) > maxTurns {
session.Messages = session.Messages[len(session.Messages)-maxTurns:]
}
session.UpdatedAt = time.Now()
_ = s.saveSession(session) // non-fatal
result := make([]ChatMessage, 0, len(session.Messages))
for _, m := range session.Messages {
result = append(result, ChatMessage{Role: m.Role, Content: m.Content})
}
return result, nil
}
// Reset clears the session for a user+channel.
func (s *ConversationStore) Reset(userID, channelID string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.api.KVDelete(sessionKey(userID, channelID))
}
func (s *ConversationStore) loadSession(userID, channelID string) *conversationSession {
data, err := s.api.KVGet(sessionKey(userID, channelID))
if err != nil || data == nil {
return nil
}
var session conversationSession
if err := json.Unmarshal(data, &session); err != nil {
return nil
}
return &session
}
func (s *ConversationStore) saveSession(session *conversationSession) error {
data, err := json.Marshal(session)
if err != nil {
return fmt.Errorf("marshal session: %w", err)
}
_, err = s.api.KVSet(sessionKey(session.UserID, session.ChannelID), data)
return err
}
// stopWordMatch checks if the trimmed, lowercased prompt matches a stop word.
func stopWordMatch(prompt string, stopWords map[string]bool) bool {
if stopWords == nil {
return false
}
return stopWords[strings.TrimSpace(strings.ToLower(prompt))]
}