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