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 -- Chat with default model **Personas:** @matty persona -- Show current persona @matty persona list -- List all personas @matty persona -- Switch persona **Other:** @matty new -- Reset conversation context @matty stop -- End conversation @matty help -- Show this Also works with /ai . /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 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, } }