mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
6c0798ca3f
* 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
276 lines
7.6 KiB
Go
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:], " ")
|
|
}
|