diff --git a/package.json b/package.json index fa210a6..bd4e6df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/channel", - "version": "0.5.0", + "version": "0.6.0", "description": "Channel SDK — let agents and external services integrate with the Feishu/Lark messaging system: reliable inbound events, message normalization, streaming replies, media upload, card interactions.", "keywords": [ "feishu", diff --git a/src/normalize/__tests__/converters.test.ts b/src/normalize/__tests__/converters.test.ts index beaf2ca..3877c26 100644 --- a/src/normalize/__tests__/converters.test.ts +++ b/src/normalize/__tests__/converters.test.ts @@ -180,4 +180,141 @@ describe('post converter', () => { const r = await convertPost('not json', ctx); expect(r.content).toBe('[rich text message]'); }); + + test('attachment zone renders files and folders', async () => { + const raw = JSON.stringify({ + zh_cn: { + title: '报告', + content: [[{ tag: 'text', text: '正文' }]], + }, + files: [ + { file_key: 'file_a', file_name: 'report.pdf' }, + { file_key: 'file_b' }, + { file_key: 'dir_1', file_name: 'assets', is_folder: true }, + ], + }); + const r = await convertPost(raw, ctx); + expect(r.content).toContain('**报告**'); + expect(r.content).toContain('正文'); + expect(r.content).toContain(''); + expect(r.content).toContain(''); + expect(r.content).toContain(''); + // Files are downloadable resources; folders are tag-only. + expect(r.resources).toContainEqual({ type: 'file', fileKey: 'file_a', fileName: 'report.pdf' }); + expect(r.resources).toContainEqual({ type: 'file', fileKey: 'file_b', fileName: undefined }); + expect(r.resources.filter((x) => x.type === 'file').length).toBe(2); + // The attachment zone belongs after the body, not interleaved with it. + expect(r.content.indexOf(' { + // The body's own resources and the attachment zone's must coexist. + const raw = JSON.stringify({ + zh_cn: { + content: [ + [ + { tag: 'img', image_key: 'img_1' }, + { tag: 'media', file_key: 'media_1' }, + ], + ], + }, + files: [{ file_key: 'file_a', file_name: 'report.pdf' }], + }); + const r = await convertPost(raw, ctx); + expect(r.resources).toContainEqual({ type: 'image', fileKey: 'img_1' }); + expect(r.resources).toContainEqual({ type: 'file', fileKey: 'media_1' }); + expect(r.resources).toContainEqual({ type: 'file', fileKey: 'file_a', fileName: 'report.pdf' }); + expect(r.resources).toHaveLength(3); + }); + + test('attachment zone survives an unusable locale document', async () => { + // `files` is a sibling of the locale documents, so attachments must still + // surface when no locale document can be unwrapped. + const raw = JSON.stringify({ + schema: '2.0', + files: [{ file_key: 'file_a', file_name: 'report.pdf' }], + }); + const r = await convertPost(raw, ctx); + expect(r.content).toBe(''); + expect(r.resources).toEqual([{ type: 'file', fileKey: 'file_a', fileName: 'report.pdf' }]); + }); + + test('attachment zone tolerates malformed wire values', async () => { + // A non-string file_name must not throw: dispatchConvert would trap it and + // replace the entire message with the unknown-message placeholder. + const raw = JSON.stringify({ + zh_cn: { content: [[{ tag: 'text', text: '正文' }]] }, + files: [ + { file_key: 'file_a', file_name: 123 }, + { file_key: 'file_b', file_name: { nested: true } }, + { file_key: '' }, + { file_key: 42 }, + null, + 'not an object', + ], + }); + const r = await convertPost(raw, ctx); + // The body survives, and only the unusable name is dropped — not the message. + expect(r.content).toContain('正文'); + expect(r.content).toContain(''); + expect(r.content).toContain(''); + expect(r.resources.filter((x) => x.type === 'file').length).toBe(2); + }); + + test('attachment zone treats only a real true as a folder', async () => { + // A truthy-but-not-true is_folder must not hide a downloadable file. + const raw = JSON.stringify({ + zh_cn: { content: [[{ tag: 'text', text: '正文' }]] }, + files: [ + { file_key: 'file_a', file_name: 'report.pdf', is_folder: 'false' }, + { file_key: 'file_b', file_name: 'other.pdf', is_folder: [] }, + ], + }); + const r = await convertPost(raw, ctx); + expect(r.content).not.toContain(' x.type === 'file').length).toBe(2); + }); + + test('attachment zone escapes quotes in key and name', async () => { + const raw = JSON.stringify({ + zh_cn: { content: [[{ tag: 'text', text: '正文' }]] }, + files: [{ file_key: 'a"b', file_name: 'c"d.pdf' }], + }); + const r = await convertPost(raw, ctx); + expect(r.content).toContain(''); + // The descriptor carries the real key, not the escaped rendering. + expect(r.resources).toContainEqual({ type: 'file', fileKey: 'a"b', fileName: 'c"d.pdf' }); + }); + + test('attachment zone ignores empty files array', async () => { + const raw = JSON.stringify({ + zh_cn: { content: [[{ tag: 'text', text: 'hi' }]] }, + files: [], + }); + const r = await convertPost(raw, ctx); + expect(r.content).toContain('hi'); + expect(r.content).not.toContain(' { + const raw = JSON.stringify({ + zh_cn: { content: [[{ tag: 'text', text: 'hi' }]] }, + files: [ + { file_key: 'file_a" onmouseover="x', file_name: 'r.pdf' }, + { file_key: 'file_b', file_name: 123 as unknown }, + ], + }); + const r = await convertPost(raw, ctx); + // key with a quote is escaped so it cannot forge attributes + expect(r.content).toContain(''); + // non-string file_name degrades to no name attribute, no throw + expect(r.content).toContain(''); + expect(r.resources).toContainEqual({ + type: 'file', + fileKey: 'file_a" onmouseover="x', + fileName: 'r.pdf', + }); + expect(r.resources).toContainEqual({ type: 'file', fileKey: 'file_b', fileName: undefined }); + }); }); diff --git a/src/normalize/converters/post.ts b/src/normalize/converters/post.ts index 8a5fca2..6353dd6 100644 --- a/src/normalize/converters/post.ts +++ b/src/normalize/converters/post.ts @@ -1,6 +1,6 @@ import type { ResourceDescriptor } from '../../types'; import type { ContentConverterFn, ConvertContext, PostElement } from '../context'; -import { applyStyle, safeParse, unwrapLocale } from '../utils'; +import { applyStyle, escapeAttr, safeParse, unwrapLocale } from '../utils'; interface PostBody { title?: string; @@ -8,26 +8,47 @@ interface PostBody { content_v2?: PostElement[][]; } +/** + * A validated attachment-zone entry. Unlike the raw wire record, every field + * here is guaranteed by `topLevelAttachments`: `fileKey` is a non-empty string, + * `isFolder` is a real boolean, and `fileName` is a string or absent. Rendering + * can then interpolate these without re-checking types. + */ +interface PostAttachment { + fileKey: string; + fileName?: string; + isFolder: boolean; +} + +const placeholder = '[rich text message]'; + const atMentionRe = /(.*?)<\/at>/g; const imageKeyRe = /!\[(.*?)\]\(([^)]+)\)/g; export const convertPost: ContentConverterFn = async (raw, ctx) => { const rawParsed = safeParse(raw); if (rawParsed == null || typeof rawParsed !== 'object') { - return { content: '[rich text message]', resources: [] }; + return { content: placeholder, resources: [] }; } + // The attachment zone is a sibling of the locale documents, not part of one, + // so it must be read before the locale guard below — otherwise a post whose + // locale document is unparseable would silently drop its attachments too. + const attachments = topLevelAttachments(rawParsed as Record); + const body = unwrapLocale(rawParsed as Record); - if (!body) return { content: '[rich text message]', resources: [] }; + if (!body && attachments.length === 0) { + return { content: placeholder, resources: [] }; + } // Choose source paragraphs: prefer content_v2, fallback to content. const sourceParagraphs = - body.content_v2 && body.content_v2.length > 0 ? body.content_v2 : (body.content ?? []); + body?.content_v2 && body.content_v2.length > 0 ? body.content_v2 : (body?.content ?? []); const resources: ResourceDescriptor[] = []; const lines: string[] = []; - if (body.title) { + if (body?.title) { lines.push(`**${body.title}**`); lines.push(''); } @@ -41,10 +62,51 @@ export const convertPost: ContentConverterFn = async (raw, ctx) => { lines.push(line); } - const content = lines.join('\n').trim() || '[rich text message]'; + // Attachment zone: files render as and are downloadable; folders + // render as tags only, mirroring the standalone converters. + for (const att of attachments) { + // Both key and name are escaped: downstream parses these tags as structured + // info, so a quote inside a key must not be able to forge an extra attribute. + const tag = att.isFolder ? 'folder' : 'file'; + const nameAttr = att.fileName ? ` name="${escapeAttr(att.fileName)}"` : ''; + lines.push(`<${tag} key="${escapeAttr(att.fileKey)}"${nameAttr}/>`); + if (!att.isFolder) { + resources.push({ type: 'file', fileKey: att.fileKey, fileName: att.fileName }); + } + } + + const content = lines.join('\n').trim() || placeholder; return { content, resources }; }; +/** + * Extract and normalize the top-level attachment-zone entries of a post message. + * + * Wire values are untrusted, so every field is narrowed here rather than at the + * point of use: a non-string `file_name` would otherwise reach `escapeAttr` and + * throw, which `dispatchConvert` traps by falling back to the unknown-message + * converter — silently replacing the whole message with a placeholder. Likewise + * `is_folder` is compared against `true` rather than tested for truthiness, so + * that a string `"false"` cannot hide a real, downloadable file behind a + * `` tag. Entries without a usable key are dropped. + */ +function topLevelAttachments(parsed: Record): PostAttachment[] { + const files = parsed.files; + if (!Array.isArray(files)) return []; + const out: PostAttachment[] = []; + for (const f of files) { + if (f == null || typeof f !== 'object') continue; + const rec = f as Record; + if (typeof rec.file_key !== 'string' || !rec.file_key) continue; + out.push({ + fileKey: rec.file_key, + fileName: typeof rec.file_name === 'string' ? rec.file_name : undefined, + isFolder: rec.is_folder === true, + }); + } + return out; +} + /** * Post-process raw markdown text from an "md" element. * Splits by fenced code block delimiters (```) and only applies