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
This commit is contained in:
2026-07-08 17:05:51 +08:00
parent bef10149a9
commit 294d51dbfe
3 changed files with 359 additions and 3 deletions
+174
View File
@@ -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<string | null> {
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<string, unknown> | null {
try {
const parsed = JSON.parse(content) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function postRoot(parsed: Record<string, unknown>): Record<string, unknown> {
const post = parsed["post"];
return isRecord(post) ? post : parsed;
}
function selectPostLocale(root: Record<string, unknown>): Record<string, unknown> | 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<string, unknown> {
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, unknown>): string {
const text = stringField(element, "text") ?? "";
const href = stringField(element, "href") ?? "";
if (href === "") return text;
return `[${text}](${href})`;
}
function renderMention(element: Record<string, unknown>): 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, unknown>): string {
const fileName = stringField(element, "file_name") ?? stringField(element, "name");
return fileName === undefined || fileName === "" ? "[Attachment]" : `[Attachment: ${fileName}]`;
}
function renderCodeBlock(element: Record<string, unknown>): string {
const language = stringField(element, "language") ?? "";
const text = stringField(element, "text") ?? "";
return `\`\`\`${language}\n${text}\n\`\`\``;
}
function renderEmoji(element: Record<string, unknown>): 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, unknown>): 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<string, unknown>, ...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<string, unknown>, field: string): void {
const value = stringField(record, field);
if (value !== undefined && value !== "") target.push(value);
}
function stringField(record: Record<string, unknown>, field: string): string | undefined {
const value = record[field];
return typeof value === "string" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function containsMarkdownSyntax(content: string): boolean {
const patterns = [
/^ {0,3}#{1,6}\s+\S/m,
+81 -3
View File
@@ -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<MessageReceiveEvent["message"]["mentions"]>,
): 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<void> {
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 `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped.
+104
View File
@@ -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);
}