picoclaw/pkg/channels/dingtalk/dingtalk.go
Cytown 6a7bf32f9e 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
2026-04-29 11:40:30 +08:00

292 lines
7.8 KiB
Go

// 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),
channels.WithChannelType(bc.Type),
)
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:], " ")
}