// PicoClaw - Ultra-lightweight personal AI agent // DingTalk channel implementation using Stream Mode package dingtalk import ( "context" "fmt" "strings" "sync" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) // DingTalkChannel implements the Channel interface for DingTalk (钉钉) // It uses WebSocket for receiving messages via stream mode and API for sending type DingTalkChannel struct { *channels.BaseChannel config *config.DingTalkSettings clientID string clientSecret string streamClient *client.StreamClient ctx context.Context cancel context.CancelFunc // Map to store session webhooks for each chat sessionWebhooks sync.Map // chatID -> sessionWebhook } // NewDingTalkChannel creates a new DingTalk channel instance func NewDingTalkChannel( bc *config.Channel, cfg *config.DingTalkSettings, messageBus *bus.MessageBus, ) (*DingTalkChannel, error) { if cfg.ClientID == "" || cfg.ClientSecret.String() == "" { return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } // Set the logger for the Stream SDK dinglog.SetLogger(logger.NewLogger("dingtalk")) base := channels.NewBaseChannel("dingtalk", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(20000), channels.WithGroupTrigger(bc.GroupTrigger), channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &DingTalkChannel{ BaseChannel: base, config: cfg, clientID: cfg.ClientID, clientSecret: cfg.ClientSecret.String(), }, nil } // Start initializes the DingTalk channel with Stream Mode func (c *DingTalkChannel) Start(ctx context.Context) error { logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...") c.ctx, c.cancel = context.WithCancel(ctx) // Create credential config cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret) // Create the stream client with options c.streamClient = client.NewStreamClient( client.WithAppCredential(cred), client.WithAutoReconnect(true), ) // Register chatbot callback handler (IChatBotMessageHandler is a function type) c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived) // Start the stream client if err := c.streamClient.Start(c.ctx); err != nil { return fmt.Errorf("failed to start stream client: %w", err) } c.SetRunning(true) logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)") return nil } // Stop gracefully stops the DingTalk channel func (c *DingTalkChannel) Stop(ctx context.Context) error { logger.InfoC("dingtalk", "Stopping DingTalk channel...") if c.cancel != nil { c.cancel() } if c.streamClient != nil { c.streamClient.Close() } c.SetRunning(false) logger.InfoC("dingtalk", "DingTalk channel stopped") return nil } // Send sends a message to DingTalk via the chatbot reply API func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning } // Get session webhook from storage sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) if !ok { return nil, fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) } sessionWebhook, ok := sessionWebhookRaw.(string) if !ok { return nil, fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } logger.DebugCF("dingtalk", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) // Use the session webhook to send the reply return nil, c.SendDirectReply(ctx, sessionWebhook, msg.Content) } // onChatBotMessageReceived implements the IChatBotMessageHandler function signature // This is called by the Stream SDK when a new message arrives // IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) func (c *DingTalkChannel) onChatBotMessageReceived( ctx context.Context, data *chatbot.BotCallbackDataModel, ) ([]byte, error) { if data == nil { return nil, nil } // Extract message content from Text field content := strings.TrimSpace(data.Text.Content) if content == "" { // Try to extract from Content interface{} if Text is empty if contentMap, ok := data.Content.(map[string]any); ok { if textContent, ok := contentMap["content"].(string); ok { content = strings.TrimSpace(textContent) } } } if content == "" { return nil, nil // Ignore empty messages } senderID := strings.TrimSpace(data.SenderStaffId) if senderID == "" { senderID = strings.TrimSpace(data.SenderId) } senderNick := strings.TrimSpace(data.SenderNick) chatID := strings.TrimSpace(data.ConversationId) if chatID == "" && data.ConversationType == "1" { // Fallback for direct chats when conversation_id is absent. chatID = senderID } if chatID == "" { return nil, nil } // Store the session webhook for this chat so we can reply later c.sessionWebhooks.Store(chatID, data.SessionWebhook) metadata := map[string]string{ "sender_name": senderNick, "conversation_id": data.ConversationId, "conversation_type": data.ConversationType, "platform": "dingtalk", "session_webhook": data.SessionWebhook, } var ( chatType string isMentioned bool ) if data.ConversationType == "1" { chatType = "direct" } else { chatType = "group" isMentioned = data.IsInAtList if isMentioned { content = stripLeadingAtMentions(content) } // In group chats, apply unified group trigger filtering respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil, nil } content = cleaned } logger.DebugCF("dingtalk", "Received message", map[string]any{ "sender_nick": senderNick, "sender_id": senderID, "preview": utils.Truncate(content, 50), }) // Build sender info platformID := senderID if platformID == "" { platformID = chatID } resolvedSenderID := senderID if resolvedSenderID == "" { resolvedSenderID = platformID } sender := bus.SenderInfo{ Platform: "dingtalk", PlatformID: platformID, CanonicalID: identity.BuildCanonicalID("dingtalk", platformID), DisplayName: senderNick, } if !c.IsAllowedSender(sender) { return nil, nil } inboundCtx := bus.InboundContext{ Channel: "dingtalk", ChatID: chatID, ChatType: chatType, SenderID: resolvedSenderID, Mentioned: isMentioned, Raw: metadata, } if data.SessionWebhook != "" { inboundCtx.ReplyHandles = map[string]string{ "session_webhook": data.SessionWebhook, } } c.HandleInboundContext(ctx, chatID, content, nil, inboundCtx, sender) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus return nil, nil } // SendDirectReply sends a direct reply using the session webhook func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error { replier := chatbot.NewChatbotReplier() // Convert string content to []byte for the API contentBytes := []byte(content) titleBytes := []byte("PicoClaw") // Send markdown formatted reply err := replier.SimpleReplyMarkdown( ctx, sessionWebhook, titleBytes, contentBytes, ) if err != nil { return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary) } return nil } func stripLeadingAtMentions(content string) string { fields := strings.Fields(content) if len(fields) == 0 { return "" } i := 0 for i < len(fields) && strings.HasPrefix(fields[i], "@") { i++ } if i == 0 { return strings.TrimSpace(content) } return strings.Join(fields[i:], " ") }