Files
curriculum-project-hub/hub/src/feishu/read.ts
T
2026-07-20 22:45:42 +08:00

130 lines
4.8 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. `im.v1.message.list` with
* `container_id_type="thread"` and `container_id` = the thread_id (NOT a
* message_id, which Feishu rejects with 230001). The caller supplies the
* thread_id via `args.id`; the trigger context exposes it as `thread_id`.
*
* 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 topic. Feishu's `im.v1.message.list` scopes
// thread replies when `container_id_type="thread"` and `container_id`
// is the thread_id (NOT a message_id — that is rejected with 230001
// "invalid container_id_type"). The caller supplies the thread_id via
// `args.id`; the trigger context exposes it as `thread_id`.
const res = await api.list({
params: {
container_id_type: "thread",
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,
};
}