From 294d51dbfeaa3b5b50093c39d9671606e8c4f1d5 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 8 Jul 2026 17:05:51 +0800 Subject: [PATCH] feat: parse inbound post (rich text) messages - parsePostMessage: full Feishu post payload parser with locale fallback, supports text/a/at/img/media/file/code_block/br/hr/emotion elements - Trigger accepts post messages, extracts text + downloads attachments - Post messages with attachments bypass batching, text-only posts batch - Add unit tests (10) for all element types and edge cases --- hub/src/feishu/client.ts | 174 ++++++++++++++++++++++++ hub/src/feishu/trigger.ts | 84 +++++++++++- hub/test/unit/feishu-post-parse.test.ts | 104 ++++++++++++++ 3 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 hub/test/unit/feishu-post-parse.test.ts diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 974cac0..0c0e880 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -126,6 +126,29 @@ export function _stripMarkdownToPlainText(text: string): string { .replace(/(^|[^_])_([^_\n]+)_/g, "$1$2"); } +export function parsePostMessage(content: string): { text: string; imageKeys: string[]; fileKeys: string[] } { + const parsed = parseJsonObject(content); + const root = parsed === null ? null : postRoot(parsed); + const locale = root === null ? null : selectPostLocale(root); + if (locale === null) { + return { text: "", imageKeys: [], fileKeys: [] }; + } + + const imageKeys: string[] = []; + const fileKeys: string[] = []; + const rows = Array.isArray(locale["content"]) ? locale["content"] : []; + const body = rows + .map((row) => { + const elements = Array.isArray(row) ? row : [row]; + return elements.map((element) => renderPostElement(element, imageKeys, fileKeys)).join(""); + }) + .join("\n"); + + const title = stringField(locale, "title"); + const text = title === undefined || title === "" ? body : body === "" ? `# ${title}` : `# ${title}\n\n${body}`; + return { text, imageKeys, fileKeys }; +} + /** Send a text message using the best Feishu message type for the content. */ export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise { const payload = buildOutboundPayload(text); @@ -389,6 +412,157 @@ function reactionIdFromResponse(res: ReactionCreateResponse): string | null { return res?.data?.reaction_id ?? res?.reaction_id ?? null; } +function parseJsonObject(content: string): Record | null { + try { + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function postRoot(parsed: Record): Record { + const post = parsed["post"]; + return isRecord(post) ? post : parsed; +} + +function selectPostLocale(root: Record): Record | null { + if (Array.isArray(root["content"])) return root; + + for (const locale of ["zh_cn", "en_us"]) { + const candidate = root[locale]; + if (isPostLocale(candidate)) return candidate; + } + + for (const candidate of Object.values(root)) { + if (isPostLocale(candidate)) return candidate; + } + return null; +} + +function isPostLocale(value: unknown): value is Record { + return isRecord(value) && (Array.isArray(value["content"]) || typeof value["title"] === "string"); +} + +function renderPostElement(element: unknown, imageKeys: string[], fileKeys: string[]): string { + if (!isRecord(element)) return ""; + + switch (stringField(element, "tag")) { + case "text": + return applyTextStyles(stringField(element, "text") ?? "", element); + case "a": + return renderLink(element); + case "at": + return renderMention(element); + case "img": + case "image": + pushStringField(imageKeys, element, "image_key"); + return "[Image]"; + case "media": + case "file": + case "audio": + case "video": + pushStringField(fileKeys, element, "file_key"); + return renderAttachmentPlaceholder(element); + case "code": + case "code_block": + return renderCodeBlock(element); + case "br": + return "\n"; + case "hr": + case "divider": + return "\n\n---\n\n"; + case "emotion": + case "emoji": + return renderEmoji(element); + case "md": + return stringField(element, "text") ?? ""; + default: + return stringField(element, "text") ?? ""; + } +} + +function renderLink(element: Record): string { + const text = stringField(element, "text") ?? ""; + const href = stringField(element, "href") ?? ""; + if (href === "") return text; + return `[${text}](${href})`; +} + +function renderMention(element: Record): string { + if (stringField(element, "user_id") === "@_all") return "@all"; + const name = + stringField(element, "user_name") ?? + stringField(element, "name") ?? + stringField(element, "text") ?? + stringField(element, "user_id") ?? + ""; + return name === "" ? "" : `@${name.replace(/^@/, "")}`; +} + +function renderAttachmentPlaceholder(element: Record): string { + const fileName = stringField(element, "file_name") ?? stringField(element, "name"); + return fileName === undefined || fileName === "" ? "[Attachment]" : `[Attachment: ${fileName}]`; +} + +function renderCodeBlock(element: Record): string { + const language = stringField(element, "language") ?? ""; + const text = stringField(element, "text") ?? ""; + return `\`\`\`${language}\n${text}\n\`\`\``; +} + +function renderEmoji(element: Record): string { + const emojiType = + stringField(element, "emoji_type") ?? + stringField(element, "emotion_key") ?? + stringField(element, "name") ?? + stringField(element, "text"); + return emojiType === undefined || emojiType === "" ? "" : `:${emojiType}:`; +} + +function applyTextStyles(text: string, element: Record): string { + let rendered = text; + if (hasPostStyle(element, "bold")) rendered = `**${rendered}**`; + if (hasPostStyle(element, "italic")) rendered = `*${rendered}*`; + if (hasPostStyle(element, "strikethrough", "strike", "line_through", "lineThrough")) rendered = `~~${rendered}~~`; + if (hasPostStyle(element, "code", "inline_code", "inlineCode")) rendered = `\`${rendered}\``; + return rendered; +} + +function hasPostStyle(element: Record, ...names: string[]): boolean { + const style = element["style"] ?? element["styles"]; + if (styleMatches(style, names)) return true; + return names.some((name) => element[name] === true); +} + +function styleMatches(style: unknown, names: readonly string[]): boolean { + if (Array.isArray(style)) { + return style.some((entry) => typeof entry === "string" && names.includes(entry)); + } + if (typeof style === "string") { + const tokens = style.split(/[\s,]+/).filter((token) => token !== ""); + return tokens.some((token) => names.includes(token)); + } + if (isRecord(style)) { + return names.some((name) => style[name] === true || style[name] === "true"); + } + return false; +} + +function pushStringField(target: string[], record: Record, field: string): void { + const value = stringField(record, field); + if (value !== undefined && value !== "") target.push(value); +} + +function stringField(record: Record, field: string): string | undefined { + const value = record[field]; + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function containsMarkdownSyntax(content: string): boolean { const patterns = [ /^ {0,3}#{1,6}\s+\S/m, diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index d95b9dd..dc14c0f 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -11,7 +11,20 @@ import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; import type { FastifyBaseLogger } from "fastify"; -import { sendText, sendTextMessage, patchTextMessage, reactToMessage, addReaction, removeReaction, downloadMessageFile, resolveCard, type CardActionEvent, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; +import { + sendText, + sendTextMessage, + patchTextMessage, + reactToMessage, + addReaction, + removeReaction, + downloadMessageFile, + parsePostMessage, + resolveCard, + type CardActionEvent, + type FeishuRuntime, + type MessageReceiveEvent, +} from "./client.js"; import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js"; import type { RuntimeSettings } from "../settings/runtime.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; @@ -361,8 +374,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { }); } - // Only react to text, file, or image messages in group chats. - if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image") return; + // Only react to supported inbound message types in group chats. + if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return; const chatId = msg.chat_id; if (chatId === "") return; @@ -408,6 +421,24 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { let cleanPrompt: string | null = null; if (msg.message_type === "text") { cleanPrompt = extractPrompt(msg); + } else if (msg.message_type === "post") { + const parsed = parsePostMessage(msg.content); + cleanPrompt = extractPostPrompt(msg, parsed.text); + if (cleanPrompt === null) return; + if (parsed.imageKeys.length > 0 || parsed.fileKeys.length > 0) { + const project = await deps.prisma.project.findUnique({ + where: { id: projectId }, + select: { workspaceDir: true }, + }); + if (project === null) return; + await downloadPostMessageFiles({ + rt, + msg, + workspaceDir: project.workspaceDir, + imageKeys: parsed.imageKeys, + fileKeys: parsed.fileKeys, + }); + } } else if (msg.message_type === "file" || msg.message_type === "image") { // Download the file/image to the workspace, build a prompt around it. const project = await deps.prisma.project.findUnique({ @@ -717,6 +748,53 @@ export function extractPrompt(msg: MessageReceiveEvent["message"]): string | nul return null; } } + +function extractPostPrompt(msg: MessageReceiveEvent["message"], parsedText: string): string | null { + const mentions = msg.mentions; + if (mentions === undefined || mentions.length === 0) return null; + + const stripped = stripParsedPostMentions(parsedText, mentions).trim(); + return stripped === "" ? null : stripped; +} + +function stripParsedPostMentions( + text: string, + mentions: NonNullable, +): string { + let stripped = text; + for (const mention of mentions) { + stripped = removeMentionMarker(stripped, mention.key); + stripped = removeMentionMarker(stripped, `@${mention.name}`); + } + return stripped; +} + +function removeMentionMarker(text: string, marker: string): string { + if (marker === "") return text; + return text.replace(new RegExp(`${escapeRegExp(marker)}\\s*`, "g"), ""); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function downloadPostMessageFiles(args: { + readonly rt: FeishuRuntime; + readonly msg: MessageReceiveEvent["message"]; + readonly workspaceDir: string; + readonly imageKeys: readonly string[]; + readonly fileKeys: readonly string[]; +}): Promise { + const timestamp = Date.now(); + for (const [index, imageKey] of args.imageKeys.entries()) { + const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`; + await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath); + } + for (const [index, fileKey] of args.fileKeys.entries()) { + const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`; + await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath); + } +} /** * Parse a leading `/` command from a prompt (e.g. `/draft 帮我写教案`). * Returns the role id and the remaining prompt with the command stripped. diff --git a/hub/test/unit/feishu-post-parse.test.ts b/hub/test/unit/feishu-post-parse.test.ts new file mode 100644 index 0000000..83f92e0 --- /dev/null +++ b/hub/test/unit/feishu-post-parse.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { parsePostMessage } from "../../src/feishu/client.js"; + +describe("parsePostMessage", () => { + it("parses a simple text post", () => { + const result = parsePostMessage(postContent({ + zh_cn: { content: [[{ tag: "text", text: "Hello" }]] }, + })); + + expect(result).toEqual({ text: "Hello", imageKeys: [], fileKeys: [] }); + }); + + it("prepends a post title as a markdown header", () => { + const result = parsePostMessage(postContent({ + zh_cn: { title: "Lesson Notes", content: [[{ tag: "text", text: "Hello" }]] }, + })); + + expect(result.text).toBe("# Lesson Notes\n\nHello"); + }); + + it("converts links to markdown", () => { + const result = parsePostMessage(postContent({ + zh_cn: { + content: [[ + { tag: "text", text: "Read " }, + { tag: "a", text: "docs", href: "https://example.com" }, + ]], + }, + })); + + expect(result.text).toBe("Read [docs](https://example.com)"); + }); + + it("converts mentions to readable @names", () => { + const result = parsePostMessage(postContent({ + zh_cn: { content: [[{ tag: "at", user_id: "@_user_1", user_name: "John" }]] }, + })); + + expect(result.text).toBe("@John"); + }); + + it("converts code blocks to fenced markdown", () => { + const result = parsePostMessage(postContent({ + zh_cn: { content: [[{ tag: "code_block", language: "ts", text: "const x = 1;" }]] }, + })); + + expect(result.text).toBe("```ts\nconst x = 1;\n```"); + }); + + it("collects image keys and inserts an image placeholder", () => { + const result = parsePostMessage(postContent({ + zh_cn: { content: [[{ tag: "img", image_key: "img_key_here" }]] }, + })); + + expect(result).toEqual({ text: "[Image]", imageKeys: ["img_key_here"], fileKeys: [] }); + }); + + it("collects file keys and inserts an attachment placeholder", () => { + const result = parsePostMessage(postContent({ + zh_cn: { content: [[{ tag: "file", file_key: "file_key_here", file_name: "worksheet.pdf" }]] }, + })); + + expect(result).toEqual({ + text: "[Attachment: worksheet.pdf]", + imageKeys: [], + fileKeys: ["file_key_here"], + }); + }); + + it("falls back to en_us when zh_cn is missing", () => { + const result = parsePostMessage(postContent({ + en_us: { content: [[{ tag: "text", text: "Hello from English" }]] }, + })); + + expect(result.text).toBe("Hello from English"); + }); + + it("unwraps content nested under a post key", () => { + const result = parsePostMessage(postContent({ + post: { + zh_cn: { content: [[{ tag: "text", text: "Wrapped" }]] }, + }, + })); + + expect(result.text).toBe("Wrapped"); + }); + + it("joins multiple rows with newlines", () => { + const result = parsePostMessage(postContent({ + zh_cn: { + content: [ + [{ tag: "text", text: "First row" }], + [{ tag: "text", text: "Second row" }], + ], + }, + })); + + expect(result.text).toBe("First row\nSecond row"); + }); +}); + +function postContent(value: unknown): string { + return JSON.stringify(value); +}