forked from bai/curriculum-project-hub
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
/**
|
|
* Feishu context reader — the real implementation behind `feishuContextTool`.
|
|
*
|
|
* ADR-0003: Hub stores only anchors, reads Feishu context on demand. The tool
|
|
* (`feishu_read_context`) is already chat-authorized by `tools.ts` (chat must
|
|
* match the project binding). Here we actually call lark's `im.v1.message`:
|
|
*
|
|
* - `trigger_message` / `reply`: `message.get` by message_id.
|
|
* - `status_card`: the run's status card message — same `message.get` by id.
|
|
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
|
|
* `parent_message_id` filter; we map "thread" to that.
|
|
*
|
|
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
|
|
* we cast through a known request/response shape and return a compact JSON for
|
|
* the model. All calls use the chat-authorized `client` from the runtime — no
|
|
* separate credential path, so the ADR-0003 invariant holds end-to-end.
|
|
*/
|
|
import type { FeishuRuntime } from "./client.js";
|
|
import type { FeishuContextArgs } from "../agent/tools.js";
|
|
import type { ToolContext } from "../agent/tools.js";
|
|
|
|
/** Pragmatic shape of the lark message object we consume. */
|
|
interface LarkMessage {
|
|
message_id?: string;
|
|
msg_type?: string;
|
|
content?: string;
|
|
body?: { content?: string };
|
|
create_time?: string;
|
|
sender?: { id?: string; sender_type?: string };
|
|
chat_id?: string;
|
|
parent_id?: string;
|
|
parent_message_id?: string;
|
|
root_id?: string;
|
|
thread_id?: string;
|
|
}
|
|
|
|
interface ImV1Message {
|
|
get: (p: { path: { message_id: string } }) => Promise<{ data?: { items?: LarkMessage[]; message?: LarkMessage } }>;
|
|
list: (p: {
|
|
params: { container_id_type: string; container_id: string; page_size?: number };
|
|
}) => Promise<{ data?: { items?: LarkMessage[] } }>;
|
|
}
|
|
|
|
function im(rt: FeishuRuntime): ImV1Message {
|
|
return (rt.client as unknown as { im: { v1: { message: ImV1Message } } }).im.v1.message;
|
|
}
|
|
|
|
/** Read on-demand Feishu context for the current run. Entry point for the tool. */
|
|
export async function readFeishuContext(
|
|
args: FeishuContextArgs,
|
|
ctx: ToolContext,
|
|
rt: unknown,
|
|
): Promise<string> {
|
|
const runtime = rt as FeishuRuntime;
|
|
const api = im(runtime);
|
|
try {
|
|
if (args.chat_id !== ctx.boundChatId) {
|
|
throw new Error("refused Feishu context read outside the current project's bound chat");
|
|
}
|
|
switch (args.anchor) {
|
|
case "trigger_message":
|
|
case "status_card":
|
|
case "reply": {
|
|
// All three are single-message reads by id.
|
|
const res = await api.get({ path: { message_id: args.id } });
|
|
const msg = res.data?.message ?? res.data?.items?.[0];
|
|
if (msg === undefined) return JSON.stringify({ error: "message not found", id: args.id });
|
|
assertBoundChat(msg, ctx.boundChatId);
|
|
return JSON.stringify(compact(msg));
|
|
}
|
|
case "thread": {
|
|
// Thread = replies to a parent message. `container_id` is the parent's
|
|
// message_id; container_id_type=message_id scopes the list to that thread.
|
|
const res = await api.list({
|
|
params: {
|
|
container_id_type: "message_id",
|
|
container_id: args.id,
|
|
page_size: 50,
|
|
},
|
|
});
|
|
const items = res.data?.items ?? [];
|
|
for (const item of items) assertBoundChat(item, ctx.boundChatId);
|
|
return JSON.stringify(items.map(compact));
|
|
}
|
|
default:
|
|
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
|
|
}
|
|
} catch (e) {
|
|
runtime.logger.warn(
|
|
{
|
|
runId: ctx.runId,
|
|
projectId: ctx.projectId,
|
|
anchor: args.anchor,
|
|
anchorId: args.id,
|
|
err: e instanceof Error ? e.message : String(e),
|
|
},
|
|
"Feishu context read refused or failed",
|
|
);
|
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}
|
|
|
|
function assertBoundChat(message: LarkMessage, boundChatId: string): void {
|
|
if (message.chat_id !== boundChatId) {
|
|
throw new Error("refused Feishu context result outside the current project's bound chat");
|
|
}
|
|
}
|
|
|
|
/** Project a lark message down to the fields the model actually needs. */
|
|
function compact(m: LarkMessage): Record<string, unknown> {
|
|
const parentId = m.parent_id ?? m.parent_message_id;
|
|
return {
|
|
message_id: m.message_id,
|
|
msg_type: m.msg_type,
|
|
content: m.body?.content ?? m.content,
|
|
create_time: m.create_time,
|
|
chat_id: m.chat_id,
|
|
sender_id: m.sender?.id,
|
|
parent_id: parentId,
|
|
parent_message_id: parentId,
|
|
root_id: m.root_id,
|
|
thread_id: m.thread_id,
|
|
};
|
|
}
|