forked from EduCraft/curriculum-project-hub
feat(hub): 飞书 @bot 触发链路 + ProjectAgentLock 不变式(ADR-0001/0002)
群消息事件 → 创建 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。
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user