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:
@@ -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]');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user