From 1dd9c83ff688915948f00127280192fb4d3c67fe Mon Sep 17 00:00:00 2001 From: zhaoyunxing Date: Sun, 8 Mar 2026 23:21:24 +0800 Subject: [PATCH] refactor(channels): Implement DingTalk client with card messaging capabilities --- go.mod | 2 +- pkg/channels/dingtalk/client.go | 274 ++++++++++++++++++++++++++++++ pkg/channels/dingtalk/dingtalk.go | 81 ++++++--- pkg/channels/dingtalk/options.go | 21 +++ pkg/config/config.go | 4 + 5 files changed, 360 insertions(+), 22 deletions(-) create mode 100644 pkg/channels/dingtalk/client.go create mode 100644 pkg/channels/dingtalk/options.go diff --git a/go.mod b/go.mod index 2bd5ddef9..a8e08deb8 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.3.1 github.com/chzyer/readline v1.5.1 + github.com/ergochat/irc-go v0.5.0 github.com/gdamore/tcell/v2 v2.13.8 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -37,7 +38,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect - github.com/ergochat/irc-go v0.5.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/pkg/channels/dingtalk/client.go b/pkg/channels/dingtalk/client.go new file mode 100644 index 000000000..d73e72595 --- /dev/null +++ b/pkg/channels/dingtalk/client.go @@ -0,0 +1,274 @@ +package dingtalk + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" +) + +const ( + endpoint = "https://api.dingtalk.com" + accessToken = "/v1.0/oauth2/accessToken" + createCardAndDeliver = "/v1.0/card/instances/createAndDeliver" + cardStreaming = "/v1.0/card/streaming" + privateChatMessages = "/v1.0/robot/privateChatMessages/send" + batchSendMessages = "/v1.0/robot/oToMessages/batchSend" +) + +// MessageType represents the type of message to be sent. +type MessageType string + +func (t MessageType) String() string { + return string(t) +} + +const ( + Markdown = MessageType("sampleMarkdown") + Text = MessageType("sampleText") + Image = MessageType("sampleImageMsg") +) + +type Client struct { + clientID string + clientSecret string + token string + // card template id + cardTemplateID string + // card template message content default content + cardTemplateContentKey string + + // robot code default use clientID + robotCode string + + expires time.Time + client *http.Client +} + +// NewClient creates a new Client instance with the provided client ID, client secret, and optional configurations. +func NewClient(clientID, clientSecret string, opts ...ClientOption) *Client { + client := &Client{clientID: clientID, clientSecret: clientSecret, + client: &http.Client{Timeout: time.Second * 30}} + for _, opt := range opts { + opt(client) + } + if client.robotCode == "" { + client.robotCode = client.clientID + } + if client.cardTemplateContentKey == "" { + client.cardTemplateContentKey = "content" + } + return client +} + +// GetToken retrieves the access token, refreshing it if it has expired. +func (c *Client) GetToken() (string, error) { + if time.Now().Before(c.expires) { + return c.token, nil + } + data := map[string]any{ + "appKey": c.clientID, + "appSecret": c.clientSecret, + } + resp := struct { + Expires int64 `json:"expireIn"` + Token string `json:"accessToken"` + }{} + err := c.httpRequest(context.Background(), http.MethodPost, accessToken, data, &resp) + if err != nil { + return "", err + } + c.expires = time.Now().Add(time.Second * time.Duration(resp.Expires)) + c.token = resp.Token + return c.token, nil +} + +// BatchSendMessages sends a message to multiple users in a batch. +func (c *Client) BatchSendMessages(ctx context.Context, msgType MessageType, userIds []string, content string) error { + body := map[string]any{ + "robotCode": c.robotCode, + "msgKey": msgType, + "userIds": userIds, + "msgParam": c.buildSendMessages(msgType, content), + } + return c.httpRequest(ctx, http.MethodPost, batchSendMessages, body, nil) +} + +// CardStreaming updates the content of a card instance identified by cardInstanceId. +func (c *Client) CardStreaming(ctx context.Context, cardInstanceId, content string) error { + id, err := uuid.NewUUID() + if err != nil { + return err + } + body := map[string]any{ + "outTrackId": cardInstanceId, + "guid": id.String(), + "key": c.cardTemplateContentKey, + "content": content, + "isFull": true, + } + return c.httpRequest(ctx, http.MethodPut, cardStreaming, body, nil) +} + +// CardCreateAndDeliver creates a card instance and delivers it to the user or group. +// It returns the outTrackId of the created card instance, which can be used for subsequent updates via CardStreaming. +// If the chatbot is in a group conversation, the card will be delivered to the group; otherwise, it will be delivered to the user. +// Card Delivery API Documentation +func (c *Client) CardCreateAndDeliver(ctx context.Context, chatbot *chatbot.BotCallbackDataModel) (string, error) { + if c.cardTemplateID == "" { + return "", errors.New("cardTemplateId is required") + } + var ( + group = chatbot.ConversationType == "2" + openSpaceId = "dtv1.card//IM_ROBOT." + chatbot.SenderStaffId + imRobotOpenDeliverModel = map[string]any{ + "spaceType": "IM_ROBOT", + } + imGroupOpenSpaceModel = map[string]any{ + "supportForward": true, + } + imRobotOpenSpaceModel = map[string]any{ + "supportForward": true, + } + imGroupOpenDeliverModel = map[string]any{ + "robotCode": c.clientID, + } + ) + + id, err := uuid.NewUUID() + if err != nil { + return "", err + } + if group { + openSpaceId = "dtv1.card//IM_GROUP." + chatbot.ConversationId + imRobotOpenDeliverModel = map[string]any{} + imGroupOpenDeliverModel["robotCode"] = c.clientID + } + + body := map[string]any{ + "cardTemplateId": c.cardTemplateID, + "outTrackId": id.String(), + "cardData": map[string]any{}, + "openSpaceId": openSpaceId, + "userIdType": 1, + "imGroupOpenDeliverModel": imGroupOpenDeliverModel, + "imGroupOpenSpaceModel": imGroupOpenSpaceModel, + "imRobotOpenSpaceModel": imRobotOpenSpaceModel, + "imRobotOpenDeliverModel": imRobotOpenDeliverModel, + } + /** + { + "result" : { + "deliverResults" : [ { + "spaceId" : "manager164", + "spaceType" : "IM_ROBOT", + "success" : true, + "carrierId" : "119X11tauuJlODPiK0wpCjIXPcGpODOnnpHc/uYFFnI=", + "errorMsg" : "" + } ], + "outTrackId" : "f51222b2-1aff-11f1-8a7c-a40c662198be" + }, + "success" : true + } + */ + resp := struct { + Result struct { + DeliverResults []struct { + SpaceId string `json:"spaceId"` + SpaceType string `json:"spaceType"` + Success bool `json:"success"` + CarrierId string `json:"carrierId"` + ErrorMsg string `json:"errorMsg"` + } `json:"deliverResults"` + OutTrackId string `json:"outTrackId"` + } `json:"result"` + Success bool `json:"success"` + }{} + if err = c.httpRequest(ctx, http.MethodPost, createCardAndDeliver, body, &resp); err != nil { + return "", err + } + if resp.Success { + return resp.Result.OutTrackId, nil + } + return "", errors.New("failed to create and deliver card instance") +} + +// PrivateChatMessages sends a message to a user in a private chat. +func (c *Client) PrivateChatMessages(ctx context.Context, msgType MessageType, openConversationId, content string) error { + body := map[string]any{ + "msgKey": msgType, + "msgParam": c.buildSendMessages(msgType, content), + "openConversationId": openConversationId, + "robotCode": c.robotCode, + } + return c.httpRequest(ctx, http.MethodPost, privateChatMessages, body, nil) +} + +func (c *Client) buildSendMessages(msgType MessageType, content string) string { + data := map[string]any{} + switch msgType { + case Markdown: + data = map[string]any{ + "title": "PicoClaw", + "text": content, + } + case Text: + data = map[string]any{ + "content": content, + } + } + msg, _ := json.Marshal(data) + return string(msg) +} + +func (c *Client) httpRequest(ctx context.Context, method, path string, body interface{}, resp interface{}) error { + var ( + err error + token string + req *http.Request + hc = c.client + res *http.Response + ) + if path == accessToken { + token = "" + } else { + token, err = c.GetToken() + if err != nil { + return err + } + } + url := endpoint + path + if body != nil { + data, _ := json.Marshal(body) + req, err = http.NewRequestWithContext(ctx, method, url, bytes.NewReader(data)) + } else { + req, err = http.NewRequestWithContext(ctx, method, url, nil) + } + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("x-acs-dingtalk-access-token", token) + } + res, err = hc.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK || err != nil { + return fmt.Errorf("API request failed:\n Status: %d\n Body: %s", res.StatusCode, string(data)) + } + if resp == nil { + return nil + } + return json.Unmarshal(data, resp) +} diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 8642ad362..6da2f6a2b 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -27,10 +27,13 @@ type DingTalkChannel struct { clientID string clientSecret string streamClient *client.StreamClient + client *Client ctx context.Context cancel context.CancelFunc // Map to store session webhooks for each chat sessionWebhooks sync.Map // chatID -> sessionWebhook + // chatID -> cardInstanceId + cardInstanceIds sync.Map } // NewDingTalkChannel creates a new DingTalk channel instance @@ -44,12 +47,18 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + // dingtalk client + dingTalkClient := NewClient(cfg.ClientID, cfg.ClientSecret, + WithRobotCode(cfg.RobotCode), + WithCardTemplateID(cfg.CardTemplateID), + WithCardTemplateContentKey(cfg.CardTemplateContentKey)) return &DingTalkChannel{ BaseChannel: base, config: cfg, clientID: cfg.ClientID, clientSecret: cfg.ClientSecret, + client: dingTalkClient, }, nil } @@ -103,25 +112,16 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err if !c.IsRunning() { return channels.ErrNotRunning } - - // Get session webhook from storage - sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) + // Check if we have a card instance ID for this chat (indicating we can send a card reply) + cardInstanceIdRaw, ok := c.cardInstanceIds.LoadAndDelete(msg.ChatID) if !ok { - return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) + return c.SendDirectReply(ctx, msg) } - - sessionWebhook, ok := sessionWebhookRaw.(string) + cardInstanceId, ok := cardInstanceIdRaw.(string) if !ok { - return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) + return c.SendDirectReply(ctx, msg) } - - 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 c.SendDirectReply(ctx, sessionWebhook, msg.Content) + return c.SendCardReply(ctx, cardInstanceId, msg.Content) } // onChatBotMessageReceived implements the IChatBotMessageHandler function signature @@ -154,9 +154,6 @@ func (c *DingTalkChannel) onChatBotMessageReceived( chatID = data.ConversationId } - // 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, @@ -196,6 +193,16 @@ func (c *DingTalkChannel) onChatBotMessageReceived( return nil, nil } + if err := c.tryCardCreateAndDeliver(ctx, chatID, data); err != nil { + logger.ErrorCF("dingtalk", "Failed to create or deliver card", map[string]any{ + "error": err.Error(), + "chat_id": chatID, + "sender_id": senderID, + }) + return nil, nil + } + // Store the session webhook for this chat so we can reply later + c.sessionWebhooks.Store(chatID, data.SessionWebhook) // Handle the message through the base channel c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender) @@ -205,11 +212,25 @@ func (c *DingTalkChannel) onChatBotMessageReceived( } // SendDirectReply sends a direct reply using the session webhook -func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error { +func (c *DingTalkChannel) SendDirectReply(ctx context.Context, msg bus.OutboundMessage) error { + // Get session webhook from storage + sessionWebhookRaw, ok := c.sessionWebhooks.LoadAndDelete(msg.ChatID) + if !ok { + return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) + } + sessionWebhook, ok := sessionWebhookRaw.(string) + if !ok { + return 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), + }) replier := chatbot.NewChatbotReplier() // Convert string content to []byte for the API - contentBytes := []byte(content) + contentBytes := []byte(msg.Content) titleBytes := []byte("PicoClaw") // Send markdown formatted reply @@ -222,6 +243,24 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c if err != nil { return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary) } - + return nil +} + +func (c *DingTalkChannel) SendCardReply(ctx context.Context, cardInstanceId, content string) error { + if err := c.client.CardStreaming(ctx, cardInstanceId, content); err != nil { + return err + } + return nil +} + +func (c *DingTalkChannel) tryCardCreateAndDeliver(ctx context.Context, chatID string, data *chatbot.BotCallbackDataModel) error { + if c.config.CardTemplateID == "" { + return nil + } + cardInstanceId, err := c.client.CardCreateAndDeliver(ctx, data) + if err != nil { + return err + } + c.cardInstanceIds.Store(chatID, cardInstanceId) return nil } diff --git a/pkg/channels/dingtalk/options.go b/pkg/channels/dingtalk/options.go new file mode 100644 index 000000000..05c1273ce --- /dev/null +++ b/pkg/channels/dingtalk/options.go @@ -0,0 +1,21 @@ +package dingtalk + +type ClientOption func(*Client) + +func WithCardTemplateID(cardTemplateID string) ClientOption { + return func(client *Client) { + client.cardTemplateID = cardTemplateID + } +} + +func WithCardTemplateContentKey(cardTemplateContentKey string) ClientOption { + return func(client *Client) { + client.cardTemplateContentKey = cardTemplateContentKey + } +} + +func WithRobotCode(robotCode string) ClientOption { + return func(client *Client) { + client.robotCode = robotCode + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index fcbfc8e78..2ab856c65 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -320,6 +320,10 @@ type DingTalkConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` + + RobotCode string `json:"robot_code,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_ROBOT_CODE"` + CardTemplateID string `json:"card_template_id,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CARD_TEMPLATE_ID"` + CardTemplateContentKey string `json:"card_template_content_key,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CARD_TEMPLATE_CONTENT_KEY"` } type SlackConfig struct {