Files
picoclaw/pkg/channels/dingtalk/dingtalk.go
T
DimonB 6c0798ca3f feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs

Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.

Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.

preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.

* style: apply golangci-lint fixes (golines)

* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 11:07:32 +08:00

276 lines
7.6 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.DingTalkConfig
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(cfg config.DingTalkConfig, 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, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.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 peer bus.Peer
if data.ConversationType == "1" {
peerID := senderID
if peerID == "" {
peerID = chatID
}
peer = bus.Peer{Kind: "direct", ID: peerID}
} else {
peer = bus.Peer{Kind: "group", ID: data.ConversationId}
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
}
// Handle the message through the base channel
c.HandleMessage(ctx, peer, "", resolvedSenderID, chatID, content, nil, metadata, 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:], " ")
}