Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/feishu/__tests__/interactive-card.test.ts
Original file line number Diff line number Diff line change
@@ -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('第二段');
});
});
14 changes: 13 additions & 1 deletion src/feishu/__tests__/merge-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('[卡片消息]');
});

Expand Down
6 changes: 4 additions & 2 deletions src/feishu/client.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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 }> = [];
Expand Down Expand Up @@ -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) {
Expand Down
119 changes: 118 additions & 1 deletion src/feishu/message-parser.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const parts: string[] = [];

// header.title 可能是 { content } 或 { text: { content } }
const header = card.header as Record<string, unknown> | undefined;
const title = header?.title as Record<string, unknown> | undefined;
if (title) {
const titleText =
(title.content as string) ??
((title.text as Record<string, unknown> | undefined)?.content as string) ??
'';
if (titleText.trim()) parts.push(titleText.trim());
}

// 卡片 elements 可能在顶层 elements 或 v2 形式的 body.elements
const body = card.body as Record<string, unknown> | 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<string, unknown>;
const tag = el.tag as string | undefined;

switch (tag) {
case 'div': {
const text = el.text as Record<string, unknown> | 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<string, unknown>)?.text as Record<string, unknown> | 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<string, unknown>)?.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<string, unknown>)?.text as Record<string, unknown> | 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<string, unknown>;
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 为可读文本
*/
Expand Down Expand Up @@ -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 '[嵌套的合并转发消息]';
Expand Down
Loading