feat: add Telegram manager channel permissions (#1339)

Co-authored-by: GoClaw Operator <operator@goclaw>
This commit is contained in:
nguyenha935andGoClaw Operator authored and GitHub committed 2026-07-04 00:25:00 +07:00
1 parent cf80d90afb
commit 4d3c6bcb05
33 files changed
+1339 -214

No files matched your search

+8
View File
@@ -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
+1
View File
@@ -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,
+9
View File
@@ -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")
}
}
}
+17 -16
View File
@@ -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,
})
+5 -1
View File
@@ -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() {
+64 -3
View File
@@ -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
}
+3
View File
@@ -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)
+2 -2
View File
@@ -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 {
+3 -17
View File
@@ -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
}
+34 -33
View File
@@ -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,
}
}
+1 -1
View File
@@ -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
}
}
+32 -14
View File
@@ -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 "...".
+35 -34
View File
@@ -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 <agentID>")
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 <agentID>")
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
+14 -13
View File
@@ -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.
+35
View File
@@ -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.
+16
View File
@@ -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()
+213
View File
@@ -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
+2
View File
@@ -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,
}
+59 -12
View File
@@ -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
+29 -21
View File
@@ -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.
+34 -33
View File
@@ -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.
+22 -12
View File
@@ -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)
}
+50
View File
@@ -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))
}
+348
View File
@@ -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
}
}
+141
View File
@@ -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)
}
}
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
@@ -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",
]);
});
});
@@ -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 && (
<>
<ConfigGroupHeader
title={t("detail.telegramManagement")}
description={t("detail.telegramManagementDesc")}
/>
<ChannelFields
fields={groups.telegramManagement}
values={values}
onChange={handleChange}
idPrefix="adv-tgm"
/>
</>
)}
</div>
{/* Footer */}
@@ -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({
</div>
);
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 (
<div className="grid gap-1.5">
<FieldLabel text={`${label}${labelSuffix}`} tip={tooltipHelp} />
<div className="flex flex-wrap gap-2">
{field.options?.map((opt) => {
const active = selected.has(opt.value);
return (
<Button
key={opt.value}
type="button"
variant={active ? "default" : "outline"}
size="sm"
aria-pressed={active}
onClick={() => toggleOption(opt.value)}
>
{t(`fieldOptions.${field.key}.${opt.value}`, { defaultValue: opt.label })}
</Button>
);
})}
</div>
{inlineHelp && <p className="text-xs text-muted-foreground">{inlineHelp}</p>}
</div>
);
}
case "tristate": {
// Tri-state: undefined = inherit, value = override.
// With options: select with Inherit + custom options (string value).
@@ -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", () => {
+12 -1
View File
@@ -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<string, FieldDef[]> = {
@@ -156,6 +165,8 @@ export const configSchema: Record<string, FieldDef[]> = {
{ 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: [