mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 12:16:54 +00:00
feat: add Telegram manager channel permissions (#1339)
Co-authored-by: GoClaw Operator <operator@goclaw>
This commit is contained in:
1 parent
cf80d90afb
commit
4d3c6bcb05
33 files changed
+1339
-214
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "...".
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user