This commit is contained in:
Cytown 2026-05-15 11:47:19 +08:00 committed by GitHub
commit 95dfd083f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 156 additions and 45 deletions

View file

@ -536,7 +536,7 @@ func (al *AgentLoop) runAgentLoop(
// Record last channel for heartbeat notifications (skip internal channels and cli)
if opts.Dispatch.Channel() != "" &&
opts.Dispatch.ChatID() != "" &&
!constants.IsInternalChannel(opts.Dispatch.Channel()) {
!constants.IsInternalChannel(opts.Dispatch.ChannelType()) {
channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID())
if err := al.RecordLastChannel(channelKey); err != nil {
logger.WarnCF(

View file

@ -53,10 +53,11 @@ func (al *AgentLoop) ProcessDirectWithChannel(
msg := bus.InboundMessage{
Context: bus.InboundContext{
Channel: channel,
ChatID: chatID,
ChatType: "direct",
SenderID: "cron",
Channel: channel,
ChannelType: channel, // For direct calls, channel name equals channel type
ChatID: chatID,
ChatType: "direct",
SenderID: "cron",
},
Content: content,
SessionKey: sessionKey,
@ -86,10 +87,11 @@ func (al *AgentLoop) ProcessHeartbeat(
}
if channel != "" || chatID != "" {
dispatch.InboundContext = &bus.InboundContext{
Channel: channel,
ChatID: chatID,
ChatType: "direct",
SenderID: "heartbeat",
Channel: channel,
ChannelType: channel, // For heartbeat, channel name equals channel type
ChatID: chatID,
ChatType: "direct",
SenderID: "heartbeat",
}
}
return al.runAgentLoop(ctx, agent, processOptions{
@ -256,7 +258,7 @@ func (al *AgentLoop) processSystemMessage(
// Parse origin channel from chat_id (format: "channel:chat_id")
var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx]
originChannel = msg.ChatID[:idx] // e.g. "telegram"
originChatID = msg.ChatID[idx+1:]
} else {
originChannel = "cli"

View file

@ -3987,7 +3987,8 @@ func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "pico",
Context: bus.InboundContext{Channel: "pico1", ChannelType: "pico"},
Channel: "pico1",
SenderID: "user1",
ChatID: "pico:test-session",
Content: "hello",
@ -4547,7 +4548,8 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
}()
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Channel: "pico",
Context: bus.InboundContext{Channel: "pico1", ChannelType: "pico"},
Channel: "pico1",
SenderID: "user-1",
ChatID: "session-1",
Content: "run with tools",

View file

@ -209,6 +209,9 @@ func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) {
if inbound.Channel != "" {
fields["inbound_channel"] = inbound.Channel
}
if inbound.ChannelType != "" {
fields["inbound_channel_type"] = inbound.ChannelType
}
if inbound.Account != "" {
fields["inbound_account"] = inbound.Account
}

View file

@ -27,6 +27,13 @@ func (r DispatchRequest) Channel() string {
return r.InboundContext.Channel
}
func (r DispatchRequest) ChannelType() string {
if r.InboundContext == nil {
return ""
}
return r.InboundContext.ChannelType
}
func (r DispatchRequest) ChatID() string {
if r.InboundContext == nil {
return ""
@ -93,6 +100,10 @@ func normalizeProcessOptions(opts processOptions) processOptions {
MessageID: strings.TrimSpace(opts.MessageID),
ReplyToMessageID: strings.TrimSpace(opts.ReplyToMessageID),
}
// Set ChannelType from Channel if not already set
if inbound.ChannelType == "" && inbound.Channel != "" {
inbound.ChannelType = inbound.Channel
}
inbound.ChatType = inferChatTypeFromSessionScope(opts.Dispatch.SessionScope)
if inbound.Channel != "" || inbound.ChatID != "" || inbound.SenderID != "" ||
inbound.MessageID != "" || inbound.ReplyToMessageID != "" {

View file

@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger"
@ -338,7 +339,7 @@ func (p *Pipeline) CallLLM(
},
)
if retry == 0 && !constants.IsInternalChannel(ts.channel) {
if retry == 0 && !constants.IsInternalChannel(ts.opts.Dispatch.ChannelType()) {
al.bus.PublishOutbound(ctx, outboundMessageForTurn(
ts,
"Context window exceeded. Compressing history and retrying...",
@ -433,11 +434,12 @@ func (p *Pipeline) CallLLM(
}
reasoningContent := responseReasoningContent(exec.response)
shouldPublishPicoToolCallInterim := ts.channel == "pico" && len(exec.response.ToolCalls) > 0
shouldPublishPicoToolCallInterim := ts.opts.Dispatch.ChannelType() == config.ChannelPico &&
len(exec.response.ToolCalls) > 0
if shouldPublishPicoToolCallInterim {
// Pico tool-call turns publish their reasoning/content/tool summary as a
// structured sequence after the tool-call payload is normalized below.
} else if ts.channel == "pico" {
} else if ts.opts.Dispatch.ChannelType() == config.ChannelPico {
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
} else {
go al.handleReasoning(
@ -476,7 +478,8 @@ func (p *Pipeline) CallLLM(
// No-tool-call path: steering check and direct response
if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal {
responseContent := exec.response.Content
if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" {
if responseContent == "" && exec.response.ReasoningContent != "" &&
ts.opts.Dispatch.ChannelType() != config.ChannelPico {
responseContent = exec.response.ReasoningContent
}
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 {

View file

@ -238,10 +238,11 @@ func (a *Agent) processUtterance(ctx context.Context, acc *speechAccumulator) {
if err := a.bus.PublishInbound(ctx, bus.InboundMessage{
Context: bus.InboundContext{
Channel: channelType,
ChatID: acc.chatID,
ChatType: "channel",
SenderID: acc.speakerID,
Channel: channelType,
ChannelType: channelType,
ChatID: acc.chatID,
ChatType: "channel",
SenderID: acc.speakerID,
Raw: map[string]string{
"is_voice": "true",
},

View file

@ -32,6 +32,7 @@ func NormalizeInboundMessage(msg InboundMessage) InboundMessage {
func (ctx InboundContext) isZero() bool {
return ctx.Channel == "" &&
ctx.ChannelType == "" &&
ctx.Account == "" &&
ctx.ChatID == "" &&
ctx.ChatType == "" &&
@ -49,6 +50,11 @@ func (ctx InboundContext) isZero() bool {
func normalizeInboundContext(ctx InboundContext) InboundContext {
ctx.Channel = strings.TrimSpace(ctx.Channel)
ctx.ChannelType = strings.TrimSpace(ctx.ChannelType)
// Set ChannelType from Channel if not already set
if ctx.ChannelType == "" && ctx.Channel != "" {
ctx.ChannelType = ctx.Channel
}
ctx.Account = strings.TrimSpace(ctx.Account)
ctx.ChatID = strings.TrimSpace(ctx.ChatID)
ctx.ChatType = normalizeKind(ctx.ChatType)

View file

@ -13,8 +13,9 @@ type SenderInfo struct {
// inbound message. This is the source of truth for routing and session
// allocation.
type InboundContext struct {
Channel string `json:"channel"`
Account string `json:"account,omitempty"`
Channel string `json:"channel"`
ChannelType string `json:"channel_type,omitempty"` // telegram, discord, slack, etc.
Account string `json:"account,omitempty"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type,omitempty"` // direct / group / channel

View file

@ -75,6 +75,11 @@ func WithReasoningChannelID(id string) BaseChannelOption {
return func(c *BaseChannel) { c.reasoningChannelID = id }
}
// WithChannelType sets the channel type (from config.Channel.Type).
func WithChannelType(channelType string) BaseChannelOption {
return func(c *BaseChannel) { c.channelType = channelType }
}
// MessageLengthProvider is an opt-in interface that channels implement
// to advertise their maximum message length. The Manager uses this via
// type assertion to decide whether to split outbound messages.
@ -87,6 +92,7 @@ type BaseChannel struct {
bus *bus.MessageBus
running atomic.Bool
name string
channelType string
allowList []string
maxMessageLength int
groupTrigger config.GroupTriggerConfig
@ -187,6 +193,11 @@ func (c *BaseChannel) Name() string {
return c.name
}
// ChannelType returns the channel type (from config.Channel.Type).
func (c *BaseChannel) ChannelType() string {
return c.channelType
}
// SetName updates the channel name. Used by the manager after channel creation
// to ensure the name matches the config key (which may differ from the type).
func (c *BaseChannel) SetName(name string) {
@ -294,6 +305,7 @@ func (c *BaseChannel) HandleMessageWithContext(
}
inboundCtx.Channel = c.name
inboundCtx.ChannelType = c.channelType
if inboundCtx.ChatID == "" {
inboundCtx.ChatID = deliveryChatID
}

View file

@ -52,6 +52,7 @@ func NewDingTalkChannel(
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &DingTalkChannel{

View file

@ -85,6 +85,7 @@ func NewDiscordChannel(
channels.WithMaxMessageLength(2000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
ch := &DiscordChannel{
@ -669,7 +670,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
}
inboundCtx := bus.InboundContext{
Channel: c.Name(),
ChatID: m.ChannelID,
ChatType: peerKind,
SenderID: senderID,

View file

@ -63,6 +63,7 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M
base := channels.NewBaseChannel("feishu", cfg, bus, bc.AllowFrom,
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
tc := newTokenCache()

View file

@ -38,6 +38,7 @@ func NewIRCChannel(bc *config.Channel, cfg *config.IRCSettings, messageBus *bus.
channels.WithMaxMessageLength(400),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &IRCChannel{

View file

@ -73,6 +73,7 @@ func NewLINEChannel(
channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &LINEChannel{

View file

@ -43,6 +43,7 @@ func NewMaixCamChannel(
bus,
bc.AllowFrom,
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &MaixCamChannel{

View file

@ -155,6 +155,10 @@ func outboundMessageChannel(msg bus.OutboundMessage) string {
return msg.Context.Channel
}
func outboundMessageChannelType(msg bus.OutboundMessage) string {
return msg.Context.ChannelType
}
func outboundMessageChatID(msg bus.OutboundMessage) string {
return msg.ChatID
}
@ -178,6 +182,10 @@ func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
return msg.Context.Channel
}
func outboundMediaChannelType(msg bus.OutboundMediaMessage) string {
return msg.Context.ChannelType
}
func outboundMediaChatID(msg bus.OutboundMediaMessage) string {
return msg.ChatID
}
@ -1200,6 +1208,7 @@ func dispatchLoop[M any](
m *Manager,
ch <-chan M,
getChannel func(M) string,
getChannelType func(M) string,
enqueue func(context.Context, *channelWorker, M) bool,
startMsg, stopMsg, unknownMsg, noWorkerMsg string,
) {
@ -1218,9 +1227,10 @@ func dispatchLoop[M any](
}
channel := getChannel(msg)
channelType := getChannelType(msg)
// Silently skip internal channels
if constants.IsInternalChannel(channel) {
if constants.IsInternalChannel(channelType) {
continue
}
@ -1250,6 +1260,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
ctx, m,
m.bus.OutboundChan(),
func(msg bus.OutboundMessage) string { return outboundMessageChannel(msg) },
func(msg bus.OutboundMessage) string { return outboundMessageChannelType(msg) },
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
select {
case w.queue <- msg:
@ -1271,6 +1282,7 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
ctx, m,
m.bus.OutboundMediaChan(),
func(msg bus.OutboundMediaMessage) string { return outboundMediaChannel(msg) },
func(msg bus.OutboundMediaMessage) string { return outboundMediaChannelType(msg) },
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
select {
case w.mediaQueue <- msg:

View file

@ -242,6 +242,7 @@ func NewMatrixChannel(
channels.WithMaxMessageLength(65536),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
ch := &MatrixChannel{

View file

@ -104,6 +104,7 @@ func NewOneBotChannel(
base := channels.NewBaseChannel("onebot", cfg, messageBus, bc.AllowFrom,
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
const dedupSize = 1024

View file

@ -39,7 +39,9 @@ func NewPicoClientChannel(
return nil, fmt.Errorf("pico_client url is required")
}
base := channels.NewBaseChannel("pico_client", cfg, messageBus, bc.AllowFrom)
base := channels.NewBaseChannel("pico_client", cfg, messageBus, bc.AllowFrom,
channels.WithChannelType(bc.Type),
)
return &PicoClientChannel{
BaseChannel: base,

View file

@ -117,7 +117,9 @@ func NewPicoChannel(
return nil, fmt.Errorf("pico token is required")
}
base := channels.NewBaseChannel("pico", cfg, messageBus, bc.AllowFrom)
base := channels.NewBaseChannel("pico", cfg, messageBus, bc.AllowFrom,
channels.WithChannelType(bc.Type),
)
allowOrigins := cfg.AllowOrigins
checkOrigin := func(r *http.Request) bool {
@ -965,12 +967,13 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
}
inboundCtx := bus.InboundContext{
Channel: "pico",
ChatID: chatID,
ChatType: "direct",
SenderID: senderID,
MessageID: msg.ID,
Raw: metadata,
Channel: c.bc.Name(),
ChannelType: config.ChannelPico,
ChatID: chatID,
ChatType: "direct",
SenderID: senderID,
MessageID: msg.ID,
Raw: metadata,
}
c.HandleInboundContext(c.ctx, chatID, content, media, inboundCtx, sender)

View file

@ -88,6 +88,7 @@ func NewQQChannel(bc *config.Channel, cfg *config.QQSettings, messageBus *bus.Me
channels.WithMaxMessageLength(cfg.MaxMessageLength),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &QQChannel{

View file

@ -56,6 +56,7 @@ func NewSlackChannel(
channels.WithMaxMessageLength(40000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &SlackChannel{
@ -381,7 +382,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
})
inboundCtx := bus.InboundContext{
Channel: c.Name(),
Account: c.teamID,
ChatID: channelID,
ChatType: peerKind,
@ -456,7 +456,6 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
"team_id": c.teamID,
}
inboundCtx := bus.InboundContext{
Channel: c.Name(),
Account: c.teamID,
ChatID: channelID,
ChatType: mentionPeerKind,
@ -521,7 +520,6 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
peerKind = "direct"
}
inboundCtx := bus.InboundContext{
Channel: c.Name(),
Account: c.teamID,
ChatID: channelID,
ChatType: peerKind,

View file

@ -95,6 +95,7 @@ func NewTeamsWebhookChannel(
"*",
}, // Output-only channel; "*" suppresses misleading "allows EVERYONE" audit warning
channels.WithMaxMessageLength(24000), // Power Automate webhook payload limit is 28KB
channels.WithChannelType(bc.Type),
)
client := goteamsnotify.NewTeamsClient()

View file

@ -123,6 +123,7 @@ func NewTelegramChannel(
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
ch := &TelegramChannel{
@ -1005,7 +1006,6 @@ func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego
}
inboundCtx := bus.InboundContext{
Channel: c.Name(),
ChatID: fmt.Sprintf("%d", chatID),
ChatType: peerKind,
SenderID: platformID,

View file

@ -45,6 +45,7 @@ func NewVKChannel(channelName string, bc *config.Channel, bus *bus.MessageBus) (
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &VKChannel{

View file

@ -122,6 +122,7 @@ func NewChannel(bc *config.Channel, cfg *config.WeComSettings, messageBus *bus.M
messageBus,
bc.AllowFrom,
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
ch := &WeComChannel{

View file

@ -78,6 +78,7 @@ func NewWeixinChannel(
bc.AllowFrom,
channels.WithMaxMessageLength(4000),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &WeixinChannel{

View file

@ -40,6 +40,7 @@ func NewWhatsAppChannel(
bc.AllowFrom,
channels.WithMaxMessageLength(65536),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
return &WhatsAppChannel{

View file

@ -70,7 +70,10 @@ func NewWhatsAppNativeChannel(
bus *bus.MessageBus,
storePath string,
) (channels.Channel, error) {
base := channels.NewBaseChannel(name, cfg, bus, bc.AllowFrom, channels.WithMaxMessageLength(65536))
base := channels.NewBaseChannel(name, cfg, bus, bc.AllowFrom,
channels.WithMaxMessageLength(65536),
channels.WithChannelType(bc.Type),
)
if storePath == "" {
storePath = "whatsapp"
}

View file

@ -10,7 +10,7 @@ var internalChannels = map[string]struct{}{
}
// IsInternalChannel returns true if the channel is an internal channel.
func IsInternalChannel(channel string) bool {
_, found := internalChannels[channel]
func IsInternalChannel(channelType string) bool {
_, found := internalChannels[channelType]
return found
}

View file

@ -12,6 +12,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -25,6 +26,7 @@ type JobExecutor interface {
// CronTool provides scheduling capabilities for the agent
type CronTool struct {
cfg *config.Config
cronService *cron.CronService
executor JobExecutor
msgBus *bus.MessageBus
@ -59,6 +61,7 @@ func NewCronTool(
execTool.SetTimeout(execTimeout)
}
return &CronTool{
cfg: config,
cronService: cronService,
executor: executor,
msgBus: msgBus,
@ -201,7 +204,22 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
if !t.execEnabled {
return ErrorResult("command execution is disabled")
}
if !constants.IsInternalChannel(channel) {
var channelType string
if t.cfg != nil {
if ch := t.cfg.Channels.Get(channel); ch != nil {
channelType = ch.Type
}
}
// Fallback: if channelType is not determined from config, use channelName
if channelType == "" {
channelType = channel
logger.DebugCF("cron", "Channel type not found in config, falling back to name", map[string]any{
"channel_name": channel,
})
}
if !constants.IsInternalChannel(channelType) {
return ErrorResult("scheduling command execution is restricted to internal channels")
}
if !t.allowCommand && !commandConfirm {

View file

@ -21,6 +21,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
)
var (
@ -35,6 +36,7 @@ func getSessionManager() *SessionManager {
}
type ExecTool struct {
cfg *config.Config
workingDir string
timeout time.Duration
denyPatterns []*regexp.Regexp
@ -169,6 +171,7 @@ func NewExecToolWithConfig(
}
return &ExecTool{
cfg: cfg,
workingDir: workingDir,
timeout: timeout,
denyPatterns: denyPatterns,
@ -270,12 +273,30 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
// GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks)
// unless explicitly opted-in via config. Fail-closed: empty channel = blocked.
if !t.allowRemote {
channel := ToolChannel(ctx)
if channel == "" {
channel, _ = args["__channel"].(string)
channelName := ToolChannel(ctx)
if channelName == "" {
channelName, _ = args["__channel"].(string)
}
channel = strings.TrimSpace(channel)
if channel == "" || !constants.IsInternalChannel(channel) {
channelName = strings.TrimSpace(channelName)
if channelName == "" {
return ErrorResult("exec is restricted to internal channels")
}
var channelType string
if t.cfg != nil {
if channelConfig := t.cfg.Channels.Get(channelName); channelConfig != nil {
channelType = channelConfig.Type
}
}
// Fallback: if channelType is not determined from config, use channelName
if channelType == "" {
channelType = channelName
logger.DebugCF("shell", "Channel type not found in config, falling back to name", map[string]any{
"channel_name": channelName,
})
}
if !constants.IsInternalChannel(channelType) {
return ErrorResult("exec is restricted to internal channels")
}
}