diff --git a/cmd/gateway.go b/cmd/gateway.go index 7a46bf47b..a3e990760 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -443,7 +443,7 @@ func runGateway() { instanceLoader.SetProviderRegistry(providerRegistry) instanceLoader.SetPendingCompactionConfig(cfg.Channels.PendingCompaction) instanceLoader.RegisterFactory(channels.TypeTelegram, telegram.FactoryWithStores(pgStores.Agents, pgStores.ConfigPermissions, pgStores.Teams, pgStores.PendingMessages)) - instanceLoader.RegisterFactory(channels.TypeDiscord, discord.FactoryWithPendingStore(pgStores.PendingMessages)) + instanceLoader.RegisterFactory(channels.TypeDiscord, discord.FactoryWithStores(pgStores.Agents, pgStores.ConfigPermissions, pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory) instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 589ee79a7..2c0edffd5 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -39,7 +39,7 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token != "" && instanceLoader == nil { - dc, err := discord.New(cfg.Channels.Discord, msgBus, nil, nil) + dc, err := discord.New(cfg.Channels.Discord, msgBus, nil, nil, nil, nil) if err != nil { slog.Error("failed to initialize discord channel", "error", err) } else { diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index f470d2ac8..5bfa70a4c 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -590,7 +590,34 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { // For non-writers: injects refusal instructions + removes SOUL.md/AGENTS.md from context files. func (l *Loop) buildGroupWriterPrompt(ctx context.Context, groupID, senderID string, files []bootstrap.ContextFile) (string, []bootstrap.ContextFile) { writers, err := l.configPermStore.ListFileWriters(ctx, l.agentUUID, groupID) - if err != nil || len(writers) == 0 { + if err != nil { + return "", files // fail-open + } + + // Discord guilds: also fetch guild-wide wildcard writers (guild:{guildID}:*). + // Per-user scope (guild:{guildID}:user:{userID}) won't find guild-wide grants + // because ListFileWriters uses exact SQL match. + if strings.HasPrefix(groupID, "guild:") { + parts := strings.SplitN(groupID, ":", 3) // ["guild", "{guildID}", "user:..."] + if len(parts) >= 2 { + guildWildcard := parts[0] + ":" + parts[1] + ":*" + if guildWriters, gErr := l.configPermStore.ListFileWriters(ctx, l.agentUUID, guildWildcard); gErr == nil { + writers = append(writers, guildWriters...) + } + // Deduplicate by UserID (user may have both guild-wide and per-user grants). + seen := make(map[string]bool, len(writers)) + deduped := writers[:0] + for _, w := range writers { + if !seen[w.UserID] { + seen[w.UserID] = true + deduped = append(deduped, w) + } + } + writers = deduped + } + } + + if len(writers) == 0 { return "", files // fail-open } diff --git a/internal/channels/discord/commands_writers.go b/internal/channels/discord/commands_writers.go new file mode 100644 index 000000000..19a20c751 --- /dev/null +++ b/internal/channels/discord/commands_writers.go @@ -0,0 +1,245 @@ +package discord + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// resolveAgentUUID looks up the agent UUID from the channel's agent key. +func (c *Channel) resolveAgentUUID(ctx context.Context) (uuid.UUID, error) { + key := c.AgentID() + if key == "" { + return uuid.Nil, fmt.Errorf("no agent key configured") + } + if id, err := uuid.Parse(key); err == nil { + return id, nil + } + agent, err := c.agentStore.GetByKey(ctx, key) + if err != nil { + return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err) + } + return agent.ID, nil +} + +// tryHandleCommand checks if the message is a known bot command and handles it. +// Returns true if the message was consumed as a command. +func (c *Channel) tryHandleCommand(m *discordgo.MessageCreate) bool { + content := strings.TrimSpace(m.Content) + if content == "" { + return false + } + + // Accept both !command and /command prefixes. + if content[0] != '!' && content[0] != '/' { + return false + } + + cmd := strings.SplitN(content, " ", 2)[0] + cmd = strings.ToLower(cmd) + // Normalize: strip prefix and compare. + cmdName := cmd[1:] // remove ! or / + + switch cmdName { + case "addwriter": + c.handleWriterCommand(m, "add") + return true + case "removewriter": + c.handleWriterCommand(m, "remove") + return true + case "writers": + c.handleListWriters(m) + return true + } + + return false +} + +// handleWriterCommand handles !addwriter and !removewriter commands. +// Target user is identified by @mention or by replying to their message. +func (c *Channel) handleWriterCommand(m *discordgo.MessageCreate, action string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + channelID := m.ChannelID + + send := func(text string) { + c.session.ChannelMessageSend(channelID, text) + } + + if m.GuildID == "" { + send("This command only works in server channels.") + return + } + + if c.configPermStore == nil || c.agentStore == nil { + send("File writer management is not available.") + return + } + + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + slog.Debug("discord writer command: agent resolve failed", "error", err) + send("File writer management is not available (no agent).") + return + } + + // Guild-wide wildcard scope for grant/revoke/list operations. + scope := fmt.Sprintf("guild:%s:*", m.GuildID) + senderID := m.Author.ID + + // Check sender's writer status using per-user scope. This matches both: + // - Auto-bootstrapped per-user perms (guild:{guildID}:user:{senderID}) via exact match + // - Guild-wide perms (guild:{guildID}:*) via matchWildcard + senderScope := fmt.Sprintf("guild:%s:user:%s", m.GuildID, senderID) + isWriter, err := c.configPermStore.CheckPermission(ctx, agentID, senderScope, "file_writer", senderID) + if err != nil { + slog.Warn("discord writer check failed", "error", err, "sender", senderID) + send("Failed to check permissions. Please try again.") + return + } + if !isWriter { + send("Only existing file writers can manage the writer list.") + return + } + + // Resolve target user: prefer reply-to, fall back to @mention. + var targetUser *discordgo.User + if m.ReferencedMessage != nil && m.ReferencedMessage.Author != nil { + targetUser = m.ReferencedMessage.Author + } else if len(m.Mentions) > 0 { + // Pick first non-bot mention that isn't the bot itself. + for _, u := range m.Mentions { + if u.ID != c.botUserID && !u.Bot { + targetUser = u + break + } + } + } + + if targetUser == nil { + verb := "add" + if action == "remove" { + verb = "remove" + } + send(fmt.Sprintf("To %s a writer: reply to their message with `!%swriter`, or mention them: `!%swriter @user`.", verb, verb, verb)) + return + } + + targetID := targetUser.ID + targetName := targetUser.Username + if targetUser.GlobalName != "" { + targetName = targetUser.GlobalName + } + + switch action { + case "add": + meta, _ := json.Marshal(map[string]string{"displayName": targetName, "username": targetUser.Username}) + if err := c.configPermStore.Grant(ctx, &store.ConfigPermission{ + AgentID: agentID, + Scope: scope, + ConfigType: "file_writer", + UserID: targetID, + Permission: "allow", + Metadata: meta, + }); err != nil { + slog.Warn("discord add writer failed", "error", err, "target", targetID) + send("Failed to add writer. Please try again.") + return + } + send(fmt.Sprintf("Added %s as a file writer.", targetName)) + + case "remove": + writers, listErr := c.configPermStore.List(ctx, agentID, "file_writer", scope) + if listErr != nil { + slog.Warn("discord list writers for remove failed", "error", listErr) + send("Failed to check writers. Please try again.") + return + } + if len(writers) <= 1 { + send("Cannot remove the last file writer.") + return + } + // Revoke guild-wide permission. + if err := c.configPermStore.Revoke(ctx, agentID, scope, "file_writer", targetID); err != nil { + slog.Warn("discord remove writer failed", "error", err, "target", targetID) + send("Failed to remove writer. Please try again.") + return + } + // Also revoke auto-bootstrapped per-user permission (guild:{guildID}:user:{userID}). + perUserScope := fmt.Sprintf("guild:%s:user:%s", m.GuildID, targetID) + _ = c.configPermStore.Revoke(ctx, agentID, perUserScope, "file_writer", targetID) + send(fmt.Sprintf("Removed %s from file writers.", targetName)) + } +} + +// handleListWriters handles the !writers command. +func (c *Channel) handleListWriters(m *discordgo.MessageCreate) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + channelID := m.ChannelID + + send := func(text string) { + c.session.ChannelMessageSend(channelID, text) + } + + if m.GuildID == "" { + send("This command only works in server channels.") + return + } + + if c.configPermStore == nil || c.agentStore == nil { + send("File writer management is not available.") + return + } + + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + slog.Debug("discord list writers: agent resolve failed", "error", err) + send("File writer management is not available (no agent).") + return + } + + // Guild-wide wildcard scope: matches any user context (guild:{guildID}:user:{userID}) + // via matchWildcard in CheckPermission. + scope := fmt.Sprintf("guild:%s:*", m.GuildID) + + writers, err := c.configPermStore.List(ctx, agentID, "file_writer", scope) + if err != nil { + slog.Warn("discord list writers failed", "error", err) + send("Failed to list writers. Please try again.") + return + } + + if len(writers) == 0 { + send("No file writers configured for this server. The first person to interact with the bot will be added automatically.") + return + } + + type fwMeta struct { + DisplayName string `json:"displayName"` + Username string `json:"username"` + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("File writers for this server (%d):\n", len(writers))) + for i, w := range writers { + var meta fwMeta + _ = json.Unmarshal(w.Metadata, &meta) + label := w.UserID + if meta.Username != "" { + label = meta.Username + } else if meta.DisplayName != "" { + label = meta.DisplayName + } + sb.WriteString(fmt.Sprintf("%d. %s (<@%s>)\n", i+1, label, w.UserID)) + } + send(sb.String()) +} diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index ea4305a36..80f96a624 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -32,10 +32,15 @@ type Channel struct { approvedGroups sync.Map // chatID → true (in-memory cache for paired groups) groupHistory *channels.PendingHistory historyLimit int + agentStore store.AgentStore // for agent key lookup (nil = writer commands disabled) + configPermStore store.ConfigPermissionStore // for group file writer management (nil = writer commands disabled) } // New creates a new Discord channel from config. -func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, pendingStore store.PendingMessageStore) (*Channel, error) { +// agentStore and configPermStore are optional (nil = writer commands disabled). +func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, + agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, + pendingStore store.PendingMessageStore) (*Channel, error) { session, err := discordgo.New("Bot " + cfg.Token) if err != nil { return nil, fmt.Errorf("create discord session: %w", err) @@ -60,13 +65,15 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.Pair } return &Channel{ - BaseChannel: base, - session: session, - config: cfg, - requireMention: requireMention, - pairingService: pairingSvc, - groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore), - historyLimit: historyLimit, + BaseChannel: base, + session: session, + config: cfg, + requireMention: requireMention, + pairingService: pairingSvc, + groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore), + historyLimit: historyLimit, + agentStore: agentStore, + configPermStore: configPermStore, }, nil } diff --git a/internal/channels/discord/factory.go b/internal/channels/discord/factory.go index b5c549bc3..f2bf2a7db 100644 --- a/internal/channels/discord/factory.go +++ b/internal/channels/discord/factory.go @@ -17,9 +17,9 @@ type discordCreds struct { // discordInstanceConfig maps the non-secret config JSONB from the channel_instances table. type discordInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` RequireMention *bool `json:"require_mention,omitempty"` HistoryLimit int `json:"history_limit,omitempty"` BlockReply *bool `json:"block_reply,omitempty"` @@ -31,9 +31,24 @@ type discordInstanceConfig struct { VoiceAgentID string `json:"voice_agent_id,omitempty"` } -// Factory creates a Discord channel from DB instance data. +// Factory creates a Discord channel from DB instance data (no extra stores). func Factory(name string, creds json.RawMessage, cfg json.RawMessage, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { + return buildChannel(name, creds, cfg, msgBus, pairingSvc, nil, nil, nil) +} + +// FactoryWithStores returns a ChannelFactory that includes agent, configPerm, and pending message stores. +func FactoryWithStores(agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, pendingStore store.PendingMessageStore) channels.ChannelFactory { + return func(name string, creds json.RawMessage, cfg json.RawMessage, + msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { + return buildChannel(name, creds, cfg, msgBus, pairingSvc, agentStore, configPermStore, pendingStore) + } +} + +func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, + msgBus *bus.MessageBus, pairingSvc store.PairingStore, + agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, + pendingStore store.PendingMessageStore) (channels.Channel, error) { var c discordCreds if len(creds) > 0 { @@ -74,7 +89,7 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, dcCfg.GroupPolicy = "pairing" } - ch, err := New(dcCfg, msgBus, pairingSvc, nil) + ch, err := New(dcCfg, msgBus, pairingSvc, agentStore, configPermStore, pendingStore) if err != nil { return nil, err } @@ -82,56 +97,3 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, ch.SetName(name) return ch, nil } - -// FactoryWithPendingStore returns a ChannelFactory with persistent history support. -func FactoryWithPendingStore(pendingStore store.PendingMessageStore) channels.ChannelFactory { - return func(name string, creds json.RawMessage, cfg json.RawMessage, - msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { - - var c discordCreds - if len(creds) > 0 { - if err := json.Unmarshal(creds, &c); err != nil { - return nil, fmt.Errorf("decode discord credentials: %w", err) - } - } - if c.Token == "" { - return nil, fmt.Errorf("discord token is required") - } - - var ic discordInstanceConfig - if len(cfg) > 0 { - if err := json.Unmarshal(cfg, &ic); err != nil { - return nil, fmt.Errorf("decode discord config: %w", err) - } - } - - dcCfg := config.DiscordConfig{ - Enabled: true, - Token: c.Token, - AllowFrom: ic.AllowFrom, - DMPolicy: ic.DMPolicy, - GroupPolicy: ic.GroupPolicy, - RequireMention: ic.RequireMention, - HistoryLimit: ic.HistoryLimit, - BlockReply: ic.BlockReply, - MediaMaxBytes: ic.MediaMaxBytes, - STTProxyURL: ic.STTProxyURL, - STTAPIKey: ic.STTAPIKey, - STTTenantID: ic.STTTenantID, - STTTimeoutSeconds: ic.STTTimeoutSeconds, - VoiceAgentID: ic.VoiceAgentID, - } - - if dcCfg.GroupPolicy == "" { - dcCfg.GroupPolicy = "pairing" - } - - ch, err := New(dcCfg, msgBus, pairingSvc, pendingStore) - if err != nil { - return nil, err - } - - ch.SetName(name) - return ch, nil - } -} diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index 6fd7d5422..9ed8d611b 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -62,6 +62,11 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate return } + // Handle bot commands (writer management, etc.) before further processing. + if c.tryHandleCommand(m) { + return + } + // Build content content := m.Content