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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
137 changes: 137 additions & 0 deletions src/normalize/__tests__/converters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<file key="file_a" name="report.pdf"/>');
expect(r.content).toContain('<file key="file_b"/>');
expect(r.content).toContain('<folder key="dir_1" name="assets"/>');
// 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('<file key="file_a"')).toBeGreaterThan(r.content.indexOf('正文'));
});

test('attachment zone adds to, and does not replace, body resources', async () => {
// 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('<file key="file_a" name="report.pdf"/>');
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('<file key="file_a"/>');
expect(r.content).toContain('<file key="file_b"/>');
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('<folder');
expect(r.resources.filter((x) => 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('<file key="a&quot;b" name="c&quot;d.pdf"/>');
// 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('<file');
expect(r.resources).toEqual([]);
});

test('attachment zone escapes key and handles non-string name', async () => {
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('<file key="file_a&quot; onmouseover=&quot;x" name="r.pdf"/>');
// non-string file_name degrades to no name attribute, no throw
expect(r.content).toContain('<file key="file_b"/>');
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 });
});
});
74 changes: 68 additions & 6 deletions src/normalize/converters/post.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,54 @@
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;
content?: PostElement[][];
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(\s+)user_id(\s*)=(\s*)"(.*?)">(.*?)<\/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<string, unknown>);

const body = unwrapLocale<PostBody>(rawParsed as Record<string, unknown>);
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('');
}
Expand All @@ -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 <file .../> and are downloadable; folders
// render as <folder .../> 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
* `<folder/>` tag. Entries without a usable key are dropped.
*/
function topLevelAttachments(parsed: Record<string, unknown>): 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<string, unknown>;
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
Expand Down