Files
curriculum-project-hub/hub/src/feishu/read.ts
T

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,
};
}