fix(dingtalk): include quoted interactive card content

This commit is contained in:
Rael LIANG
2026-05-25 22:27:36 +08:00
parent 8e04d66bf6
commit eb383c306c
2 changed files with 416 additions and 33 deletions

View File

@@ -27,11 +27,11 @@ func init() {
}
type replyContext struct {
sessionWebhook string
conversationId string
senderStaffId string
isGroup bool
proactive bool // true when constructed by ReconstructReplyCtx (no sessionWebhook)
sessionWebhook string
conversationId string
senderStaffId string
isGroup bool
proactive bool // true when constructed by ReconstructReplyCtx (no sessionWebhook)
}
// richTextContent mirrors the full structure of the DingTalk "text" JSON field,
@@ -52,6 +52,8 @@ type repliedTextContent struct {
Text string `json:"text"`
}
const maxQuotedMessageRunes = 4000
type downloadResponse struct {
DownloadUrl string `json:"downloadUrl"`
}
@@ -60,7 +62,7 @@ type Platform struct {
clientID string
clientSecret string
robotCode string
agentID int64 // Agent ID for work notifications API (numeric)
agentID int64 // Agent ID for work notifications API (numeric)
allowFrom string
shareSessionInChannel bool
streamClient *dingtalkClient.StreamClient
@@ -300,10 +302,10 @@ func (p *Platform) onMessage(data *chatbot.BotCallbackDataModel, richText *richT
MessageID: data.MsgId,
ChannelKey: data.ConversationId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
},
}
@@ -368,12 +370,12 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio
MessageID: data.MsgId,
ChannelKey: data.ConversationId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
},
FromVoice: true,
FromVoice: true,
}
p.handler(p, msg)
}
@@ -392,12 +394,12 @@ func (p *Platform) handleAudioMessage(data *chatbot.BotCallbackDataModel, sessio
MessageID: data.MsgId,
ChannelKey: data.ConversationId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
isGroup: data.ConversationType == "2",
},
FromVoice: true,
FromVoice: true,
Audio: &core.AudioAttachment{
MimeType: mimeType,
Data: audioBytes,
@@ -468,9 +470,9 @@ func (p *Platform) handleImageMessage(data *chatbot.BotCallbackDataModel, sessio
UserName: data.SenderNick,
MessageID: data.MsgId,
ReplyCtx: replyContext{
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
sessionWebhook: data.SessionWebhook,
conversationId: data.ConversationId,
senderStaffId: data.SenderStaffId,
},
Images: []core.ImageAttachment{{
MimeType: mimeType,
@@ -992,8 +994,8 @@ func (p *Platform) compressAudioWithFFmpeg(ctx context.Context, audio []byte, fo
args := []string{
"-i", "pipe:0",
"-ar", "16000", // 16kHz sample rate for voice
"-ac", "1", // mono
"-b:a", "64k", // 64 kbps bitrate (voice quality)
"-ac", "1", // mono
"-b:a", "64k", // 64 kbps bitrate (voice quality)
"-f", "mp3",
"-y",
"pipe:1",
@@ -1106,22 +1108,174 @@ func (p *Platform) formatReplyContent(richText *richTextContent, fallback string
return content
}
if richText.RepliedMsg.MsgType != "text" {
slog.Debug("dingtalk: quoted message type not supported", "type", richText.RepliedMsg.MsgType)
quotedText := p.extractQuotedMessageText(richText.RepliedMsg)
if quotedText == "" {
return content
}
return fmt.Sprintf("引用: \"%s\"\n\n%s", quotedText, content)
}
func (p *Platform) extractQuotedMessageText(msg *repliedMessage) string {
if msg == nil {
return ""
}
switch msg.MsgType {
case "text":
return p.extractQuotedTextMessageText(msg.Content)
case "interactiveCard":
return p.extractInteractiveCardQuotedText(msg.Content)
default:
slog.Debug("dingtalk: quoted message type not supported", "type", msg.MsgType)
return ""
}
}
func (p *Platform) extractQuotedTextMessageText(raw json.RawMessage) string {
var repliedContent repliedTextContent
if err := json.Unmarshal(richText.RepliedMsg.Content, &repliedContent); err != nil {
if err := json.Unmarshal(raw, &repliedContent); err != nil {
slog.Debug("dingtalk: failed to parse replied message content", "error", err)
return content
return ""
}
return repliedContent.Text
}
func (p *Platform) extractInteractiveCardQuotedText(raw json.RawMessage) string {
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
slog.Debug("dingtalk: failed to parse quoted interactiveCard content", "error", err)
return ""
}
text := p.extractInteractiveCardTextValue(payload, 0)
if text == "" {
slog.Debug("dingtalk: quoted interactiveCard content has no extractable text")
}
return normalizeQuotedMessageText(text)
}
func (p *Platform) extractInteractiveCardTextValue(value any, depth int) string {
if depth > 4 {
return ""
}
if repliedContent.Text == "" {
return content
switch v := value.(type) {
case string:
decoded, ok := decodeJSONObjectOrArray(v)
if !ok {
return ""
}
return p.extractInteractiveCardTextValue(decoded, depth+1)
case map[string]any:
for _, key := range p.interactiveCardTemplateKeys() {
if text := p.extractInteractiveCardPath(v, depth, "cardData", "cardParamMap", key); text != "" {
return text
}
}
for _, key := range p.interactiveCardTemplateKeys() {
if text := p.extractInteractiveCardPath(v, depth, "cardParamMap", key); text != "" {
return text
}
}
for _, key := range p.interactiveCardTopLevelKeys() {
if text := p.extractInteractiveCardPath(v, depth, key); text != "" {
return text
}
}
case []any:
for _, item := range v {
if text := p.extractInteractiveCardTextValue(item, depth+1); text != "" {
return text
}
}
}
return ""
}
func (p *Platform) extractInteractiveCardPath(root map[string]any, depth int, path ...string) string {
var current any = root
for _, part := range path {
m, ok := mapFromJSONValue(current)
if !ok {
return ""
}
next, ok := m[part]
if !ok {
return ""
}
current = next
}
return p.extractInteractiveCardLeafText(current, depth+1)
}
func (p *Platform) extractInteractiveCardLeafText(value any, depth int) string {
if depth > 4 {
return ""
}
return fmt.Sprintf("引用: \"%s\"\n\n%s", repliedContent.Text, content)
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case map[string]any, []any:
return p.extractInteractiveCardTextValue(value, depth+1)
default:
return ""
}
}
func (p *Platform) interactiveCardTemplateKeys() []string {
key := strings.TrimSpace(p.cardTemplateKey)
if key == "" {
key = "content"
}
if key == "content" {
return []string{"content"}
}
return []string{key, "content"}
}
func (p *Platform) interactiveCardTopLevelKeys() []string {
return []string{"content", "text", "markdown", "title"}
}
func mapFromJSONValue(value any) (map[string]any, bool) {
switch v := value.(type) {
case map[string]any:
return v, true
case string:
decoded, ok := decodeJSONObjectOrArray(v)
if !ok {
return nil, false
}
m, ok := decoded.(map[string]any)
return m, ok
default:
return nil, false
}
}
func decodeJSONObjectOrArray(s string) (any, bool) {
text := strings.TrimSpace(s)
if text == "" || (!strings.HasPrefix(text, "{") && !strings.HasPrefix(text, "[")) {
return nil, false
}
var decoded any
if err := json.Unmarshal([]byte(text), &decoded); err != nil {
return nil, false
}
return decoded, true
}
func normalizeQuotedMessageText(s string) string {
text := strings.TrimSpace(s)
if text == "" {
return ""
}
runes := []rune(text)
if len(runes) <= maxQuotedMessageRunes {
return text
}
return string(runes[:maxQuotedMessageRunes]) + "..."
}
// ReconstructReplyCtx implements core.ReplyContextReconstructor.

View File

@@ -8,6 +8,8 @@ import (
"sync"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
// ──────────────────────────────────────────────────────────────
@@ -58,7 +60,7 @@ func TestGetAccessToken_ConcurrentAccess(t *testing.T) {
func TestGetAccessToken_MutexExists(t *testing.T) {
// Verify that the tokenMu mutex field exists and works
p := &Platform{
clientID: "test_client",
clientID: "test_client",
clientSecret: "test_secret",
}
@@ -274,6 +276,24 @@ func TestFormatReplyContent_EmptyContent_UsesFallback(t *testing.T) {
}
}
func TestFormatReplyContent_TextQuotePreservesWhitespace(t *testing.T) {
p := &Platform{}
repliedContent, _ := json.Marshal(repliedTextContent{Text: " original message "})
richText := &richTextContent{
Content: "user reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "text",
Content: repliedContent,
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \" original message \"\n\nuser reply"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_NilRepliedMsg(t *testing.T) {
p := &Platform{}
richText := &richTextContent{
@@ -303,6 +323,215 @@ func TestFormatReplyContent_NonTextMsgType(t *testing.T) {
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardContent(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
richText := &richTextContent{
Content: "user reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": {
"cardParamMap": {
"config": "{\"autoLayout\":true}",
"content": "bot card answer"
}
}
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"bot card answer\"\n\nuser reply"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardCustomTemplateKey(t *testing.T) {
p := &Platform{cardTemplateKey: "body"}
richText := &richTextContent{
Content: "next question",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": {
"cardParamMap": {
"content": "default content",
"body": "custom body content"
}
}
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"custom body content\"\n\nnext question"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardNestedJSONEnvelope(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
richText := &richTextContent{
Content: "continue",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": "{\"cardParamMap\":{\"content\":\"nested card answer\"}}"
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"nested card answer\"\n\ncontinue"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_WithQuotedInteractiveCardTopLevelFallback(t *testing.T) {
p := &Platform{}
richText := &richTextContent{
Content: "what next?",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"title": "Run Summary",
"markdown": "all checks passed"
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"all checks passed\"\n\nwhat next?"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_InteractiveCardPreservesVisibleJSONContent(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
richText := &richTextContent{
Content: "follow up",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"cardData": {
"cardParamMap": {
"config": "{\"autoLayout\":true}",
"content": "{\"status\":\"ok\"}"
}
}
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"{\"status\":\"ok\"}\"\n\nfollow up"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_InteractiveCardTopLevelFallbackIgnoresCustomKey(t *testing.T) {
p := &Platform{cardTemplateKey: "body"}
richText := &richTextContent{
Content: "follow up",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: json.RawMessage(`{
"body": "custom top-level body",
"content": "top-level content"
}`),
},
}
result := p.formatReplyContent(richText, "fallback")
expected := "引用: \"top-level content\"\n\nfollow up"
if result != expected {
t.Errorf("formatReplyContent() = %q, want %q", result, expected)
}
}
func TestFormatReplyContent_TruncatesLongQuotedInteractiveCardContent(t *testing.T) {
p := &Platform{cardTemplateKey: "content"}
longText := strings.Repeat("x", maxQuotedMessageRunes+1)
cardContent, err := json.Marshal(map[string]any{
"cardData": map[string]any{
"cardParamMap": map[string]string{
"content": longText,
},
},
})
if err != nil {
t.Fatalf("marshal card content: %v", err)
}
richText := &richTextContent{
Content: "short reply",
IsReplyMsg: true,
RepliedMsg: &repliedMessage{
MsgType: "interactiveCard",
Content: cardContent,
},
}
result := p.formatReplyContent(richText, "fallback")
expectedPrefix := "引用: \"" + strings.Repeat("x", maxQuotedMessageRunes) + "...\"\n\nshort reply"
if result != expectedPrefix {
t.Errorf("formatReplyContent() length = %d, want truncated output length %d", len([]rune(result)), len([]rune(expectedPrefix)))
}
}
func TestOnRawMessage_QuotedInteractiveCardEnrichesMessageContent(t *testing.T) {
var got *core.Message
p := &Platform{
cardTemplateKey: "content",
handler: func(_ core.Platform, msg *core.Message) {
got = msg
},
}
p.onRawMessage(`{
"msgtype": "text",
"msgId": "msg-1",
"conversationType": "2",
"conversationId": "conv-1",
"conversationTitle": "team chat",
"senderStaffId": "user-1",
"senderNick": "Alice",
"sessionWebhook": "https://example.invalid/webhook",
"text": {
"content": "please continue",
"isReplyMsg": true,
"repliedMsg": {
"msgType": "interactiveCard",
"content": {
"cardData": {
"cardParamMap": {
"content": "previous card answer"
}
}
}
}
}
}`)
if got == nil {
t.Fatal("handler was not called")
}
expected := "引用: \"previous card answer\"\n\nplease continue"
if got.Content != expected {
t.Errorf("message content = %q, want %q", got.Content, expected)
}
}
func TestFormatReplyContent_EmptyQuotedText(t *testing.T) {
p := &Platform{}
repliedContent, _ := json.Marshal(repliedTextContent{Text: ""})