From 4d3c6bcb053cc77f50ba4c3a65b660a7a06fd70d Mon Sep 17 00:00:00 2001 From: nguyenha935 Date: Sat, 4 Jul 2026 00:25:00 +0700 Subject: [PATCH] feat: add Telegram manager channel permissions (#1339) Co-authored-by: GoClaw Operator --- cmd/gateway.go | 8 + cmd/gateway_builtin_tools.go | 1 + cmd/gateway_builtin_tools_test.go | 9 + cmd/gateway_consumer_normal.go | 33 +- cmd/gateway_tools_wiring.go | 6 +- internal/agent/image_gen_gate_test.go | 67 +++- internal/agent/loop_context.go | 3 + internal/agent/loop_history.go | 4 +- internal/agent/loop_history_toolnames.go | 20 +- internal/agent/loop_pipeline_adapter.go | 67 ++-- internal/agent/loop_pipeline_callbacks.go | 2 +- internal/agent/loop_tool_filter.go | 46 ++- internal/agent/loop_types.go | 69 ++-- internal/bus/types.go | 27 +- internal/channels/channel.go | 35 ++ internal/channels/manager.go | 16 + internal/channels/telegram/channel.go | 213 +++++++++++ internal/channels/telegram/factory.go | 2 + internal/channels/telegram/handlers.go | 71 +++- internal/config/config_channels.go | 50 +-- internal/pipeline/run_state.go | 67 ++-- internal/tools/context_keys.go | 34 +- internal/tools/telegram_forum.go | 50 +++ internal/tools/telegram_manager.go | 348 ++++++++++++++++++ internal/tools/telegram_manager_test.go | 141 +++++++ ui/web/src/i18n/locales/en/channels.json | 22 ++ ui/web/src/i18n/locales/vi/channels.json | 22 ++ ui/web/src/i18n/locales/zh/channels.json | 22 ++ .../channel-advanced-dialog.test.ts | 10 + .../channel-advanced-dialog.tsx | 19 +- ui/web/src/pages/channels/channel-fields.tsx | 38 ++ .../pages/channels/channel-schemas.test.ts | 18 + ui/web/src/pages/channels/channel-schemas.ts | 13 +- 33 files changed, 1339 insertions(+), 214 deletions(-) create mode 100644 internal/tools/telegram_manager.go create mode 100644 internal/tools/telegram_manager_test.go diff --git a/cmd/gateway.go b/cmd/gateway.go index 3e103179..d665c0d2 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -717,6 +717,14 @@ func runGateway() { gl.SetGroupMemberLister(channelMgr.ListGroupMembers) } } + // Wire Telegram manager on telegram_manager tool. + for _, toolName := range []string{"telegram_manager", "create_forum_topic"} { + if t, ok := toolsReg.Get(toolName); ok { + if tm, ok := t.(tools.TelegramManagerAware); ok { + tm.SetTelegramManager(channelMgr.ManageTelegram) + } + } + } // Load channel instances from DB. var instanceLoader *channels.InstanceLoader diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index 2021c6ea..50a63772 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -93,6 +93,7 @@ func builtinToolSeedData() []store.BuiltinToolDef { // messaging {Name: "message", DisplayName: "Message", Description: "Send a proactive message to a user on a connected channel (Telegram, Discord, etc.)", Category: "messaging", Enabled: true}, {Name: "send_file", DisplayName: "Send File", Description: "Send an existing workspace file as an attachment in the current chat (does not create or modify the file)", Category: "messaging", Enabled: true}, + {Name: "create_forum_topic", DisplayName: "Create Telegram Forum Topic", Description: "Create a Telegram forum topic and return its message_thread_id for routing", Category: "messaging", Enabled: true}, // scheduling {Name: "cron", DisplayName: "Cron Scheduler", Description: "Schedule or manage recurring tasks using cron expressions, at-times, or intervals", Category: "scheduling", Enabled: true, diff --git a/cmd/gateway_builtin_tools_test.go b/cmd/gateway_builtin_tools_test.go index a4beea53..8af55815 100644 --- a/cmd/gateway_builtin_tools_test.go +++ b/cmd/gateway_builtin_tools_test.go @@ -18,3 +18,12 @@ func TestBuiltinToolSeedDataIncludesWait(t *testing.T) { } t.Fatal("builtinToolSeedData() missing wait") } + +func TestBuiltinToolSeedDataDoesNotIncludeTelegramManager(t *testing.T) { + t.Parallel() + for _, def := range builtinToolSeedData() { + if def.Name == "telegram_manager" { + t.Fatal("telegram_manager must be configured from Telegram channel settings, not seeded as a visible builtin tool") + } + } +} diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index f36638a8..f7516eeb 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -455,22 +455,23 @@ func processNormalMessage( // Forward Bitrix24 portal domain from channel metadata so the // system prompt can teach the LLM the correct entity URL host. // Empty for non-bitrix24 channels — section is skipped downstream. - BitrixPortalDomain: msg.Metadata["bitrix_portal"], - ChatTitle: msg.Metadata[tools.MetaChatTitle], - ChatID: msg.ChatID, - WorkspaceChatID: msg.ChatID, - PeerKind: peerKind, - LocalKey: msg.Metadata["local_key"], - UserID: userID, - SenderID: effectiveSenderID, - Role: effectiveRole, - SenderName: resolveSenderName(msg), - RunID: runID, - Stream: providerStream, - HistoryLimit: msg.HistoryLimit, - ToolAllow: msg.ToolAllow, - ExtraSystemPrompt: extraPrompt, - SkillFilter: skillFilter, + BitrixPortalDomain: msg.Metadata["bitrix_portal"], + ChatTitle: msg.Metadata[tools.MetaChatTitle], + ChatID: msg.ChatID, + WorkspaceChatID: msg.ChatID, + PeerKind: peerKind, + LocalKey: msg.Metadata["local_key"], + UserID: userID, + SenderID: effectiveSenderID, + Role: effectiveRole, + SenderName: resolveSenderName(msg), + RunID: runID, + Stream: providerStream, + HistoryLimit: msg.HistoryLimit, + ToolAllow: msg.ToolAllow, + TelegramManagerPermissions: msg.TelegramManagerPermissions, + ExtraSystemPrompt: extraPrompt, + SkillFilter: skillFilter, }, scheduler.ScheduleOpts{ MaxConcurrent: maxConcurrent, }) diff --git a/cmd/gateway_tools_wiring.go b/cmd/gateway_tools_wiring.go index 43d11ec8..eaa5f8e2 100644 --- a/cmd/gateway_tools_wiring.go +++ b/cmd/gateway_tools_wiring.go @@ -67,7 +67,11 @@ func wireExtraTools( toolsReg.Register(tools.NewSendFileTool(workspace, agentCfg.RestrictToWorkspace)) // Group members tool (list members in group chats) toolsReg.Register(tools.NewListGroupMembersTool()) - slog.Info("session + message + send_file tools registered") + // Telegram manager tool (admin/forum/message management; gated by tool policy) + // create_forum_topic is kept as a backward-compatible wrapper for topic.create. + toolsReg.Register(tools.NewCreateForumTopicTool(nil)) + toolsReg.Register(tools.NewTelegramManagerTool()) + slog.Info("session + message + send_file + telegram_manager tools registered") // Register legacy tool aliases (backward-compat names from policy.go). for alias, canonical := range tools.LegacyToolAliases() { diff --git a/internal/agent/image_gen_gate_test.go b/internal/agent/image_gen_gate_test.go index 9c20f9c7..15354299 100644 --- a/internal/agent/image_gen_gate_test.go +++ b/internal/agent/image_gen_gate_test.go @@ -9,6 +9,7 @@ package agent // Additionally: final-iteration stripping takes priority — all tools removed. import ( + "slices" "testing" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -152,10 +153,10 @@ func TestImageGenGate_FilteringNoPanic(t *testing.T) { provider: prov, allowImageGeneration: true, tools: exec, - orchMode: "spawn", // Triggers orchModeDenyTools + orchMode: "spawn", // Triggers orchModeDenyTools disabledTools: map[string]bool{"read_file": true}, // Triggers disabled tools filter - agentType: "open", // Triggers bootstrap filter - skillEvolve: false, // Triggers skill evolve filter + agentType: "open", // Triggers bootstrap filter + skillEvolve: false, // Triggers skill evolve filter } req := &RunRequest{ @@ -174,3 +175,63 @@ func TestImageGenGate_FilteringNoPanic(t *testing.T) { _ = defs } +func TestChannelAwareToolsHiddenWithoutChannelType(t *testing.T) { + reg := tools.NewRegistry() + reg.Register(tools.NewTelegramManagerTool()) + l := &Loop{ + provider: &stubProvider{}, + allowImageGeneration: false, + tools: reg, + } + + defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 1, 10, nil, nil) + + if hasFunctionTool(defs, "telegram_manager") { + t.Fatal("telegram_manager must be hidden when no channel type is present") + } +} + +func TestTelegramManagerHiddenUntilChannelPermissionEnabled(t *testing.T) { + reg := tools.NewRegistry() + reg.Register(tools.NewTelegramManagerTool()) + l := &Loop{ + provider: &stubProvider{}, + allowImageGeneration: false, + tools: reg, + } + + defs, _, _ := l.buildFilteredTools(&RunRequest{ChannelType: "telegram"}, false, 1, 10, nil, nil) + if hasFunctionTool(defs, "telegram_manager") { + t.Fatal("telegram_manager must be hidden for Telegram runs until the channel grants permissions") + } + + defs, _, _ = l.buildFilteredTools(&RunRequest{ + ChannelType: "telegram", + TelegramManagerPermissions: []string{"topic"}, + }, false, 1, 10, nil, nil) + if !hasFunctionTool(defs, "telegram_manager") { + t.Fatal("telegram_manager must be visible for Telegram runs with channel permissions") + } +} + +func TestTelegramManagerHiddenFromPromptUntilChannelPermissionEnabled(t *testing.T) { + reg := tools.NewRegistry() + reg.Register(tools.NewTelegramManagerTool()) + l := &Loop{tools: reg} + + if slices.Contains(l.filteredToolNamesForChannel("telegram", nil), "telegram_manager") { + t.Fatal("telegram_manager must be hidden from prompt until channel permissions are granted") + } + if !slices.Contains(l.filteredToolNamesForChannel("telegram", []string{"topic"}), "telegram_manager") { + t.Fatal("telegram_manager must appear in prompt for Telegram runs with channel permissions") + } +} + +func hasFunctionTool(defs []providers.ToolDefinition, name string) bool { + for _, d := range defs { + if d.Function != nil && d.Function.Name == name { + return true + } + } + return false +} diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 601ae570..fce882f9 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -91,6 +91,9 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup if req.ChannelType != "" { ctx = tools.WithToolChannelType(ctx, req.ChannelType) } + if len(req.TelegramManagerPermissions) > 0 { + ctx = tools.WithTelegramManagerPermissions(ctx, req.TelegramManagerPermissions) + } // Inject per-agent overrides from DB so tools honor per-agent settings. if l.restrictToWs != nil { ctx = tools.WithRestrictToWorkspace(ctx, *l.restrictToWs) diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 85a7a77d..37ec117c 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -17,7 +17,7 @@ import ( // buildMessages constructs the full message list for an LLM request. // Returns the messages and whether BOOTSTRAP.md was present in context files // (used by the caller for auto-cleanup without an extra DB roundtrip). -func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, summary, userMessage, extraSystemPrompt, sessionKey, channel, channelType, bitrixPortalDomain, chatTitle, chatID, peerKind, userID, senderName string, historyLimit int, skillFilter []string, lightContext bool) ([]providers.Message, bool) { +func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, summary, userMessage, extraSystemPrompt, sessionKey, channel, channelType, bitrixPortalDomain, chatTitle, chatID, peerKind, userID, senderName string, historyLimit int, skillFilter []string, lightContext bool, telegramManagerPermissions []string) ([]providers.Message, bool) { var messages []providers.Message // Build system prompt — 3-layer mode resolution: runtime > auto-detect > config @@ -130,7 +130,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s // Build tool list, filtering out skill_manage when skill_evolve is off. // Also applies ChannelAware filtering so channel-specific tools don't // appear in ## Tooling when the current channel doesn't support them. - toolNames := l.filteredToolNamesForChannel(channelType) + toolNames := l.filteredToolNamesForChannel(channelType, telegramManagerPermissions) if !l.skillEvolve { filtered := toolNames[:0:0] for _, n := range toolNames { diff --git a/internal/agent/loop_history_toolnames.go b/internal/agent/loop_history_toolnames.go index f9cdd3f3..19ecf140 100644 --- a/internal/agent/loop_history_toolnames.go +++ b/internal/agent/loop_history_toolnames.go @@ -1,11 +1,5 @@ package agent -import ( - "slices" - - "github.com/nextlevelbuilder/goclaw/internal/tools" -) - // bootstrapToolAllowlist is the set of tools available during bootstrap onboarding. // Only write_file (and its alias Write) are needed to save USER.md and clear BOOTSTRAP.md. var bootstrapToolAllowlist = map[string]bool{ @@ -44,21 +38,13 @@ func (l *Loop) filteredToolNames() []string { // and ChannelAware filters. Tools that implement ChannelAware and don't list // the current channelType are excluded — keeps the system prompt Tooling // section consistent with the actual tool definitions sent to the LLM. -func (l *Loop) filteredToolNamesForChannel(channelType string) []string { +func (l *Loop) filteredToolNamesForChannel(channelType string, telegramManagerPermissions []string) []string { names := l.filteredToolNames() - if channelType == "" { - return names - } filtered := names[:0:0] for _, name := range names { - if tool, ok := l.tools.Get(name); ok { - if ca, ok := tool.(tools.ChannelAware); ok { - if !slices.Contains(ca.RequiredChannelTypes(), channelType) { - continue - } - } + if l.toolVisibleForChannel(name, channelType, telegramManagerPermissions) { + filtered = append(filtered, name) } - filtered = append(filtered, name) } return filtered } diff --git a/internal/agent/loop_pipeline_adapter.go b/internal/agent/loop_pipeline_adapter.go index 8504a75b..2abe66b4 100644 --- a/internal/agent/loop_pipeline_adapter.go +++ b/internal/agent/loop_pipeline_adapter.go @@ -218,39 +218,40 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P // convertRunInput converts agent.RunRequest to pipeline.RunInput. func convertRunInput(req *RunRequest) *pipeline.RunInput { return &pipeline.RunInput{ - SessionKey: req.SessionKey, - Message: req.Message, - Media: req.Media, - ForwardMedia: req.ForwardMedia, - Channel: req.Channel, - ChannelType: req.ChannelType, - BitrixPortalDomain: req.BitrixPortalDomain, - ChatTitle: req.ChatTitle, - ChatID: req.ChatID, - PeerKind: req.PeerKind, - RunID: req.RunID, - UserID: req.UserID, - SenderID: req.SenderID, - SenderName: req.SenderName, - Stream: req.Stream, - ExtraSystemPrompt: req.ExtraSystemPrompt, - SkillFilter: req.SkillFilter, - HistoryLimit: req.HistoryLimit, - ToolAllow: req.ToolAllow, - LightContext: req.LightContext, - RunKind: req.RunKind, - DelegationID: req.DelegationID, - TeamID: req.TeamID, - TeamTaskID: req.TeamTaskID, - ParentAgentID: req.ParentAgentID, - MaxIterations: req.MaxIterations, - ModelOverride: req.ModelOverride, - HideInput: req.HideInput, - ContentSuffix: req.ContentSuffix, - LeaderAgentID: req.LeaderAgentID, - WorkspaceChannel: req.WorkspaceChannel, - WorkspaceChatID: req.WorkspaceChatID, - TeamWorkspace: req.TeamWorkspace, + SessionKey: req.SessionKey, + Message: req.Message, + Media: req.Media, + ForwardMedia: req.ForwardMedia, + Channel: req.Channel, + ChannelType: req.ChannelType, + BitrixPortalDomain: req.BitrixPortalDomain, + ChatTitle: req.ChatTitle, + ChatID: req.ChatID, + PeerKind: req.PeerKind, + RunID: req.RunID, + UserID: req.UserID, + SenderID: req.SenderID, + SenderName: req.SenderName, + Stream: req.Stream, + ExtraSystemPrompt: req.ExtraSystemPrompt, + SkillFilter: req.SkillFilter, + HistoryLimit: req.HistoryLimit, + ToolAllow: req.ToolAllow, + TelegramManagerPermissions: req.TelegramManagerPermissions, + LightContext: req.LightContext, + RunKind: req.RunKind, + DelegationID: req.DelegationID, + TeamID: req.TeamID, + TeamTaskID: req.TeamTaskID, + ParentAgentID: req.ParentAgentID, + MaxIterations: req.MaxIterations, + ModelOverride: req.ModelOverride, + HideInput: req.HideInput, + ContentSuffix: req.ContentSuffix, + LeaderAgentID: req.LeaderAgentID, + WorkspaceChannel: req.WorkspaceChannel, + WorkspaceChatID: req.WorkspaceChatID, + TeamWorkspace: req.TeamWorkspace, } } diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index e7856911..d430df0d 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -139,7 +139,7 @@ func (l *Loop) makeBuildMessages() func(ctx context.Context, input *pipeline.Run input.SessionKey, input.Channel, input.ChannelType, input.BitrixPortalDomain, input.ChatTitle, input.ChatID, input.PeerKind, input.UserID, input.SenderName, - input.HistoryLimit, input.SkillFilter, input.LightContext) + input.HistoryLimit, input.SkillFilter, input.LightContext, input.TelegramManagerPermissions) return msgs, nil } } diff --git a/internal/agent/loop_tool_filter.go b/internal/agent/loop_tool_filter.go index abbb8c99..2b5e049e 100644 --- a/internal/agent/loop_tool_filter.go +++ b/internal/agent/loop_tool_filter.go @@ -13,6 +13,27 @@ import ( // object — no "function" wrapper, no parameters. var imageGenToolDef = providers.ToolDefinition{Type: "image_generation"} +func (l *Loop) toolVisibleForChannel(name, channelType string, telegramManagerPermissions []string) bool { + if name == "telegram_manager" { + return channelType == "telegram" && len(telegramManagerPermissions) > 0 + } + if l.tools == nil { + return true + } + tool, ok := l.tools.Get(name) + if !ok { + return true + } + ca, ok := tool.(tools.ChannelAware) + if !ok { + return true + } + if channelType == "" { + return false + } + return slices.Contains(ca.RequiredChannelTypes(), channelType) +} + // buildFilteredTools resolves the per-iteration tool definitions based on policy, // disabled tools, bootstrap mode, skill visibility, channel type, and iteration budget. // Per-user MCP tools (require_user_credentials servers) are passed in via userTools — @@ -119,23 +140,20 @@ func (l *Loop) buildFilteredTools(req *RunRequest, hadBootstrap bool, iteration, toolDefs = filtered } - // Hide channel-specific tools when channel type doesn't match. - if req.ChannelType != "" { - filtered := toolDefs[:0:0] - for _, td := range toolDefs { - if td.Function != nil { - if tool, ok := l.tools.Get(td.Function.Name); ok { - if ca, ok := tool.(tools.ChannelAware); ok { - if !slices.Contains(ca.RequiredChannelTypes(), req.ChannelType) { - continue - } - } - } + // Hide channel-specific tools when channel type doesn't match. Channel-aware + // tools are hidden when there is no channel context; telegram_manager also + // requires explicit channel permissions before it is visible to the LLM. + filtered := toolDefs[:0:0] + for _, td := range toolDefs { + if td.Function != nil && !l.toolVisibleForChannel(td.Function.Name, req.ChannelType, req.TelegramManagerPermissions) { + if allowedTools != nil { + delete(allowedTools, td.Function.Name) } - filtered = append(filtered, td) + continue } - toolDefs = filtered + filtered = append(filtered, td) } + toolDefs = filtered // Final iteration: strip all tools to force a text-only response. // Without this the model may keep requesting tools and exit with "...". diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index f0be3305..715188f9 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -449,10 +449,10 @@ type LoopConfig struct { MemoryStore store.MemoryStore // Per-user MCP tools (servers requiring per-user credentials) - MCPStore store.MCPServerStore // for credential lookup - MCPPool *mcpbridge.Pool // user-keyed connection pool - MCPUserCredSrvs []store.MCPAccessInfo // servers needing per-user creds - MCPGrantChecker mcpbridge.GrantChecker // runtime grant verification (nil = skip) + MCPStore store.MCPServerStore // for credential lookup + MCPPool *mcpbridge.Pool // user-keyed connection pool + MCPUserCredSrvs []store.MCPAccessInfo // servers needing per-user creds + MCPGrantChecker mcpbridge.GrantChecker // runtime grant verification (nil = skip) MCPOAuthTokenProvider mcpbridge.OAuthTokenProvider // OAuth Bearer token injection (nil = disabled) // V3 orchestration mode (resolved by resolver, controls tool visibility) @@ -608,36 +608,37 @@ func NewLoop(cfg LoopConfig) *Loop { // RunRequest is the input for processing a message through the agent. type RunRequest struct { - SessionKey string // composite key: agent:{agentId}:{channel}:{peerKind}:{chatId} - Message string // user message - Media []bus.MediaFile // local media files with MIME types - ForwardMedia []bus.MediaFile // media files to forward to output (from delegation results) - Channel string // source channel instance name (e.g. "my-telegram-bot") - ChannelType string // platform type (e.g. "zalo_personal", "telegram") — for system prompt context - BitrixPortalDomain string // bitrix24-only: portal domain (e.g. "tamgiac.bitrix24.com") for entity URL construction - ChatTitle string // group chat display name (e.g. Telegram group title) - ChatID string // source chat ID - 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) - SenderName string // display name from channel metadata (for bootstrap auto-contact) - Role string // caller's RBAC role (admin/operator/viewer/owner); bypasses per-user grants for authenticated admins (#915) - Stream bool // whether to stream response chunks - ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) - SkillFilter []string // per-request skill override: nil=use agent default, []=no skills, ["x","y"]=whitelist - HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) - ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx") - LocalKey string // composite key with topic/thread suffix for routing (e.g. "-100123:topic:42") - ParentTraceID uuid.UUID // if set, reuse parent trace instead of creating new (announce runs) - ParentRootSpanID uuid.UUID // if set, nest announce agent span under this parent span - LinkedTraceID uuid.UUID // if set, create new trace with parent_trace_id pointing to this (team task runs) - TraceName string // override trace name (default: "chat ") - TraceTags []string // additional tags for the trace (e.g. "cron") - MaxIterations int // per-request override (0 = use agent default, must be lower) - ModelOverride string // per-request model override (heartbeat uses cheaper model) - ProviderOverride providers.Provider // per-request provider override (heartbeat uses different provider) - LightContext bool // skip loading context files (only inject ExtraSystemPrompt) + SessionKey string // composite key: agent:{agentId}:{channel}:{peerKind}:{chatId} + Message string // user message + Media []bus.MediaFile // local media files with MIME types + ForwardMedia []bus.MediaFile // media files to forward to output (from delegation results) + Channel string // source channel instance name (e.g. "my-telegram-bot") + ChannelType string // platform type (e.g. "zalo_personal", "telegram") — for system prompt context + BitrixPortalDomain string // bitrix24-only: portal domain (e.g. "tamgiac.bitrix24.com") for entity URL construction + ChatTitle string // group chat display name (e.g. Telegram group title) + ChatID string // source chat ID + 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) + SenderName string // display name from channel metadata (for bootstrap auto-contact) + Role string // caller's RBAC role (admin/operator/viewer/owner); bypasses per-user grants for authenticated admins (#915) + Stream bool // whether to stream response chunks + ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) + SkillFilter []string // per-request skill override: nil=use agent default, []=no skills, ["x","y"]=whitelist + HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) + ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx") + TelegramManagerPermissions []string // hidden Telegram management permission groups granted by the channel config + LocalKey string // composite key with topic/thread suffix for routing (e.g. "-100123:topic:42") + ParentTraceID uuid.UUID // if set, reuse parent trace instead of creating new (announce runs) + ParentRootSpanID uuid.UUID // if set, nest announce agent span under this parent span + LinkedTraceID uuid.UUID // if set, create new trace with parent_trace_id pointing to this (team task runs) + TraceName string // override trace name (default: "chat ") + TraceTags []string // additional tags for the trace (e.g. "cron") + MaxIterations int // per-request override (0 = use agent default, must be lower) + ModelOverride string // per-request model override (heartbeat uses cheaper model) + ProviderOverride providers.Provider // per-request provider override (heartbeat uses different provider) + LightContext bool // skip loading context files (only inject ExtraSystemPrompt) // Run classification RunKind string // "delegation", "announce" — empty for user-initiated runs diff --git a/internal/bus/types.go b/internal/bus/types.go index c3477589..a37af650 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -19,19 +19,20 @@ type MediaFile struct { // InboundMessage represents a message received from a channel (Telegram, Discord, etc.) type InboundMessage struct { - Channel string `json:"channel"` - SenderID string `json:"sender_id"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []MediaFile `json:"media,omitempty"` - SessionKey string `json:"session_key"` // deprecated: gateway builds canonical key - PeerKind string `json:"peer_kind,omitempty"` // "direct" or "group" (used for session key) - TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope from channel instance - AgentID string `json:"agent_id,omitempty"` // target agent (for multi-agent routing) - UserID string `json:"user_id,omitempty"` // external user ID for per-user scoping (memory, bootstrap) - HistoryLimit int `json:"history_limit,omitempty"` // max turns to keep in context (0=unlimited, from channel config) - ToolAllow []string `json:"tool_allow,omitempty"` // per-group tool allow list (nil = no restriction) - Metadata map[string]string `json:"metadata,omitempty"` + Channel string `json:"channel"` + SenderID string `json:"sender_id"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []MediaFile `json:"media,omitempty"` + SessionKey string `json:"session_key"` // deprecated: gateway builds canonical key + PeerKind string `json:"peer_kind,omitempty"` // "direct" or "group" (used for session key) + TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope from channel instance + AgentID string `json:"agent_id,omitempty"` // target agent (for multi-agent routing) + UserID string `json:"user_id,omitempty"` // external user ID for per-user scoping (memory, bootstrap) + HistoryLimit int `json:"history_limit,omitempty"` // max turns to keep in context (0=unlimited, from channel config) + ToolAllow []string `json:"tool_allow,omitempty"` // per-group tool allow list (nil = no restriction) + TelegramManagerPermissions []string `json:"telegram_manager_permissions,omitempty"` // hidden Telegram management permission groups for this inbound run + Metadata map[string]string `json:"metadata,omitempty"` } // OutboundMessage represents a message to be sent to a channel. diff --git a/internal/channels/channel.go b/internal/channels/channel.go index 0952fe14..1b35b035 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -701,6 +701,41 @@ type GroupMemberProvider interface { ListGroupMembers(ctx context.Context, chatID string) ([]GroupMember, error) } +// TelegramManagerRequest describes a whitelisted Telegram Bot API management +// action requested by the agent-facing telegram_manager tool. +type TelegramManagerRequest struct { + Action string `json:"action"` + ChatID string `json:"chat_id,omitempty"` + MessageThreadID int `json:"message_thread_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + UserID int64 `json:"user_id,omitempty"` + Name string `json:"name,omitempty"` + Text string `json:"text,omitempty"` + InviteLink string `json:"invite_link,omitempty"` + IconColor int `json:"icon_color,omitempty"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` + ExpireDate int64 `json:"expire_date,omitempty"` + MemberLimit int `json:"member_limit,omitempty"` + DisableNotification bool `json:"disable_notification,omitempty"` + CreatesJoinRequest bool `json:"creates_join_request,omitempty"` + OnlyIfBanned bool `json:"only_if_banned,omitempty"` + RevokeMessages bool `json:"revoke_messages,omitempty"` + Params map[string]any `json:"params,omitempty"` +} + +// TelegramManagerResult is a normalized response returned by Telegram-capable +// channels after executing a whitelisted management action. +type TelegramManagerResult struct { + Action string `json:"action"` + Result map[string]any `json:"result,omitempty"` +} + +// TelegramManagerProvider is optionally implemented by channels that expose +// Telegram Bot API management actions through the channel manager. +type TelegramManagerProvider interface { + ManageTelegram(ctx context.Context, req TelegramManagerRequest) (TelegramManagerResult, error) +} + // PendingCompactable is optionally implemented by channels that have a PendingHistory // supporting LLM-based compaction. InstanceLoader uses this to wire compaction config // after channel creation. diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 484f45ca..a7ca96c9 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -322,6 +322,22 @@ func (m *Manager) ListGroupMembers(ctx context.Context, channelName, chatID stri return gmp.ListGroupMembers(ctx, chatID) } +// ManageTelegram delegates a whitelisted Telegram management action to a +// Telegram-capable channel instance. +func (m *Manager) ManageTelegram(ctx context.Context, channelName string, req TelegramManagerRequest) (TelegramManagerResult, error) { + m.mu.RLock() + ch, ok := m.channels[channelName] + m.mu.RUnlock() + if !ok { + return TelegramManagerResult{}, fmt.Errorf("channel %q not found", channelName) + } + mp, ok := ch.(TelegramManagerProvider) + if !ok { + return TelegramManagerResult{}, fmt.Errorf("channel %q does not support Telegram management", channelName) + } + return mp.ManageTelegram(ctx, req) +} + // UnregisterChannel removes a channel from the manager. func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 9c05cd2c..9ca14968 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -561,6 +561,219 @@ func (c *Channel) CreateForumTopic(ctx context.Context, chatID int64, name strin return topic.MessageThreadID, topic.Name, nil } +// ManageTelegram executes whitelisted Telegram Bot API management actions. +func (c *Channel) ManageTelegram(ctx context.Context, req channels.TelegramManagerRequest) (channels.TelegramManagerResult, error) { + result := func(values map[string]any) channels.TelegramManagerResult { + return channels.TelegramManagerResult{Action: req.Action, Result: values} + } + ok := func() channels.TelegramManagerResult { + return result(map[string]any{"ok": true}) + } + + switch req.Action { + case "topic.get_icon_stickers": + stickers, err := c.bot.GetForumTopicIconStickers(ctx) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"stickers": stickers, "count": len(stickers)}), nil + } + + chatID, err := parseRawChatID(req.ChatID) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("invalid chat_id %q: %w", req.ChatID, err) + } + tgChatID := telego.ChatID{ID: chatID} + + switch req.Action { + case "chat.get": + chat, err := c.bot.GetChat(ctx, &telego.GetChatParams{ChatID: tgChatID}) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"chat": chat}), nil + case "chat.get_administrators": + admins, err := c.bot.GetChatAdministrators(ctx, &telego.GetChatAdministratorsParams{ChatID: tgChatID}) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"administrators": admins, "count": len(admins)}), nil + case "chat.get_member_count": + count, err := c.bot.GetChatMemberCount(ctx, &telego.GetChatMemberCountParams{ChatID: tgChatID}) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"count": count}), nil + case "chat.get_member": + member, err := c.bot.GetChatMember(ctx, &telego.GetChatMemberParams{ChatID: tgChatID, UserID: req.UserID}) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"member": member}), nil + case "chat.set_title": + if err := c.bot.SetChatTitle(ctx, &telego.SetChatTitleParams{ChatID: tgChatID, Title: req.Text}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "chat.set_description": + if err := c.bot.SetChatDescription(ctx, &telego.SetChatDescriptionParams{ChatID: tgChatID, Description: req.Text}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "chat.leave": + if err := c.bot.LeaveChat(ctx, &telego.LeaveChatParams{ChatID: tgChatID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "member.ban": + if err := c.bot.BanChatMember(ctx, &telego.BanChatMemberParams{ChatID: tgChatID, UserID: req.UserID, RevokeMessages: req.RevokeMessages}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "member.unban": + if err := c.bot.UnbanChatMember(ctx, &telego.UnbanChatMemberParams{ChatID: tgChatID, UserID: req.UserID, OnlyIfBanned: req.OnlyIfBanned}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "member.set_custom_title": + if err := c.bot.SetChatAdministratorCustomTitle(ctx, &telego.SetChatAdministratorCustomTitleParams{ChatID: tgChatID, UserID: req.UserID, CustomTitle: req.Text}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "join_request.approve": + if err := c.bot.ApproveChatJoinRequest(ctx, &telego.ApproveChatJoinRequestParams{ChatID: tgChatID, UserID: req.UserID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "join_request.decline": + if err := c.bot.DeclineChatJoinRequest(ctx, &telego.DeclineChatJoinRequestParams{ChatID: tgChatID, UserID: req.UserID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "invite.export": + inviteLink, err := c.bot.ExportChatInviteLink(ctx, &telego.ExportChatInviteLinkParams{ChatID: tgChatID}) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"invite_link": inviteLink}), nil + case "invite.create": + inviteLink, err := c.bot.CreateChatInviteLink(ctx, &telego.CreateChatInviteLinkParams{ChatID: tgChatID, Name: req.Name, ExpireDate: req.ExpireDate, MemberLimit: req.MemberLimit, CreatesJoinRequest: req.CreatesJoinRequest}) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"invite_link": inviteLink}), nil + case "invite.revoke": + inviteLink, err := c.bot.RevokeChatInviteLink(ctx, &telego.RevokeChatInviteLinkParams{ChatID: tgChatID, InviteLink: req.InviteLink}) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return result(map[string]any{"invite_link": inviteLink}), nil + case "message.delete": + if err := c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{ChatID: tgChatID, MessageID: req.MessageID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "message.pin": + if err := c.bot.PinChatMessage(ctx, &telego.PinChatMessageParams{ChatID: tgChatID, MessageID: req.MessageID, DisableNotification: req.DisableNotification}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "message.unpin": + if err := c.bot.UnpinChatMessage(ctx, &telego.UnpinChatMessageParams{ChatID: tgChatID, MessageID: req.MessageID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "message.unpin_all": + if err := c.bot.UnpinAllChatMessages(ctx, &telego.UnpinAllChatMessagesParams{ChatID: tgChatID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.create": + return c.manageCreateForumTopic(ctx, req, tgChatID) + case "topic.edit": + iconEmojiID := req.IconCustomEmojiID + params := &telego.EditForumTopicParams{ChatID: tgChatID, MessageThreadID: req.MessageThreadID, Name: req.Name} + if iconEmojiID != "" { + params.IconCustomEmojiID = &iconEmojiID + } + if err := c.bot.EditForumTopic(ctx, params); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.close": + if err := c.bot.CloseForumTopic(ctx, &telego.CloseForumTopicParams{ChatID: tgChatID, MessageThreadID: req.MessageThreadID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.reopen": + if err := c.bot.ReopenForumTopic(ctx, &telego.ReopenForumTopicParams{ChatID: tgChatID, MessageThreadID: req.MessageThreadID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.delete": + if err := c.bot.DeleteForumTopic(ctx, &telego.DeleteForumTopicParams{ChatID: tgChatID, MessageThreadID: req.MessageThreadID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.unpin_all_messages": + if err := c.bot.UnpinAllForumTopicMessages(ctx, &telego.UnpinAllForumTopicMessagesParams{ChatID: tgChatID, MessageThreadID: req.MessageThreadID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.general.edit": + if err := c.bot.EditGeneralForumTopic(ctx, &telego.EditGeneralForumTopicParams{ChatID: tgChatID, Name: req.Name}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.general.close": + if err := c.bot.CloseGeneralForumTopic(ctx, &telego.CloseGeneralForumTopicParams{ChatID: tgChatID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.general.reopen": + if err := c.bot.ReopenGeneralForumTopic(ctx, &telego.ReopenGeneralForumTopicParams{ChatID: tgChatID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.general.hide": + if err := c.bot.HideGeneralForumTopic(ctx, &telego.HideGeneralForumTopicParams{ChatID: tgChatID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.general.unhide": + if err := c.bot.UnhideGeneralForumTopic(ctx, &telego.UnhideGeneralForumTopicParams{ChatID: tgChatID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + case "topic.general.unpin_all_messages": + if err := c.bot.UnpinAllGeneralForumTopicMessages(ctx, &telego.UnpinAllGeneralForumTopicMessagesParams{ChatID: tgChatID}); err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return ok(), nil + default: + return channels.TelegramManagerResult{}, fmt.Errorf("unsupported Telegram management action %q", req.Action) + } +} + +func (c *Channel) manageCreateForumTopic(ctx context.Context, req channels.TelegramManagerRequest, chatID telego.ChatID) (channels.TelegramManagerResult, error) { + params := &telego.CreateForumTopicParams{ChatID: chatID, Name: req.Name} + if req.IconColor > 0 { + params.IconColor = req.IconColor + } + if req.IconCustomEmojiID != "" { + params.IconCustomEmojiID = req.IconCustomEmojiID + } + topic, err := c.bot.CreateForumTopic(ctx, params) + if err != nil { + return channels.TelegramManagerResult{}, fmt.Errorf("telegram API: %w", err) + } + return channels.TelegramManagerResult{Action: req.Action, Result: map[string]any{ + "message_thread_id": topic.MessageThreadID, + "name": topic.Name, + "chat_id": req.ChatID, + }}, nil +} + // telegramGeneralTopicID is the fixed topic ID for the "General" topic in forum supergroups. // TS ref: TELEGRAM_GENERAL_TOPIC_ID in src/telegram/bot/helpers.ts:12. const telegramGeneralTopicID = 1 diff --git a/internal/channels/telegram/factory.go b/internal/channels/telegram/factory.go index a9a6155c..093b0bcb 100644 --- a/internal/channels/telegram/factory.go +++ b/internal/channels/telegram/factory.go @@ -41,6 +41,7 @@ type telegramInstanceConfig struct { ForceIPv4 bool `json:"force_ipv4,omitempty"` AllowFrom []string `json:"allow_from,omitempty"` Groups map[string]*config.TelegramGroupConfig `json:"groups,omitempty"` + TelegramManager *config.TelegramManagerConfig `json:"telegram_manager,omitempty"` } // Factory creates a Telegram channel from DB instance data (no extra stores). @@ -120,6 +121,7 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, BlockReply: ic.BlockReply, ChatBehavior: ic.ChatBehavior, ForceIPv4: ic.ForceIPv4, + TelegramManager: ic.TelegramManager, Groups: ic.Groups, } diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index ade1f1f4..de7aa945 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -697,19 +697,21 @@ func (c *Channel) processResolvedMessage(ctx context.Context, rctx resolvedMessa } } + telegramManagerPermissions := c.telegramManagerPermissions() c.Bus().PublishInbound(bus.InboundMessage{ - Channel: c.Name(), - SenderID: rctx.senderID, - ChatID: rctx.chatIDStr, - Content: finalContent, - Media: mediaFiles, - PeerKind: peerKind, - UserID: rctx.userID, - AgentID: targetAgentID, - HistoryLimit: c.HistoryLimit(), - ToolAllow: rctx.topicCfg.tools, - TenantID: c.TenantID(), - Metadata: metadata, + Channel: c.Name(), + SenderID: rctx.senderID, + ChatID: rctx.chatIDStr, + Content: finalContent, + Media: mediaFiles, + PeerKind: peerKind, + UserID: rctx.userID, + AgentID: targetAgentID, + HistoryLimit: c.HistoryLimit(), + ToolAllow: telegramToolAllowWithManager(rctx.topicCfg.tools, telegramManagerPermissions), + TelegramManagerPermissions: telegramManagerPermissions, + TenantID: c.TenantID(), + Metadata: metadata, }) // Clear pending history after sending to agent. @@ -718,6 +720,51 @@ func (c *Channel) processResolvedMessage(ctx context.Context, rctx resolvedMessa } } +func (c *Channel) telegramManagerPermissions() []string { + if c.config.TelegramManager == nil || !c.config.TelegramManager.Enabled { + return nil + } + return normalizeTelegramManagerPermissions(c.config.TelegramManager.AllowedActions) +} + +func normalizeTelegramManagerPermissions(values []string) []string { + allowed := map[string]struct{}{ + "chat": {}, + "member": {}, + "join_request": {}, + "invite": {}, + "message": {}, + "topic": {}, + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + key := strings.ToLower(strings.TrimSpace(value)) + if _, ok := allowed[key]; !ok { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + return out +} + +func telegramToolAllowWithManager(toolAllow []string, permissions []string) []string { + if len(toolAllow) == 0 || len(permissions) == 0 { + return toolAllow + } + for _, name := range toolAllow { + if name == "telegram_manager" { + return toolAllow + } + } + merged := append([]string{}, toolAllow...) + return append(merged, "telegram_manager") +} + func (c *Channel) transcribeMediaAudio(ctx context.Context, m MediaInfo) (string, error) { if c.audioMgr == nil { return "", nil diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 41c8353f..82cc7725 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -52,6 +52,13 @@ type PendingCompactionConfig struct { Model string `json:"model,omitempty"` // model for summarization; empty = use agent's model } +// TelegramManagerConfig controls the hidden Telegram management tool for this channel. +// It is intentionally channel-scoped instead of part of the global builtin tool profiles. +type TelegramManagerConfig struct { + Enabled bool `json:"enabled,omitempty"` + AllowedActions []string `json:"allowed_actions,omitempty"` +} + // ChannelsConfig contains per-channel configuration. type ChannelsConfig struct { Telegram TelegramConfig `json:"telegram"` @@ -65,27 +72,28 @@ type ChannelsConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled"` - Token string `json:"token"` - Proxy string `json:"proxy,omitempty"` - APIServer string `json:"api_server,omitempty"` // custom Telegram Bot API server URL (e.g. "http://localhost:8081") - AllowFrom FlexibleStringSlice `json:"allow_from"` - DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" - GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" - RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true) - MentionMode string `json:"mention_mode,omitempty"` // "strict" (default) = only respond when mentioned; "yield" = respond unless another bot is mentioned - HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled) - DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively - GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively - DraftTransport *bool `json:"draft_transport,omitempty"` // use sendMessageDraft for DM streaming (default true) — stealth preview, no notifications per edit - ReasoningDelivery string `json:"reasoning_delivery,omitempty"` // off, streaming_only, always_bubbles - ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message when provider emits thinking events (default true) - ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions - MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB) - LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true) - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) - ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) - ForceIPv4 bool `json:"force_ipv4,omitempty"` // force IPv4 for all Telegram API requests (use when IPv6 routing is broken) + Enabled bool `json:"enabled"` + Token string `json:"token"` + Proxy string `json:"proxy,omitempty"` + APIServer string `json:"api_server,omitempty"` // custom Telegram Bot API server URL (e.g. "http://localhost:8081") + AllowFrom FlexibleStringSlice `json:"allow_from"` + DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" + GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" + RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true) + MentionMode string `json:"mention_mode,omitempty"` // "strict" (default) = only respond when mentioned; "yield" = respond unless another bot is mentioned + HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled) + DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively + GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively + DraftTransport *bool `json:"draft_transport,omitempty"` // use sendMessageDraft for DM streaming (default true) — stealth preview, no notifications per edit + ReasoningDelivery string `json:"reasoning_delivery,omitempty"` // off, streaming_only, always_bubbles + ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message when provider emits thinking events (default true) + ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions + MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB) + LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true) + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) + ForceIPv4 bool `json:"force_ipv4,omitempty"` // force IPv4 for all Telegram API requests (use when IPv6 routing is broken) + TelegramManager *TelegramManagerConfig `json:"telegram_manager,omitempty"` // hidden Telegram-only management tool permissions // Optional STT (Speech-to-Text) pipeline for voice/audio inbound messages. // When stt_proxy_url is set, audio/voice messages are transcribed before being forwarded to the agent. diff --git a/internal/pipeline/run_state.go b/internal/pipeline/run_state.go index 5351f6a4..1055d94d 100644 --- a/internal/pipeline/run_state.go +++ b/internal/pipeline/run_state.go @@ -72,39 +72,40 @@ func (rs *RunState) BuildResult() *RunResult { // RunInput is the pipeline's view of a run request. // Converted from agent.RunRequest by the adapter in Phase 8. type RunInput struct { - SessionKey string - Message string - Media []bus.MediaFile - ForwardMedia []bus.MediaFile - Channel string - ChannelType string - BitrixPortalDomain string // bitrix24-only: portal domain for entity URL construction - ChatTitle string - ChatID string - PeerKind string - RunID string - UserID string - SenderID string - SenderName string - Stream bool - ExtraSystemPrompt string - SkillFilter []string - HistoryLimit int - ToolAllow []string - LightContext bool - RunKind string - DelegationID string - TeamID string - TeamTaskID string - ParentAgentID string - MaxIterations int - ModelOverride string - HideInput bool - ContentSuffix string - LeaderAgentID string - WorkspaceChannel string - WorkspaceChatID string - TeamWorkspace string + SessionKey string + Message string + Media []bus.MediaFile + ForwardMedia []bus.MediaFile + Channel string + ChannelType string + BitrixPortalDomain string // bitrix24-only: portal domain for entity URL construction + ChatTitle string + ChatID string + PeerKind string + RunID string + UserID string + SenderID string + SenderName string + Stream bool + ExtraSystemPrompt string + SkillFilter []string + HistoryLimit int + ToolAllow []string + TelegramManagerPermissions []string + LightContext bool + RunKind string + DelegationID string + TeamID string + TeamTaskID string + ParentAgentID string + MaxIterations int + ModelOverride string + HideInput bool + ContentSuffix string + LeaderAgentID string + WorkspaceChannel string + WorkspaceChatID string + TeamWorkspace string } // MediaResult represents a media file produced during tool execution. diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index 5c79681a..2664b871 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -21,18 +21,19 @@ import ( type toolContextKey string const ( - ctxChannel toolContextKey = "tool_channel" - ctxChannelType toolContextKey = "tool_channel_type" - ctxChatID toolContextKey = "tool_chat_id" - ctxPeerKind toolContextKey = "tool_peer_kind" - ctxLocalKey toolContextKey = "tool_local_key" // composite key with topic/thread suffix for routing - ctxSandboxKey toolContextKey = "tool_sandbox_key" - ctxAsyncCB toolContextKey = "tool_async_cb" - ctxWorkspace toolContextKey = "tool_workspace" - ctxAgentKey toolContextKey = "tool_agent_key" - ctxAgentPolicy toolContextKey = "tool_agent_policy" // per-agent tool policy for MCP bridge enforcement - ctxSessionKey toolContextKey = "tool_session_key" // origin session key for announce routing - ctxRunKind toolContextKey = "tool_run_kind" // "notification", "announce", "delegation" + ctxChannel toolContextKey = "tool_channel" + ctxChannelType toolContextKey = "tool_channel_type" + ctxChatID toolContextKey = "tool_chat_id" + ctxPeerKind toolContextKey = "tool_peer_kind" + ctxLocalKey toolContextKey = "tool_local_key" // composite key with topic/thread suffix for routing + ctxSandboxKey toolContextKey = "tool_sandbox_key" + ctxAsyncCB toolContextKey = "tool_async_cb" + ctxWorkspace toolContextKey = "tool_workspace" + ctxAgentKey toolContextKey = "tool_agent_key" + ctxAgentPolicy toolContextKey = "tool_agent_policy" // per-agent tool policy for MCP bridge enforcement + ctxSessionKey toolContextKey = "tool_session_key" // origin session key for announce routing + ctxRunKind toolContextKey = "tool_run_kind" // "notification", "announce", "delegation" + ctxTelegramManagerPermissions toolContextKey = "telegram_manager_permissions" ) // ctxRateLimitOverride carries a per-agent tool rate limit (calls/hour) that @@ -75,6 +76,15 @@ func ToolChannelTypeFromCtx(ctx context.Context) string { return "" } +func WithTelegramManagerPermissions(ctx context.Context, permissions []string) context.Context { + return context.WithValue(ctx, ctxTelegramManagerPermissions, permissions) +} + +func TelegramManagerPermissionsFromCtx(ctx context.Context) []string { + v, _ := ctx.Value(ctxTelegramManagerPermissions).([]string) + return v +} + func WithToolChatID(ctx context.Context, chatID string) context.Context { return context.WithValue(ctx, ctxChatID, chatID) } diff --git a/internal/tools/telegram_forum.go b/internal/tools/telegram_forum.go index 83ede7e1..d1630d57 100644 --- a/internal/tools/telegram_forum.go +++ b/internal/tools/telegram_forum.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/channels" ) // ForumTopicCreator can create forum topics in a Telegram supergroup. @@ -19,6 +21,7 @@ type ForumTopicCreatorProvider func() ForumTopicCreator // CreateForumTopicTool lets agents create forum topics in Telegram supergroups. type CreateForumTopicTool struct { provider ForumTopicCreatorProvider + manager TelegramManagerFunc } // NewCreateForumTopicTool creates a new create_forum_topic tool. @@ -26,6 +29,8 @@ func NewCreateForumTopicTool(provider ForumTopicCreatorProvider) *CreateForumTop return &CreateForumTopicTool{provider: provider} } +func (t *CreateForumTopicTool) SetTelegramManager(m TelegramManagerFunc) { t.manager = m } + func (t *CreateForumTopicTool) Name() string { return "create_forum_topic" } func (t *CreateForumTopicTool) RequiredChannelTypes() []string { return []string{"telegram"} } @@ -61,6 +66,12 @@ func (t *CreateForumTopicTool) Parameters() map[string]any { } func (t *CreateForumTopicTool) Execute(ctx context.Context, args map[string]any) *Result { + if t.manager != nil { + return t.executeViaTelegramManager(ctx, args) + } + if t.provider == nil { + return &Result{ForLLM: "Error: no Telegram channel available", IsError: true} + } creator := t.provider() if creator == nil { return &Result{ForLLM: "Error: no Telegram channel available", IsError: true} @@ -101,3 +112,42 @@ func (t *CreateForumTopicTool) Execute(ctx context.Context, args map[string]any) jsonBytes, _ := json.Marshal(result) return &Result{ForLLM: string(jsonBytes)} } + +func (t *CreateForumTopicTool) executeViaTelegramManager(ctx context.Context, args map[string]any) *Result { + channel := argString(args, "channel") + if channel == "" { + channel = ToolChannelFromCtx(ctx) + } + if channel == "" { + return ErrorResult("channel is required (no current channel in context)") + } + + chatID := argString(args, "chat_id") + if chatID == "" { + chatID = ToolChatIDFromCtx(ctx) + } + if chatID == "" { + return ErrorResult("chat_id is required (no current chat in context)") + } + + name := argString(args, "name") + if name == "" { + return ErrorResult("name is required") + } + if len(name) > 128 { + return ErrorResult("topic name must be 1-128 characters") + } + + result, err := t.manager(ctx, channel, channels.TelegramManagerRequest{ + Action: "topic.create", + ChatID: chatID, + Name: name, + IconColor: argInt(args, "icon_color"), + IconCustomEmojiID: argString(args, "icon_custom_emoji_id"), + }) + if err != nil { + return ErrorResult(fmt.Sprintf("Error creating forum topic: %v", err)) + } + jsonBytes, _ := json.Marshal(result.Result) + return NewResult(string(jsonBytes)) +} diff --git a/internal/tools/telegram_manager.go b/internal/tools/telegram_manager.go new file mode 100644 index 00000000..92f28c22 --- /dev/null +++ b/internal/tools/telegram_manager.go @@ -0,0 +1,348 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/channels" +) + +// TelegramManagerFunc executes a whitelisted Telegram management action on a +// named channel instance. Implemented by channels.Manager.ManageTelegram. +type TelegramManagerFunc func(ctx context.Context, channel string, req channels.TelegramManagerRequest) (channels.TelegramManagerResult, error) + +// TelegramManagerAware tools can receive a Telegram manager function. +type TelegramManagerAware interface { + SetTelegramManager(TelegramManagerFunc) +} + +// TelegramManagerTool exposes selected Telegram Bot API management actions to agents. +type TelegramManagerTool struct { + manager TelegramManagerFunc +} + +func NewTelegramManagerTool() *TelegramManagerTool { return &TelegramManagerTool{} } + +func (t *TelegramManagerTool) SetTelegramManager(m TelegramManagerFunc) { t.manager = m } + +func (t *TelegramManagerTool) RequiredChannelTypes() []string { return []string{"telegram"} } + +func (t *TelegramManagerTool) Name() string { return "telegram_manager" } + +func (t *TelegramManagerTool) Description() string { + return "Manage Telegram chats through whitelisted Bot API actions. Supports chat info, member admin actions, message pin/delete actions, and forum topic management when the bot has the required Telegram permissions." +} + +func (t *TelegramManagerTool) Parameters() map[string]any { + actions := make([]string, 0, len(telegramManagerActions)) + for action := range telegramManagerActions { + actions = append(actions, action) + } + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "description": "Whitelisted Telegram management action, e.g. chat.get, member.ban, message.pin, topic.create.", + "enum": actions, + }, + "channel": map[string]any{ + "type": "string", + "description": "GoClaw Telegram channel instance name. Defaults to current channel.", + }, + "chat_id": map[string]any{ + "type": "string", + "description": "Telegram chat ID or @username. Defaults to current chat.", + }, + "message_thread_id": map[string]any{"type": "integer", "description": "Telegram forum topic thread ID."}, + "message_id": map[string]any{"type": "integer", "description": "Telegram message ID."}, + "user_id": map[string]any{"type": "integer", "description": "Telegram user ID."}, + "name": map[string]any{"type": "string", "description": "Topic name, general topic name, or other Telegram name parameter."}, + "text": map[string]any{"type": "string", "description": "Text parameter for actions that need text."}, + "invite_link": map[string]any{"type": "string", "description": "Telegram invite link for revoke actions."}, + "icon_color": map[string]any{"type": "integer", "description": "Telegram forum topic icon color."}, + "icon_custom_emoji_id": map[string]any{ + "type": "string", + "description": "Telegram custom emoji ID for forum topic icon.", + }, + "expire_date": map[string]any{"type": "integer", "description": "Unix timestamp when an invite link expires."}, + "member_limit": map[string]any{"type": "integer", "description": "Maximum members for an invite link."}, + "disable_notification": map[string]any{"type": "boolean", "description": "Disable notification for pin actions."}, + "creates_join_request": map[string]any{"type": "boolean", "description": "Whether invite link joins require admin approval."}, + "only_if_banned": map[string]any{"type": "boolean", "description": "Only unban if currently banned."}, + "revoke_messages": map[string]any{"type": "boolean", "description": "Revoke prior messages when banning."}, + "params": map[string]any{"type": "object", "description": "Reserved structured options for future whitelisted actions."}, + }, + "required": []string{"action"}, + } +} + +func (t *TelegramManagerTool) Execute(ctx context.Context, args map[string]any) *Result { + if ToolChannelTypeFromCtx(ctx) != "telegram" { + return ErrorResult("telegram_manager is only available inside Telegram channel runs") + } + permissions := TelegramManagerPermissionsFromCtx(ctx) + if len(permissions) == 0 { + return ErrorResult("telegram_manager is disabled for this Telegram channel") + } + if t.manager == nil { + return ErrorResult("telegram_manager: no Telegram manager available") + } + + action := argString(args, "action") + if action == "" { + return ErrorResult("action is required") + } + if _, ok := telegramManagerActions[action]; !ok { + return ErrorResult(fmt.Sprintf("unsupported telegram_manager action: %s", action)) + } + permissionGroup := telegramManagerPermissionGroup(action) + if permissionGroup == "" || !telegramManagerPermissionAllowed(permissions, permissionGroup) { + return ErrorResult(fmt.Sprintf("telegram_manager action %s is not allowed by this channel", action)) + } + + channel := argString(args, "channel") + if channel == "" { + channel = ToolChannelFromCtx(ctx) + } + if channel == "" { + return ErrorResult("channel is required (no current channel in context)") + } + + chatID := argString(args, "chat_id") + if chatID == "" { + chatID = ToolChatIDFromCtx(ctx) + } + + req := channels.TelegramManagerRequest{ + Action: action, + ChatID: chatID, + MessageThreadID: argInt(args, "message_thread_id"), + MessageID: argInt(args, "message_id"), + UserID: argInt64(args, "user_id"), + Name: argString(args, "name"), + Text: argString(args, "text"), + InviteLink: argString(args, "invite_link"), + IconColor: argInt(args, "icon_color"), + IconCustomEmojiID: argString(args, "icon_custom_emoji_id"), + ExpireDate: argInt64(args, "expire_date"), + MemberLimit: argInt(args, "member_limit"), + DisableNotification: argBool(args, "disable_notification"), + CreatesJoinRequest: argBool(args, "creates_join_request"), + OnlyIfBanned: argBool(args, "only_if_banned"), + RevokeMessages: argBool(args, "revoke_messages"), + } + if rawParams, ok := args["params"].(map[string]any); ok { + req.Params = rawParams + } + + if err := validateTelegramManagerRequest(req); err != nil { + return ErrorResult(err.Error()) + } + + result, err := t.manager(ctx, channel, req) + if err != nil { + return ErrorResult(fmt.Sprintf("telegram_manager %s failed: %v", action, err)) + } + if result.Action == "" { + result.Action = action + } + + data, _ := json.Marshal(map[string]any{ + "ok": true, + "channel": channel, + "chat_id": req.ChatID, + "action": result.Action, + "result": result.Result, + }) + return NewResult(string(data)) +} + +var telegramManagerActions = map[string]struct{}{ + "chat.get": {}, + "chat.get_administrators": {}, + "chat.get_member_count": {}, + "chat.get_member": {}, + "chat.set_title": {}, + "chat.set_description": {}, + "chat.leave": {}, + "member.ban": {}, + "member.unban": {}, + "member.set_custom_title": {}, + "join_request.approve": {}, + "join_request.decline": {}, + "invite.export": {}, + "invite.create": {}, + "invite.revoke": {}, + "message.delete": {}, + "message.pin": {}, + "message.unpin": {}, + "message.unpin_all": {}, + "topic.get_icon_stickers": {}, + "topic.create": {}, + "topic.edit": {}, + "topic.close": {}, + "topic.reopen": {}, + "topic.delete": {}, + "topic.unpin_all_messages": {}, + "topic.general.edit": {}, + "topic.general.close": {}, + "topic.general.reopen": {}, + "topic.general.hide": {}, + "topic.general.unhide": {}, + "topic.general.unpin_all_messages": {}, +} + +var telegramManagerPermissionGroups = map[string]struct{}{ + "chat": {}, + "member": {}, + "join_request": {}, + "invite": {}, + "message": {}, + "topic": {}, +} + +func telegramManagerPermissionGroup(action string) string { + prefix, _, _ := strings.Cut(action, ".") + if prefix == "join_request" { + return "join_request" + } + if _, ok := telegramManagerPermissionGroups[prefix]; ok { + return prefix + } + return "" +} + +func telegramManagerPermissionAllowed(permissions []string, group string) bool { + for _, permission := range permissions { + if strings.EqualFold(strings.TrimSpace(permission), group) { + return true + } + } + return false +} + +func validateTelegramManagerRequest(req channels.TelegramManagerRequest) error { + switch req.Action { + case "topic.get_icon_stickers": + return nil + case "chat.get", "chat.get_administrators", "chat.get_member_count", "message.unpin_all", + "chat.leave", "invite.export", "invite.create", + "topic.general.close", "topic.general.reopen", "topic.general.hide", "topic.general.unhide", "topic.general.unpin_all_messages": + return requireChatID(req) + case "chat.get_member", "member.ban", "member.unban", "join_request.approve", "join_request.decline": + if err := requireChatID(req); err != nil { + return err + } + if req.UserID == 0 { + return fmt.Errorf("user_id is required for %s", req.Action) + } + return nil + case "member.set_custom_title": + if err := requireChatID(req); err != nil { + return err + } + if req.UserID == 0 { + return fmt.Errorf("user_id is required for %s", req.Action) + } + if strings.TrimSpace(req.Text) == "" { + return fmt.Errorf("text is required for %s", req.Action) + } + return nil + case "chat.set_title", "chat.set_description": + if err := requireChatID(req); err != nil { + return err + } + if strings.TrimSpace(req.Text) == "" { + return fmt.Errorf("text is required for %s", req.Action) + } + return nil + case "invite.revoke": + if err := requireChatID(req); err != nil { + return err + } + if strings.TrimSpace(req.InviteLink) == "" { + return fmt.Errorf("invite_link is required for %s", req.Action) + } + return nil + case "message.delete", "message.pin": + if err := requireChatID(req); err != nil { + return err + } + if req.MessageID == 0 { + return fmt.Errorf("message_id is required for %s", req.Action) + } + return nil + case "message.unpin": + return requireChatID(req) + case "topic.create", "topic.general.edit": + if err := requireChatID(req); err != nil { + return err + } + if strings.TrimSpace(req.Name) == "" { + return fmt.Errorf("name is required for %s", req.Action) + } + return nil + case "topic.edit", "topic.close", "topic.reopen", "topic.delete", "topic.unpin_all_messages": + if err := requireChatID(req); err != nil { + return err + } + if req.MessageThreadID == 0 { + return fmt.Errorf("message_thread_id is required for %s", req.Action) + } + return nil + default: + return fmt.Errorf("unsupported telegram_manager action: %s", req.Action) + } +} + +func requireChatID(req channels.TelegramManagerRequest) error { + if strings.TrimSpace(req.ChatID) == "" { + return fmt.Errorf("chat_id is required for %s (no current chat in context)", req.Action) + } + return nil +} + +func argInt(m map[string]any, key string) int { return int(argInt64(m, key)) } + +func argInt64(m map[string]any, key string) int64 { + v, ok := m[key] + if !ok || v == nil { + return 0 + } + switch n := v.(type) { + case int: + return int64(n) + case int64: + return n + case float64: + return int64(n) + case json.Number: + parsed, _ := n.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64) + return parsed + default: + return 0 + } +} + +func argBool(m map[string]any, key string) bool { + v, ok := m[key] + if !ok || v == nil { + return false + } + switch b := v.(type) { + case bool: + return b + case string: + parsed, _ := strconv.ParseBool(strings.TrimSpace(b)) + return parsed + default: + return false + } +} diff --git a/internal/tools/telegram_manager_test.go b/internal/tools/telegram_manager_test.go new file mode 100644 index 00000000..b782dd16 --- /dev/null +++ b/internal/tools/telegram_manager_test.go @@ -0,0 +1,141 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/channels" +) + +type fakeTelegramManager struct { + channel string + req channels.TelegramManagerRequest +} + +func (f *fakeTelegramManager) manage(ctx context.Context, channel string, req channels.TelegramManagerRequest) (channels.TelegramManagerResult, error) { + f.channel = channel + f.req = req + return channels.TelegramManagerResult{ + Action: req.Action, + Result: map[string]any{ + "chat_id": req.ChatID, + "message_thread_id": req.MessageThreadID, + "name": req.Name, + }, + }, nil +} + +func TestTelegramManagerToolUsesCurrentChannelAndChat(t *testing.T) { + t.Parallel() + fake := &fakeTelegramManager{} + tool := NewTelegramManagerTool() + tool.SetTelegramManager(fake.manage) + + ctx := WithTelegramManagerPermissions( + WithToolChannelType( + WithToolChatID(WithToolChannel(context.Background(), "telegram-main"), "-100123"), + "telegram", + ), + []string{"topic"}, + ) + res := tool.Execute(ctx, map[string]any{ + "action": "topic.create", + "name": "Daily Ops", + "message_thread_id": 42, + }) + + if res == nil || res.IsError { + t.Fatalf("Execute() error = %#v", res) + } + if fake.channel != "telegram-main" { + t.Fatalf("channel = %q, want current channel", fake.channel) + } + if fake.req.ChatID != "-100123" { + t.Fatalf("chat_id = %q, want current chat", fake.req.ChatID) + } + if fake.req.Action != "topic.create" || fake.req.Name != "Daily Ops" || fake.req.MessageThreadID != 42 { + t.Fatalf("request = %#v", fake.req) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(res.ForLLM), &payload); err != nil { + t.Fatalf("result is not JSON: %v", err) + } + if payload["action"] != "topic.create" { + t.Fatalf("result action = %#v", payload["action"]) + } +} + +func TestTelegramManagerToolRejectsUnknownAction(t *testing.T) { + t.Parallel() + tool := NewTelegramManagerTool() + tool.SetTelegramManager(func(context.Context, string, channels.TelegramManagerRequest) (channels.TelegramManagerResult, error) { + t.Fatal("manager should not be called for unknown actions") + return channels.TelegramManagerResult{}, nil + }) + + res := tool.Execute(context.Background(), map[string]any{"action": "raw.call"}) + if res == nil || !res.IsError { + t.Fatalf("Execute() should reject unknown action, got %#v", res) + } +} + +func TestTelegramManagerToolRequiresTelegramChannelPermission(t *testing.T) { + t.Parallel() + tool := NewTelegramManagerTool() + tool.SetTelegramManager(func(context.Context, string, channels.TelegramManagerRequest) (channels.TelegramManagerResult, error) { + t.Fatal("manager should not be called without Telegram manager permission") + return channels.TelegramManagerResult{}, nil + }) + + ctx := WithToolChatID(WithToolChannel(context.Background(), "telegram-main"), "-100123") + res := tool.Execute(ctx, map[string]any{"action": "topic.create", "name": "Daily Ops"}) + if res == nil || !res.IsError { + t.Fatalf("Execute() should reject without Telegram channel context, got %#v", res) + } + + ctx = WithToolChannelType(ctx, "telegram") + res = tool.Execute(ctx, map[string]any{"action": "topic.create", "name": "Daily Ops"}) + if res == nil || !res.IsError { + t.Fatalf("Execute() should reject without Telegram manager permissions, got %#v", res) + } +} + +func TestTelegramManagerToolRejectsDisallowedPermissionGroup(t *testing.T) { + t.Parallel() + tool := NewTelegramManagerTool() + tool.SetTelegramManager(func(context.Context, string, channels.TelegramManagerRequest) (channels.TelegramManagerResult, error) { + t.Fatal("manager should not be called for a disallowed permission group") + return channels.TelegramManagerResult{}, nil + }) + + ctx := WithTelegramManagerPermissions( + WithToolChannelType( + WithToolChatID(WithToolChannel(context.Background(), "telegram-main"), "-100123"), + "telegram", + ), + []string{"topic"}, + ) + res := tool.Execute(ctx, map[string]any{"action": "message.delete", "message_id": 12}) + if res == nil || !res.IsError { + t.Fatalf("Execute() should reject message action when only topic is allowed, got %#v", res) + } +} + +func TestTelegramManagerToolRequiresManager(t *testing.T) { + t.Parallel() + tool := NewTelegramManagerTool() + ctx := WithTelegramManagerPermissions( + WithToolChannelType( + WithToolChatID(WithToolChannel(context.Background(), "telegram-main"), "-100123"), + "telegram", + ), + []string{"chat"}, + ) + + res := tool.Execute(ctx, map[string]any{"action": "chat.get"}) + if res == nil || !res.IsError { + t.Fatalf("Execute() should fail without manager, got %#v", res) + } +} diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index d8647381..0a0aad8d 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -110,6 +110,8 @@ "behaviorDesc": "Response behavior configuration", "accessControl": "Access Control", "accessControlDesc": "User access restrictions", + "telegramManagement": "Telegram Management", + "telegramManagementDesc": "Enable the hidden tool and choose Telegram actions for agents using this channel", "reviewDiagnostics": "Review the latest diagnosis in this channel before changing settings.", "whatHappened": "What happened", "recommendedAction": "Recommended action", @@ -346,6 +348,16 @@ "media_max_mb": { "label": "Max Media Size (MB)" }, "link_preview": { "label": "Link Preview" }, "allow_from": { "label": "Allowed Users" }, + "telegram_manager": { + "enabled": { + "label": "Enable Telegram management tool", + "help": "Only applies to agents running through this Telegram channel. This is a hidden tool and does not appear in shared tool profiles." + }, + "allowed_actions": { + "label": "Telegram management permissions", + "help": "Choose which Telegram actions the agent may use. If no permission is selected, the tool is not granted to the agent." + } + }, "domain": { "label": "Domain" }, "connection_mode": { "label": "Connection Mode", @@ -504,6 +516,16 @@ "always_bubbles": "Always as bubbles", "off": "Off" }, + "telegram_manager": { + "allowed_actions": { + "topic": "Manage topics", + "message": "Manage messages", + "member": "Manage members", + "invite": "Manage invite links", + "chat": "Chat info/settings", + "join_request": "Approve join requests" + } + }, "chat_behavior": { "enabled": { "inherit": "Inherit from gateway", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index a31ab666..069b5bd9 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -109,6 +109,8 @@ "behaviorDesc": "Cấu hình hành vi phản hồi", "accessControl": "Kiểm soát truy cập", "accessControlDesc": "Giới hạn truy cập người dùng", + "telegramManagement": "Quản trị Telegram", + "telegramManagementDesc": "Bật tool ẩn và chọn quyền thao tác Telegram cho agent chạy qua channel này", "reviewDiagnostics": "Xem chẩn đoán mới nhất của channel này trước khi thay đổi cài đặt.", "whatHappened": "Điều gì đã xảy ra", "recommendedAction": "Hành động được khuyến nghị", @@ -309,6 +311,16 @@ "media_max_mb": { "label": "Kích thước media tối đa (MB)" }, "link_preview": { "label": "Xem trước liên kết" }, "allow_from": { "label": "Người dùng được phép" }, + "telegram_manager": { + "enabled": { + "label": "Bật công cụ quản trị Telegram", + "help": "Chỉ áp dụng cho agent chạy qua channel Telegram này. Tool này là tool ẩn, không hiện trong hồ sơ công cụ chung." + }, + "allowed_actions": { + "label": "Quyền quản trị Telegram", + "help": "Chọn các thao tác Telegram mà agent được phép dùng. Nếu chưa chọn quyền nào thì tool không được cấp cho agent." + } + }, "domain": { "label": "Tên miền" }, "connection_mode": { "label": "Chế độ kết nối", "help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài" }, "webhook_port": { "label": "Cổng webhook", "help": "0 = chia sẻ cổng gateway chính (khuyến nghị)" }, @@ -434,6 +446,16 @@ "always_bubbles": "Luôn thành bubble", "off": "Tắt" }, + "telegram_manager": { + "allowed_actions": { + "topic": "Quản lý chủ đề", + "message": "Quản lý tin nhắn", + "member": "Quản lý thành viên", + "invite": "Quản lý link mời", + "chat": "Thông tin/cài đặt nhóm", + "join_request": "Duyệt yêu cầu tham gia" + } + }, "chat_behavior": { "enabled": { "inherit": "Kế thừa từ gateway", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 0f5703e5..e887e45a 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -109,6 +109,8 @@ "behaviorDesc": "响应行为配置", "accessControl": "访问控制", "accessControlDesc": "用户访问限制", + "telegramManagement": "Telegram 管理", + "telegramManagementDesc": "为使用此频道的 Agent 启用隐藏工具并选择 Telegram 操作权限", "reviewDiagnostics": "在更改设置前,请先查看此 Channel 的最新诊断。", "whatHappened": "发生了什么", "recommendedAction": "建议操作", @@ -309,6 +311,16 @@ "media_max_mb": { "label": "最大媒体大小 (MB)" }, "link_preview": { "label": "链接预览" }, "allow_from": { "label": "允许的用户" }, + "telegram_manager": { + "enabled": { + "label": "启用 Telegram 管理工具", + "help": "仅适用于通过此 Telegram 频道运行的 Agent。此工具为隐藏工具,不会出现在共享工具配置中。" + }, + "allowed_actions": { + "label": "Telegram 管理权限", + "help": "选择 Agent 可执行的 Telegram 操作。未选择任何权限时,不会向 Agent 授予此工具。" + } + }, "domain": { "label": "域名" }, "connection_mode": { "label": "连接模式", "help": "WebSocket 无需公网 IP — 仅需出站连接" }, "webhook_port": { "label": "Webhook 端口", "help": "0 = 共享主网关端口(推荐)" }, @@ -434,6 +446,16 @@ "always_bubbles": "始终作为气泡", "off": "关闭" }, + "telegram_manager": { + "allowed_actions": { + "topic": "管理话题", + "message": "管理消息", + "member": "管理成员", + "invite": "管理邀请链接", + "chat": "群组信息/设置", + "join_request": "审批入群请求" + } + }, "chat_behavior": { "enabled": { "inherit": "继承自 gateway", diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.test.ts b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.test.ts index 7b6068a5..7ac5bbc4 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.test.ts +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildAdvancedConfigUpdate, deriveAdvancedInitialValues } from "./channel-advanced-config"; +import { getAdvancedFields } from "./channel-advanced-dialog"; describe("channel advanced config payload", () => { it("removes a stale allow_from entry when the allowed users picker is cleared", () => { @@ -61,4 +62,13 @@ describe("channel advanced config payload", () => { }, }); }); + + it("shows Telegram manager settings in the advanced edit dialog", () => { + const groups = getAdvancedFields("telegram"); + + expect(groups.telegramManagement.map((field) => field.key)).toEqual([ + "telegram_manager.enabled", + "telegram_manager.allowed_actions", + ]); + }); }); diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx index f0b44a4e..81190d88 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx @@ -30,8 +30,9 @@ const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit" const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_delivery", "native_stream", "debounce_delay", "thread_ttl"]); const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "render_mode", "topic_session_mode"]); const ACCESS_KEYS = new Set(["allow_from", "group_allow_from"]); +const TELEGRAM_MANAGEMENT_KEYS = new Set(["telegram_manager.enabled", "telegram_manager.allowed_actions"]); -function getAdvancedFields(channelType: string) { +export function getAdvancedFields(channelType: string) { const allFields = configSchema[channelType] ?? []; const advanced = allFields.filter((f) => !ESSENTIAL_CONFIG_KEYS.has(f.key)); return { @@ -40,6 +41,7 @@ function getAdvancedFields(channelType: string) { streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)), behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key) || f.key.startsWith("chat_behavior.")), access: advanced.filter((f) => ACCESS_KEYS.has(f.key)), + telegramManagement: advanced.filter((f) => TELEGRAM_MANAGEMENT_KEYS.has(f.key)), }; } @@ -169,6 +171,21 @@ export function ChannelAdvancedDialog({ /> )} + + {groups.telegramManagement.length > 0 && ( + <> + + + + )} {/* Footer */} diff --git a/ui/web/src/pages/channels/channel-fields.tsx b/ui/web/src/pages/channels/channel-fields.tsx index 1d7365d8..4716df13 100644 --- a/ui/web/src/pages/channels/channel-fields.tsx +++ b/ui/web/src/pages/channels/channel-fields.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { MultiUserPicker } from "@/components/shared/multi-user-picker"; @@ -276,6 +277,43 @@ function FieldRenderer({ ); + case "multi-select": { + const selected = new Set(Array.isArray(value) ? value.map(String) : []); + const toggleOption = (optionValue: string) => { + const next = new Set(selected); + if (next.has(optionValue)) { + next.delete(optionValue); + } else { + next.add(optionValue); + } + onChange(next.size > 0 ? Array.from(next) : undefined); + }; + + return ( +
+ +
+ {field.options?.map((opt) => { + const active = selected.has(opt.value); + return ( + + ); + })} +
+ {inlineHelp &&

{inlineHelp}

} +
+ ); + } + case "tristate": { // Tri-state: undefined = inherit, value = override. // With options: select with Inherit + custom options (string value). diff --git a/ui/web/src/pages/channels/channel-schemas.test.ts b/ui/web/src/pages/channels/channel-schemas.test.ts index c4bc3830..3db7ea96 100644 --- a/ui/web/src/pages/channels/channel-schemas.test.ts +++ b/ui/web/src/pages/channels/channel-schemas.test.ts @@ -53,6 +53,24 @@ describe("telegram configSchema", () => { expect(field.help?.trim(), `${field.key} should have tooltip help`).toBeTruthy(); } }); + + it("exposes Telegram manager permissions as fixed multi-select actions", () => { + const enabled = telegramConfig.find((field) => field.key === "telegram_manager.enabled"); + const allowedActions = telegramConfig.find((field) => field.key === "telegram_manager.allowed_actions"); + + expect(enabled).toBeDefined(); + expect(enabled!.type).toBe("boolean"); + expect(allowedActions).toBeDefined(); + expect(allowedActions!.type).toBe("multi-select"); + expect(allowedActions!.options!.map((option) => option.value)).toEqual([ + "topic", + "message", + "member", + "invite", + "chat", + "join_request", + ]); + }); }); describe("pancake configSchema", () => { diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index a771141c..37938ee7 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -5,7 +5,7 @@ import { reasoningDeliveryOptions } from "./reasoning-delivery-config"; export interface FieldDef { key: string; label: string; - type: "text" | "password" | "number" | "boolean" | "select" | "tags" | "tristate" | "textarea" | "tool-select" | "skill-select"; + type: "text" | "password" | "number" | "boolean" | "select" | "multi-select" | "tags" | "tristate" | "textarea" | "tool-select" | "skill-select"; placeholder?: string; required?: boolean; defaultValue?: string | number | boolean | string[]; @@ -73,6 +73,15 @@ const mentionModeOptions = [ { value: "yield", label: "Multi-bot (respond unless another bot is @mentioned)" }, ]; +const telegramManagerActionOptions = [ + { value: "topic", label: "Manage topics" }, + { value: "message", label: "Manage messages" }, + { value: "member", label: "Manage members" }, + { value: "invite", label: "Manage invite links" }, + { value: "chat", label: "Manage chat settings" }, + { value: "join_request", label: "Approve join requests" }, +]; + // --- Credentials schemas --- export const credentialsSchema: Record = { @@ -156,6 +165,8 @@ export const configSchema: Record = { { key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 20, help: "Default: 20 MB (cloud API). Increase when using local Bot API server." }, { key: "link_preview", label: "Link Preview", type: "boolean", defaultValue: true }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "User IDs or @usernames, one per line or comma-separated" }, + { key: "telegram_manager.enabled", label: "Telegram Management Tool", type: "boolean", defaultValue: false, help: "Enable the hidden Telegram-only management tool for agents using this channel." }, + { key: "telegram_manager.allowed_actions", label: "Telegram Management Permissions", type: "multi-select", options: telegramManagerActionOptions, help: "Choose which Telegram management actions the hidden tool may run for agents using this channel. The tool stays hidden unless at least one permission is selected." }, ...chatBehaviorOverrideFields, ], discord: [