forked from bai/curriculum-project-hub
294d51dbfe
- 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
710 lines
24 KiB
TypeScript
710 lines
24 KiB
TypeScript
/**
|
|
* Feishu lark client + long-connection event dispatcher.
|
|
*
|
|
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
|
|
* message sending, streaming patch via interactive cards, emoji
|
|
* reactions, file download/upload, and the card action callback.
|
|
*/
|
|
import * as lark from "@larksuiteoapi/node-sdk";
|
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import type { FastifyBaseLogger } from "fastify";
|
|
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
|
|
|
export interface FeishuConfig {
|
|
readonly appId: string;
|
|
readonly appSecret: string;
|
|
readonly botOpenId: string;
|
|
}
|
|
|
|
export interface FeishuRuntime {
|
|
readonly client: lark.Client;
|
|
readonly logger: FastifyBaseLogger;
|
|
}
|
|
|
|
export interface OutboundPayload {
|
|
readonly msgType: string;
|
|
readonly content: string;
|
|
}
|
|
|
|
/** Raw `im.message.receive_v1` event payload. */
|
|
export interface MessageReceiveEvent {
|
|
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
|
|
readonly event_id?: string;
|
|
readonly event_type?: string;
|
|
readonly header?: { readonly event_id?: string; readonly event_type?: string };
|
|
readonly message: {
|
|
readonly message_id: string;
|
|
readonly root_id?: string;
|
|
readonly parent_id?: string;
|
|
readonly thread_id?: string;
|
|
readonly create_time?: string;
|
|
readonly update_time?: string;
|
|
readonly chat_id: string;
|
|
readonly chat_type: string;
|
|
readonly message_type: string;
|
|
readonly content: string;
|
|
readonly mentions?: ReadonlyArray<{
|
|
readonly key: string;
|
|
readonly id: { readonly open_id?: string };
|
|
readonly name: string;
|
|
}>;
|
|
};
|
|
readonly sender: {
|
|
readonly sender_id: { readonly open_id?: string };
|
|
readonly sender_type: string;
|
|
};
|
|
}
|
|
|
|
/** Card action trigger event (button clicks, select changes). */
|
|
export interface CardActionEvent {
|
|
readonly operator: { readonly open_id?: string };
|
|
readonly action: {
|
|
readonly value?: unknown;
|
|
readonly tag?: string;
|
|
readonly option?: string;
|
|
};
|
|
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
|
|
readonly token?: string;
|
|
}
|
|
|
|
// --- Message helpers ---
|
|
|
|
export function buildOutboundPayload(content: string): OutboundPayload {
|
|
if (containsMarkdownTable(content)) {
|
|
return { msgType: "text", content: JSON.stringify({ text: content }) };
|
|
}
|
|
if (containsMarkdownSyntax(content)) {
|
|
return {
|
|
msgType: "post",
|
|
content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }),
|
|
};
|
|
}
|
|
return { msgType: "text", content: JSON.stringify({ text: content }) };
|
|
}
|
|
|
|
export function _buildMarkdownPostRows(content: string): unknown[][] {
|
|
if (!content.includes("```")) {
|
|
return [[{ tag: "md", text: content }]];
|
|
}
|
|
|
|
const rows: unknown[][] = [];
|
|
const codeBlockPattern = /```[\s\S]*?```/g;
|
|
let cursor = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = codeBlockPattern.exec(content)) !== null) {
|
|
if (match.index > cursor) {
|
|
rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]);
|
|
}
|
|
rows.push([{ tag: "md", text: match[0] }]);
|
|
cursor = match.index + match[0].length;
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
return [[{ tag: "md", text: content }]];
|
|
}
|
|
if (cursor < content.length) {
|
|
rows.push([{ tag: "md", text: content.slice(cursor) }]);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export function _stripMarkdownToPlainText(text: string): string {
|
|
return text
|
|
.replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1")
|
|
.replace(/`([^`\n]+)`/g, "$1")
|
|
.replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1")
|
|
.replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1")
|
|
.replace(/^ {0,3}#{1,6}\s+/gm, "")
|
|
.replace(/^ {0,3}>\s?/gm, "")
|
|
.replace(/^(\s*)[-*+]\s+/gm, "$1")
|
|
.replace(/^(\s*)\d+[.)]\s+/gm, "$1")
|
|
.replace(/\*\*([^*\n]+)\*\*/g, "$1")
|
|
.replace(/__([^_\n]+)__/g, "$1")
|
|
.replace(/~~([^~\n]+)~~/g, "$1")
|
|
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2")
|
|
.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);
|
|
const client = rt.client as unknown as {
|
|
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
|
|
};
|
|
try {
|
|
const res = await client.im.v1.message.create({
|
|
params: { receive_id_type: "chat_id" },
|
|
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
|
|
});
|
|
return messageIdFromResponse(res);
|
|
} catch (e) {
|
|
if (payload.msgType === "post" && isPostContentFormatError(e)) {
|
|
try {
|
|
const res = await client.im.v1.message.create({
|
|
params: { receive_id_type: "chat_id" },
|
|
data: {
|
|
receive_id: chatId,
|
|
msg_type: "text",
|
|
content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }),
|
|
},
|
|
});
|
|
return messageIdFromResponse(res);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Send long text as multiple Feishu messages. Returns the last successful message_id. */
|
|
export async function sendLongText(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
|
|
let lastMessageId: string | null = null;
|
|
for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) {
|
|
const messageId = await sendTextMessage(rt, chatId, chunk);
|
|
if (messageId !== null) {
|
|
lastMessageId = messageId;
|
|
}
|
|
}
|
|
return lastMessageId;
|
|
}
|
|
|
|
/** Send a plain text message (fire-and-forget). */
|
|
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
|
|
await sendTextMessage(rt, chatId, text);
|
|
}
|
|
|
|
/** Patch a text-as-card message in-place (streaming updates). */
|
|
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
|
|
const payload = buildOutboundPayload(text);
|
|
const card = buildInteractiveCardFromOutboundPayload(payload);
|
|
const client = rt.client as unknown as {
|
|
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
|
};
|
|
try {
|
|
await client.im.v1.message.patch({
|
|
path: { message_id: messageId },
|
|
params: { msg_type: "interactive" },
|
|
data: { content: JSON.stringify(card) },
|
|
});
|
|
} catch (e) {
|
|
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchTextMessage failed");
|
|
}
|
|
}
|
|
|
|
/** Send an approval card with buttons. Returns message_id on success. */
|
|
export async function sendApprovalCard(
|
|
rt: FeishuRuntime,
|
|
chatId: string,
|
|
title: string,
|
|
body: string,
|
|
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
|
|
): Promise<string | null> {
|
|
const card = {
|
|
config: { wide_screen_mode: true },
|
|
header: { title: { content: title, tag: "plain_text" }, template: "orange" },
|
|
elements: [
|
|
{ tag: "markdown", content: body },
|
|
{
|
|
tag: "action",
|
|
actions: buttons.map((button) => ({
|
|
tag: "button",
|
|
text: { tag: "plain_text", content: button.label },
|
|
type: button.style ?? "default",
|
|
value: { approval_action: button.value },
|
|
})),
|
|
},
|
|
],
|
|
};
|
|
const client = rt.client as unknown as {
|
|
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
|
|
};
|
|
try {
|
|
const res = await client.im.v1.message.create({
|
|
params: { receive_id_type: "chat_id" },
|
|
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
|
|
});
|
|
return messageIdFromResponse(res);
|
|
} catch (e) {
|
|
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Update a card to show it has been resolved. */
|
|
export async function resolveCard(
|
|
rt: FeishuRuntime,
|
|
messageId: string,
|
|
icon: string,
|
|
label: string,
|
|
template: "green" | "red",
|
|
resolvedBy: string,
|
|
): Promise<void> {
|
|
const card = {
|
|
config: { wide_screen_mode: true },
|
|
header: { title: { content: `${icon} ${label}`, tag: "plain_text" }, template },
|
|
elements: [{ tag: "markdown", content: `${icon} **${label}** by ${resolvedBy}` }],
|
|
};
|
|
const client = rt.client as unknown as {
|
|
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
|
|
};
|
|
try {
|
|
await client.im.v1.message.patch({
|
|
path: { message_id: messageId },
|
|
params: { msg_type: "interactive" },
|
|
data: { content: JSON.stringify(card) },
|
|
});
|
|
} catch (e) {
|
|
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "resolveCard failed");
|
|
}
|
|
}
|
|
|
|
/** React to a message with an emoji. */
|
|
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
|
|
try {
|
|
await rt.client.request({
|
|
method: "POST",
|
|
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
|
|
data: { reaction_type: { emoji_type: emoji } },
|
|
});
|
|
} catch (e) {
|
|
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "reactToMessage failed");
|
|
}
|
|
}
|
|
|
|
/** Add a reaction to a message, returning the reaction_id (needed for later removal). */
|
|
export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise<string | null> {
|
|
try {
|
|
const res = await rt.client.request({
|
|
method: "POST",
|
|
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
|
|
data: { reaction_type: { emoji_type: emojiType } },
|
|
}) as ReactionCreateResponse;
|
|
const reactionId = reactionIdFromResponse(res);
|
|
if (reactionId === null) {
|
|
rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id");
|
|
}
|
|
return reactionId;
|
|
} catch (e) {
|
|
rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Remove a previously added reaction by its reaction_id. */
|
|
export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise<boolean> {
|
|
try {
|
|
await rt.client.request({
|
|
method: "DELETE",
|
|
url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`,
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- File helpers ---
|
|
|
|
/** Download a file/image from a Feishu message to a local path. */
|
|
export async function downloadMessageFile(
|
|
rt: FeishuRuntime,
|
|
messageId: string,
|
|
fileKey: string,
|
|
savePath: string,
|
|
): Promise<void> {
|
|
await mkdir(dirname(savePath), { recursive: true });
|
|
try {
|
|
const res = await rt.client.request({
|
|
method: "GET",
|
|
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
|
|
params: { type: "file" },
|
|
responseType: "arraybuffer",
|
|
}) as { data: Buffer };
|
|
await writeFile(savePath, res.data);
|
|
} catch (e) {
|
|
rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed");
|
|
}
|
|
}
|
|
|
|
/** Upload and send a file to a chat. */
|
|
export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: string, fileName: string): Promise<string | null> {
|
|
const client = rt.client as unknown as {
|
|
im: { v1: {
|
|
file: { create: (p: unknown) => Promise<FileCreateResponse> }
|
|
message: { create: (p: unknown) => Promise<MessageCreateResponse> }
|
|
} };
|
|
};
|
|
try {
|
|
const buf = await readFile(filePath);
|
|
const ext = fileName.split(".").pop() ?? "";
|
|
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
|
|
const uploadRes = await client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } });
|
|
const fileKey = fileKeyFromResponse(uploadRes);
|
|
if (fileKey === undefined) {
|
|
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
|
|
return null;
|
|
}
|
|
const msgRes = await client.im.v1.message.create({
|
|
params: { receive_id_type: "chat_id" },
|
|
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
|
|
});
|
|
const messageId = messageIdFromResponse(msgRes);
|
|
if (messageId === null) {
|
|
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
|
|
}
|
|
return messageId;
|
|
} catch (e) {
|
|
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
type MessageCreateResponse = {
|
|
readonly message_id?: string | undefined;
|
|
readonly data?: { readonly message_id?: string | undefined } | undefined;
|
|
} | null;
|
|
|
|
type FileCreateResponse = {
|
|
readonly file_key?: string | undefined;
|
|
readonly data?: { readonly file_key?: string | undefined } | undefined;
|
|
} | null;
|
|
|
|
type ReactionCreateResponse = {
|
|
readonly reaction_id?: string | undefined;
|
|
readonly data?: { readonly reaction_id?: string | undefined } | undefined;
|
|
} | null;
|
|
|
|
function messageIdFromResponse(res: MessageCreateResponse): string | null {
|
|
return res?.data?.message_id ?? res?.message_id ?? null;
|
|
}
|
|
|
|
function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
|
|
return res?.file_key ?? res?.data?.file_key;
|
|
}
|
|
|
|
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,
|
|
/^ {0,3}>\s+\S/m,
|
|
/^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m,
|
|
/```/,
|
|
/`[^`\n]+`/,
|
|
/\*\*[^*\n]+\*\*/,
|
|
/__[^_\n]+__/,
|
|
/~~[^~\n]+~~/,
|
|
/(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/,
|
|
/(^|[^_])_[^_\s][^_\n]*_(?!_)/,
|
|
/\[[^\]\n]+\]\([^)]+\)/,
|
|
];
|
|
return patterns.some((pattern) => pattern.test(content));
|
|
}
|
|
|
|
function containsMarkdownTable(content: string): boolean {
|
|
const lines = content.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length - 1; i += 1) {
|
|
const current = lines[i];
|
|
const next = lines[i + 1];
|
|
if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isTableHeaderLine(line: string): boolean {
|
|
const trimmed = line.trim();
|
|
return trimmed.startsWith("|") && trimmed.includes("|", 1);
|
|
}
|
|
|
|
function isTableSeparatorLine(line: string): boolean {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("|")) return false;
|
|
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|");
|
|
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
|
}
|
|
|
|
function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } {
|
|
return {
|
|
elements: rowsFromOutboundPayload(payload).map((row) => ({
|
|
tag: "div",
|
|
text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") },
|
|
})),
|
|
};
|
|
}
|
|
|
|
function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] {
|
|
if (payload.msgType !== "post") {
|
|
return [[{ tag: "md", text: textFromTextPayload(payload.content) }]];
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } };
|
|
return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]];
|
|
} catch {
|
|
return [[{ tag: "md", text: "" }]];
|
|
}
|
|
}
|
|
|
|
function textFromTextPayload(content: string): string {
|
|
try {
|
|
const parsed = JSON.parse(content) as { text?: unknown };
|
|
return typeof parsed.text === "string" ? parsed.text : content;
|
|
} catch {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
function markdownTextFromPostElement(element: unknown): string {
|
|
if (typeof element !== "object" || element === null) return "";
|
|
const text = (element as { text?: unknown }).text;
|
|
return typeof text === "string" ? text : "";
|
|
}
|
|
|
|
function isPostContentFormatError(error: unknown): boolean {
|
|
return errorText(error).includes("content format of the post type is incorrect");
|
|
}
|
|
|
|
function errorText(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return `${error.message} ${errorText((error as { response?: unknown }).response)}`;
|
|
}
|
|
if (typeof error === "string") return error;
|
|
if (typeof error === "object" && error !== null) {
|
|
try {
|
|
return JSON.stringify(error);
|
|
} catch {
|
|
return String(error);
|
|
}
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
// --- Lark client + ws listener ---
|
|
|
|
export function createLarkClient(config: FeishuConfig): lark.Client {
|
|
return new lark.Client({
|
|
appId: config.appId,
|
|
appSecret: config.appSecret,
|
|
appType: lark.AppType.SelfBuild,
|
|
});
|
|
}
|
|
|
|
export function startFeishuListenerWithClient(
|
|
config: FeishuConfig,
|
|
client: lark.Client,
|
|
logger: FastifyBaseLogger,
|
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
|
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
|
): FeishuRuntime {
|
|
const wsClient = new lark.WSClient({
|
|
appId: config.appId,
|
|
appSecret: config.appSecret,
|
|
domain: lark.Domain.Feishu,
|
|
loggerLevel: lark.LoggerLevel.info,
|
|
});
|
|
const rt: FeishuRuntime = { client, logger };
|
|
const handlers: Record<string, (data: unknown) => Promise<void>> = {
|
|
"im.message.receive_v1": async (data) => {
|
|
try { await onMessage(data as MessageReceiveEvent, rt); }
|
|
catch (e) { logger.error({ err: e }, "feishu message handler threw"); }
|
|
},
|
|
};
|
|
if (onCardAction !== undefined) {
|
|
handlers["card.action.trigger"] = async (data) => {
|
|
try { await onCardAction(data as CardActionEvent, rt); }
|
|
catch (e) { logger.error({ err: e }, "feishu card action handler threw"); }
|
|
};
|
|
}
|
|
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
|
return rt;
|
|
}
|
|
|
|
export function startFeishuListener(
|
|
config: FeishuConfig,
|
|
logger: FastifyBaseLogger,
|
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
|
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
|
): FeishuRuntime {
|
|
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
|
|
}
|