feat(dingtalk): derive chat-list title from content (#1269) (#1288)

* feat(dingtalk): derive chat-list title from content (#1269)

Both Reply (sessionWebhook) and sendProactiveMessage (oToMessages/batchSend)
hardcoded markdown.title="reply", so every DingTalk chat list entry
showed the same preview regardless of what the agent said.

Replace with cardTitleFromContent(content):
  - strips markdown via core.StripMarkdown (already used by wecom/line)
  - takes first non-empty line
  - truncates to 20 runes (CJK counted as 1 rune so we never split mid-char)
  - falls back to 'reply' for empty / whitespace-only / pure-format /
    pure-emoji input

Behaviour:
  "**Standup notes** for today" -> "Standup notes for to"  (truncated)
  "你好世界..." (22 runes)        -> "你好世界..." (first 20 runes)
  "****"                          -> "reply"              (fallback)
  "🎉🎊"                          -> "reply"              (fallback)

Tests:
  TestCardTitleFromContent (22 subtests covering plain, markdown stripping,
    truncation, multi-byte CJK, fallback cases)
  TestCardTitleFromContent_UsedInReplyPayload (smoke test that the derived
    title actually flows into the outgoing sessionWebhook JSON payload)

Other platform adapters (feishu/telegram/slack/...) untouched.

Fixes #1269

* ci: retrigger #1288 CI (TestCUJ_H2_TwoPlatformsConcurrentNoBleed flaky)

#1288 (dingtalk chat-list title) is QA fresh review APPROVE on code (review
PRR_kwDORa0x388AAAABDNua8A, 0 P0/P1/P2/P3 blockers). Local TestCUJ_H2 runs
1/3 flaky on pr-1288, 3/3 PASS on origin/main; #1288 doesn't touch
core/cuj_test.go, so the failure is from #1348 CUJ framework's 30s tight
timeout, not from this PR.

Re-trigger CI via empty commit; if stable fail on 2-3 retries, the flaky
test fix belongs in #1348, not here.

---------

Co-authored-by: dev-claudecode <dev-claudecode@spaceship.local>
Co-authored-by: dev-claudecode <dev-claudecode@cc-connect.local>
This commit is contained in:
authored and GitHub committed 2026-08-13 19:04:20 +08:00
1 parent f8b82946d9
commit 022d661c1c
3 files changed
+183 -2

No files matched your search

+5
View File
@@ -32,6 +32,11 @@
misleading "new sessions" wording that made users think they needed `/new` to
see the change take effect (#1368).
## Unreleased
### Fixed
- **DingTalk message list title**: derive the `markdown.title` field on outgoing DingTalk messages from the message content (markdown stripped, first non-empty line, truncated to 20 runes) instead of the hardcoded `"reply"`. The DingTalk chat list now shows the actual reply preview instead of "reply" on every entry. Empty / pure-format / pure-emoji messages still fall back to "reply" so the title is never blank (#1269).
## v1.3.3 (2026-06-15)
First stable release of the 1.3.3 series. Stabilizes the v1.3.3-beta.1 → v1.3.3-beta.5
+59 -2
View File
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"time"
"unicode"
"github.com/chenhg5/cc-connect/core"
@@ -60,6 +61,8 @@ const (
defaultReactionEmoji = "🤔Thinking"
customTextEmotionID = "2659900"
customTextEmotionBackground = "im_bg_1"
cardTitleMaxRunes = 20
cardTitleFallback = "reply"
)
type downloadResponse struct {
@@ -851,7 +854,7 @@ func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
payload := map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{"title": "reply", "text": content},
"markdown": map[string]string{"title": cardTitleFromContent(content), "text": content},
}
if len(atUserIds) > 0 {
payload["at"] = map[string]any{
@@ -1661,7 +1664,7 @@ func (p *Platform) sendProactiveMessage(ctx context.Context, rc replyContext, co
} else if rc.senderStaffId != "" {
// Direct message via /v1.0/robot/oToMessages/batchSend
apiURL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
msgParam, _ := json.Marshal(map[string]string{"title": "reply", "text": content})
msgParam, _ := json.Marshal(map[string]string{"title": cardTitleFromContent(content), "text": content})
requestBody = map[string]any{
"robotCode": p.robotCode,
"userIds": []string{rc.senderStaffId},
@@ -1751,3 +1754,57 @@ func preprocessDingTalkMarkdown(s string) string {
}
return sb.String()
}
// cardTitleFromContent derives a short single-line preview title from message
// content, suitable for the DingTalk chat list and notification previews
// (the `markdown.title` field on sampleMarkdown messages).
//
// Behaviour:
// - Strips markdown formatting via core.StripMarkdown so titles don't show
// leftover ** / # / ` markers.
// - Trims whitespace, takes the first non-empty line, and truncates to
// cardTitleMaxRunes runes (Chinese / emoji counted as 1 rune each so we
// never split a multi-byte character mid-codepoint).
// - Falls back to cardTitleFallback when nothing readable remains — empty
// input, whitespace-only, pure emoji, or content that strips down to
// only orphan markdown markers (e.g. "****", "####"). Pure emoji is
// treated as not-readable on purpose: the user's spec lists emoji-only
// as a fallback case, and showing a lone 🎉 as a DingTalk chat list
// preview is usually less helpful than the previous "reply".
//
// Previously both Reply and sendProactiveMessage hardcoded "reply", which
// made every DingTalk chat list entry look identical regardless of what the
// agent actually said (#1269).
func cardTitleFromContent(content string) string {
if strings.TrimSpace(content) == "" {
return cardTitleFallback
}
stripped := strings.TrimSpace(core.StripMarkdown(content))
if stripped == "" || !hasReadableChar(stripped) {
return cardTitleFallback
}
if idx := strings.IndexByte(stripped, '\n'); idx >= 0 {
stripped = strings.TrimSpace(stripped[:idx])
if stripped == "" || !hasReadableChar(stripped) {
return cardTitleFallback
}
}
runes := []rune(stripped)
if len(runes) > cardTitleMaxRunes {
return string(runes[:cardTitleMaxRunes])
}
return stripped
}
// hasReadableChar reports whether s contains at least one letter or digit
// (including CJK ideographs). Used to decide whether a stripped title has
// enough semantic content to show in the chat list — pure markdown markers,
// pure emoji, and pure whitespace are treated as "not readable".
func hasReadableChar(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
+119
View File
@@ -1322,3 +1322,122 @@ func TestReply_NoAtUserIdsWhenNoMention(t *testing.T) {
t.Fatal("timed out waiting for reply")
}
}
// ──────────────────────────────────────────────────────────────
// Card title derivation (Fixes #1269)
// ──────────────────────────────────────────────────────────────
func TestCardTitleFromContent(t *testing.T) {
// Long input — 30 ASCII chars. Verifies plain truncation at 20 runes.
longPlain := "012345678901234567890123456789"
if len(longPlain) != 30 {
t.Fatalf("longPlain fixture = %d chars, want 30", len(longPlain))
}
// Chinese / CJK fixture — 22 runes. Verifies []rune counting so we never
// split a multi-byte character mid-codepoint.
chinese22 := "你好世界这是一段测试中文超过二十字符测试数据"
if got := len([]rune(chinese22)); got != 22 {
t.Fatalf("chinese22 fixture = %d runes, want 22", got)
}
tests := []struct {
name string
in string
want string
}{
// ── Fallback cases (#1269 acceptance: empty / pure formatting) ──
{"empty", "", cardTitleFallback},
{"whitespace only", " \n\n \t", cardTitleFallback},
{"pure bold markers", "****", cardTitleFallback},
{"pure heading markers", "####", cardTitleFallback},
{"pure italic markers", "**", cardTitleFallback},
{"pure emoji (fallback per spec)", "🎉🎊✨", cardTitleFallback},
{"empty fenced code block", "```\n```", cardTitleFallback},
// ── Plain text — return as-is (within limit) ──
{"short plain", "hello world", "hello world"},
{"plain exactly 20 runes", "01234567890123456789", "01234567890123456789"},
// ── Plain text truncation (ASCII) ──
{"plain 30 chars truncated to 20", longPlain, "01234567890123456789"},
// ── Markdown stripping — format removed, then first line truncated ──
{"bold stripped", "**hello** world", "hello world"},
{"italic stripped", "this is *italic* text", "this is italic text"},
{"heading stripped", "## My Title\nbody", "My Title"},
{"inline code stripped", "use `os.path.join` now", "use os.path.join now"},
{"link keeps text and url", "[a](b) c", "a (b) c"},
// ── First-line wins (DingTalk title is single-line) ──
{"first line wins", "first line\nsecond line", "first line"},
{"first line wins after markdown strip", "# heading\nbody", "heading"},
{"markdown strip keeps first non-empty line", "**bold first**\nplain second", "bold first"},
// ── Truncation respects markdown stripping (#1269: don't break markdown) ──
// The marker must be removed BEFORE truncation so we never cut mid-**bold**.
{"bold span not split", "**" + strings.Repeat("a", 25) + "**", strings.Repeat("a", 20)},
// ── Multi-byte / CJK rune counting ──
{"chinese 22 runes truncated to 20", chinese22, "你好世界这是一段测试中文超过二十字符测试"},
{"chinese under limit passes through", "你好世界", "你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cardTitleFromContent(tt.in)
if got != tt.want {
t.Errorf("cardTitleFromContent(%q) = %q, want %q", tt.in, got, tt.want)
}
// Defensive: every returned title must fit within the cap so the
// DingTalk chat list never sees a runaway title.
if got != cardTitleFallback && len([]rune(got)) > cardTitleMaxRunes {
t.Errorf("cardTitleFromContent(%q) returned %d runes, exceeds cap %d",
tt.in, len([]rune(got)), cardTitleMaxRunes)
}
})
}
}
// TestCardTitleFromContent_UsedInReplyPayload is a smoke test that the
// title derivation actually flows into the `markdown.title` field of the
// outgoing sessionWebhook payload (Reply path). It captures the POST body
// and asserts the title no longer equals the old hardcoded "reply".
func TestCardTitleFromContent_UsedInReplyPayload(t *testing.T) {
gotPayload := make(chan map[string]any, 1)
sessionWebhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var p map[string]any
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Errorf("decode: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gotPayload <- p
w.WriteHeader(http.StatusOK)
}))
defer sessionWebhook.Close()
p := &Platform{}
rc := replyContext{sessionWebhook: sessionWebhook.URL}
if err := p.Reply(context.Background(), rc, "**Standup notes** for today — see PRs."); err != nil {
t.Fatalf("Reply: %v", err)
}
select {
case payload := <-gotPayload:
markdown, ok := payload["markdown"].(map[string]any)
if !ok {
t.Fatalf("payload[markdown] = %T, want map[string]any", payload["markdown"])
}
title, _ := markdown["title"].(string)
if title == "reply" {
t.Errorf("title still hardcoded to %q after fix — regression of #1269", title)
}
want := "Standup notes for to"
if title != want {
t.Errorf("title = %q, want %q", title, want)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for reply payload")
}
}