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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user