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
+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.