feat(log): show ARK card title in JSON segment preview (#96)

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.
This commit is contained in:
DrSmoothl
2026-06-11 20:47:54 +08:00
parent a3a36718d6
commit e9ae2ec751
2 changed files with 120 additions and 1 deletions
+49
View File
@@ -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]');
+71 -1
View File
@@ -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, unknown>): 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.<single-key>.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<string, unknown>;
const meta = root.meta;
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
const m = meta as Record<string, unknown>;
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<string, unknown>).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,