From e9ae2ec751a43144a8da307d1df6ba207ca982a4 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Thu, 11 Jun 2026 20:47:54 +0800 Subject: [PATCH] feat(log): show ARK card title in JSON segment preview (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JSON segment preview was a fixed "[JSON]" placeholder, hiding both real card titles and QQ's server-side fallback prompts (e.g. cardId -896954183 "当前QQ版本不支持此应用,请升级", which is what #96 reported seeing). Best-effort extract a title from the common ARK fields — meta.{detail_1,detail,news,mannounce,music,video}.title, single-section meta, then root.prompt / root.desc — and render as "[卡片:...]"; fall back to "[JSON]" on parse failure or an unrecognised schema. Title is truncated at 30 chars so a long prompt cannot blow up the log line. OneBot segment shape is unchanged: downstream consumers still get the full payload at data.data; this is purely a log-preview readability fix. The helper is pure best-effort and never throws — every failure path falls through to the existing "[JSON]" output. --- packages/core/tests/event-format.test.ts | 49 ++++++++++++++++ packages/protocol/src/format.ts | 72 +++++++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/event-format.test.ts b/packages/core/tests/event-format.test.ts index e9031944..de188683 100644 --- a/packages/core/tests/event-format.test.ts +++ b/packages/core/tests/event-format.test.ts @@ -119,6 +119,55 @@ describe('formatMessageSegments', () => { .toBe('[文件]'); }); + it('renders json card segments by extracting the card title from common ARK fields', () => { + // mini-program share: meta.detail_1.title + const miniApp = JSON.stringify({ + app: 'com.tencent.miniapp_01', + meta: { detail_1: { title: '哔哩哔哩', desc: '某个视频标题' } }, + }); + expect(formatMessageSegments([{ type: 'json', data: { data: miniApp } }])) + .toBe('[卡片:哔哩哔哩]'); + + // news share: meta.news.title + const news = JSON.stringify({ meta: { news: { title: '新闻标题' } } }); + expect(formatMessageSegments([{ type: 'json', data: { data: news } }])) + .toBe('[卡片:新闻标题]'); + + // QQ server-side fallback (cardId -896954183): only `prompt` is present + const fallback = JSON.stringify({ + app: 'com.tencent.structmsg', + prompt: '当前QQ版本不支持此应用,请升级', + }); + expect(formatMessageSegments([{ type: 'json', data: { data: fallback } }])) + .toBe('[卡片:当前QQ版本不支持此应用,请升级]'); + + // generic single-section meta (no recognised key, but only one section) + const generic = JSON.stringify({ meta: { custom_section: { title: '通用卡片' } } }); + expect(formatMessageSegments([{ type: 'json', data: { data: generic } }])) + .toBe('[卡片:通用卡片]'); + + // long titles are truncated + const long = JSON.stringify({ prompt: 'a'.repeat(100) }); + expect(formatMessageSegments([{ type: 'json', data: { data: long } }])) + .toBe(`[卡片:${'a'.repeat(30)}...]`); + }); + + it('falls back to [JSON] when the card payload is unparseable or empty', () => { + // non-string data.data + expect(formatMessageSegments([{ type: 'json', data: {} }])).toBe('[JSON]'); + // invalid JSON + expect(formatMessageSegments([{ type: 'json', data: { data: 'not-json{' } }])) + .toBe('[JSON]'); + // JSON without any recognised title field + expect(formatMessageSegments([{ type: 'json', data: { data: '{"foo":1}' } }])) + .toBe('[JSON]'); + // empty string + expect(formatMessageSegments([{ type: 'json', data: { data: '' } }])).toBe('[JSON]'); + // JSON array (not an object) + expect(formatMessageSegments([{ type: 'json', data: { data: '[1,2,3]' } }])) + .toBe('[JSON]'); + }); + it('renders unknown segment types as [type]', () => { expect(formatMessageSegments([{ type: 'something_new', data: {} }])) .toBe('[something_new]'); diff --git a/packages/protocol/src/format.ts b/packages/protocol/src/format.ts index 86488fcf..b6bd1a6f 100644 --- a/packages/protocol/src/format.ts +++ b/packages/protocol/src/format.ts @@ -16,6 +16,7 @@ export interface ReplyEventLookup { const MAX_TEXT_PREVIEW = 50; const MAX_REPLY_BODY_PREVIEW = 30; +const MAX_CARD_TITLE_PREVIEW = 30; /** "[群名(12345)]" if known, else "12345" (or "0" if missing). */ export function formatGroup(identity: IdentityService, groupId: number): string { @@ -102,7 +103,18 @@ function renderSegment(type: string, data: Record): string { const fileName = data.name ?? data.file; return fileName ? `[文件:${truncate(String(fileName), 20)}]` : '[文件]'; } - case 'json': return '[JSON]'; + case 'json': { + // OneBot json segments carry Tencent's ARK / mini-program / share card + // payload as a JSON string under data.data. For the log preview we try + // to surface a human-readable title — "[卡片:某某分享]" instead of just + // "[JSON]". The full payload stays intact in the OneBot segment for any + // downstream consumer that wants to parse it. + // For QQ's server-side fallback (e.g. cardId -896954183 "当前QQ版本不支持 + // 此应用,请升级"), the `prompt` field carries that message — surfaces it + // so the operator sees the actual reason instead of "[JSON]". + const title = extractCardTitle(data.data); + return title ? `[卡片:${truncate(title, MAX_CARD_TITLE_PREVIEW)}]` : '[JSON]'; + } case 'xml': return '[XML]'; case 'markdown': return '[Markdown]'; case 'forward': return '[聊天记录]'; @@ -115,6 +127,64 @@ function truncate(s: string, max: number): string { return s.length > max ? `${s.slice(0, max)}...` : s; } +/** + * Best-effort extraction of an ARK / mini-program / share card's title for the + * log preview. The OneBot json segment's `data.data` is the raw JSON string + * Tencent embeds in the lightApp element; its shape varies by card type, but + * the common locations for a human-readable headline are: + * + * - meta.detail_1.title — mini-program shares + * - meta.detail.title — news / image shares + * - meta.news.title — news shares (alt schema) + * - meta.{music,video,mannounce}.title — domain-specific shares + * - meta..title — generic single-section ARK + * - prompt — QQ's server-side fallback cards (e.g. cardId + * -896954183 "当前QQ版本不支持此应用,请升级") + * - desc — some richer cards + * + * Returns null on parse failure / no recognised field — caller falls back to + * "[JSON]". Pure best-effort: this is a log-preview hint, not a contract. + */ +function extractCardTitle(raw: unknown): string | null { + if (typeof raw !== 'string' || raw.length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const root = parsed as Record; + + const meta = root.meta; + if (meta && typeof meta === 'object' && !Array.isArray(meta)) { + const m = meta as Record; + const sections = ['detail_1', 'detail', 'news', 'mannounce', 'music', 'video']; + for (const key of sections) { + const t = readTitle(m[key]); + if (t) return t; + } + // Fallback: meta has exactly one section — use its title. + const keys = Object.keys(m); + if (keys.length === 1) { + const t = readTitle(m[keys[0]]); + if (t) return t; + } + } + + if (typeof root.prompt === 'string' && root.prompt.trim()) return root.prompt.trim(); + if (typeof root.desc === 'string' && root.desc.trim()) return root.desc.trim(); + return null; +} + +function readTitle(node: unknown): string | null { + if (!node || typeof node !== 'object' || Array.isArray(node)) return null; + const t = (node as Record).title; + if (typeof t !== 'string') return null; + const trimmed = t.trim(); + return trimmed.length > 0 ? trimmed : null; +} + /** * Resolve a `reply` segment's referenced message and return a short * "[回复 user: body...]" string. On miss (older than store retention,