Compare commits

..

2 Commits

Author SHA1 Message Date
sutong 9731bff268 fix: 捕获钉钉 Stream SDK 内部 panic,避免崩溃
钉钉 SDK v0.9.1 在连接断开时有 send on closed channel panic,
加 defer recover 捕获后自动重连。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 00:41:13 +08:00
sutong c01712ee6a feat: 钉钉消息添加表情回复 TypingIndicator 支持
收到消息自动贴 👌 表情,回复完成贴  表情。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 00:38:15 +08:00
+102
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -32,6 +33,7 @@ type replyContext struct {
senderStaffId string senderStaffId string
isGroup bool 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, // richTextContent mirrors the full structure of the DingTalk "text" JSON field,
@@ -179,9 +181,16 @@ func (p *Platform) Start(handler core.MessageHandler) error {
default: default:
} }
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("dingtalk: stream panic, reconnecting", "recover", r)
}
}()
if err := p.streamClient.Start(ctx); err != nil { if err := p.streamClient.Start(ctx); err != nil {
slog.Warn("dingtalk: stream disconnected, reconnecting", "error", err) slog.Warn("dingtalk: stream disconnected, reconnecting", "error", err)
} }
}()
// Brief pause before reconnecting to avoid tight loop on persistent failures. // Brief pause before reconnecting to avoid tight loop on persistent failures.
select { select {
@@ -299,6 +308,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
sessionWebhook: data.SessionWebhook, sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId, conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId, senderStaffId: data.SenderStaffId,
msgId: data.MsgId,
}, },
} }
p.handler(p, msg) p.handler(p, msg)
@@ -335,6 +345,7 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
conversationId: data.ConversationId, conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId, senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2", isGroup: data.ConversationType == "2",
msgId: data.MsgId,
}, },
} }
@@ -414,6 +425,7 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio
conversationId: data.ConversationId, conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId, senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2", isGroup: data.ConversationType == "2",
msgId: data.MsgId,
}, },
FromVoice: true, FromVoice: true,
} }
@@ -438,6 +450,7 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio
conversationId: data.ConversationId, conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId, senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2", isGroup: data.ConversationType == "2",
msgId: data.MsgId,
}, },
FromVoice: true, FromVoice: true,
Audio: &core.AudioAttachment{ Audio: &core.AudioAttachment{
@@ -513,6 +526,7 @@ func (p *Platform) handleImageMessage(data *chatbot.BotCallbackDataModel, sessio
sessionWebhook: data.SessionWebhook, sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId, conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId, senderStaffId: data.SenderStaffId,
msgId: data.MsgId,
}, },
Images: []core.ImageAttachment{{ Images: []core.ImageAttachment{{
MimeType: mimeType, MimeType: mimeType,
@@ -832,6 +846,8 @@ func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttach
var _ core.ImageSender = (*Platform)(nil) var _ core.ImageSender = (*Platform)(nil)
var _ core.StreamingCardPlatform = (*Platform)(nil) var _ core.StreamingCardPlatform = (*Platform)(nil)
var _ core.ReplyContextReconstructor = (*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. // CreateStreamingCard creates a new streaming card for the given reply context.
// Implements core.StreamingCardPlatform. // Implements core.StreamingCardPlatform.
@@ -1456,6 +1472,92 @@ func (p *Platform) sendProactiveMessage(ctx context.Context, rc replyContext, co
return nil 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: // preprocessDingTalkMarkdown adapts content for DingTalk's markdown renderer:
// - Leading spaces → non-breaking spaces (prevents markdown from stripping indentation) // - Leading spaces → non-breaking spaces (prevents markdown from stripping indentation)
// - Single \n between non-empty lines → trailing two-space forced line break // - Single \n between non-empty lines → trailing two-space forced line break