feat(discord): add writer management commands (#309)

* feat(discord): add writer management commands (!addwriter, !removewriter, !writers)

Port Telegram's file writer commands to Discord with guild-wide scope.
Uses wildcard scope (guild:{guildID}:*) to match per-user contexts.
Also fixes system prompt injection to find guild-wide writers.

* fix(discord): use per-user scope for writer self-check

CheckPermission with guild-wide wildcard scope (guild:{guildID}:*)
fails to match auto-bootstrapped per-user perms. Use per-user scope
(guild:{guildID}:user:{senderID}) which matches both per-user exact
and guild-wide wildcard stored patterns via matchWildcard.

* fix(discord): deduplicate writers in prompt injection, add context timeout

- Deduplicate writers by UserID after merging guild-wide and per-user
  scope results in buildGroupWriterPrompt to prevent duplicates when a
  user has both grant types.
- Add 10s context timeout to Discord writer command handlers instead of
  using context.Background() to prevent DB calls from hanging.

---------

Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
Goonandviettranx authored and GitHub committed 2026-03-20 19:44:31 +07:00
1 parent cab9338f14
commit 7040081756
7 files changed
+315 -69

No files matched your search

+1 -1
View File
@@ -446,7 +446,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))
+1 -1
View File
@@ -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 {
+28 -1
View File
@@ -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
}
@@ -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())
}
+15 -8
View File
@@ -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
}
+20 -58
View File
@@ -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
}
}
+5
View File
@@ -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