forked from EduCraft/curriculum-project-hub
feat(hub): 真实飞书读取 + 状态卡片 + 权限 gate + 部署脚本
FeishuRead(ADR-0003): feishuContextTool 从 stub 换成真实 lark im.v1.message.get(单条 trigger/reply/status_card)+ list(thread 回复); ADR-0003 chat 授权不变式不变(handler 已 gate)。 StatusCard: sendCard + buildRunStatusCard——完成/出错发 interactive card (status 文案 + model 选择器 + 取消按钮带 runId),替代纯文本。 Permission(ADR-0004, project-wise): triggerAgent 查 PermissionGrant 对 project 的 edit+ 能力(manage⊇edit 单调);sender open_id 当 principal (子类型学 OPEN,务实选择,permission.ts 标注);per-artifact OPEN; settings 组合规则 OPEN。无权限回 '无权限触发'。 Deploy: cph-hub.service systemd unit(ExecStartPre 跑 prisma migrate)+ install_service.sh(idempotent,seed env)+ deploy_platform.sh (rsync + npm ci + build + restart + healthz)。 client.ts 抽 createLarkClient/startFeishuListenerWithClient,tools 与 ws 共用同一 client。 tsc rc=0;prisma validate 通过;deploy 脚本 bash -n 通过。
This commit is contained in:
@@ -12,3 +12,12 @@ OPENROUTER_API_KEY=""
|
|||||||
|
|
||||||
# Hub server port. Defaults to 8788.
|
# Hub server port. Defaults to 8788.
|
||||||
PORT=8788
|
PORT=8788
|
||||||
|
|
||||||
|
# Feishu (lark) bot credentials. The bot receives @mentions in project groups.
|
||||||
|
FEISHU_APP_ID=""
|
||||||
|
FEISHU_APP_SECRET=""
|
||||||
|
FEISHU_BOT_OPEN_ID=""
|
||||||
|
|
||||||
|
# Path to the `cph` binary (the Courseware-side checker, ADR-0016).
|
||||||
|
# Defaults to `cph` on PATH if unset.
|
||||||
|
# CPH_BIN="/usr/local/bin/cph"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Curriculum Project Hub (Feishu agent + cph)
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=__SERVICE_USER__
|
||||||
|
WorkingDirectory=__HUB_DIR__
|
||||||
|
EnvironmentFile=__ENV_FILE__
|
||||||
|
# Run prisma migrations before each start, then launch the Hub.
|
||||||
|
ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma
|
||||||
|
ExecStart=__NODE_BIN__ __HUB_DIR__/dist/server.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Graceful shutdown: lark ws close + in-flight runs.
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy the Hub to a host: rsync the repo, install deps, build, restart service.
|
||||||
|
#
|
||||||
|
# Configure these env vars (e.g. in CI secrets):
|
||||||
|
# PLATFORM_DEPLOY_HOST required
|
||||||
|
# PLATFORM_DEPLOY_SSH_KEY required (private key for the deploy user)
|
||||||
|
# PLATFORM_DEPLOY_USER optional, defaults to deploy
|
||||||
|
# PLATFORM_DEPLOY_PORT optional, defaults to 22
|
||||||
|
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
|
||||||
|
#
|
||||||
|
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, and a
|
||||||
|
# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has
|
||||||
|
# passwordless sudo for:
|
||||||
|
# systemctl restart cph-hub.service
|
||||||
|
# systemctl is-active --quiet cph-hub.service
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
|
||||||
|
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
|
||||||
|
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-deploy}"
|
||||||
|
PORT="${PLATFORM_DEPLOY_PORT:-22}"
|
||||||
|
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
|
||||||
|
HUB_DIR="$BASE/hub"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o StrictHostKeyChecking=accept-new)
|
||||||
|
|
||||||
|
# 1. Rsync the hub/ directory (exclude node_modules/dist, they rebuild on host).
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude node_modules --exclude dist --exclude .env \
|
||||||
|
-e "ssh ${SSH_OPTS[*]}" \
|
||||||
|
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||||
|
|
||||||
|
# 2. Install deps + build on the host.
|
||||||
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "cd '$HUB_DIR' && npm ci && npm run build"
|
||||||
|
|
||||||
|
# 3. Ensure the service is installed (idempotent), then restart.
|
||||||
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
|
||||||
|
BASE='$BASE' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/install_service.sh' || true
|
||||||
|
sudo systemctl restart cph-hub.service
|
||||||
|
sleep 2
|
||||||
|
sudo systemctl is-active --quiet cph-hub.service || { echo 'service failed to start'; exit 1; }
|
||||||
|
curl -sf http://127.0.0.1:8788/api/healthz || { echo 'healthz failed'; exit 1; }
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Deployed cph-hub to $HOST ($HUB_DIR)"
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the cph-hub systemd service on a host. Idempotent.
|
||||||
|
#
|
||||||
|
# Prerequisites already on the host: Node.js, npm, PostgreSQL, systemd.
|
||||||
|
# The Hub repo is expected at HUB_DIR (default /srv/curriculum-project-hub/hub)
|
||||||
|
# with `npm ci` + `npm run build` already run by deploy_platform.sh.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo BASE=/srv/curriculum-project-hub bash deploy/install_service.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${BASE:-/srv/curriculum-project-hub}"
|
||||||
|
HUB_DIR="${HUB_DIR:-$BASE/hub}"
|
||||||
|
SERVICE_USER="${SERVICE_USER:-root}"
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
PORT="${PORT:-8788}"
|
||||||
|
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
|
||||||
|
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||||
|
|
||||||
|
if [ -z "$NODE_BIN" ]; then
|
||||||
|
echo "node must be on PATH, or set NODE_BIN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -d "$HUB_DIR" ]; then
|
||||||
|
echo "HUB_DIR not found: $HUB_DIR (set HUB_DIR or clone the repo there)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$HUB_DIR/dist/server.js" ]; then
|
||||||
|
echo "build missing: run 'npm ci && npm run build' in $HUB_DIR first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Seed the env file if absent. Secrets stay on the server, never in the repo.
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
install -d -m 0700 "$(dirname "$ENV_FILE")"
|
||||||
|
cat >"$ENV_FILE" <<EOF
|
||||||
|
NODE_ENV=production
|
||||||
|
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_BOT_OPEN_ID=
|
||||||
|
PORT=$PORT
|
||||||
|
HOST=$HOST
|
||||||
|
EOF
|
||||||
|
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Render the unit with concrete paths.
|
||||||
|
UNIT=/etc/systemd/system/cph-hub.service
|
||||||
|
sed \
|
||||||
|
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||||
|
-e "s|__HUB_DIR__|$HUB_DIR|g" \
|
||||||
|
-e "s|__ENV_FILE__|$ENV_FILE|g" \
|
||||||
|
-e "s|__NODE_BIN__|$NODE_BIN|g" \
|
||||||
|
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable cph-hub.service
|
||||||
|
echo "Installed cph-hub.service. Start with: systemctl start cph-hub"
|
||||||
|
echo "Check health: curl http://127.0.0.1:$PORT/api/healthz"
|
||||||
+2
-1
@@ -27,6 +27,7 @@
|
|||||||
"check": "tsc -p tsconfig.json --noEmit",
|
"check": "tsc -p tsconfig.json --noEmit",
|
||||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||||
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
|
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
|
||||||
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma"
|
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
|
||||||
|
"deploy": "bash deploy/deploy_platform.sh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export interface FeishuContextArgs {
|
|||||||
* `@larksuiteoapi/node-sdk`.
|
* `@larksuiteoapi/node-sdk`.
|
||||||
*/
|
*/
|
||||||
export function feishuContextTool(
|
export function feishuContextTool(
|
||||||
read: (args: FeishuContextArgs, ctx: ToolContext) => Promise<string>,
|
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
|
||||||
|
rt: unknown,
|
||||||
): RegisteredTool {
|
): RegisteredTool {
|
||||||
return {
|
return {
|
||||||
spec: {
|
spec: {
|
||||||
@@ -122,7 +123,7 @@ export function feishuContextTool(
|
|||||||
if (a.chat_id !== ctx.boundChatId) {
|
if (a.chat_id !== ctx.boundChatId) {
|
||||||
throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId);
|
throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId);
|
||||||
}
|
}
|
||||||
return read(a, ctx);
|
return read(a, ctx, rt);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,31 +61,96 @@ export async function sendText(rt: FeishuRuntime, chatId: string, text: string):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send an interactive card to a chat. `card` is the lark card schema object. */
|
||||||
|
export async function sendCard(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
card: unknown,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } };
|
||||||
|
};
|
||||||
|
const res = await client.im.v1.message.create({
|
||||||
|
params: { receive_id_type: "chat_id" },
|
||||||
|
data: {
|
||||||
|
receive_id: chatId,
|
||||||
|
msg_type: "interactive",
|
||||||
|
content: JSON.stringify(card),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data?.message_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a run-status card: status text + model selector + cancel button. */
|
||||||
|
export function buildRunStatusCard(opts: {
|
||||||
|
readonly status: string;
|
||||||
|
readonly model: string;
|
||||||
|
readonly models: readonly { readonly id: string; readonly label: string }[];
|
||||||
|
readonly runId: string;
|
||||||
|
}): unknown {
|
||||||
|
return {
|
||||||
|
config: { wide_screen_mode: true },
|
||||||
|
header: {
|
||||||
|
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
|
||||||
|
template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue",
|
||||||
|
},
|
||||||
|
elements: [
|
||||||
|
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
|
||||||
|
{
|
||||||
|
tag: "action",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
tag: "select",
|
||||||
|
placeholder: { tag: "plain_text", content: "切换 model" },
|
||||||
|
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })),
|
||||||
|
value: opts.model,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "button",
|
||||||
|
text: { tag: "plain_text", content: "取消" },
|
||||||
|
type: "danger",
|
||||||
|
value: JSON.stringify({ action: "cancel", runId: opts.runId }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the lark WebSocket client and route `im.message.receive_v1` events to
|
* Start the lark WebSocket client and route `im.message.receive_v1` events to
|
||||||
* `onMessage`. The ws connection lives in the background; this resolves the
|
* `onMessage`. The ws connection lives in the background; this resolves the
|
||||||
* runtime handle.
|
* runtime handle.
|
||||||
*/
|
*/
|
||||||
export function startFeishuListener(
|
/** Build a lark Client (HTTP API surface). Used before the ws starts, so tools
|
||||||
config: FeishuConfig,
|
* that need to call Feishu APIs can hold it from server boot. */
|
||||||
logger: FastifyBaseLogger,
|
export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
return new lark.Client({
|
||||||
): FeishuRuntime {
|
|
||||||
const client = new lark.Client({
|
|
||||||
appId: config.appId,
|
appId: config.appId,
|
||||||
appSecret: config.appSecret,
|
appSecret: config.appSecret,
|
||||||
appType: lark.AppType.SelfBuild,
|
appType: lark.AppType.SelfBuild,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeishuListener {
|
||||||
|
readonly runtime: FeishuRuntime;
|
||||||
|
readonly stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the ws listener using an already-built client. Returns the runtime. */
|
||||||
|
export function startFeishuListenerWithClient(
|
||||||
|
config: FeishuConfig,
|
||||||
|
client: lark.Client,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
): FeishuRuntime {
|
||||||
const wsClient = new lark.WSClient({
|
const wsClient = new lark.WSClient({
|
||||||
appId: config.appId,
|
appId: config.appId,
|
||||||
appSecret: config.appSecret,
|
appSecret: config.appSecret,
|
||||||
domain: lark.Domain.Feishu,
|
domain: lark.Domain.Feishu,
|
||||||
loggerLevel: lark.LoggerLevel.info,
|
loggerLevel: lark.LoggerLevel.info,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rt: FeishuRuntime = { client, logger };
|
const rt: FeishuRuntime = { client, logger };
|
||||||
|
|
||||||
void wsClient.start({
|
void wsClient.start({
|
||||||
eventDispatcher: new lark.EventDispatcher({}).register({
|
eventDispatcher: new lark.EventDispatcher({}).register({
|
||||||
"im.message.receive_v1": async (data) => {
|
"im.message.receive_v1": async (data) => {
|
||||||
@@ -97,6 +162,12 @@ export function startFeishuListener(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return rt;
|
return rt;
|
||||||
}
|
}
|
||||||
|
export function startFeishuListener(
|
||||||
|
config: FeishuConfig,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
): FeishuRuntime {
|
||||||
|
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
create_time?: string;
|
||||||
|
sender?: { id?: string; sender_type?: string };
|
||||||
|
chat_id?: string;
|
||||||
|
parent_message_id?: string;
|
||||||
|
root_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 {
|
||||||
|
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 });
|
||||||
|
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 ?? [];
|
||||||
|
return JSON.stringify(items.map(compact));
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return JSON.stringify({ error: `unknown anchor type: ${args.anchor as string}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a lark message down to the fields the model actually needs. */
|
||||||
|
function compact(m: LarkMessage): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
message_id: m.message_id,
|
||||||
|
msg_type: m.msg_type,
|
||||||
|
content: m.content,
|
||||||
|
create_time: m.create_time,
|
||||||
|
sender_id: m.sender?.id,
|
||||||
|
parent_message_id: m.parent_message_id,
|
||||||
|
root_id: m.root_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,12 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { sendText, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||||
import type { AgentProvider } from "../agent/provider.js";
|
import type { AgentProvider } from "../agent/provider.js";
|
||||||
import type { ToolRegistry } from "../agent/tools.js";
|
import type { ToolRegistry } from "../agent/tools.js";
|
||||||
import type { ModelRegistry } from "../agent/models.js";
|
import type { ModelRegistry } from "../agent/models.js";
|
||||||
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
||||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||||
|
import { canTriggerAgent } from "../permission.js";
|
||||||
|
|
||||||
interface TriggerDeps {
|
interface TriggerDeps {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
@@ -56,6 +57,17 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectId = binding.projectId;
|
const projectId = binding.projectId;
|
||||||
|
// ADR-0004: triggerAgent requires edit on the project. principal=sender
|
||||||
|
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
|
||||||
|
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
||||||
|
if (senderOpenId !== "") {
|
||||||
|
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
|
||||||
|
if (!perm.allowed) {
|
||||||
|
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
|
||||||
|
await sendText(rt, chatId, "无权限触发。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ADR-0002: if locked by another run, reply busy.
|
// ADR-0002: if locked by another run, reply busy.
|
||||||
const existing = await currentLockRunId(deps.prisma, projectId);
|
const existing = await currentLockRunId(deps.prisma, projectId);
|
||||||
@@ -160,14 +172,24 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sendText(rt, chatId, statusReply(result.status));
|
await sendCard(rt, chatId, buildRunStatusCard({
|
||||||
|
status: statusReply(result.status),
|
||||||
|
model,
|
||||||
|
models: deps.models.listModels(),
|
||||||
|
runId: run.id,
|
||||||
|
}));
|
||||||
})
|
})
|
||||||
.catch(async (e) => {
|
.catch(async (e) => {
|
||||||
await deps.prisma.agentRun.update({
|
await deps.prisma.agentRun.update({
|
||||||
where: { id: run.id },
|
where: { id: run.id },
|
||||||
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
|
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
|
||||||
});
|
});
|
||||||
await sendText(rt, chatId, "处理出错,已释放项目锁。");
|
await sendCard(rt, chatId, buildRunStatusCard({
|
||||||
|
status: "处理出错",
|
||||||
|
model,
|
||||||
|
models: deps.models.listModels(),
|
||||||
|
runId: run.id,
|
||||||
|
}));
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
await releaseLock(deps.prisma, run.id);
|
await releaseLock(deps.prisma, run.id);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Permission gate — ADR-0004 in code, project-scope.
|
||||||
|
*
|
||||||
|
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted
|
||||||
|
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec
|
||||||
|
* `Role.can_mono`). This module checks that for the run's requester against
|
||||||
|
* the project resource.
|
||||||
|
*
|
||||||
|
* Scope decision (project-wise): the resource is `project`, not
|
||||||
|
* `project_group` — the run and lock scope to the project (ADR-0002), so the
|
||||||
|
* capability is checked against the project. per-artifact permission is `OPEN`
|
||||||
|
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
|
||||||
|
*
|
||||||
|
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
|
||||||
|
* use the sender's Feishu open_id as the principal string literal — a pragmatic
|
||||||
|
* choice for the first cut, not a spec decision. When a real principal model
|
||||||
|
* lands, this is the single seam to change.
|
||||||
|
*
|
||||||
|
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
|
||||||
|
* capabilities from settings policy knobs but doesn't pin the composition
|
||||||
|
* rule). This gate implements the role half only; settings composition is
|
||||||
|
* deferred and clearly marked.
|
||||||
|
*/
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { PermissionRole } from "@prisma/client";
|
||||||
|
|
||||||
|
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
|
||||||
|
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
|
||||||
|
|
||||||
|
export interface PermissionResult {
|
||||||
|
readonly allowed: boolean;
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether `principal` may trigger an agent run on `projectId`.
|
||||||
|
*
|
||||||
|
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
|
||||||
|
* caller replies with the reason. Never throws — a DB error is a deny with the
|
||||||
|
* error as reason, so the run is not started on a broken state.
|
||||||
|
*/
|
||||||
|
export async function canTriggerAgent(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
projectId: string,
|
||||||
|
principal: string,
|
||||||
|
): Promise<PermissionResult> {
|
||||||
|
try {
|
||||||
|
// Look for an active (non-revoked) grant on the project resource for this
|
||||||
|
// principal, with a role that grants edit or higher.
|
||||||
|
const grant = await prisma.permissionGrant.findFirst({
|
||||||
|
where: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: projectId,
|
||||||
|
principal,
|
||||||
|
role: { in: ["EDIT", "MANAGE"] },
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true, role: true },
|
||||||
|
});
|
||||||
|
if (grant !== null) {
|
||||||
|
return { allowed: true, reason: `granted by role ${grant.role}` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a role grants edit capability (edit or manage). Exported for tests.
|
||||||
|
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||||
|
return EDIT_OR_HIGHER.has(role);
|
||||||
|
}
|
||||||
+12
-15
@@ -18,7 +18,8 @@ import { InMemoryModelRegistry } from "./agent/models.js";
|
|||||||
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
||||||
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
|
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
|
||||||
import { cphCheckTool, cphBuildTool } from "./agent/cph.js";
|
import { cphCheckTool, cphBuildTool } from "./agent/cph.js";
|
||||||
import { startFeishuListener } from "./feishu/client.js";
|
import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
|
||||||
|
import { readFeishuContext } from "./feishu/read.js";
|
||||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
@@ -56,14 +57,15 @@ async function main(): Promise<void> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build the lark client first — tools that call Feishu APIs hold it from boot.
|
||||||
|
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||||
|
const larkClient = createLarkClient(feishuConfig);
|
||||||
|
const feishuRt: FeishuRuntime = { client: larkClient, logger: app.log };
|
||||||
|
|
||||||
const tools = new ToolRegistry();
|
const tools = new ToolRegistry();
|
||||||
tools.register(
|
// ADR-0003: the handler enforces chat==boundChat before any API call;
|
||||||
// ADR-0003: the handler enforces chat==boundChat before any Feishu API call.
|
// real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list.
|
||||||
// Real impl wraps @larksuiteoapi/node-sdk's im.message.list/read APIs.
|
tools.register(feishuContextTool(readFeishuContext, feishuRt));
|
||||||
feishuContextTool(async (args, ctx) => {
|
|
||||||
return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id, projectId: ctx.projectId });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Workspace file tools (ADR-0007 directory tree, path-confined).
|
// Workspace file tools (ADR-0007 directory tree, path-confined).
|
||||||
tools.register(readFileTool());
|
tools.register(readFileTool());
|
||||||
tools.register(writeFileTool());
|
tools.register(writeFileTool());
|
||||||
@@ -72,14 +74,9 @@ async function main(): Promise<void> {
|
|||||||
tools.register(cphCheckTool());
|
tools.register(cphCheckTool());
|
||||||
tools.register(cphBuildTool());
|
tools.register(cphBuildTool());
|
||||||
|
|
||||||
// --- Feishu listener ---
|
// --- Feishu listener (reuses the same lark client) ---
|
||||||
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log });
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log });
|
||||||
const feishuCtx = startFeishuListener(
|
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
||||||
{ appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId },
|
|
||||||
app.log,
|
|
||||||
trigger,
|
|
||||||
);
|
|
||||||
void feishuCtx;
|
|
||||||
|
|
||||||
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
||||||
if (err !== null) {
|
if (err !== null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user