ylib-syim 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 示例:用 iflow 接口作为 Agent,所有 IM 消息对接该 agent
3
+ *
4
+ * 使用方式:
5
+ * export IFLOW_API_KEY=sk-xxx
6
+ * # 可选:export IFLOW_BASE_URL=http://92.113.117.22:10068/proxy/iflow
7
+ * pnpm run example:iflow-agent
8
+ *
9
+ * 钉钉回发需同时设置 DINGTALK_CLIENT_ID、DINGTALK_CLIENT_SECRET。
10
+ * 测试:curl -X POST http://localhost:3100/inbound -H "Content-Type: application/json" -d '{"provider":"dingtalk","peer":{"kind":"direct","id":"YOUR_STAFF_ID"},"sender":{"id":"YOUR_STAFF_ID","name":"测试"},"messageId":"test-1","timestamp":1700000000000,"text":"你好"}'
11
+ */
12
+
13
+ import http from "node:http";
14
+ import {
15
+ createHubFromConfig,
16
+ createIflowAgent,
17
+ createDingTalkAdapter,
18
+ createInMemorySessionStore,
19
+ type InboundMessage,
20
+ } from "../src/index.js";
21
+
22
+ const PORT = Number(process.env.PORT) || 3100;
23
+
24
+ if (!process.env.IFLOW_API_KEY) {
25
+ console.error("请设置环境变量 IFLOW_API_KEY");
26
+ process.exit(1);
27
+ }
28
+
29
+ const clientId = process.env.DINGTALK_CLIENT_ID;
30
+ const clientSecret = process.env.DINGTALK_CLIENT_SECRET;
31
+
32
+ const adapters: ReturnType<typeof createDingTalkAdapter>[] = [];
33
+ if (clientId && clientSecret) {
34
+ adapters.push(createDingTalkAdapter({ config: { clientId, clientSecret } }));
35
+ } else {
36
+ console.warn("未设置钉钉 clientId/secret,回复仅打印到控制台");
37
+ adapters.push({
38
+ provider: "dingtalk",
39
+ async send({ to, message }) {
40
+ console.log("[Mock] 回复:", message.text?.slice(0, 120));
41
+ },
42
+ });
43
+ }
44
+
45
+ const hub = createHubFromConfig({
46
+ defaultAgentId: "main",
47
+ agents: [
48
+ createIflowAgent({
49
+ id: "main",
50
+ systemPrompt: "你是助手。回复简洁友好。",
51
+ historyLimit: 20,
52
+ }),
53
+ ],
54
+ adapters,
55
+ bindings: [{ agentId: "main", match: { channel: "dingtalk" } }],
56
+ sessions: createInMemorySessionStore(),
57
+ });
58
+
59
+ function parseBody(req: http.IncomingMessage): Promise<string> {
60
+ return new Promise((resolve, reject) => {
61
+ const chunks: Buffer[] = [];
62
+ req.on("data", (chunk) => chunks.push(chunk));
63
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
64
+ req.on("error", reject);
65
+ });
66
+ }
67
+
68
+ const server = http.createServer(async (req, res) => {
69
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
70
+ if (req.method === "GET" && (req.url === "/" || req.url === "/health")) {
71
+ res.writeHead(200);
72
+ res.end(JSON.stringify({ ok: true, agent: "iflow", port: PORT }));
73
+ return;
74
+ }
75
+ if (req.method === "POST" && req.url === "/inbound") {
76
+ let raw: string;
77
+ try {
78
+ raw = await parseBody(req);
79
+ } catch {
80
+ res.writeHead(400);
81
+ res.end(JSON.stringify({ error: "Failed to read body" }));
82
+ return;
83
+ }
84
+ let input: InboundMessage;
85
+ try {
86
+ input = JSON.parse(raw) as InboundMessage;
87
+ } catch {
88
+ res.writeHead(400);
89
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
90
+ return;
91
+ }
92
+ if (!input.provider || !input.peer || input.text === undefined) {
93
+ res.writeHead(400);
94
+ res.end(JSON.stringify({ error: "Missing provider, peer or text" }));
95
+ return;
96
+ }
97
+ try {
98
+ await hub.handleInbound(input);
99
+ res.writeHead(200);
100
+ res.end(JSON.stringify({ ok: true, text: input.text }));
101
+ } catch (err) {
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ console.error("[example-iflow-agent] error:", err);
104
+ res.writeHead(500);
105
+ res.end(JSON.stringify({ error: msg }));
106
+ }
107
+ return;
108
+ }
109
+ res.writeHead(404);
110
+ res.end(JSON.stringify({ error: "Not found" }));
111
+ });
112
+
113
+ server.listen(PORT, () => {
114
+ console.log(`example-iflow-agent: http://localhost:${PORT}`);
115
+ console.log(" IFLOW_API_KEY 已设置,baseUrl 默认: " + (process.env.IFLOW_BASE_URL || "http://92.113.117.22:10068/proxy/iflow"));
116
+ console.log(" POST /inbound 测试,配置钉钉后回复会发到钉钉");
117
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 示例:一个简单 agent 接收 im-agent-hub 中所有 IM 消息(钉钉 / 飞书等均路由到该 agent)
3
+ *
4
+ * 用于验证:createHubFromConfig + bindings 下,所有 channel 消息是否都能对接同一 agent。
5
+ *
6
+ * 使用方式:
7
+ * 1. 仅 HTTP 测试(不连真实钉钉):不设 DINGTALK_*,会提示缺少配置但仍启动 HTTP,可用 mock adapter 或跳过 send。
8
+ * 2. 钉钉回发:设置 DINGTALK_CLIENT_ID、DINGTALK_CLIENT_SECRET 后启动,POST /inbound 的回复会发到钉钉。
9
+ *
10
+ * 启动:
11
+ * cd packages/im-agent-hub
12
+ * DINGTALK_CLIENT_ID=xxx DINGTALK_CLIENT_SECRET=yyy pnpm run example:single-agent
13
+ *
14
+ * 测试(替换 YOUR_STAFF_ID):
15
+ * curl -X POST http://localhost:3100/inbound -H "Content-Type: application/json" -d '{
16
+ * "provider": "dingtalk",
17
+ * "peer": { "kind": "direct", "id": "YOUR_STAFF_ID" },
18
+ * "sender": { "id": "YOUR_STAFF_ID", "name": "测试" },
19
+ * "messageId": "test-1",
20
+ * "timestamp": 1700000000000,
21
+ * "text": "你好"
22
+ * }'
23
+ */
24
+
25
+ import http from "node:http";
26
+ import {
27
+ createHubFromConfig,
28
+ createDingTalkAdapter,
29
+ createInMemorySessionStore,
30
+ createLangChainLikeAgent,
31
+ type InboundMessage,
32
+ } from "../src/index.js";
33
+
34
+ const PORT = Number(process.env.PORT) || 3100;
35
+
36
+ const clientId = process.env.DINGTALK_CLIENT_ID;
37
+ const clientSecret = process.env.DINGTALK_CLIENT_SECRET;
38
+
39
+ // 简单 agent:所有 IM 消息都走这个 agent
40
+ const simpleAgent = createLangChainLikeAgent({
41
+ id: "main",
42
+ systemPrompt: "你是测试助手。所有 IM(钉钉、飞书等)的消息都会发到你这里,请简短回复。",
43
+ historyLimit: 20,
44
+ runnable: async ({ messages }) => {
45
+ const last = messages[messages.length - 1];
46
+ const content = last?.content ?? "";
47
+ return { content: `[测试 agent] 收到:${content}` };
48
+ },
49
+ });
50
+
51
+ const adapters: ReturnType<typeof createDingTalkAdapter>[] = [];
52
+ if (clientId && clientSecret) {
53
+ adapters.push(createDingTalkAdapter({ config: { clientId, clientSecret } }));
54
+ } else {
55
+ console.warn("未设置 DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET,仅能通过 HTTP /inbound 测试,回复不会发到钉钉。");
56
+ // 没有 adapter 时 hub 会报错「未找到 provider」。用占位 adapter:只打日志不真实发送。
57
+ adapters.push({
58
+ provider: "dingtalk",
59
+ async send({ to, message }) {
60
+ console.log("[Mock 钉钉] 若已配置真实 clientId/secret 会发到钉钉,当前仅打印:", to, message.text?.slice(0, 60));
61
+ },
62
+ });
63
+ }
64
+
65
+ // 所有 channel 的消息都路由到 main:用 bindings 显式写 dingtalk/feishu 均走 main
66
+ const hub = createHubFromConfig({
67
+ defaultAgentId: "main",
68
+ agents: [simpleAgent],
69
+ adapters,
70
+ bindings: [
71
+ { agentId: "main", match: { channel: "dingtalk" } },
72
+ { agentId: "main", match: { channel: "feishu" } },
73
+ ],
74
+ sessions: createInMemorySessionStore(),
75
+ });
76
+
77
+ function parseBody(req: http.IncomingMessage): Promise<string> {
78
+ return new Promise((resolve, reject) => {
79
+ const chunks: Buffer[] = [];
80
+ req.on("data", (chunk) => chunks.push(chunk));
81
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
82
+ req.on("error", reject);
83
+ });
84
+ }
85
+
86
+ const server = http.createServer(async (req, res) => {
87
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
88
+
89
+ if (req.method === "GET" && (req.url === "/" || req.url === "/health")) {
90
+ res.writeHead(200);
91
+ res.end(JSON.stringify({ ok: true, example: "single-agent", port: PORT }));
92
+ return;
93
+ }
94
+
95
+ if (req.method === "POST" && req.url === "/inbound") {
96
+ let raw: string;
97
+ try {
98
+ raw = await parseBody(req);
99
+ } catch {
100
+ res.writeHead(400);
101
+ res.end(JSON.stringify({ error: "Failed to read body" }));
102
+ return;
103
+ }
104
+ let input: InboundMessage;
105
+ try {
106
+ input = JSON.parse(raw) as InboundMessage;
107
+ } catch {
108
+ res.writeHead(400);
109
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
110
+ return;
111
+ }
112
+ if (!input.provider || !input.peer || input.text === undefined) {
113
+ res.writeHead(400);
114
+ res.end(
115
+ JSON.stringify({
116
+ error: "Missing required fields: provider, peer, text",
117
+ example: {
118
+ provider: "dingtalk",
119
+ peer: { kind: "direct", id: "YOUR_STAFF_ID" },
120
+ sender: { id: "YOUR_STAFF_ID", name: "测试" },
121
+ messageId: "test-1",
122
+ timestamp: Date.now(),
123
+ text: "你好",
124
+ },
125
+ }),
126
+ );
127
+ return;
128
+ }
129
+ try {
130
+ await hub.handleInbound(input);
131
+ res.writeHead(200);
132
+ res.end(JSON.stringify({ ok: true, text: input.text, agentId: "main" }));
133
+ } catch (err) {
134
+ const msg = err instanceof Error ? err.message : String(err);
135
+ console.error("[example-single-agent] handleInbound error:", err);
136
+ res.writeHead(500);
137
+ res.end(JSON.stringify({ error: msg }));
138
+ }
139
+ return;
140
+ }
141
+
142
+ res.writeHead(404);
143
+ res.end(JSON.stringify({ error: "Not found" }));
144
+ });
145
+
146
+ server.listen(PORT, () => {
147
+ console.log(`example-single-agent: http://localhost:${PORT}`);
148
+ console.log(" GET /health -> 健康检查");
149
+ console.log(" POST /inbound -> 发送 InboundMessage,所有消息由同一 agent 处理");
150
+ console.log(" 设置 DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 后,钉钉消息会真实回发");
151
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 飞书 → yuce-gpt 桥接(瘦进程,对齐 dingtalk-stdio-bridge「远端大脑」思路)
3
+ *
4
+ * - 收飞书 HTTP 事件:POST /webhook/feishu(URL 校验 + im.message.receive_v1 文本)
5
+ * - 调 yuce:POST /v1/chat/completions,body.user 为飞书专用稳定键(与钉钉 dingtalk: 前缀分离、互不撞车)+ ext.feishu(与 yuce connector_channels 一致)
6
+ * - 回消息:直接调飞书 **open-apis/im/v1/messages**(与 OpenClaw 官方插件底层同一 HTTP,不依赖 npm 包 @openclaw/feishu——该包在独立安装时会缺 openclaw/plugin-sdk)
7
+ *
8
+ * 与完整 OpenClaw(Gateway + models.providers → yuce)相比:本桥不实现 bot.ts 级策略/话题/卡片;仅适合「HTTP 订阅 + 文本闭环」。
9
+ *
10
+ * 环境变量:
11
+ * PORT=3210
12
+ * FEISHU_APP_ID / FEISHU_APP_SECRET
13
+ * YUCE_CHAT_COMPLETIONS_URL 默认 http://127.0.0.1:4999/v1/chat/completions
14
+ * OPENCLAW_GATEWAY_API_KEY 或 GATEWAY_TOKEN
15
+ */
16
+
17
+ import http from "node:http";
18
+ import type { InboundMessage } from "../src/types.js";
19
+ import { parseFeishuWebhookBodyWithVerification } from "../src/providers/feishu.js";
20
+
21
+ const PORT = Number(process.env.PORT) || 3210;
22
+ const APP_ID = (process.env.FEISHU_APP_ID || "").trim();
23
+ const APP_SECRET = (process.env.FEISHU_APP_SECRET || "").trim();
24
+ const YUCE_URL = (
25
+ process.env.YUCE_CHAT_COMPLETIONS_URL ||
26
+ process.env.FEISHU_YUCE_URL ||
27
+ "http://127.0.0.1:4999/v1/chat/completions"
28
+ ).trim();
29
+ const GATEWAY_TOKEN = (
30
+ process.env.OPENCLAW_GATEWAY_API_KEY ||
31
+ process.env.GATEWAY_TOKEN ||
32
+ process.env.OPENCLAW_GATEWAY_TOKEN ||
33
+ ""
34
+ ).trim();
35
+
36
+ const TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
37
+ const MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages";
38
+
39
+ let cachedToken = "";
40
+ let cachedExpire = 0;
41
+
42
+ async function getTenantToken(): Promise<string> {
43
+ const now = Date.now() / 1000;
44
+ if (cachedToken && cachedExpire > now + 60) return cachedToken;
45
+ const res = await fetch(TOKEN_URL, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
49
+ });
50
+ const data = (await res.json()) as { code?: number; tenant_access_token?: string; expire?: number };
51
+ if (data.code !== 0 || !data.tenant_access_token) {
52
+ throw new Error(`feishu token: ${JSON.stringify(data)}`);
53
+ }
54
+ cachedToken = data.tenant_access_token;
55
+ cachedExpire = now + (data.expire ?? 7200);
56
+ return cachedToken;
57
+ }
58
+
59
+ /** 与 yuce channel_feishu_views 一致:receive_id_type=chat_id */
60
+ async function sendFeishuChatText(chatId: string, text: string): Promise<void> {
61
+ const token = await getTenantToken();
62
+ const url = `${MSG_URL}?receive_id_type=chat_id`;
63
+ const res = await fetch(url, {
64
+ method: "POST",
65
+ headers: {
66
+ Authorization: `Bearer ${token}`,
67
+ "Content-Type": "application/json; charset=utf-8",
68
+ },
69
+ body: JSON.stringify({
70
+ receive_id: chatId,
71
+ msg_type: "text",
72
+ content: JSON.stringify({ text }),
73
+ }),
74
+ });
75
+ const data = (await res.json()) as { code?: number; msg?: string };
76
+ if (data.code !== 0) {
77
+ throw new Error(`feishu send: ${JSON.stringify(data)}`);
78
+ }
79
+ }
80
+
81
+ function isFeishuAppSender(raw: string): boolean {
82
+ try {
83
+ const body = JSON.parse(raw) as { event?: { sender?: { sender_type?: string } } };
84
+ return (body.event?.sender?.sender_type || "").toLowerCase() === "app";
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function readBody(req: http.IncomingMessage): Promise<string> {
91
+ return new Promise((resolve, reject) => {
92
+ const chunks: Buffer[] = [];
93
+ req.on("data", (c) => chunks.push(c));
94
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
95
+ req.on("error", reject);
96
+ });
97
+ }
98
+
99
+ function buildYuceBody(inbound: InboundMessage): Record<string, unknown> {
100
+ const { peer, sender, text, messageId } = inbound;
101
+ /** 飞书专用会话键(与钉钉 dingtalk: 分离):应用 + 会话维度 + 发送者,多应用部署时不串号 */
102
+ const appScope = APP_ID || "unknown_app";
103
+ const user = `feishu:${appScope}:${peer.kind}:${peer.id}:${sender.id}`;
104
+ const extFeishu = {
105
+ appId: appScope,
106
+ chatId: peer.id,
107
+ chatType: peer.kind === "direct" ? "p2p" : "group",
108
+ senderOpenId: sender.id,
109
+ messageId: messageId || undefined,
110
+ peerKind: peer.kind,
111
+ peerId: peer.id,
112
+ };
113
+ return {
114
+ model: "main",
115
+ stream: false,
116
+ user,
117
+ messages: [{ role: "user", content: text }],
118
+ ext: {
119
+ channel: "feishu",
120
+ feishu: extFeishu,
121
+ },
122
+ };
123
+ }
124
+
125
+ async function postYuce(body: Record<string, unknown>): Promise<string> {
126
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
127
+ if (GATEWAY_TOKEN) headers.Authorization = `Bearer ${GATEWAY_TOKEN}`;
128
+ const res = await fetch(YUCE_URL, {
129
+ method: "POST",
130
+ headers,
131
+ body: JSON.stringify(body),
132
+ });
133
+ const data = (await res.json()) as {
134
+ choices?: Array<{ message?: { content?: string } }>;
135
+ error?: { message?: string };
136
+ };
137
+ if (!res.ok) {
138
+ throw new Error(data.error?.message || `yuce HTTP ${res.status}`);
139
+ }
140
+ const content = data.choices?.[0]?.message?.content;
141
+ return typeof content === "string" ? content : "";
142
+ }
143
+
144
+ const server = http.createServer(async (req, res) => {
145
+ const path = req.url?.split("?")[0] ?? "";
146
+
147
+ if (req.method === "GET" && (path === "/health" || path === "/")) {
148
+ res.writeHead(200, { "Content-Type": "application/json" });
149
+ res.end(JSON.stringify({ ok: true, service: "feishu-yuce-bridge" }));
150
+ return;
151
+ }
152
+
153
+ if (req.method !== "POST" || path !== "/webhook/feishu") {
154
+ res.writeHead(404);
155
+ res.end();
156
+ return;
157
+ }
158
+
159
+ if (!APP_ID || !APP_SECRET) {
160
+ res.writeHead(500, { "Content-Type": "application/json" });
161
+ res.end(JSON.stringify({ error: "FEISHU_APP_ID / FEISHU_APP_SECRET required" }));
162
+ return;
163
+ }
164
+
165
+ let raw: string;
166
+ try {
167
+ raw = await readBody(req);
168
+ } catch {
169
+ res.writeHead(400);
170
+ res.end();
171
+ return;
172
+ }
173
+
174
+ const parsed = parseFeishuWebhookBodyWithVerification(raw);
175
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
176
+
177
+ if (parsed && typeof parsed === "object" && "challenge" in parsed) {
178
+ res.writeHead(200);
179
+ res.end(JSON.stringify({ challenge: (parsed as { challenge: string }).challenge }));
180
+ return;
181
+ }
182
+
183
+ if (!parsed) {
184
+ res.writeHead(200);
185
+ res.end(JSON.stringify({ msg: "ok" }));
186
+ return;
187
+ }
188
+
189
+ if (isFeishuAppSender(raw)) {
190
+ res.writeHead(200);
191
+ res.end(JSON.stringify({ msg: "ok", skip: "app_sender" }));
192
+ return;
193
+ }
194
+
195
+ const inbound = parsed as InboundMessage;
196
+ if (!(inbound.text || "").trim()) {
197
+ res.writeHead(200);
198
+ res.end(JSON.stringify({ msg: "ok", skip: "empty_text" }));
199
+ return;
200
+ }
201
+
202
+ if (!GATEWAY_TOKEN) {
203
+ res.writeHead(500);
204
+ res.end(JSON.stringify({ error: "OPENCLAW_GATEWAY_API_KEY or GATEWAY_TOKEN required for yuce" }));
205
+ return;
206
+ }
207
+
208
+ try {
209
+ const yuceBody = buildYuceBody(inbound);
210
+ const reply = await postYuce(yuceBody);
211
+ const out = (reply || "").trim() || "(暂无回复)";
212
+ await sendFeishuChatText(inbound.peer.id, out);
213
+ res.writeHead(200);
214
+ res.end(JSON.stringify({ msg: "ok" }));
215
+ } catch (e) {
216
+ console.error("[feishu-yuce-bridge]", e);
217
+ res.writeHead(200);
218
+ res.end(JSON.stringify({ msg: "error", error: String((e as Error).message) }));
219
+ }
220
+ });
221
+
222
+ server.listen(PORT, () => {
223
+ console.error(`[feishu-yuce-bridge] listening http://0.0.0.0:${PORT}`);
224
+ console.error(`[feishu-yuce-bridge] POST /webhook/feishu -> yuce ${YUCE_URL}`);
225
+ console.error(`[feishu-yuce-bridge] outbound: Feishu im/v1/messages (chat_id), same API as official plugin`);
226
+ });
@@ -0,0 +1,98 @@
1
+ # 钉钉桥接安装说明(独立 im-agent-hub)
2
+
3
+ 本仓库已从 OpenClaw monorepo 拆出,安装脚本使用 **npm**,不再依赖 `pnpm-workspace`。
4
+
5
+ ## 前置
6
+
7
+ - `package.json` 中 `@dingtalk-real-ai/dingtalk-connector` 若为 `file:../dingtalk-openclaw-connector`,请保证该目录与当前仓库相对位置正确,否则先改为已发布的 npm 版本再安装。
8
+ - 交互式终端(安装时需输入配置)。
9
+
10
+ ## 安装
11
+
12
+ 在 **本仓库根目录** 的上一级执行(或先 `cd` 到仓库根):
13
+
14
+ ```bash
15
+ cd /path/to/im-agent-hub
16
+ export USE_CN_MIRROR=1 # 可选:npm 使用 npmmirror
17
+ bash scripts/install/install-dingtalk-bridge.sh
18
+ ```
19
+
20
+ 会把你输入的 **钉钉插件**(`channels.dingtalk-connector`)参数写入根目录 `openclaw.json`。
21
+
22
+ - **精简结构**:可与仓库默认一致,仅包含 `channels`(可选 `gatewayPort`,若 Gateway URL 中带端口会自动写入)。
23
+ - **合并规则**:已存在文件时只合并 `channels.dingtalk-connector`,其它顶级键(若你以后扩展)保留。
24
+ - **不写入** `bindings`(由你自行维护或不需要)。
25
+
26
+ 随后执行 `npm install`、安装 `~/.local/bin/im-agent-hub-ctl`。
27
+
28
+ ## 运行(与本地调试一致)
29
+
30
+ ```bash
31
+ npx tsx scripts/dingtalk-stdio-bridge.ts
32
+ ```
33
+
34
+ 打包成单文件 JS(仍需系统已装 Node,分发时只需带 `dist/*.mjs` + `openclaw.json`):
35
+
36
+ ```bash
37
+ npm run build:bridge
38
+ node dist/dingtalk-stdio-bridge.cjs
39
+ ```
40
+
41
+ `build:bridge` 默认外置重型依赖并 **minify**,cjs 约 **200KB+**(可读调试用 `npm run build:bridge:debug`)。全量打入:`npm run build:bridge:all`。
42
+
43
+ 详见 **[docs/packaging-二进制与分发.md](../docs/packaging-二进制与分发.md)**。`im-agent-hub-ctl start` 若发现 `dist/dingtalk-stdio-bridge.cjs`(或旧版 `.mjs`)会优先用它。
44
+
45
+ 后台:
46
+
47
+ ```bash
48
+ im-agent-hub-ctl start
49
+ ```
50
+
51
+ ## 更新
52
+
53
+ **推荐**(从 `manifest.env` 读取项目路径,先停服务、再拉代码、装依赖、再启动):
54
+
55
+ ```bash
56
+ im-agent-hub-ctl update
57
+ ```
58
+
59
+ 查看当前安装目录、包版本、git 提交与上次更新时间:
60
+
61
+ ```bash
62
+ im-agent-hub-ctl version
63
+ ```
64
+
65
+ 说明与故障处理:
66
+
67
+ ```bash
68
+ im-agent-hub-ctl update-help
69
+ ```
70
+
71
+ 手动等价步骤(无 ctl 或仅想手工执行时):
72
+
73
+ ```bash
74
+ cd /path/to/im-agent-hub && git pull && npm install
75
+ im-agent-hub-ctl restart
76
+ ```
77
+
78
+ `update` 默认会在项目根目录备份一份 `openclaw.json.bak.<时间戳>`;不需要备份时设 `IM_AGENT_HUB_NO_BACKUP=1`。国内 npm 镜像:`USE_CN_MIRROR=1 im-agent-hub-ctl update`。
79
+
80
+ ## 卸载(不删 Node)
81
+
82
+ 移除 **`im-agent-hub-ctl`**、**`~/.im-agent-hub/`**(manifest、日志、pid),并尝试停止桥接进程。
83
+
84
+ **不会**删除:本机 **Node / nvm**、项目里的 **`node_modules`**、**`openclaw.json`**、**`dist/`**。
85
+
86
+ ```bash
87
+ bash scripts/install/uninstall-im-agent-hub.sh
88
+ # 或跳过确认:
89
+ bash scripts/install/uninstall-im-agent-hub.sh -y
90
+ ```
91
+
92
+ 若数据目录不在默认路径,安装时曾设置 `IM_AGENT_HUB_HOME`,卸载时请同样导出该变量。
93
+
94
+ 重新安装后 `manifest.env` 会包含 `IM_AGENT_HUB_CTL_PATH`,便于准确删除 ctl;旧安装仍会通过常见路径与脚本内容特征尝试删除。
95
+
96
+ ## systemd(可选)
97
+
98
+ 见 `systemd/im-agent-hub-dingtalk.service.example`(把路径改成你的 `im-agent-hub` 绝对路径)。