refactor: standardize channel identification and decouple name from provider type

Decouple channel names (config keys) from channel types (provider IDs) to allow
multiple instances of the same provider. This ensures robust identification
across the message bus and agent dispatch logic.

- Introduce ChannelType in InboundContext for consistent provider tracking
- Refactor IsInternalChannel to use provider type instead of generic names
- Update all channel implementations to pass through their provider type via BaseChannel
- Standardize agent dispatch and heartbeat recording to use ChannelType
- Enhance shell and cron tools with improved execution tracking and fallback logic
- Fix redundant channel field assignments and hardcoded types in providers
- Resolve build issues by adding missing logger imports to tool packages
This commit is contained in:
Cytown 2026-04-28 12:13:06 +08:00
parent c44bd6138c
commit 6a7bf32f9e
33 changed files with 156 additions and 45 deletions

View file

@ -474,7 +474,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{
@ -247,7 +249,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

@ -3911,7 +3911,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",
@ -4471,7 +4472,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

@ -217,6 +217,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"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
@ -292,7 +293,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...",
@ -382,11 +383,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(
@ -425,7 +427,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

@ -74,6 +74,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

@ -123,6 +123,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
}
@ -146,6 +150,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
}
@ -1066,6 +1074,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,
) {
@ -1084,9 +1093,10 @@ func dispatchLoop[M any](
}
channel := getChannel(msg)
channelType := getChannelType(msg)
// Silently skip internal channels
if constants.IsInternalChannel(channel) {
if constants.IsInternalChannel(channelType) {
continue
}
@ -1116,6 +1126,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:
@ -1136,6 +1147,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

@ -104,6 +104,7 @@ func NewTelegramChannel(
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
channels.WithChannelType(bc.Type),
)
ch := &TelegramChannel{
@ -891,7 +892,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
}
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")
}
}