package cmd import ( "context" "fmt" "log/slog" "strings" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/discord" "github.com/nextlevelbuilder/goclaw/internal/channels/feishu" slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack" "github.com/nextlevelbuilder/goclaw/internal/channels/telegram" "github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo" zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/zalomethods" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // registerConfigChannels registers config-based channels as fallback when no DB instances are loaded. func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, msgBus *bus.MessageBus, pgStores *store.Stores, instanceLoader *channels.InstanceLoader) { if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil { tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing, nil, nil, nil) if err != nil { slog.Error("failed to initialize telegram channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeTelegram, tg) slog.Info("telegram channel enabled (config)") } } if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token != "" && instanceLoader == nil { dc, err := discord.New(cfg.Channels.Discord, msgBus, nil, nil) if err != nil { slog.Error("failed to initialize discord channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeDiscord, dc) slog.Info("discord channel enabled (config)") } } if cfg.Channels.WhatsApp.Enabled && cfg.Channels.WhatsApp.BridgeURL != "" && instanceLoader == nil { wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil) if err != nil { slog.Error("failed to initialize whatsapp channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeWhatsApp, wa) slog.Info("whatsapp channel enabled (config)") } } if cfg.Channels.Zalo.Enabled && cfg.Channels.Zalo.Token != "" && instanceLoader == nil { z, err := zalo.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing) if err != nil { slog.Error("failed to initialize zalo channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeZaloOA, z) slog.Info("zalo channel enabled (config)") } } if cfg.Channels.ZaloPersonal.Enabled && instanceLoader == nil { zp, err := zalopersonal.New(cfg.Channels.ZaloPersonal, msgBus, pgStores.Pairing, nil) if err != nil { slog.Error("failed to initialize zca channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeZaloPersonal, zp) slog.Info("zca (zalo personal) channel enabled (config)") } } if cfg.Channels.Slack.Enabled && cfg.Channels.Slack.BotToken != "" && cfg.Channels.Slack.AppToken != "" && instanceLoader == nil { sl, err := slackchannel.New(cfg.Channels.Slack, msgBus, nil, nil) if err != nil { slog.Error("failed to initialize slack channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeSlack, sl) slog.Info("slack channel enabled (config)") } } if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppID != "" && instanceLoader == nil { f, err := feishu.New(cfg.Channels.Feishu, msgBus, pgStores.Pairing, nil) if err != nil { slog.Error("failed to initialize feishu channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeFeishu, f) slog.Info("feishu/lark channel enabled (config)") } } } // wireChannelRPCMethods registers WS RPC methods for channels, instances, agent links, and teams. func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, channelMgr *channels.Manager, agentRouter *agent.Router, msgBus *bus.MessageBus, dataDir string) { // Register channels RPC methods (after channelMgr is initialized with all channels) methods.NewChannelsMethods(channelMgr).Register(server.Router()) // Register channel instances WS RPC methods if pgStores.ChannelInstances != nil { methods.NewChannelInstancesMethods(pgStores.ChannelInstances, msgBus, msgBus).Register(server.Router()) zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router()) zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router()) } // Register agent links WS RPC methods if pgStores.AgentLinks != nil && pgStores.Agents != nil { methods.NewAgentLinksMethods(pgStores.AgentLinks, pgStores.Agents, agentRouter, msgBus, msgBus).Register(server.Router()) } // Register agent teams WS RPC methods if pgStores.Teams != nil { methods.NewTeamsMethods(pgStores.Teams, pgStores.Agents, pgStores.AgentLinks, agentRouter, msgBus, msgBus, dataDir).Register(server.Router()) } } // wireChannelEventSubscribers sets up event subscribers for channel instance cache invalidation, // pairing approval/revocation, and agent cascade disable. func wireChannelEventSubscribers( msgBus *bus.MessageBus, server *gateway.Server, pgStores *store.Stores, channelMgr *channels.Manager, instanceLoader *channels.InstanceLoader, pairingMethods *methods.PairingMethods, cfg *config.Config, ) { // Cache invalidation: reload channel instances on changes. if instanceLoader != nil { msgBus.Subscribe(bus.TopicCacheChannelInstances, func(event bus.Event) { if event.Name != protocol.EventCacheInvalidate { return } payload, ok := event.Payload.(bus.CacheInvalidatePayload) if !ok || payload.Kind != bus.CacheKindChannelInstances { return } go instanceLoader.Reload(context.Background()) }) } // Wire pairing approval notification → channel (matching TS notifyPairingApproved). botName := cfg.ResolveDisplayName("default") pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID, senderID string) { // Browser/internal channels use WebSocket — UI polls approval status directly. if channels.IsInternalChannel(channel) { slog.Debug("pairing approved for internal channel, skipping notification", "channel", channel) return } msg := fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName) // Group pairings need group_id metadata so channels (e.g. Zalo) route to group API. if strings.HasPrefix(senderID, "group:") { msgBus.PublishOutbound(bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: msg, Metadata: map[string]string{"group_id": chatID}, }) } else if err := channelMgr.SendToChannel(ctx, channel, chatID, msg); err != nil { slog.Warn("failed to send pairing approval notification", "channel", channel, "chatID", chatID, "error", err) } }) // Wire pairing revocation → force disconnect active WebSocket sessions. msgBus.Subscribe(bus.TopicPairingRevoked, func(event bus.Event) { if event.Name != bus.EventPairingRevoked { return } payload, ok := event.Payload.(bus.PairingRevokedPayload) if !ok { return } go server.DisconnectByPairing(payload.SenderID, payload.Channel) }) // Cascade: when an agent becomes inactive, disable its linked channel instances. if pgStores.ChannelInstances != nil { ciStore := pgStores.ChannelInstances msgBus.Subscribe(bus.TopicAgentStatusChanged, func(event bus.Event) { if event.Name != bus.EventAgentStatusChanged { return } payload, ok := event.Payload.(bus.AgentStatusChangedPayload) if !ok || payload.NewStatus != store.AgentStatusInactive { return } go func() { agentID, err := uuid.Parse(payload.AgentID) if err != nil { return } all, err := ciStore.ListAll(context.Background()) if err != nil { slog.Warn("cascade disable: failed to list channel instances", "error", err) return } disabled := 0 for _, inst := range all { if inst.AgentID == agentID && inst.Enabled { if err := ciStore.Update(context.Background(), inst.ID, map[string]any{"enabled": false}); err != nil { slog.Warn("cascade disable: failed to disable channel instance", "name", inst.Name, "error", err) } else { disabled++ } } } if disabled > 0 { slog.Info("cascade disabled channel instances for inactive agent", "agent_id", payload.AgentID, "count", disabled) // Trigger channel reload so disabled instances are stopped. msgBus.Broadcast(bus.Event{ Name: protocol.EventCacheInvalidate, Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindChannelInstances}, }) } }() }) } }