mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 10:14:51 +00:00
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:
1 parent
cab9338f14
commit
7040081756
7 files changed
+315
-69
No files matched your search
+1
-1
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user