diff --git a/src/feishu/__tests__/interactive-card.test.ts b/src/feishu/__tests__/interactive-card.test.ts new file mode 100644 index 0000000..0c3cc32 --- /dev/null +++ b/src/feishu/__tests__/interactive-card.test.ts @@ -0,0 +1,131 @@ +// @ts-nocheck — test file +import { describe, it, expect } from 'vitest'; +import { formatInteractiveCard } from '../message-parser.js'; + +describe('formatInteractiveCard', () => { + it('extracts header title from plain_text', () => { + const card = JSON.stringify({ + header: { title: { tag: 'plain_text', content: '🚀 SpaceX 发射通知' } }, + elements: [], + }); + expect(formatInteractiveCard(card)).toContain('SpaceX 发射通知'); + }); + + it('extracts header title nested under text.content', () => { + const card = JSON.stringify({ + header: { title: { text: { content: '嵌套标题' } } }, + }); + expect(formatInteractiveCard(card)).toContain('嵌套标题'); + }); + + it('extracts text from div element with lark_md content', () => { + const card = JSON.stringify({ + elements: [ + { tag: 'div', text: { tag: 'lark_md', content: '**核心内容**' } }, + ], + }); + expect(formatInteractiveCard(card)).toContain('**核心内容**'); + }); + + it('extracts text from div fields array', () => { + const card = JSON.stringify({ + elements: [ + { + tag: 'div', + fields: [ + { is_short: true, text: { tag: 'lark_md', content: '字段A' } }, + { is_short: true, text: { tag: 'lark_md', content: '字段B' } }, + ], + }, + ], + }); + const out = formatInteractiveCard(card); + expect(out).toContain('字段A'); + expect(out).toContain('字段B'); + }); + + it('extracts text from note element with nested plain_text', () => { + const card = JSON.stringify({ + elements: [ + { tag: 'note', elements: [{ tag: 'plain_text', content: '提示文字' }] }, + ], + }); + expect(formatInteractiveCard(card)).toContain('提示文字'); + }); + + it('extracts text from column_set with nested columns', () => { + const card = JSON.stringify({ + elements: [ + { + tag: 'column_set', + columns: [ + { elements: [{ tag: 'markdown', content: '左列' }] }, + { elements: [{ tag: 'markdown', content: '右列' }] }, + ], + }, + ], + }); + const out = formatInteractiveCard(card); + expect(out).toContain('左列'); + expect(out).toContain('右列'); + }); + + it('extracts button text from action element', () => { + const card = JSON.stringify({ + elements: [ + { + tag: 'action', + actions: [ + { tag: 'button', text: { tag: 'plain_text', content: '查看详情' } }, + ], + }, + ], + }); + expect(formatInteractiveCard(card)).toContain('[按钮: 查看详情]'); + }); + + it('supports v2 card with body.elements', () => { + const card = JSON.stringify({ + header: { title: { content: 'V2 卡片' } }, + body: { elements: [{ tag: 'markdown', content: '正文内容' }] }, + }); + const out = formatInteractiveCard(card); + expect(out).toContain('V2 卡片'); + expect(out).toContain('正文内容'); + }); + + it('falls back to recursive content extraction for unknown element tags', () => { + const card = JSON.stringify({ + elements: [ + { + tag: 'unknown_share_card', + some_field: { content: '第三方卡片内容' }, + }, + ], + }); + expect(formatInteractiveCard(card)).toContain('第三方卡片内容'); + }); + + it('returns placeholder for empty card', () => { + expect(formatInteractiveCard('{}')).toBe('[卡片消息]'); + }); + + it('returns parse-failed placeholder for invalid JSON', () => { + expect(formatInteractiveCard('not json')).toBe('[卡片消息 - 解析失败]'); + }); + + it('combines header + multiple elements into multi-line output', () => { + const card = JSON.stringify({ + header: { title: { content: '标题' } }, + elements: [ + { tag: 'div', text: { content: '第一段' } }, + { tag: 'hr' }, + { tag: 'div', text: { content: '第二段' } }, + ], + }); + const out = formatInteractiveCard(card); + expect(out).toContain('标题'); + expect(out).toContain('第一段'); + expect(out).toContain('第二段'); + }); +}); diff --git a/src/feishu/__tests__/merge-forward.test.ts b/src/feishu/__tests__/merge-forward.test.ts index c0f2bf0..a0dbdee 100644 --- a/src/feishu/__tests__/merge-forward.test.ts +++ b/src/feishu/__tests__/merge-forward.test.ts @@ -240,7 +240,19 @@ describe('formatMergeForwardSubMessage', () => { expect(formatMergeForwardSubMessage('{}', 'media')).toBe('[视频]'); }); - it('should return placeholder for interactive (card) message type', () => { + it('should extract title and text from interactive (card) message', () => { + const card = JSON.stringify({ + header: { title: { tag: 'plain_text', content: '🚀 SpaceX 发射' } }, + elements: [ + { tag: 'div', text: { tag: 'lark_md', content: 'XOVR 任务详情' } }, + ], + }); + const result = formatMergeForwardSubMessage(card, 'interactive'); + expect(result).toContain('SpaceX 发射'); + expect(result).toContain('XOVR 任务详情'); + }); + + it('should fall back to placeholder for empty interactive card', () => { expect(formatMergeForwardSubMessage('{}', 'interactive')).toBe('[卡片消息]'); }); diff --git a/src/feishu/client.ts b/src/feishu/client.ts index 0e0eedc..6a44d59 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -1,7 +1,7 @@ import * as lark from '@larksuiteoapi/node-sdk'; import { logger } from '../utils/logger.js'; -import { formatMergeForwardSubMessage } from './message-parser.js'; +import { formatMergeForwardSubMessage, formatInteractiveCard } from './message-parser.js'; import { chatBotRegistry } from './bot-registry.js'; /** @@ -617,7 +617,7 @@ export class FeishuClient { } if (item.deleted) { diagSkipped.push({ id: item.message_id ?? '?', type: item.msg_type ?? '?', reason: 'deleted' }); continue; } const msgType = item.msg_type ?? ''; - if (msgType !== 'text' && msgType !== 'post' && msgType !== 'merge_forward' && msgType !== 'file' && msgType !== 'image') { diagSkipped.push({ id: item.message_id ?? '?', type: msgType, reason: 'unsupported_type' }); continue; } + if (msgType !== 'text' && msgType !== 'post' && msgType !== 'merge_forward' && msgType !== 'file' && msgType !== 'image' && msgType !== 'interactive') { diagSkipped.push({ id: item.message_id ?? '?', type: msgType, reason: 'unsupported_type' }); continue; } const senderType = item.sender?.sender_type === 'app' ? 'app' as const : 'user' as const; let content = ''; const imageRefs: Array<{ imageKey: string }> = []; @@ -645,6 +645,8 @@ export class FeishuClient { imageRefs.push({ imageKey }); } } catch { /* ignore parse errors */ } + } else if (msgType === 'interactive') { + content = formatInteractiveCard(item.body?.content ?? '{}'); } else if (msgType === 'merge_forward') { const messageId = item.message_id; if (messageId) { diff --git a/src/feishu/message-parser.ts b/src/feishu/message-parser.ts index 87a7f53..d433db2 100644 --- a/src/feishu/message-parser.ts +++ b/src/feishu/message-parser.ts @@ -1,3 +1,120 @@ +/** + * 解析飞书 interactive 卡片 JSON,提取可读文本(标题 + 各 element 的文本内容)。 + * 支持已知 element tag(div/markdown/note/column_set/action/img/hr)并对未知结构做递归 content 提取兜底。 + */ +export function formatInteractiveCard(contentJson: string): string { + try { + const card = JSON.parse(contentJson || '{}') as Record; + const parts: string[] = []; + + // header.title 可能是 { content } 或 { text: { content } } + const header = card.header as Record | undefined; + const title = header?.title as Record | undefined; + if (title) { + const titleText = + (title.content as string) ?? + ((title.text as Record | undefined)?.content as string) ?? + ''; + if (titleText.trim()) parts.push(titleText.trim()); + } + + // 卡片 elements 可能在顶层 elements 或 v2 形式的 body.elements + const body = card.body as Record | undefined; + const topElements = (card.elements ?? body?.elements) as unknown[] | undefined; + if (Array.isArray(topElements)) { + walkCardElements(topElements, parts); + } + + const joined = parts.filter(s => s && s.trim()).join('\n').trim(); + return joined || '[卡片消息]'; + } catch { + return '[卡片消息 - 解析失败]'; + } +} + +function walkCardElements(elements: unknown[], parts: string[]): void { + for (const raw of elements) { + if (!raw || typeof raw !== 'object') continue; + const el = raw as Record; + const tag = el.tag as string | undefined; + + switch (tag) { + case 'div': { + const text = el.text as Record | undefined; + const t = text?.content as string | undefined; + if (t) parts.push(t); + const fields = el.fields as unknown[] | undefined; + if (Array.isArray(fields)) { + for (const f of fields) { + const fc = (f as Record)?.text as Record | undefined; + const ft = fc?.content as string | undefined; + if (ft) parts.push(ft); + } + } + break; + } + case 'markdown': + case 'plain_text': + case 'lark_md': { + const t = el.content as string | undefined; + if (t) parts.push(t); + break; + } + case 'note': { + const sub = el.elements as unknown[] | undefined; + if (Array.isArray(sub)) walkCardElements(sub, parts); + break; + } + case 'column_set': { + const cols = el.columns as unknown[] | undefined; + if (Array.isArray(cols)) { + for (const col of cols) { + const sub = (col as Record)?.elements as unknown[] | undefined; + if (Array.isArray(sub)) walkCardElements(sub, parts); + } + } + break; + } + case 'action': { + const actions = el.actions as unknown[] | undefined; + if (Array.isArray(actions)) { + for (const a of actions) { + const text = (a as Record)?.text as Record | undefined; + const t = text?.content as string | undefined; + if (t) parts.push(`[按钮: ${t}]`); + } + } + break; + } + case 'img': + parts.push('[图片]'); + break; + case 'hr': + break; + default: { + // 未知 tag:递归挖出所有 content 字符串(兜底,处理 share-card 等第三方卡片格式) + extractContentStrings(el, parts); + } + } + } +} + +function extractContentStrings(obj: unknown, parts: string[], depth = 0): void { + if (depth > 6 || !obj || typeof obj !== 'object') return; + if (Array.isArray(obj)) { + for (const item of obj) extractContentStrings(item, parts, depth + 1); + return; + } + const rec = obj as Record; + for (const [k, v] of Object.entries(rec)) { + if ((k === 'content' || k === 'text') && typeof v === 'string' && v.trim()) { + parts.push(v.trim()); + } else if (v && typeof v === 'object') { + extractContentStrings(v, parts, depth + 1); + } + } +} + /** * 解析合并转发子消息的 body.content 为可读文本 */ @@ -66,7 +183,7 @@ export function formatMergeForwardSubMessage( if (msgType === 'video') return '[视频]'; if (msgType === 'media') return '[视频]'; if (msgType === 'sticker') return '[表情]'; - if (msgType === 'interactive') return '[卡片消息]'; + if (msgType === 'interactive') return formatInteractiveCard(contentJson); if (msgType === 'share_chat') return '[群名片]'; if (msgType === 'share_user') return '[个人名片]'; if (msgType === 'merge_forward') return '[嵌套的合并转发消息]';