forked from EduCraft/curriculum-project-hub
231 lines
8.2 KiB
TypeScript
231 lines
8.2 KiB
TypeScript
/**
|
|
* Feishu lark client + long-connection event dispatcher.
|
|
*
|
|
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
|
|
* message sending (text-as-card for patchability), streaming patch, 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";
|
|
|
|
export interface FeishuConfig {
|
|
readonly appId: string;
|
|
readonly appSecret: string;
|
|
readonly botOpenId: string;
|
|
}
|
|
|
|
export interface FeishuRuntime {
|
|
readonly client: lark.Client;
|
|
readonly logger: FastifyBaseLogger;
|
|
}
|
|
|
|
/** Raw `im.message.receive_v1` event payload. */
|
|
export interface MessageReceiveEvent {
|
|
readonly header?: { readonly event_id?: string; readonly event_type?: string };
|
|
readonly message: {
|
|
readonly message_id: 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 ---
|
|
|
|
/** Send a text-as-card message (looks like text, but patchable). */
|
|
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
|
|
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: 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: "interactive", content: JSON.stringify(card) },
|
|
});
|
|
return messageIdFromResponse(res);
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** 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 card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
|
|
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");
|
|
}
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
}
|
|
|
|
// --- 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;
|
|
|
|
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;
|
|
}
|
|
|
|
// --- 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);
|
|
}
|