Files

248 lines
8.8 KiB
Go

package main
import (
"fmt"
"sort"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
const helpText = `### Matty -- forkless' annoying sidekick
@mention the bot in any channel to start a conversation. The bot responds only when addressed and disengages naturally.
**Chat:**
@matty <prompt> -- Chat with default model
**Personas:**
@matty persona -- Show current persona
@matty persona list -- List all personas
@matty persona <name> -- Switch persona
**Other:**
@matty new -- Reset conversation context
@matty stop -- End conversation
@matty help -- Show this
Also works with /ai <prompt>. /ai new clears memory.
`
// ExecuteCommand handles slash commands (/ai, /resetmatterless).
func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (resp *model.CommandResponse, err *model.AppError) {
defer func() {
if r := recover(); r != nil {
p.API.LogError("ExecuteCommand panic",
"recover", fmt.Sprintf("%v", r),
"p_is_nil", fmt.Sprintf("%v", p == nil),
"api_nil", fmt.Sprintf("%v", p != nil && p.API == nil),
"store_nil", fmt.Sprintf("%v", p != nil && p.conversationStore == nil),
"engine_nil", fmt.Sprintf("%v", p != nil && p.engagementEngine == nil),
"rate_nil", fmt.Sprintf("%v", p != nil && p.rateLimiter == nil),
"config_nil", fmt.Sprintf("%v", p != nil && p.configuration == nil),
"args_nil", fmt.Sprintf("%v", args == nil),
)
resp = &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "Command failed due to an internal error. Check server logs for details.",
}
}
}()
// /ai — dispatch subcommands.
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":
p.API.LogInfo("Debug /ai new", "store_nil", p.conversationStore == nil, "engine_nil", p.engagementEngine == nil)
if p.conversationStore != nil {
p.conversationStore.Reset(args.UserId, args.ChannelId)
}
if p.engagementEngine != nil {
p.engagementEngine.Sleep(args.UserId, args.ChannelId)
}
return p.response("Conversation reset. @matty to start again."), nil
case parts[0] == "persona" && len(parts) == 1:
return p.handlePersonaShow(args)
case parts[0] == "persona" && len(parts) >= 2 && parts[1] == "list":
return p.handlePersonaList(args)
case parts[0] == "persona" && len(parts) >= 2:
return p.handlePersonaSwitch(args, parts[1])
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
}
// handlePersonaShow shows the currently active persona.
func (p *Plugin) handlePersonaShow(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
override := p.GetPersonaOverride(args.UserId, args.ChannelId)
if override != "" {
return p.response("Active persona: " + override + "."), nil
}
cfg := p.getConfiguration()
if cfg.CustomPersona != "" {
return p.response("Active persona: custom."), nil
}
return p.response("Active persona: " + cfg.Persona + "."), nil
}
// handlePersonaList lists available personas with descriptions.
func (p *Plugin) handlePersonaList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
return p.response(p.listPersonas()), nil
}
// handlePersonaSwitch persists a persona override for the user+channel.
func (p *Plugin) handlePersonaSwitch(args *model.CommandArgs, personaID string) (*model.CommandResponse, *model.AppError) {
if personaID != "custom" {
resolved := resolvePersona(personaID)
if resolved == "" {
return p.response("Unknown persona. Available: " + p.listPersonas()), nil
}
}
if err := p.SavePersonaOverride(args.UserId, args.ChannelId, personaID); err != nil {
return p.response("Failed to save persona: " + err.Error()), nil
}
// Auto-reset conversation history so old persona context doesn't bleed.
if p.conversationStore != nil {
p.conversationStore.Reset(args.UserId, args.ChannelId)
}
if p.engagementEngine != nil {
p.engagementEngine.Sleep(args.UserId, args.ChannelId)
}
return p.response("Persona switched to: " + personaID + ". @matty to test."), nil
}
// listPersonas returns a formatted list of persona IDs with short descriptions.
func (p *Plugin) listPersonas() string {
ids := make([]string, 0, len(personaLibrary))
for id := range personaLibrary {
ids = append(ids, id)
}
sort.Strings(ids)
// Dropdown-only entries (e.g. "custom") go at the end.
extra := []string{}
for id := range personaDisplayNames {
if _, ok := personaLibrary[id]; !ok {
extra = append(extra, id)
}
}
ids = append(ids, extra...)
var b strings.Builder
b.WriteString("Available personas:\n\n```\n")
for _, id := range ids {
name := personaDisplayNames[id]
if name == "" {
name = personaLibrary[id]
}
b.WriteString(fmt.Sprintf(" %-12s %s\n", id, name))
}
b.WriteString("```\nUse /ai persona <name> to switch.")
return b.String()
}
// 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
}
// personaOverrideKey returns the KV store key for a user+channel persona override.
func personaOverrideKey(userID, channelID string) string {
return "persona_" + userID + "_" + channelID
}
// SavePersonaOverride persists a persona override to the KV store.
func (p *Plugin) SavePersonaOverride(userID, channelID, personaID string) error {
if appErr := p.API.KVSet(personaOverrideKey(userID, channelID), []byte(personaID)); appErr != nil {
return fmt.Errorf("kvset: %w", appErr)
}
return nil
}
// GetPersonaOverride returns the persisted persona override for a user+channel.
func (p *Plugin) GetPersonaOverride(userID, channelID string) string {
data, appErr := p.API.KVGet(personaOverrideKey(userID, channelID))
if appErr != nil || data == nil {
return ""
}
return string(data)
}
// 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,
}
}