forked from bai/curriculum-project-hub
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user