From 86b172405046216d21ab8b5bfdb2f8ed8256ab6a Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Sun, 22 Feb 2026 18:57:24 +0700 Subject: [PATCH] Add group file writer management for Telegram with permission-based file editing Implement group file writer allowlist system with Telegram commands (/addwriter, /removewriter, /writers) for managing who can edit protected files in group chats. Wire AgentStore through Telegram factory, inject SenderID context for permission checks, and auto-bootstrap first group member as writer. Only existing writers can manage the list, preventing removal of the last writer. --- cmd/gateway.go | 4 +- cmd/gateway_consumer.go | 1 + cmd/gateway_managed.go | 18 ++ internal/agent/loop.go | 5 + internal/channels/telegram/channel.go | 19 ++- internal/channels/telegram/commands.go | 155 +++++++++++++++++- internal/channels/telegram/factory.go | 18 +- internal/channels/telegram/handlers.go | 4 +- internal/store/agent_store.go | 13 ++ internal/store/context.go | 17 ++ internal/store/pg/group_file_writers.go | 64 ++++++++ internal/tools/context_file_interceptor.go | 29 ++++ migrations/000002_group_file_writers.down.sql | 1 + migrations/000002_group_file_writers.up.sql | 9 + 14 files changed, 342 insertions(+), 15 deletions(-) create mode 100644 internal/store/pg/group_file_writers.go create mode 100644 migrations/000002_group_file_writers.down.sql create mode 100644 migrations/000002_group_file_writers.up.sql diff --git a/cmd/gateway.go b/cmd/gateway.go index 7a969dfa..2bfe2056 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -610,7 +610,7 @@ func runGateway() { var loadedNames map[string]struct{} if managedStores != nil && managedStores.ChannelInstances != nil { instanceLoader = channels.NewInstanceLoader(managedStores.ChannelInstances, managedStores.Agents, channelMgr, msgBus, pairingStore) - instanceLoader.RegisterFactory("telegram", telegram.Factory) + instanceLoader.RegisterFactory("telegram", telegram.FactoryWithAgentStore(managedStores.Agents)) instanceLoader.RegisterFactory("discord", discord.Factory) instanceLoader.RegisterFactory("feishu", feishu.Factory) instanceLoader.RegisterFactory("zalo_oa", zalo.Factory) @@ -626,7 +626,7 @@ func runGateway() { // Other channels: skip config registration in managed mode if any DB instances loaded. if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" { if _, loaded := loadedNames["telegram"]; !loaded { - tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore) + tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil) if err != nil { slog.Error("failed to initialize telegram channel", "error", err) } else { diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 404946a5..452b7838 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -134,6 +134,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents ChatID: msg.ChatID, PeerKind: peerKind, UserID: userID, + SenderID: msg.SenderID, RunID: runID, Stream: enableStream, HistoryLimit: msg.HistoryLimit, diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 40c78569..95eac4db 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -3,6 +3,7 @@ package cmd import ( "context" "log/slog" + "strings" "github.com/google/uuid" @@ -60,6 +61,23 @@ func wireManagedExtras( if !isNew { return nil // already profiled = already seeded } + + // Auto-add first group member as a file writer (bootstrap the allowlist). + if strings.HasPrefix(userID, "group:") { + senderID := store.SenderIDFromContext(ctx) + if senderID != "" { + parts := strings.SplitN(senderID, "|", 2) + numericID := parts[0] + senderUsername := "" + if len(parts) > 1 { + senderUsername = parts[1] + } + if addErr := as.AddGroupFileWriter(ctx, agentID, userID, numericID, "", senderUsername); addErr != nil { + slog.Warn("failed to auto-add group file writer", "error", addErr, "sender", numericID, "group", userID) + } + } + } + _, err = bootstrap.SeedUserFiles(ctx, as, agentID, userID, agentType) return err } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index e66b0421..3bf6dcde 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -205,6 +205,7 @@ type RunRequest struct { PeerKind string // "direct" or "group" (for session key building and tool context) RunID string // unique run identifier UserID string // external user ID (TEXT, free-form) for multi-tenant scoping + SenderID string // original individual sender ID (preserved in group chats for permission checks) Stream bool // whether to stream response chunks ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) @@ -314,6 +315,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if l.agentType != "" { ctx = store.WithAgentType(ctx, l.agentType) } + // Inject original sender ID for group file writer permission checks + if req.SenderID != "" { + ctx = store.WithSenderID(ctx, req.SenderID) + } // Ensure per-user context files exist (first-chat seeding, managed mode) if l.ensureUserFiles != nil && req.UserID != "" { diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 0a6836a2..ebbbc44d 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -24,13 +24,14 @@ type Channel struct { bot *telego.Bot config config.TelegramConfig pairingService store.PairingStore - placeholders sync.Map // localKey string → messageID int - stopThinking sync.Map // localKey string → *thinkingCancel - streams sync.Map // localKey string → *DraftStream (streaming preview) - reactions sync.Map // localKey string → *StatusReactionController - pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies) - threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing) - approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval) + agentStore store.AgentStore // for group file writer management (nil in standalone) + placeholders sync.Map // localKey string → messageID int + stopThinking sync.Map // localKey string → *thinkingCancel + streams sync.Map // localKey string → *DraftStream (streaming preview) + reactions sync.Map // localKey string → *StatusReactionController + pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies) + threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing) + approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval) groupHistory *channels.PendingHistory historyLimit int requireMention bool @@ -50,7 +51,8 @@ func (c *thinkingCancel) Cancel() { // New creates a new Telegram channel from config. // pairingSvc is optional (nil = fall back to allowlist only). -func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) { +// agentStore is optional (nil = group file writer commands disabled). +func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore) (*Channel, error) { var opts []telego.BotOption if cfg.Proxy != "" { @@ -87,6 +89,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai bot: bot, config: cfg, pairingService: pairingSvc, + agentStore: agentStore, groupHistory: channels.NewPendingHistory(), historyLimit: historyLimit, requireMention: requireMention, diff --git a/internal/channels/telegram/commands.go b/internal/channels/telegram/commands.go index 952dad8e..296a7839 100644 --- a/internal/channels/telegram/commands.go +++ b/internal/channels/telegram/commands.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/mymmrac/telego" tu "github.com/mymmrac/telego/telegoutil" @@ -15,7 +16,7 @@ import ( // handleBotCommand checks if the message is a known bot command and handles it. // Returns true if the message was handled as a command. -func (c *Channel) handleBotCommand(ctx context.Context, chatID int64, chatIDStr, localKey, text, senderID string, isGroup, isForum bool, messageThreadID int) bool { +func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, chatID int64, chatIDStr, localKey, text, senderID string, isGroup, isForum bool, messageThreadID int) bool { if len(text) == 0 || text[0] != '/' { return false } @@ -47,6 +48,9 @@ func (c *Channel) handleBotCommand(ctx context.Context, chatID int64, chatIDStr, "/help — Show this help message\n" + "/reset — Reset conversation history\n" + "/status — Show bot status\n" + + "/writers — List file writers for this group\n" + + "/addwriter — Add a file writer (reply to their message)\n" + + "/removewriter — Remove a file writer (reply to their message)\n" + "\nJust send a message to chat with the AI." msg := tu.Message(chatIDObj, helpText) setThread(msg) @@ -85,11 +89,157 @@ func (c *Channel) handleBotCommand(ctx context.Context, chatID int64, chatIDStr, setThread(msg) c.bot.SendMessage(ctx, msg) return true + + case "/addwriter": + c.handleWriterCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "add") + return true + + case "/removewriter": + c.handleWriterCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "remove") + return true + + case "/writers": + c.handleListWriters(ctx, chatID, chatIDStr, isGroup, setThread) + return true } return false } +// handleWriterCommand handles /addwriter and /removewriter commands. +// The target user is identified by replying to one of their messages. +func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Message, chatID int64, chatIDStr, senderID string, isGroup bool, setThread func(*telego.SendMessageParams), action string) { + chatIDObj := tu.ID(chatID) + + send := func(text string) { + msg := tu.Message(chatIDObj, text) + setThread(msg) + c.bot.SendMessage(ctx, msg) + } + + if !isGroup { + send("This command only works in group chats.") + return + } + + if c.agentStore == nil { + send("File writer management is not available.") + return + } + + agentID, err := uuid.Parse(c.AgentID()) + if err != nil { + send("File writer management is not available (no agent).") + return + } + + groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr) + senderNumericID := strings.SplitN(senderID, "|", 2)[0] + + // Check if sender is an existing writer (only writers can manage the list) + isWriter, err := c.agentStore.IsGroupFileWriter(ctx, agentID, groupID, senderNumericID) + if err != nil { + slog.Warn("writer check failed", "error", err, "sender", senderNumericID) + send("Failed to check permissions. Please try again.") + return + } + if !isWriter { + send("Only existing file writers can manage the writer list.") + return + } + + // Extract target user from reply-to message + if message.ReplyToMessage == nil || message.ReplyToMessage.From == nil { + send(fmt.Sprintf("Reply to a message from the user you want to %s as a writer.", action)) + return + } + + targetUser := message.ReplyToMessage.From + targetID := fmt.Sprintf("%d", targetUser.ID) + targetName := targetUser.FirstName + if targetUser.Username != "" { + targetName = "@" + targetUser.Username + } + + switch action { + case "add": + if err := c.agentStore.AddGroupFileWriter(ctx, agentID, groupID, targetID, targetUser.FirstName, targetUser.Username); err != nil { + slog.Warn("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": + // Prevent removing the last writer + writers, _ := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID) + if len(writers) <= 1 { + send("Cannot remove the last file writer.") + return + } + if err := c.agentStore.RemoveGroupFileWriter(ctx, agentID, groupID, targetID); err != nil { + slog.Warn("remove writer failed", "error", err, "target", targetID) + send("Failed to remove writer. Please try again.") + return + } + send(fmt.Sprintf("Removed %s from file writers.", targetName)) + } +} + +// handleListWriters handles the /writers command. +func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr string, isGroup bool, setThread func(*telego.SendMessageParams)) { + chatIDObj := tu.ID(chatID) + + send := func(text string) { + msg := tu.Message(chatIDObj, text) + setThread(msg) + c.bot.SendMessage(ctx, msg) + } + + if !isGroup { + send("This command only works in group chats.") + return + } + + if c.agentStore == nil { + send("File writer management is not available.") + return + } + + agentID, err := uuid.Parse(c.AgentID()) + if err != nil { + send("File writer management is not available (no agent).") + return + } + + groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr) + + writers, err := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID) + if err != nil { + slog.Warn("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 group. The first person to interact with the bot will be added automatically.") + return + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("File writers for this group (%d):\n", len(writers))) + for i, w := range writers { + label := w.UserID + if w.Username != nil && *w.Username != "" { + label = "@" + *w.Username + } else if w.DisplayName != nil && *w.DisplayName != "" { + label = *w.DisplayName + } + sb.WriteString(fmt.Sprintf("%d. %s (ID: %s)\n", i+1, label, w.UserID)) + } + send(sb.String()) +} + // --- Pairing UX --- // buildPairingReply builds the pairing reply message matching TS behavior. @@ -201,5 +351,8 @@ func DefaultMenuCommands() []telego.BotCommand { {Command: "help", Description: "Show available commands"}, {Command: "reset", Description: "Reset conversation history"}, {Command: "status", Description: "Show bot status"}, + {Command: "writers", Description: "List file writers for this group"}, + {Command: "addwriter", Description: "Add a file writer (reply to their message)"}, + {Command: "removewriter", Description: "Remove a file writer (reply to their message)"}, } } diff --git a/internal/channels/telegram/factory.go b/internal/channels/telegram/factory.go index bfb1d04e..01903b6a 100644 --- a/internal/channels/telegram/factory.go +++ b/internal/channels/telegram/factory.go @@ -29,9 +29,23 @@ type telegramInstanceConfig struct { AllowFrom []string `json:"allow_from,omitempty"` } -// Factory creates a Telegram channel from DB instance data. +// Factory creates a Telegram channel from DB instance data (no agent store = no group file writer commands). 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) +} + +// FactoryWithAgentStore returns a ChannelFactory that includes the agent store +// for group file writer management (/addwriter, /removewriter, /writers commands). +func FactoryWithAgentStore(agentStore store.AgentStore) 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) + } +} + +func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, + msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore) (channels.Channel, error) { var c telegramCreds if len(creds) > 0 { @@ -71,7 +85,7 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, tgCfg.GroupPolicy = "pairing" } - ch, err := New(tgCfg, msgBus, pairingSvc) + ch, err := New(tgCfg, msgBus, pairingSvc, agentStore) if err != nil { return nil, err } diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index 92a861fc..eb39063a 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -210,8 +210,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { content = "[empty message]" } - // Handle bot commands (/start, /help, /reset, /status). - if handled := c.handleBotCommand(ctx, chatID, chatIDStr, localKey, content, senderID, isGroup, isForum, messageThreadID); handled { + // Handle bot commands (/start, /help, /reset, /status, /addwriter, /removewriter, /writers). + if handled := c.handleBotCommand(ctx, message, chatID, chatIDStr, localKey, content, senderID, isGroup, isForum, messageThreadID); handled { return } diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index b66a6aba..a2024105 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -167,4 +167,17 @@ type AgentStore interface { // User-agent profiles GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace string) (isNew bool, err error) + + // Group file writers (allowlist for protected file edits in group chats) + IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error) + AddGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID, displayName, username string) error + RemoveGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) error + ListGroupFileWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]GroupFileWriterData, error) +} + +// GroupFileWriterData represents a group file writer entry. +type GroupFileWriterData struct { + UserID string `json:"user_id"` + DisplayName *string `json:"display_name,omitempty"` + Username *string `json:"username,omitempty"` } diff --git a/internal/store/context.go b/internal/store/context.go index 9c4f3134..79f7888b 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -15,6 +15,10 @@ const ( AgentIDKey contextKey = "goclaw_agent_id" // AgentTypeKey is the context key for the agent type ("open" or "predefined"). AgentTypeKey contextKey = "goclaw_agent_type" + // SenderIDKey is the original individual sender's ID (not group-scoped). + // In group chats, UserIDKey is group-scoped but SenderIDKey preserves + // the actual person who sent the message. + SenderIDKey contextKey = "goclaw_sender_id" ) // WithUserID returns a new context with the given user ID. @@ -55,3 +59,16 @@ func AgentTypeFromContext(ctx context.Context) string { } return "" } + +// WithSenderID returns a new context with the original individual sender ID. +func WithSenderID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, SenderIDKey, id) +} + +// SenderIDFromContext extracts the sender ID from context. Returns "" if not set. +func SenderIDFromContext(ctx context.Context) string { + if v, ok := ctx.Value(SenderIDKey).(string); ok { + return v + } + return "" +} diff --git a/internal/store/pg/group_file_writers.go b/internal/store/pg/group_file_writers.go new file mode 100644 index 00000000..780ea751 --- /dev/null +++ b/internal/store/pg/group_file_writers.go @@ -0,0 +1,64 @@ +package pg + +import ( + "context" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *PGAgentStore) IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error) { + var exists bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 AND user_id=$3)`, + agentID, groupID, userID, + ).Scan(&exists) + if err != nil { + return false, err + } + return exists, nil +} + +func (s *PGAgentStore) AddGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID, displayName, username string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO group_file_writers (agent_id, group_id, user_id, display_name, username) + VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,'')) + ON CONFLICT (agent_id, group_id, user_id) DO UPDATE SET display_name=NULLIF($4,''), username=NULLIF($5,'')`, + agentID, groupID, userID, displayName, username, + ) + return err +} + +func (s *PGAgentStore) RemoveGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 AND user_id=$3`, + agentID, groupID, userID, + ) + return err +} + +func (s *PGAgentStore) ListGroupFileWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]store.GroupFileWriterData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT user_id, display_name, username FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 ORDER BY created_at`, + agentID, groupID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var writers []store.GroupFileWriterData + for rows.Next() { + var w store.GroupFileWriterData + if err := rows.Scan(&w.UserID, &w.DisplayName, &w.Username); err != nil { + return nil, err + } + writers = append(writers, w) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return writers, nil +} diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go index 1fd0153b..60865588 100644 --- a/internal/tools/context_file_interceptor.go +++ b/internal/tools/context_file_interceptor.go @@ -2,6 +2,8 @@ package tools import ( "context" + "fmt" + "log/slog" "path/filepath" "strings" "sync" @@ -13,6 +15,16 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// protectedFileSet defines files that require group file writer permission in group chats. +// These files control the agent's identity and behavior — only allowlisted users can modify them. +var protectedFileSet = map[string]bool{ + "SOUL.md": true, + "IDENTITY.md": true, + "AGENTS.md": true, + "HEARTBEAT.md": true, + "USER.md": true, +} + // contextFileSet is the set of filenames routed to DB in managed mode. var contextFileSet = map[string]bool{ "SOUL.md": true, @@ -233,6 +245,23 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st userID := store.UserIDFromContext(ctx) agentType := store.AgentTypeFromContext(ctx) + // Permission check: protected files in group context require allowlist membership. + if strings.HasPrefix(userID, "group:") && protectedFileSet[fileName] { + senderID := store.SenderIDFromContext(ctx) + if senderID != "" { + numericID := strings.SplitN(senderID, "|", 2)[0] + isWriter, err := b.agentStore.IsGroupFileWriter(ctx, agentID, userID, numericID) + if err != nil { + slog.Warn("security.group_file_writer_check_failed", + "error", err, "sender", numericID, "file", fileName, "group", userID) + // fail open: allow write if DB check fails + } else if !isWriter { + return true, fmt.Errorf("permission denied: you are not authorized to modify %s in this group. Ask a group file writer to add you with /addwriter", fileName) + } + } + // senderID empty = system context (cron, subagent) → fail open + } + // BOOTSTRAP.md deletion: empty content = first-run completed → delete row if fileName == "BOOTSTRAP.md" && content == "" && userID != "" { err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName) diff --git a/migrations/000002_group_file_writers.down.sql b/migrations/000002_group_file_writers.down.sql new file mode 100644 index 00000000..2c01092a --- /dev/null +++ b/migrations/000002_group_file_writers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS group_file_writers; diff --git a/migrations/000002_group_file_writers.up.sql b/migrations/000002_group_file_writers.up.sql new file mode 100644 index 00000000..468dcec6 --- /dev/null +++ b/migrations/000002_group_file_writers.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE group_file_writers ( + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + group_id VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + username VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (agent_id, group_id, user_id) +);