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.
This commit is contained in:
Viet Tran committed 2026-02-22 18:57:24 +07:00
1 parent 8221cc2d37
commit 86b1724050
14 files changed
+342 -15

No files matched your search

+2 -2
View File
@@ -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 {
+1
View File
@@ -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,
+18
View File
@@ -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
}
+5
View File
@@ -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 != "" {
+11 -8
View File
@@ -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,
+154 -1
View File
@@ -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)"},
}
}
+16 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+13
View File
@@ -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"`
}
+17
View File
@@ -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 ""
}
+64
View File
@@ -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
}
@@ -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)
@@ -0,0 +1 @@
DROP TABLE IF EXISTS group_file_writers;
@@ -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)
);