Scaffold Go plugin skeleton with Ollama client, conversation store, rate limiter
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&Plugin{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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))]
|
||||
}
|
||||
Reference in New Issue
Block a user