mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
fix(dingtalk): include quoted interactive card content
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: ""})
|
||||
|
||||
Reference in New Issue
Block a user