mu-agent-bridge 1.0.5 → 1.0.13

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.
Files changed (42) hide show
  1. package/README.md +16 -11
  2. package/dist/src-ts/channel-sessions.js +68 -0
  3. package/dist/src-ts/cli.js +60 -25
  4. package/dist/src-ts/control-server.js +75 -94
  5. package/dist/src-ts/local-aids.js +26 -0
  6. package/dist/src-ts/log-store.js +64 -0
  7. package/dist/src-ts/plugin-manager.js +240 -39
  8. package/dist/src-ts/plugin-manifest.js +5 -1
  9. package/dist/src-ts/plugins/abweb/index.js +1 -1
  10. package/dist/src-ts/plugins/abweb/public/app.js +164 -100
  11. package/dist/src-ts/plugins/abweb/public/index.html +7 -6
  12. package/dist/src-ts/plugins/abweb/public/style.css +12 -13
  13. package/dist/src-ts/plugins/aun/evolcore.js +54 -0
  14. package/dist/src-ts/plugins/aun/index.js +253 -51
  15. package/dist/src-ts/plugins/aun/public/app.js +66 -0
  16. package/dist/src-ts/plugins/aun/public/index.html +14 -0
  17. package/dist/src-ts/plugins/aun/public/style.css +21 -0
  18. package/dist/src-ts/plugins/doubao/cdp-client.js +5 -4
  19. package/dist/src-ts/plugins/doubao/index.js +3 -2
  20. package/dist/src-ts/plugins/feishu/client.js +133 -0
  21. package/dist/src-ts/plugins/feishu/index.js +257 -0
  22. package/dist/src-ts/plugins/feishu/public/app.js +147 -0
  23. package/dist/src-ts/plugins/feishu/public/index.html +17 -0
  24. package/dist/src-ts/plugins/feishu/public/style.css +30 -0
  25. package/dist/src-ts/plugins/workbuddy/cdp-client.js +118 -0
  26. package/dist/src-ts/plugins/workbuddy/cli-client.js +39 -0
  27. package/dist/src-ts/plugins/workbuddy/index.js +139 -0
  28. package/dist/src-ts/version-manager.js +9 -1
  29. package/dist/tests-ts/abweb-plugin.test.js +13 -2
  30. package/dist/tests-ts/aun-plugin.test.js +108 -4
  31. package/dist/tests-ts/control-server.test.js +88 -6
  32. package/dist/tests-ts/doubao-plugin.test.js +18 -0
  33. package/dist/tests-ts/feishu-client.test.js +72 -0
  34. package/dist/tests-ts/feishu-page.test.js +53 -0
  35. package/dist/tests-ts/feishu-plugin.test.js +148 -0
  36. package/dist/tests-ts/log-store.test.js +27 -0
  37. package/dist/tests-ts/platform-integration.test.js +40 -1
  38. package/dist/tests-ts/plugin-catalog.test.js +8 -0
  39. package/dist/tests-ts/plugin-manager.test.js +219 -13
  40. package/dist/tests-ts/workbuddy-plugin.test.js +85 -0
  41. package/package.json +30 -6
  42. package/plugins/abweb/plugin.json +1 -1
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FeishuClient = void 0;
4
+ exports.parseFeishuEvent = parseFeishuEvent;
5
+ const node_sdk_1 = require("@larksuiteoapi/node-sdk");
6
+ const defaultTransport = { fetch, WebSocket };
7
+ function parseFeishuEvent(input) {
8
+ if (!input || typeof input !== "object")
9
+ return undefined;
10
+ const root = input;
11
+ if (root.type === "ping" || root.type === "challenge")
12
+ return undefined;
13
+ const event = root.event ?? root.data?.event ?? root;
14
+ const message = event?.message;
15
+ const messageId = typeof message?.message_id === "string" ? message.message_id : typeof event?.message_id === "string" ? event.message_id : "";
16
+ const peerId = typeof event?.sender?.sender_id?.open_id === "string" ? event.sender.sender_id.open_id : typeof event?.sender?.sender_id?.user_id === "string" ? event.sender.sender_id.user_id : "";
17
+ const chatId = typeof message?.chat_id === "string" ? message.chat_id : undefined;
18
+ const threadId = typeof message?.thread_id === "string" ? message.thread_id : undefined;
19
+ if (!messageId || !peerId || typeof message?.message_type !== "undefined" && message.message_type !== "text")
20
+ return undefined;
21
+ let text = "";
22
+ try {
23
+ const content = typeof message.content === "string" ? JSON.parse(message.content) : message.content;
24
+ text = typeof content?.text === "string" ? content.text : "";
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ if (!text.trim())
30
+ return undefined;
31
+ return { messageId, peerId, ...(chatId ? { chatId } : {}), ...(threadId ? { threadId } : {}), text: text.trim() };
32
+ }
33
+ class FeishuClient {
34
+ config;
35
+ transport;
36
+ socket;
37
+ sdkClient;
38
+ token = "";
39
+ tokenExpires = 0;
40
+ constructor(config, transport = defaultTransport) {
41
+ this.config = config;
42
+ this.transport = transport;
43
+ }
44
+ async start(onMessage, signal) {
45
+ if (this.config.ws_url) {
46
+ const url = this.config.ws_url;
47
+ const socket = new this.transport.WebSocket(url);
48
+ this.socket = socket;
49
+ await new Promise((resolve, reject) => { socket.onopen = () => resolve(); socket.onerror = () => reject(new Error("飞书长连接失败")); });
50
+ socket.onmessage = (event) => { void this.receive(String(event.data), onMessage); };
51
+ }
52
+ else {
53
+ await this.startSdk(onMessage);
54
+ }
55
+ signal?.addEventListener("abort", () => this.close(), { once: true });
56
+ }
57
+ close() { this.sdkClient?.close({ force: true }); this.sdkClient = undefined; this.socket?.close(); this.socket = undefined; }
58
+ async startSdk(onMessage) {
59
+ const dispatcher = new node_sdk_1.EventDispatcher({}).register({
60
+ "im.message.receive_v1": async (data) => { const event = parseFeishuEvent(data); if (event)
61
+ await onMessage(event); },
62
+ });
63
+ try {
64
+ await new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => { reject(new Error("飞书连接超时,请检查凭据及长连接订阅设置")); }, 20_000);
66
+ const finish = (error) => { clearTimeout(timer); if (error)
67
+ reject(error);
68
+ else
69
+ resolve(); };
70
+ const client = new node_sdk_1.WSClient({
71
+ appId: this.config.app_id, appSecret: this.config.app_secret,
72
+ ...(this.config.api_base ? { domain: this.apiBase() } : {}),
73
+ loggerLevel: node_sdk_1.LoggerLevel.fatal,
74
+ handshakeTimeoutMs: 10_000,
75
+ onReady: () => finish(), onError: (error) => finish(error),
76
+ });
77
+ this.sdkClient = client;
78
+ void client.start({ eventDispatcher: dispatcher }).catch(finish);
79
+ });
80
+ }
81
+ catch (error) {
82
+ this.close();
83
+ throw error;
84
+ }
85
+ }
86
+ async send(peerId, text, threadId) {
87
+ const token = await this.accessToken();
88
+ const body = { receive_id: peerId, msg_type: "text", content: JSON.stringify({ text }) };
89
+ if (threadId)
90
+ body.reply_in_thread = true;
91
+ const response = await this.transport.fetch(`${this.apiBase()}/open-apis/im/v1/messages?receive_id_type=open_id`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) });
92
+ if (!response.ok)
93
+ throw new Error(`飞书发送消息失败:HTTP ${response.status}`);
94
+ const result = await response.json();
95
+ if (result.code && result.code !== 0)
96
+ throw new Error(`飞书发送消息失败:${result.msg ?? result.code}`);
97
+ }
98
+ async receive(raw, onMessage) {
99
+ let payload;
100
+ try {
101
+ payload = JSON.parse(raw);
102
+ }
103
+ catch {
104
+ return;
105
+ }
106
+ if (payload.type === "ping") {
107
+ this.socket?.send(JSON.stringify({ type: "pong" }));
108
+ return;
109
+ }
110
+ if (payload.type === "url_verification" && payload.challenge) {
111
+ this.socket?.send(JSON.stringify({ challenge: payload.challenge }));
112
+ return;
113
+ }
114
+ const event = parseFeishuEvent(payload);
115
+ if (event)
116
+ await onMessage(event);
117
+ }
118
+ async accessToken() {
119
+ if (this.token && Date.now() < this.tokenExpires)
120
+ return this.token;
121
+ const response = await this.transport.fetch(`${this.apiBase()}/open-apis/auth/v3/tenant_access_token/internal`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app_id: this.config.app_id, app_secret: this.config.app_secret }) });
122
+ if (!response.ok)
123
+ throw new Error(`获取飞书令牌失败:HTTP ${response.status}`);
124
+ const result = await response.json();
125
+ if (result.code && result.code !== 0 || !result.tenant_access_token)
126
+ throw new Error(`获取飞书令牌失败:${result.msg ?? "响应无令牌"}`);
127
+ this.token = result.tenant_access_token;
128
+ this.tokenExpires = Date.now() + Math.max(60, (result.expire ?? 7200) - 60) * 1000;
129
+ return this.token;
130
+ }
131
+ apiBase() { return (this.config.api_base || "https://open.feishu.cn").replace(/\/$/, ""); }
132
+ }
133
+ exports.FeishuClient = FeishuClient;
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FeishuPlugin = void 0;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_path_1 = require("node:path");
6
+ const qrcode_1 = require("qrcode");
7
+ const client_1 = require("./client");
8
+ const manifest = { id: "feishu", version: "1.0.3", type: "channel", displayName: "飞书通道", entry: "dist/src-ts/plugins/feishu/index.js", apiVersion: "1", process: "in-process", capabilities: ["channel", "channel.bot.setup", "channel.receive", "channel.send", "health"], configSchema: "config.schema.json", managementPage: "/abweb/channels/feishu" };
9
+ class FeishuPlugin {
10
+ options;
11
+ manifest = manifest;
12
+ context;
13
+ ingress;
14
+ config = {};
15
+ bindings = {};
16
+ status = "degraded";
17
+ message = "尚未配置飞书机器人";
18
+ unregister = [];
19
+ bots = [];
20
+ clients = new Map();
21
+ botStates = new Map();
22
+ bindingTimes = {};
23
+ seen = new Set();
24
+ constructor(options = {}) {
25
+ this.options = options;
26
+ this.ingress = options.ingress;
27
+ }
28
+ identity = {
29
+ list: async () => ({ evolcore: [], muAgentBridge: this.bots.filter((bot) => this.botStates.get(bot.app_id)?.status === "ready").map((bot) => bot.app_id), localMuAgentBridge: [] }),
30
+ create: async (aid) => { if (!aid.trim())
31
+ throw new Error("机器人标识不能为空"); return { aid, status: "请通过配置接口提供 app_id 和 app_secret" }; },
32
+ bind: async (pluginId, aid) => { await this.bindBot(aid || String(this.config.app_id || ""), aid ? pluginId : ""); },
33
+ };
34
+ async activate(context) {
35
+ this.context = context;
36
+ this.config = { ...context.config };
37
+ this.ingress ??= context.ingress;
38
+ if (!this.ingress)
39
+ throw new Error("运行时未提供通道消息入口");
40
+ for (const [method, path, handler] of [
41
+ ["POST", "/api/plugins/feishu/bot", this.configureBot],
42
+ ["POST", "/api/plugins/feishu/settings", this.configureSettings],
43
+ ["POST", "/api/plugins/feishu/setup/begin", this.setupBegin],
44
+ ["POST", "/api/plugins/feishu/setup/poll", this.setupPoll],
45
+ ["POST", "/api/plugins/feishu/bind", this.bindAgent],
46
+ ["GET", "/api/plugins/feishu/setup/status", this.setupStatus],
47
+ ...(["", "/app.js", "/style.css"].map((suffix) => ["GET", `/abweb/channels/feishu${suffix}`, this.servePage])),
48
+ ]) {
49
+ const route = context.http.register({ method, path, handler });
50
+ if (typeof route === "function")
51
+ this.unregister.push(route);
52
+ }
53
+ this.validate();
54
+ this.bindings = this.config.bindings && typeof this.config.bindings === "object" ? { ...this.config.bindings } : {};
55
+ this.bots = Array.isArray(this.config.bots) ? this.config.bots.map((bot) => ({ ...bot })) : [];
56
+ if (this.config.app_id && this.config.app_secret && !this.bots.some((bot) => bot.app_id === this.config.app_id))
57
+ this.bots.unshift({ app_id: this.config.app_id, app_secret: this.config.app_secret });
58
+ const ids = new Set();
59
+ for (const bot of this.bots) {
60
+ if (!bot || typeof bot.app_id !== "string" || !bot.app_id.trim() || typeof bot.app_secret !== "string" || !bot.app_secret.trim() || ids.has(bot.app_id))
61
+ throw new Error("机器人凭据无效或 App ID 重复");
62
+ ids.add(bot.app_id);
63
+ }
64
+ this.bindingTimes = { ...(this.config.binding_times || {}) };
65
+ this.botStates.clear();
66
+ await Promise.all(this.bots.map(async (bot) => {
67
+ const client = this.options.clientFactory?.(bot) ?? this.options.client ?? new client_1.FeishuClient({ ...this.config, ...bot });
68
+ this.clients.set(bot.app_id, client);
69
+ try {
70
+ await client.start((event) => this.onMessage(event, bot.app_id));
71
+ this.botStates.set(bot.app_id, { status: "ready", message: "已连接" });
72
+ }
73
+ catch (error) {
74
+ const message = error instanceof Error ? error.message : String(error);
75
+ client.close();
76
+ this.botStates.set(bot.app_id, { status: "degraded", message });
77
+ context.logger.warn("飞书通道连接失败", { appId: bot.app_id, error: message });
78
+ }
79
+ }));
80
+ this.status = this.bots.length && this.bots.every((bot) => this.botStates.get(bot.app_id)?.status === "ready") ? "ready" : "degraded";
81
+ this.message = this.status === "ready" ? "已连接" : [...this.botStates.values()].find((state) => state.status !== "ready")?.message || "尚未配置飞书机器人";
82
+ }
83
+ async deactivate(_reason) { for (const client of this.clients.values())
84
+ client.close(); this.clients.clear(); for (const dispose of this.unregister.splice(0))
85
+ dispose(); this.status = "degraded"; this.context = undefined; }
86
+ async health() { return { status: this.status, message: this.message, details: { appId: this.config.app_id || undefined, bindings: this.bindings } }; }
87
+ async handle(_request) { throw new Error("飞书是入站通道插件"); }
88
+ async onMessage(event, appId) {
89
+ const allowedFrom = this.csv("allow_from");
90
+ const allowedChats = this.csv("allow_chats");
91
+ if (allowedFrom.length && !allowedFrom.includes(event.peerId) || allowedChats.length && (!event.chatId || !allowedChats.includes(event.chatId)))
92
+ return;
93
+ const key = JSON.stringify([appId, event.messageId]);
94
+ if (this.seen.has(key))
95
+ return;
96
+ this.seen.add(key);
97
+ if (this.seen.size > 10000)
98
+ this.seen.delete(this.seen.values().next().value);
99
+ const channelMessage = { channel: "feishu", recipientId: appId, chatId: event.chatId, peerId: event.peerId, messageId: event.messageId, text: event.text, threadId: event.threadId, agentPlugin: this.bindings[appId] ?? this.config.agent_plugin ?? "qwenwork", reply: async (text) => { if (text.trim())
100
+ await this.clients.get(appId)?.send(event.peerId, text, event.threadId); } };
101
+ try {
102
+ await this.ingress.publish(channelMessage);
103
+ }
104
+ catch (error) {
105
+ this.context?.logger.warn("飞书消息处理失败", { error: error instanceof Error ? error.message : String(error) });
106
+ }
107
+ }
108
+ validate() { for (const key of ["app_id", "app_secret", "agent_plugin", "allow_from", "allow_chats", "ws_url", "api_base"])
109
+ if (this.config[key] !== undefined && typeof this.config[key] !== "string")
110
+ throw new Error(`${key} 必须是字符串`); }
111
+ csv(key) { return String(this.config[key] ?? "").split(",").map((value) => value.trim()).filter(Boolean); }
112
+ async persist() { await (0, promises_1.mkdir)(this.context.dataDir, { recursive: true }); this.config.bindings = this.bindings; this.config.bots = this.bots; this.config.binding_times = this.bindingTimes; await (0, promises_1.writeFile)((0, node_path_1.join)(this.context.dataDir, "config.json"), `${JSON.stringify(this.config, null, 2)}\n`, { mode: 0o600 }); }
113
+ setupStatus = async (_request, response) => { this.json(response, 200, { app_id: this.config.app_id || "", configured: Boolean(this.config.app_id && this.config.app_secret), status: this.status, message: this.message, bots: this.bots.map((bot) => ({ app_id: bot.app_id, configured: true, ...(this.botStates.get(bot.app_id) || { status: "degraded", message: "等待连接" }), pluginId: this.bindings[bot.app_id] || "", binding_time: this.bindingTimes[bot.app_id] || "" })), bindings: this.bindings, agent_plugin: this.config.agent_plugin || "", allow_from: this.config.allow_from || "", allow_chats: this.config.allow_chats || "" }); };
114
+ bindAgent = async (request, response) => {
115
+ try {
116
+ const body = await this.readJson(request);
117
+ const pluginId = typeof body.pluginId === "string" ? body.pluginId.trim() : "";
118
+ const appId = typeof body.app_id === "string" ? body.app_id.trim() : String(this.config.app_id || "");
119
+ await this.bindBot(appId, pluginId);
120
+ this.json(response, 200, { ok: true, app_id: appId, pluginId });
121
+ }
122
+ catch (error) {
123
+ this.context?.logger.warn("飞书 Agent 关联失败", { error: error instanceof Error ? error.message : String(error) });
124
+ this.json(response, 400, { error: error instanceof Error ? error.message : String(error) });
125
+ }
126
+ };
127
+ async bindBot(appId, pluginId) {
128
+ if (!this.bots.some((bot) => bot.app_id === appId))
129
+ throw new Error("飞书机器人标识不存在");
130
+ if (pluginId && (["aun", "feishu", "abweb"].includes(pluginId) || this.context?.pluginStatus(pluginId) !== "ready"))
131
+ throw new Error("目标 Agent 插件未启动或类型无效");
132
+ const previous = { ...this.bindings };
133
+ const times = { ...this.bindingTimes };
134
+ if (pluginId) {
135
+ this.bindings[appId] = pluginId;
136
+ this.bindingTimes[appId] = new Date().toISOString();
137
+ }
138
+ else {
139
+ delete this.bindings[appId];
140
+ delete this.bindingTimes[appId];
141
+ }
142
+ try {
143
+ await this.persist();
144
+ }
145
+ catch (error) {
146
+ this.bindings = previous;
147
+ this.bindingTimes = times;
148
+ throw error;
149
+ }
150
+ this.context?.logger.info("飞书 Agent 关联已更新", { appId, pluginId });
151
+ }
152
+ saveBot(appId, appSecret) {
153
+ const index = this.bots.findIndex((bot) => bot.app_id === appId);
154
+ if (index < 0)
155
+ this.bots.push({ app_id: appId, app_secret: appSecret });
156
+ else
157
+ this.bots[index] = { ...this.bots[index], app_secret: appSecret };
158
+ this.config = { ...this.config, app_id: appId, app_secret: appSecret };
159
+ }
160
+ servePage = async (request, response) => {
161
+ const path = new URL(request.url ?? "/", "http://localhost").pathname;
162
+ const file = path.endsWith("/app.js") ? "app.js" : path.endsWith("/style.css") ? "style.css" : "index.html";
163
+ try {
164
+ const content = await (0, promises_1.readFile)((0, node_path_1.join)(__dirname, "public", file));
165
+ response.statusCode = 200;
166
+ response.setHeader("content-type", file.endsWith(".js") ? "text/javascript; charset=utf-8" : file.endsWith(".css") ? "text/css; charset=utf-8" : "text/html; charset=utf-8");
167
+ response.setHeader("cache-control", "no-store");
168
+ response.end(content);
169
+ }
170
+ catch {
171
+ this.json(response, 503, { error: "飞书设置页面资源不可用" });
172
+ }
173
+ };
174
+ configureBot = async (request, response) => {
175
+ try {
176
+ const body = await this.readJson(request);
177
+ const appId = typeof body.app_id === "string" ? body.app_id.trim() : "";
178
+ const providedSecret = typeof body.app_secret === "string" ? body.app_secret.trim() : "";
179
+ const appSecret = providedSecret || (this.bots.find((bot) => bot.app_id === appId)?.app_secret || "");
180
+ if (!appId || !appSecret)
181
+ return this.json(response, 400, { error: "新机器人需要同时提供 App ID 和 App Secret" });
182
+ this.saveBot(appId, appSecret);
183
+ await this.persist();
184
+ this.context?.logger.info("飞书机器人凭据已保存", { appId });
185
+ this.json(response, 200, { app_id: appId, status: "已保存,重载插件后连接" });
186
+ }
187
+ catch (error) {
188
+ this.json(response, 400, { error: error instanceof Error ? error.message : String(error) });
189
+ }
190
+ };
191
+ configureSettings = async (request, response) => {
192
+ try {
193
+ const body = await this.readJson(request);
194
+ for (const key of ["agent_plugin", "allow_from", "allow_chats"])
195
+ if (typeof body[key] !== "string")
196
+ return this.json(response, 400, { error: `${key} 必须是字符串` });
197
+ this.config = { ...this.config, agent_plugin: body.agent_plugin, allow_from: body.allow_from, allow_chats: body.allow_chats };
198
+ await this.persist();
199
+ this.context?.logger.info("飞书通道配置已保存");
200
+ this.json(response, 200, { ok: true });
201
+ }
202
+ catch (error) {
203
+ this.context?.logger.warn("飞书通道配置失败", { error: error instanceof Error ? error.message : String(error) });
204
+ this.json(response, 400, { error: error instanceof Error ? error.message : String(error) });
205
+ }
206
+ };
207
+ setupBegin = async (_request, response) => {
208
+ try {
209
+ const init = await this.registration("init");
210
+ if (init.error)
211
+ throw new Error(init.error_description ? `${init.error}: ${init.error_description}` : init.error);
212
+ const result = await this.registration("begin", { archetype: "PersonalAgent", auth_method: "client_secret", request_user_info: "open_id" });
213
+ if (result.error)
214
+ throw new Error(result.error_description ? `${result.error}: ${result.error_description}` : result.error);
215
+ if (typeof result.device_code !== "string" || typeof result.verification_uri_complete !== "string")
216
+ throw new Error("飞书授权响应缺少设备码或授权 URL");
217
+ const url = new URL(result.verification_uri_complete);
218
+ if (url.protocol !== "https:")
219
+ throw new Error("飞书授权 URL 必须使用 HTTPS");
220
+ this.json(response, 200, { device_code: result.device_code, url: url.href, qr_url: url.href, qr_image: await (0, qrcode_1.toDataURL)(url.href, { width: 256, margin: 4 }), interval: Number(result.interval ?? 5), expires_in: Number(result.expires_in ?? result.expire_in ?? 600) });
221
+ }
222
+ catch (error) {
223
+ this.json(response, 502, { error: error instanceof Error ? error.message : String(error) });
224
+ }
225
+ };
226
+ setupPoll = async (request, response) => {
227
+ try {
228
+ const body = await this.readJson(request);
229
+ const deviceCode = typeof body.device_code === "string" ? body.device_code.trim() : "";
230
+ if (!deviceCode)
231
+ return this.json(response, 400, { error: "device_code 必填" });
232
+ const result = await this.registration("poll", { device_code: deviceCode });
233
+ const user = result.user_info;
234
+ const appId = typeof result.client_id === "string" ? result.client_id : "";
235
+ const appSecret = typeof result.client_secret === "string" ? result.client_secret : "";
236
+ if (appId && appSecret) {
237
+ this.saveBot(appId, appSecret);
238
+ await this.persist();
239
+ this.context?.logger.info("飞书授权凭据已自动保存", { appId });
240
+ return this.json(response, 200, { status: "completed", app_id: appId, owner_open_id: user?.open_id });
241
+ }
242
+ const errorCode = typeof result.error === "string" ? result.error : "";
243
+ const status = ["authorization_pending", "slow_down"].includes(errorCode) ? "pending" : errorCode === "access_denied" ? "denied" : errorCode === "expired_token" ? "expired" : "error";
244
+ return this.json(response, 200, { status, ...(status === "error" ? { error: result.error_description || errorCode || "飞书授权响应无效" } : {}), ...(errorCode === "slow_down" ? { slow_down: true } : {}) });
245
+ }
246
+ catch (error) {
247
+ this.json(response, 502, { error: error instanceof Error ? error.message : String(error) });
248
+ }
249
+ };
250
+ async registration(action, params = {}) { const form = new URLSearchParams({ action, ...params }); const response = await fetch("https://accounts.feishu.cn/oauth/v1/app/registration", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form }); if (!response.ok)
251
+ throw new Error(`飞书授权服务失败:HTTP ${response.status}`); return await response.json(); }
252
+ async readJson(request) { let raw = ""; for await (const chunk of request)
253
+ raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); const body = JSON.parse(raw || "{}"); return body && typeof body === "object" && !Array.isArray(body) ? body : {}; }
254
+ json(response, status, value) { response.statusCode = status; response.setHeader("content-type", "application/json; charset=utf-8"); response.end(JSON.stringify(value)); }
255
+ }
256
+ exports.FeishuPlugin = FeishuPlugin;
257
+ exports.default = FeishuPlugin;
@@ -0,0 +1,147 @@
1
+ (() => {
2
+ const find = (selector) => document.querySelector(selector);
3
+ const notice = find("#notice");
4
+ const authState = find("#authorization-state");
5
+ const authLink = find("#authorization-link");
6
+ const authDetails = find("#authorization-details");
7
+ const authQr = find("#authorization-qr");
8
+ const authUrl = find("#authorization-url");
9
+ const credentials = find("#credentials");
10
+ const botTable = find("#bots");
11
+ let bots = [];
12
+ let loading = false;
13
+ const settings = find("#settings");
14
+ let pollTimer;
15
+ let setup = 0;
16
+ let savedAppId = "";
17
+ let secretMasked = false;
18
+ const authorizationKey = "mab-feishu-authorization";
19
+
20
+ async function request(path, method = "GET", body) {
21
+ const token = localStorage.getItem("mab-login-token");
22
+ const response = await fetch(path, { method, signal: AbortSignal.timeout(30_000), headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
23
+ const result = await response.json();
24
+ if (!response.ok) throw new Error(result.error || `请求失败:${response.status}`);
25
+ return result;
26
+ }
27
+ function show(message, failed = false) { notice.textContent = message; notice.className = failed ? "error" : ""; }
28
+ async function load(refreshForm = true) {
29
+ if (loading) return;
30
+ loading = true;
31
+ try {
32
+ const [state, plugins] = await Promise.all([request("/api/plugins/feishu/setup/status"), request("/api/plugins")]);
33
+ const agents = (plugins.plugins || []).filter((plugin) => plugin.type === "agent" && plugin.status === "ready");
34
+ const options = agents.map((agent) => new Option(agent.displayName || agent.id, agent.id));
35
+ bots = state.bots || [];
36
+ botTable.replaceChildren();
37
+ for (const bot of [...bots].sort((a, b) => Number(Boolean(b.pluginId)) - Number(Boolean(a.pluginId)) || Number(b.status === "ready") - Number(a.status === "ready") || (Date.parse(b.binding_time) || 0) - (Date.parse(a.binding_time) || 0))) {
38
+ const row = document.createElement("tr");
39
+ for (const text of [bot.app_id, bot.status === "ready" ? "已连接" : bot.message || "未连接", bot.pluginId || "未关联", bot.binding_time ? new Date(bot.binding_time).toLocaleString() : "—"]) { const cell = document.createElement("td"); cell.textContent = text; row.append(cell); }
40
+ const controls = document.createElement("td");
41
+ const select = document.createElement("select"); select.setAttribute("aria-label", `${bot.app_id} 关联 Agent`);
42
+ select.append(new Option("选择 Agent", ""), ...agents.map((agent) => new Option(agent.displayName || agent.id, agent.id))); select.value = bot.pluginId || "";
43
+ function button(text, action) { const button = document.createElement("button"); button.type = "button"; button.textContent = text; button.onclick = async () => { button.disabled = true; try { await action(); } catch (error) { show(String(error), true); } finally { button.disabled = false; } }; return button; }
44
+ async function bind(pluginId) { await request("/api/plugins/feishu/bind", "POST", { app_id: bot.app_id, pluginId }); await load(false); show(`${bot.app_id} ${pluginId ? "已关联 " + pluginId : "已解除关联"}`); }
45
+ controls.append(select, button("关联", () => bind(select.value)), button("解除", () => bind("")), button("编辑凭据", async () => { credentials.elements.app_id.value = bot.app_id; savedAppId = bot.app_id; secretMasked = true; credentials.elements.app_secret.value = "********"; credentials.elements.app_secret.required = false; }));
46
+ row.append(controls); botTable.append(row);
47
+ }
48
+ if (refreshForm) {
49
+ settings.elements.agent_plugin.replaceChildren(new Option("默认 Agent", ""), ...options);
50
+ settings.elements.agent_plugin.value = state.agent_plugin || "";
51
+ settings.elements.allow_from.value = state.allow_from || "";
52
+ settings.elements.allow_chats.value = state.allow_chats || "";
53
+ credentials.elements.app_id.value = state.app_id || "";
54
+ savedAppId = state.configured ? state.app_id : "";
55
+ secretMasked = state.configured;
56
+ credentials.elements.app_secret.value = secretMasked ? "********" : "";
57
+ credentials.elements.app_secret.required = !state.configured;
58
+ credentials.elements.app_secret.placeholder = state.configured ? "已保存,留空使用已有凭据" : "App Secret 必填";
59
+ }
60
+ find("#connection").textContent = state.message || state.status;
61
+ find("#app-id").textContent = state.app_id || "未配置";
62
+ const bound = state.bindings?.[state.app_id] || "";
63
+ find("#bound-agent").textContent = bound || state.agent_plugin || "未关联";
64
+
65
+ show(state.configured ? state.status === "ready" ? "机器人已连接" : `凭据已保存,但连接失败:${state.message || state.status}` : "请授权或手动配置机器人凭据", state.configured && state.status !== "ready");
66
+ return state;
67
+ } catch (error) { show(String(error), true); throw error; } finally { loading = false; }
68
+ }
69
+ async function reconnect() {
70
+ await request("/api/plugins/feishu/reload", "POST");
71
+ const state = await load(false);
72
+ const bot = state.bots?.find((bot) => bot.app_id === credentials.elements.app_id.value.trim());
73
+ if ((bot?.status || state.status) !== "ready") throw new Error(bot?.message || state.message || "飞书尚未连接");
74
+ }
75
+ async function poll(deviceCode, interval, deadline, currentSetup) {
76
+ if (currentSetup !== setup) return;
77
+ if (Date.now() >= deadline) { sessionStorage.removeItem(authorizationKey); authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
78
+ try {
79
+ const result = await request("/api/plugins/feishu/setup/poll", "POST", { device_code: deviceCode });
80
+ if (currentSetup !== setup) return;
81
+ if (result.status === "completed") {
82
+ sessionStorage.removeItem(authorizationKey);
83
+ credentials.elements.app_id.value = result.app_id;
84
+ find("#app-id").textContent = result.app_id;
85
+ authState.textContent = "授权完成,凭据已自动保存,正在连接";
86
+ try { await reconnect(); authState.textContent = "授权完成,机器人已连接"; }
87
+ catch (error) { authState.textContent = "授权完成,凭据已自动保存,但连接失败"; show(String(error), true); }
88
+ find("#begin").disabled = false;
89
+ return;
90
+ }
91
+ if (["denied", "expired", "error"].includes(result.status)) sessionStorage.removeItem(authorizationKey);
92
+ if (result.status === "denied") { authState.textContent = "授权已拒绝"; find("#begin").disabled = false; return; }
93
+ if (result.status === "expired") { authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
94
+ if (result.status === "error") { authState.textContent = `授权失败:${result.error || "未知错误"}`; find("#begin").disabled = false; return; }
95
+ authState.textContent = "等待飞书授权";
96
+ const nextInterval = interval + (result.slow_down ? 5000 : 0);
97
+ pollTimer = setTimeout(() => void poll(deviceCode, nextInterval, deadline, currentSetup), nextInterval);
98
+ } catch (error) { if (currentSetup === setup) { authState.textContent = `授权检查暂时失败,将重试:${String(error)}`; pollTimer = setTimeout(() => void poll(deviceCode, interval, deadline, currentSetup), interval); } }
99
+ }
100
+ find("#begin").onclick = async () => {
101
+ const currentSetup = ++setup;
102
+ find("#begin").disabled = true;
103
+ clearTimeout(pollTimer); authLink.hidden = true; authDetails.hidden = true; authState.textContent = "正在申请设备授权";
104
+ try {
105
+ const result = await request("/api/plugins/feishu/setup/begin", "POST");
106
+ if (currentSetup !== setup) return;
107
+ const url = new URL(result.url);
108
+ if (url.protocol !== "https:") throw new Error("飞书授权地址无效");
109
+ authLink.href = url.href; authLink.hidden = false;
110
+ authQr.src = result.qr_image;
111
+ authUrl.textContent = url.href;
112
+ authDetails.hidden = false;
113
+ authState.textContent = "等待飞书授权";
114
+ const interval = Math.max(1, Number(result.interval) || 5) * 1000;
115
+ const deadline = Date.now() + (Number(result.expires_in) || 600) * 1000;
116
+ sessionStorage.setItem(authorizationKey, JSON.stringify({ deviceCode: result.device_code, interval, deadline }));
117
+ pollTimer = setTimeout(() => void poll(result.device_code, interval, deadline, currentSetup), interval);
118
+ } catch (error) { if (currentSetup === setup) { authState.textContent = `启动授权失败:${String(error)}`; find("#begin").disabled = false; } }
119
+ };
120
+ credentials.elements.app_id.oninput = () => {
121
+
122
+ const changed = credentials.elements.app_id.value.trim() !== savedAppId;
123
+ if (changed && secretMasked) { credentials.elements.app_secret.value = ""; secretMasked = false; }
124
+ credentials.elements.app_secret.required = changed;
125
+ };
126
+ credentials.elements.app_secret.oninput = () => { secretMasked = false; };
127
+ credentials.elements.app_secret.addEventListener("invalid", () => show("首次配置或更换机器人时,App ID 和 App Secret 都需要填写", true));
128
+ credentials.onsubmit = async (event) => {
129
+ event.preventDefault(); const button = credentials.querySelector("button"); button.disabled = true; show("正在保存凭据并连接飞书");
130
+ try { await request("/api/plugins/feishu/bot", "POST", { app_id: credentials.elements.app_id.value.trim(), app_secret: secretMasked ? "" : credentials.elements.app_secret.value }); savedAppId = credentials.elements.app_id.value.trim(); secretMasked = true; credentials.elements.app_secret.value = "********"; await reconnect(); show("凭据已保存,机器人已连接"); }
131
+ catch (error) { show(String(error), true); }
132
+ finally { button.disabled = false; }
133
+ };
134
+
135
+ settings.onsubmit = (event) => { event.preventDefault(); void request("/api/plugins/feishu/settings", "POST", { agent_plugin: settings.elements.agent_plugin.value, allow_from: settings.elements.allow_from.value, allow_chats: settings.elements.allow_chats.value }).then(load).then(() => show("配置已保存")).catch((error) => show(String(error), true)); };
136
+ find("#refresh").onclick = () => void load().catch(() => {});
137
+ window.addEventListener("pagehide", () => { setup++; clearTimeout(pollTimer); clearInterval(refreshTimer); });
138
+ void load().catch(() => {});
139
+ const refreshTimer = setInterval(() => { if (!document.hidden) void load(false).catch(() => {}); }, 10_000);
140
+ try {
141
+ const pending = JSON.parse(sessionStorage.getItem(authorizationKey) || "null");
142
+ if (pending && typeof pending.deviceCode === "string" && Number.isFinite(pending.deadline) && Number.isFinite(pending.interval) && pending.interval >= 1000) {
143
+ find("#begin").disabled = true;
144
+ void poll(pending.deviceCode, pending.interval, pending.deadline, setup);
145
+ }
146
+ } catch { sessionStorage.removeItem(authorizationKey); }
147
+ })();
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/abweb/channels/feishu/style.css"><title>飞书通道设置</title></head>
4
+ <body>
5
+ <main>
6
+ <header><h1>飞书机器人</h1><button id="refresh" type="button">刷新</button></header>
7
+ <p id="notice" role="status"></p>
8
+ <dl><div><dt>连接状态</dt><dd id="connection">未配置</dd></div><div><dt>App ID</dt><dd id="app-id">未配置</dd></div><div><dt>关联 Agent</dt><dd id="bound-agent">未关联</dd></div></dl>
9
+ <section><h2>机器人关联</h2><div class="table-wrap"><table><thead><tr><th>App ID</th><th>连接状态</th><th>关联 Agent</th><th>最近关联时间</th><th>操作</th></tr></thead><tbody id="bots"></tbody></table></div></section>
10
+ <section><h2>创建设备授权</h2><div class="actions"><button id="begin" type="button">创建飞书机器人</button><a id="authorization-link" target="_blank" rel="noopener noreferrer" hidden>打开飞书创建链接</a></div><div id="authorization-details" hidden><img id="authorization-qr" width="256" height="256" alt="飞书机器人创建授权二维码"><p id="authorization-url"></p></div><p id="authorization-state" role="status"></p></section>
11
+ <form id="credentials"><h2>手动配置凭据</h2><div class="fields"><label>App ID<input name="app_id" required autocomplete="off"></label><label>App Secret<input name="app_secret" type="password" required autocomplete="new-password"></label></div><button type="submit">保存并连接</button></form>
12
+
13
+ <form id="settings"><h2>通道配置</h2><div class="fields"><label>默认 Agent<select name="agent_plugin"></select></label><label>允许的 open_id<input name="allow_from"></label><label>允许的 chat_id<input name="allow_chats"></label></div><button type="submit">保存配置</button></form>
14
+ </main>
15
+ <script src="/abweb/channels/feishu/app.js"></script>
16
+ </body>
17
+ </html>
@@ -0,0 +1,30 @@
1
+ :root { font-family: "Segoe UI", "Microsoft YaHei", sans-serif; color: #182027; background: #fff; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; }
4
+ main { padding: 8px 2px 20px; }
5
+ header { display: flex; align-items: center; justify-content: space-between; }
6
+ h1 { font-size: 17px; margin: 0; }
7
+ h2 { font-size: 15px; margin: 0 0 12px; }
8
+ p { min-height: 20px; color: #54665d; font-size: 13px; }
9
+ dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 12px 0 20px; }
10
+ dl div { border-bottom: 1px solid #d9dfdc; padding-bottom: 9px; min-width: 0; }
11
+ dt { color: #566761; font-size: 12px; }
12
+ dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 13px; }
13
+ section, form { padding: 18px 0; border-top: 1px solid #d9dfdc; }
14
+ .fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
15
+ label { display: grid; gap: 5px; font-size: 13px; }
16
+ input, select, button { min-height: 34px; padding: 6px 9px; border: 1px solid #bfc9c5; border-radius: 5px; background: white; color: inherit; font: inherit; }
17
+ button { cursor: pointer; }
18
+ button:hover { background: #e5f2ea; }
19
+ .actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
20
+ #authorization-details { margin-top: 12px; }
21
+ #authorization-qr { display: block; width: min(256px, 100%); height: auto; aspect-ratio: 1; }
22
+ #authorization-url { overflow-wrap: anywhere; }
23
+ a { color: #176848; }
24
+ .error { color: #a83427; }
25
+ @media (max-width: 620px) { .fields, dl { grid-template-columns: 1fr; } }
26
+
27
+ .table-wrap { overflow-x: auto; }
28
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
29
+ th, td { padding: 8px; text-align: left; border-bottom: 1px solid #d9dfdc; }
30
+ td:last-child { display: flex; gap: 6px; flex-wrap: wrap; }