forked from EduCraft/curriculum-project-hub
313e890b6c
群消息事件 → 创建 AgentRun → 获锁 → 调 runAgent → 释放锁 + 状态回写: - feishu/client.ts: lark WSClient 长连接接收 im.message.receive_v1; sendText 经 client.im.v1 发送(SDK 运行时动态 API,类型弱化务实收口) - feishu/trigger.ts: @bot 识别(chat 绑定解析 ADR-0001)→ 创建 run+ session(provider-bound ADR-0017)→ acquireLock(ADR-0002,竞态回退 busy)→ runAgent(fire-and-forget)→ finally releaseLock;非项目群/无 @bot/非文本 全部忽略 - lock.ts: ADR-0002 WellFormed 落代码——currentLockRunId 读锁时校验 持锁 run 非终止,终止 run 持锁即 release-path bug(抛而非静默) - models.ts: resolve 提到 ModelRegistry 接口(之前漏在实现上) - server.ts: Fastify + lark ws 启动,/api/healthz prompt 提取 smoke 通过(@bot+prompt→prompt;纯@bot→null;无mention→null; 非文本→null)。修了 mention 正则 bug(/@_\w+_/ 的回溯错误)。tsc rc=0。
103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
/**
|
|
* Feishu lark client + long-connection event dispatcher.
|
|
*
|
|
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The
|
|
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw
|
|
* event to our handler. This version of the SDK does not auto-normalize on the
|
|
* ws path — `normalize()` is a separate helper — so we work with the raw event
|
|
* shape directly (it carries chat_id, message_type, content, mentions).
|
|
*
|
|
* ADR-0001: the bot operates inside a project's bound group. It does not own
|
|
* the lock (ADR-0002) and is not the session owner.
|
|
*/
|
|
import * as lark from "@larksuiteoapi/node-sdk";
|
|
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 (subset we use). */
|
|
export interface MessageReceiveEvent {
|
|
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;
|
|
};
|
|
}
|
|
|
|
/** Send a text message to a chat. */
|
|
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
|
|
// SDK exposes im.v1 dynamically at runtime; the generated types are weak
|
|
// there, so cast through the known shape.
|
|
const client = rt.client as unknown as {
|
|
im: { v1: { message: { create: (p: unknown) => Promise<unknown> } } };
|
|
};
|
|
await client.im.v1.message.create({
|
|
params: { receive_id_type: "chat_id" },
|
|
data: {
|
|
receive_id: chatId,
|
|
msg_type: "text",
|
|
content: JSON.stringify({ text }),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start the lark WebSocket client and route `im.message.receive_v1` events to
|
|
* `onMessage`. The ws connection lives in the background; this resolves the
|
|
* runtime handle.
|
|
*/
|
|
export function startFeishuListener(
|
|
config: FeishuConfig,
|
|
logger: FastifyBaseLogger,
|
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
|
): FeishuRuntime {
|
|
const client = new lark.Client({
|
|
appId: config.appId,
|
|
appSecret: config.appSecret,
|
|
appType: lark.AppType.SelfBuild,
|
|
});
|
|
|
|
const wsClient = new lark.WSClient({
|
|
appId: config.appId,
|
|
appSecret: config.appSecret,
|
|
domain: lark.Domain.Feishu,
|
|
loggerLevel: lark.LoggerLevel.info,
|
|
});
|
|
|
|
const rt: FeishuRuntime = { client, logger };
|
|
|
|
void wsClient.start({
|
|
eventDispatcher: new lark.EventDispatcher({}).register({
|
|
"im.message.receive_v1": async (data) => {
|
|
try {
|
|
await onMessage(data as MessageReceiveEvent, rt);
|
|
} catch (e) {
|
|
logger.error({ err: e }, "feishu message handler threw");
|
|
}
|
|
},
|
|
}),
|
|
});
|
|
|
|
return rt;
|
|
}
|