From 61657d46d1829d3dcf54abf7b251dbed96d19bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Apr 2026 15:48:40 +0800 Subject: [PATCH] feat(feishu): extractInboundPayload recognizes image/file/post attachments Co-Authored-By: Claude Sonnet 4.6 --- .../feishu/__tests__/extract-payload.test.ts | 77 +++++++++++++++ adapters/feishu/extract-payload.ts | 95 +++++++++++++++++++ adapters/feishu/index.ts | 21 +--- 3 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 adapters/feishu/__tests__/extract-payload.test.ts create mode 100644 adapters/feishu/extract-payload.ts diff --git a/adapters/feishu/__tests__/extract-payload.test.ts b/adapters/feishu/__tests__/extract-payload.test.ts new file mode 100644 index 00000000..494b58c1 --- /dev/null +++ b/adapters/feishu/__tests__/extract-payload.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'bun:test' +import { extractInboundPayload } from '../extract-payload.js' + +describe('extractInboundPayload', () => { + it('pulls text out of a text message', () => { + const result = extractInboundPayload( + JSON.stringify({ text: 'hello world' }), + 'text', + ) + expect(result.text).toBe('hello world') + expect(result.pendingDownloads).toEqual([]) + }) + + it('pulls text out of a post (rich text) message', () => { + const content = JSON.stringify({ + zh_cn: { + content: [[{ tag: 'text', text: 'hi ' }, { tag: 'text', text: 'there' }]], + }, + }) + const result = extractInboundPayload(content, 'post') + expect(result.text).toBe('hi there') + expect(result.pendingDownloads).toEqual([]) + }) + + it('identifies an image message as a pending image download', () => { + const content = JSON.stringify({ image_key: 'img_key_abc' }) + const result = extractInboundPayload(content, 'image') + expect(result.text).toBe('') + expect(result.pendingDownloads).toEqual([ + { kind: 'image', fileKey: 'img_key_abc' }, + ]) + }) + + it('identifies a file message as a pending file download with file_name', () => { + const content = JSON.stringify({ + file_key: 'file_key_xyz', + file_name: 'spec.pdf', + }) + const result = extractInboundPayload(content, 'file') + expect(result.pendingDownloads).toEqual([ + { kind: 'file', fileKey: 'file_key_xyz', fileName: 'spec.pdf' }, + ]) + }) + + it('identifies file_archive the same way as file', () => { + const content = JSON.stringify({ file_key: 'fk1', file_name: 'x.zip' }) + const result = extractInboundPayload(content, 'file_archive') + expect(result.pendingDownloads).toEqual([ + { kind: 'file', fileKey: 'fk1', fileName: 'x.zip' }, + ]) + }) + + it('extracts img + file elements from a post message', () => { + const content = JSON.stringify({ + zh_cn: { + content: [ + [{ tag: 'text', text: 'look: ' }], + [{ tag: 'img', image_key: 'img_post_1' }], + [{ tag: 'text', text: ' and ' }], + [{ tag: 'file', file_key: 'file_post_1', file_name: 'note.txt' }], + ], + }, + }) + const result = extractInboundPayload(content, 'post') + expect(result.text).toBe('look: and ') + expect(result.pendingDownloads).toEqual([ + { kind: 'image', fileKey: 'img_post_1' }, + { kind: 'file', fileKey: 'file_post_1', fileName: 'note.txt' }, + ]) + }) + + it('returns empty on malformed JSON', () => { + const result = extractInboundPayload('not json', 'text') + expect(result.text).toBe('') + expect(result.pendingDownloads).toEqual([]) + }) +}) diff --git a/adapters/feishu/extract-payload.ts b/adapters/feishu/extract-payload.ts new file mode 100644 index 00000000..440359f1 --- /dev/null +++ b/adapters/feishu/extract-payload.ts @@ -0,0 +1,95 @@ +/** + * Feishu inbound message parser. + * + * Converts a raw Feishu `im.message.receive_v1` event payload (the JSON + * string inside `message.content` plus its `message_type`) into a + * structured `InboundPayload` containing: + * - plain text (for direct forwarding to Claude) + * - a list of `PendingDownload` refs describing any attachments we + * need to fetch via FeishuMediaService.downloadResource() + * + * Supports the five message_type values we care about: + * - text → text only + * - post → rich text (text nodes + img + file elements) + * - image → single image_key + * - file → single file_key + * - file_archive → single file_key (same shape as file) + * + * Any other shape returns an empty payload (text: '', downloads: []). + */ + +export type PendingDownload = + | { kind: 'image'; fileKey: string; fileName?: string } + | { kind: 'file'; fileKey: string; fileName?: string } + +export interface InboundPayload { + text: string + pendingDownloads: PendingDownload[] +} + +export function extractInboundPayload(content: string, msgType: string): InboundPayload { + let parsed: any + try { + parsed = JSON.parse(content) + } catch { + return { text: '', pendingDownloads: [] } + } + + if (msgType === 'text') { + return { + text: typeof parsed.text === 'string' ? parsed.text : '', + pendingDownloads: [], + } + } + + if (msgType === 'image') { + if (typeof parsed.image_key === 'string' && parsed.image_key) { + return { + text: '', + pendingDownloads: [{ kind: 'image', fileKey: parsed.image_key }], + } + } + return { text: '', pendingDownloads: [] } + } + + if (msgType === 'file' || msgType === 'file_archive') { + if (typeof parsed.file_key === 'string' && parsed.file_key) { + return { + text: '', + pendingDownloads: [ + { + kind: 'file', + fileKey: parsed.file_key, + fileName: typeof parsed.file_name === 'string' ? parsed.file_name : undefined, + }, + ], + } + } + return { text: '', pendingDownloads: [] } + } + + if (msgType === 'post') { + const nodes = (parsed.zh_cn?.content ?? parsed.en_us?.content ?? []) as any[] + const flat = nodes.flat() + const textParts: string[] = [] + const downloads: PendingDownload[] = [] + for (const node of flat) { + if (!node || typeof node !== 'object') continue + if (node.tag === 'text' || node.tag === 'md') { + const t = node.text ?? node.content ?? '' + if (typeof t === 'string') textParts.push(t) + } else if (node.tag === 'img' && typeof node.image_key === 'string') { + downloads.push({ kind: 'image', fileKey: node.image_key }) + } else if (node.tag === 'file' && typeof node.file_key === 'string') { + downloads.push({ + kind: 'file', + fileKey: node.file_key, + fileName: typeof node.file_name === 'string' ? node.file_name : undefined, + }) + } + } + return { text: textParts.join(''), pendingDownloads: downloads } + } + + return { text: '', pendingDownloads: [] } +} diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 46c406df..a788fb50 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -23,6 +23,7 @@ import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { optimizeMarkdownForFeishu } from './markdown-style.js' +import { extractInboundPayload } from './extract-payload.js' // ---------- init ---------- @@ -769,24 +770,8 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< // ---------- extract message text ---------- function extractText(content: string, msgType: string): string | null { - try { - const parsed = JSON.parse(content) - if (msgType === 'text') { - return parsed.text ?? null - } - if (msgType === 'post') { - const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? [] - return zhContent - .flat() - .filter((n: any) => n.tag === 'text' || n.tag === 'md') - .map((n: any) => n.text ?? n.content ?? '') - .join('') - .trim() || null - } - return null - } catch { - return null - } + const { text } = extractInboundPayload(content, msgType) + return text.trim() || null } function isBotMentioned(mentions?: Array<{ id?: { open_id?: string } }>): boolean {