diff --git a/platform/dingtalk/dingtalk.go b/platform/dingtalk/dingtalk.go index 92d03f4..9a8b0dd 100644 --- a/platform/dingtalk/dingtalk.go +++ b/platform/dingtalk/dingtalk.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log" "log/slog" "mime/multipart" "net/http" @@ -31,7 +32,8 @@ type replyContext struct { conversationId string senderStaffId string isGroup bool - proactive bool // true when constructed by ReconstructReplyCtx (no sessionWebhook) + proactive bool // true when constructed by ReconstructReplyCtx (no sessionWebhook) + msgId string // message ID, used for emotion reactions } // richTextContent mirrors the full structure of the DingTalk "text" JSON field, @@ -299,6 +301,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT sessionWebhook: data.SessionWebhook, conversationId: data.ConversationId, senderStaffId: data.SenderStaffId, + msgId: data.MsgId, }, } p.handler(p, msg) @@ -335,6 +338,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT conversationId: data.ConversationId, senderStaffId: data.SenderStaffId, isGroup: data.ConversationType == "2", + msgId: data.MsgId, }, } @@ -414,6 +418,7 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio conversationId: data.ConversationId, senderStaffId: data.SenderStaffId, isGroup: data.ConversationType == "2", + msgId: data.MsgId, }, FromVoice: true, } @@ -438,6 +443,7 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio conversationId: data.ConversationId, senderStaffId: data.SenderStaffId, isGroup: data.ConversationType == "2", + msgId: data.MsgId, }, FromVoice: true, Audio: &core.AudioAttachment{ @@ -513,6 +519,7 @@ func (p *Platform) handleImageMessage(data *chatbot.BotCallbackDataModel, sessio sessionWebhook: data.SessionWebhook, conversationId: data.ConversationId, senderStaffId: data.SenderStaffId, + msgId: data.MsgId, }, Images: []core.ImageAttachment{{ MimeType: mimeType, @@ -832,6 +839,8 @@ func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttach var _ core.ImageSender = (*Platform)(nil) var _ core.StreamingCardPlatform = (*Platform)(nil) var _ core.ReplyContextReconstructor = (*Platform)(nil) +var _ core.TypingIndicator = (*Platform)(nil) +var _ core.TypingIndicatorDone = (*Platform)(nil) // CreateStreamingCard creates a new streaming card for the given reply context. // Implements core.StreamingCardPlatform. @@ -1456,6 +1465,92 @@ func (p *Platform) sendProactiveMessage(ctx context.Context, rc replyContext, co return nil } +// StartTyping adds a "thinking" emoji reaction to the user's message. +// Implements core.TypingIndicator. +func (p *Platform) StartTyping(ctx context.Context, replyCtx any) func() { + rc, ok := replyCtx.(replyContext) + if !ok || rc.msgId == "" || rc.conversationId == "" { + return func() {} + } + + err := p.addEmotion(ctx, rc.msgId, rc.conversationId, "👌", "") + if err != nil { + slog.Warn("dingtalk: failed to add thinking reaction", "error", err) + } + + return func() { + log.Println("回答完毕") + } +} + +// AddDoneReaction adds a done emoji on top of the thinking emoji. +// Implements core.TypingIndicatorDone. +func (p *Platform) AddDoneReaction(replyCtx any) { + rc, ok := replyCtx.(replyContext) + if !ok || rc.msgId == "" || rc.conversationId == "" { + return + } + + cctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Add done emoji + if err := p.addEmotion(cctx, rc.msgId, rc.conversationId, "✅", ""); err != nil { + slog.Debug("dingtalk: failed to add done reaction", "error", err) + } +} + +// addEmotion adds an emoji reaction to a message via DingTalk's emotion API. +func (p *Platform) addEmotion(ctx context.Context, msgId, convId, emoji, _ string) error { + token, err := p.getAccessToken() + if err != nil { + return fmt.Errorf("dingtalk: addEmotion get token: %w", err) + } + + body := map[string]any{ + "robotCode": p.robotCode, + "openMsgId": msgId, + "openConversationId": convId, + "emotionType": 2, + "emotionName": emoji, + "textEmotion": map[string]string{ + "emotionName": emoji, + "text": emoji, + "backgroundId": "im_bg_2", + }, + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("dingtalk: addEmotion marshal: %w", err) + } + + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, + "https://api.dingtalk.com/v1.0/robot/emotion/reply", + bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("dingtalk: addEmotion create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-acs-dingtalk-access-token", token) + + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("dingtalk: addEmotion do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("dingtalk: addEmotion failed: status=%d, body=%s", resp.StatusCode, string(respBody)) + } + + return nil +} + // preprocessDingTalkMarkdown adapts content for DingTalk's markdown renderer: // - Leading spaces → non-breaking spaces (prevents markdown from stripping indentation) // - Single \n between non-empty lines → trailing two-space forced line break