agent-bridge-feishu 0.0.0-stage → 1.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.
package/README.md CHANGED
@@ -1,3 +1,12 @@
1
- # Temporary Holding Version
1
+ # 飞书通道插件
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在管理页面打开飞书通道的设置页,点击「创建飞书机器人」。页面会显示二维码和创建链接,授权完成后自动保存凭据并连接。随后在「关联 Agent」表单选择本地 Agent。
4
+
5
+ 1. `POST /api/plugins/feishu/setup/begin`,响应中的 `url`(兼容字段 `qr_url`)即飞书授权 URL,`qr_image` 为 PNG 二维码 data URL。
6
+ 2. 用户打开 URL 完成授权。
7
+ 3. 按 `interval` 秒间隔轮询 `POST /api/plugins/feishu/setup/poll`,提交第一步返回的 `device_code`;完成后插件会保存 `app_id/app_secret`。凭据不会出现在响应中。
8
+ 4. 重载飞书插件,插件通过长连接接收文本消息,并将消息转发到已启动的本地 Agent 插件。
9
+
10
+ 也可以直接配置飞书开放平台应用的 `app_id` 和 `app_secret`。通过配置中的 `agent_plugin` 或管理界面的绑定功能指定目标 Agent。
11
+
12
+ 飞书应用需要开启机器人能力和接收消息事件权限。插件默认使用飞书中国区 API,可通过 `api_base` 指定其他区域地址。
@@ -0,0 +1,14 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ "app_id": { "type": "string" },
5
+ "app_secret": { "type": "string", "secret": true },
6
+ "agent_plugin": { "type": "string" },
7
+ "bindings": { "type": "object", "additionalProperties": { "type": "string" } },
8
+ "allow_from": { "type": "string" },
9
+ "allow_chats": { "type": "string" },
10
+ "ws_url": { "type": "string" },
11
+ "api_base": { "type": "string" }
12
+ },
13
+ "additionalProperties": false
14
+ }
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AgentError = void 0;
4
+ class AgentError extends Error {
5
+ constructor(message, options) {
6
+ super(message, options);
7
+ this.name = "AgentError";
8
+ }
9
+ }
10
+ exports.AgentError = AgentError;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FeishuClient = void 0;
4
+ exports.parseFeishuEvent = parseFeishuEvent;
5
+ const defaultTransport = { fetch, WebSocket };
6
+ function parseFeishuEvent(input) {
7
+ if (!input || typeof input !== "object")
8
+ return undefined;
9
+ const root = input;
10
+ if (root.type === "ping" || root.type === "challenge")
11
+ return undefined;
12
+ const event = root.event ?? root.data?.event ?? root;
13
+ const message = event?.message;
14
+ const messageId = typeof message?.message_id === "string" ? message.message_id : typeof event?.message_id === "string" ? event.message_id : "";
15
+ 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 : "";
16
+ const chatId = typeof message?.chat_id === "string" ? message.chat_id : undefined;
17
+ const threadId = typeof message?.thread_id === "string" ? message.thread_id : undefined;
18
+ if (!messageId || !peerId || typeof message?.message_type !== "undefined" && message.message_type !== "text")
19
+ return undefined;
20
+ let text = "";
21
+ try {
22
+ const content = typeof message.content === "string" ? JSON.parse(message.content) : message.content;
23
+ text = typeof content?.text === "string" ? content.text : "";
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ if (!text.trim())
29
+ return undefined;
30
+ return { messageId, peerId, ...(chatId ? { chatId } : {}), ...(threadId ? { threadId } : {}), text: text.trim() };
31
+ }
32
+ class FeishuClient {
33
+ config;
34
+ transport;
35
+ socket;
36
+ token = "";
37
+ tokenExpires = 0;
38
+ constructor(config, transport = defaultTransport) {
39
+ this.config = config;
40
+ this.transport = transport;
41
+ }
42
+ async start(onMessage, signal) {
43
+ const url = this.config.ws_url ?? await this.endpoint();
44
+ const socket = new this.transport.WebSocket(url);
45
+ this.socket = socket;
46
+ await new Promise((resolve, reject) => {
47
+ socket.onopen = () => resolve();
48
+ socket.onerror = () => reject(new Error("飞书长连接失败"));
49
+ });
50
+ socket.onmessage = (event) => { void this.receive(String(event.data), onMessage); };
51
+ signal?.addEventListener("abort", () => this.close(), { once: true });
52
+ }
53
+ close() { this.socket?.close(); this.socket = undefined; }
54
+ async send(peerId, text, threadId) {
55
+ const token = await this.accessToken();
56
+ const body = { receive_id: peerId, msg_type: "text", content: JSON.stringify({ text }) };
57
+ if (threadId)
58
+ body.reply_in_thread = true;
59
+ 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) });
60
+ if (!response.ok)
61
+ throw new Error(`飞书发送消息失败:HTTP ${response.status}`);
62
+ const result = await response.json();
63
+ if (result.code && result.code !== 0)
64
+ throw new Error(`飞书发送消息失败:${result.msg ?? result.code}`);
65
+ }
66
+ async receive(raw, onMessage) {
67
+ let payload;
68
+ try {
69
+ payload = JSON.parse(raw);
70
+ }
71
+ catch {
72
+ return;
73
+ }
74
+ if (payload.type === "ping") {
75
+ this.socket?.send(JSON.stringify({ type: "pong" }));
76
+ return;
77
+ }
78
+ if (payload.type === "url_verification" && payload.challenge) {
79
+ this.socket?.send(JSON.stringify({ challenge: payload.challenge }));
80
+ return;
81
+ }
82
+ const event = parseFeishuEvent(payload);
83
+ if (event)
84
+ await onMessage(event);
85
+ }
86
+ async endpoint() {
87
+ const response = await this.transport.fetch(`${this.apiBase()}/open-apis/callback/ws/endpoint?app_id=${encodeURIComponent(this.config.app_id)}&app_secret=${encodeURIComponent(this.config.app_secret)}`);
88
+ if (!response.ok)
89
+ throw new Error(`获取飞书长连接地址失败:HTTP ${response.status}`);
90
+ const result = await response.json();
91
+ const url = result.data?.URL ?? result.data?.url;
92
+ if (result.code && result.code !== 0 || !url)
93
+ throw new Error(`获取飞书长连接地址失败:${result.msg ?? "响应无 URL"}`);
94
+ return url;
95
+ }
96
+ async accessToken() {
97
+ if (this.token && Date.now() < this.tokenExpires)
98
+ return this.token;
99
+ 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 }) });
100
+ if (!response.ok)
101
+ throw new Error(`获取飞书令牌失败:HTTP ${response.status}`);
102
+ const result = await response.json();
103
+ if (result.code && result.code !== 0 || !result.tenant_access_token)
104
+ throw new Error(`获取飞书令牌失败:${result.msg ?? "响应无令牌"}`);
105
+ this.token = result.tenant_access_token;
106
+ this.tokenExpires = Date.now() + Math.max(60, (result.expire ?? 7200) - 60) * 1000;
107
+ return this.token;
108
+ }
109
+ apiBase() { return (this.config.api_base || "https://open.feishu.cn").replace(/\/$/, ""); }
110
+ }
111
+ exports.FeishuClient = FeishuClient;
@@ -0,0 +1,209 @@
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.1", 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
+ client;
14
+ ingress;
15
+ config = {};
16
+ bindings = {};
17
+ status = "degraded";
18
+ message = "尚未配置飞书机器人";
19
+ unregister = [];
20
+ seen = new Set();
21
+ constructor(options = {}) {
22
+ this.options = options;
23
+ this.client = options.client;
24
+ this.ingress = options.ingress;
25
+ }
26
+ identity = {
27
+ list: async () => ({ evolcore: [], muAgentBridge: this.config.app_id ? [String(this.config.app_id)] : [], localMuAgentBridge: [] }),
28
+ create: async (aid) => { if (!aid.trim())
29
+ throw new Error("机器人标识不能为空"); return { aid, status: "请通过配置接口提供 app_id 和 app_secret" }; },
30
+ bind: async (pluginId, aid) => { if (pluginId === "feishu" || pluginId === "abweb")
31
+ throw new Error("只能关联 Agent 插件"); if (aid && aid !== this.config.app_id)
32
+ throw new Error("飞书机器人标识不存在"); if (this.context?.pluginStatus(pluginId) !== "ready")
33
+ throw new Error("目标 Agent 插件未启动"); if (aid)
34
+ this.bindings[String(aid)] = pluginId;
35
+ else
36
+ delete this.bindings[String(this.config.app_id)]; await this.persist(); },
37
+ };
38
+ async activate(context) {
39
+ this.context = context;
40
+ this.config = { ...context.config };
41
+ this.ingress ??= context.ingress;
42
+ if (!this.ingress)
43
+ throw new Error("运行时未提供通道消息入口");
44
+ for (const [method, path, handler] of [
45
+ ["POST", "/api/plugins/feishu/bot", this.configureBot],
46
+ ["POST", "/api/plugins/feishu/settings", this.configureSettings],
47
+ ["POST", "/api/plugins/feishu/setup/begin", this.setupBegin],
48
+ ["POST", "/api/plugins/feishu/setup/poll", this.setupPoll],
49
+ ["POST", "/api/plugins/feishu/bind", this.bindAgent],
50
+ ["GET", "/api/plugins/feishu/setup/status", this.setupStatus],
51
+ ...(["", "/app.js", "/style.css"].map((suffix) => ["GET", `/abweb/channels/feishu${suffix}`, this.servePage])),
52
+ ]) {
53
+ const route = context.http.register({ method, path, handler });
54
+ if (typeof route === "function")
55
+ this.unregister.push(route);
56
+ }
57
+ this.validate();
58
+ this.bindings = this.config.bindings && typeof this.config.bindings === "object" ? { ...this.config.bindings } : {};
59
+ if (!this.config.app_id || !this.config.app_secret)
60
+ return;
61
+ this.client ??= new client_1.FeishuClient(this.config);
62
+ try {
63
+ await this.client.start((event) => this.onMessage(event));
64
+ this.status = "ready";
65
+ this.message = "已连接";
66
+ }
67
+ catch (error) {
68
+ this.status = "degraded";
69
+ this.message = error instanceof Error ? error.message : String(error);
70
+ context.logger.warn("飞书通道连接失败", { error: this.message });
71
+ }
72
+ }
73
+ async deactivate(_reason) { this.client?.close(); for (const dispose of this.unregister.splice(0))
74
+ dispose(); this.status = "degraded"; this.context = undefined; }
75
+ async health() { return { status: this.status, message: this.message, details: { appId: this.config.app_id || undefined, bindings: this.bindings } }; }
76
+ async handle(_request) { throw new Error("飞书是入站通道插件"); }
77
+ async onMessage(event) {
78
+ const allowedFrom = this.csv("allow_from");
79
+ const allowedChats = this.csv("allow_chats");
80
+ if (allowedFrom.length && !allowedFrom.includes(event.peerId) || allowedChats.length && (!event.chatId || !allowedChats.includes(event.chatId)))
81
+ return;
82
+ if (this.seen.has(event.messageId))
83
+ return;
84
+ this.seen.add(event.messageId);
85
+ if (this.seen.size > 10000)
86
+ this.seen.delete(this.seen.values().next().value);
87
+ const channelMessage = { channel: "feishu", peerId: event.peerId, messageId: event.messageId, text: event.text, threadId: event.threadId, agentPlugin: this.bindings[String(this.config.app_id)] ?? this.config.agent_plugin ?? "qwenwork", reply: async (text) => { if (text.trim())
88
+ await this.client?.send(event.peerId, text, event.threadId); } };
89
+ try {
90
+ await this.ingress.publish(channelMessage);
91
+ }
92
+ catch (error) {
93
+ this.context?.logger.warn("飞书消息处理失败", { error: error instanceof Error ? error.message : String(error) });
94
+ }
95
+ }
96
+ validate() { for (const key of ["app_id", "app_secret", "agent_plugin", "allow_from", "allow_chats", "ws_url", "api_base"])
97
+ if (this.config[key] !== undefined && typeof this.config[key] !== "string")
98
+ throw new Error(`${key} 必须是字符串`); }
99
+ csv(key) { return String(this.config[key] ?? "").split(",").map((value) => value.trim()).filter(Boolean); }
100
+ async persist() { await (0, promises_1.mkdir)(this.context.dataDir, { recursive: true }); this.config.bindings = this.bindings; await (0, promises_1.writeFile)((0, node_path_1.join)(this.context.dataDir, "config.json"), `${JSON.stringify(this.config, null, 2)}\n`, { mode: 0o600 }); }
101
+ 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, bindings: this.bindings, agent_plugin: this.config.agent_plugin || "", allow_from: this.config.allow_from || "", allow_chats: this.config.allow_chats || "" }); };
102
+ bindAgent = async (request, response) => {
103
+ try {
104
+ const body = await this.readJson(request);
105
+ const pluginId = typeof body.pluginId === "string" ? body.pluginId.trim() : "";
106
+ if (!this.config.app_id || !pluginId)
107
+ return this.json(response, 400, { error: "请先配置机器人并选择 Agent" });
108
+ await this.identity.bind(pluginId, String(this.config.app_id), 0);
109
+ this.context?.logger.info("飞书 Agent 已关联", { appId: this.config.app_id, pluginId });
110
+ this.json(response, 200, { ok: true, app_id: this.config.app_id, pluginId });
111
+ }
112
+ catch (error) {
113
+ this.context?.logger.warn("飞书 Agent 关联失败", { error: error instanceof Error ? error.message : String(error) });
114
+ this.json(response, 400, { error: error instanceof Error ? error.message : String(error) });
115
+ }
116
+ };
117
+ servePage = async (request, response) => {
118
+ const path = new URL(request.url ?? "/", "http://localhost").pathname;
119
+ const file = path.endsWith("/app.js") ? "app.js" : path.endsWith("/style.css") ? "style.css" : "index.html";
120
+ try {
121
+ const content = await (0, promises_1.readFile)((0, node_path_1.join)(__dirname, "public", file));
122
+ response.statusCode = 200;
123
+ 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");
124
+ response.setHeader("cache-control", "no-store");
125
+ response.end(content);
126
+ }
127
+ catch {
128
+ this.json(response, 503, { error: "飞书设置页面资源不可用" });
129
+ }
130
+ };
131
+ configureBot = async (request, response) => { let raw = ""; for await (const chunk of request)
132
+ raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); let body; try {
133
+ body = JSON.parse(raw || "{}");
134
+ }
135
+ catch {
136
+ response.statusCode = 400;
137
+ response.end(JSON.stringify({ error: "JSON 请求格式无效" }));
138
+ return;
139
+ } if (typeof body.app_id !== "string" || typeof body.app_secret !== "string" || !body.app_id.trim() || !body.app_secret.trim()) {
140
+ response.statusCode = 400;
141
+ response.end(JSON.stringify({ error: "app_id 和 app_secret 必填" }));
142
+ return;
143
+ } this.config = { ...this.config, ...body }; await this.persist(); response.statusCode = 200; response.setHeader("content-type", "application/json; charset=utf-8"); response.end(JSON.stringify({ app_id: body.app_id, status: "已保存,重载插件后连接" })); };
144
+ configureSettings = async (request, response) => {
145
+ try {
146
+ const body = await this.readJson(request);
147
+ for (const key of ["agent_plugin", "allow_from", "allow_chats"])
148
+ if (typeof body[key] !== "string")
149
+ return this.json(response, 400, { error: `${key} 必须是字符串` });
150
+ this.config = { ...this.config, agent_plugin: body.agent_plugin, allow_from: body.allow_from, allow_chats: body.allow_chats };
151
+ await this.persist();
152
+ this.context?.logger.info("飞书通道配置已保存");
153
+ this.json(response, 200, { ok: true });
154
+ }
155
+ catch (error) {
156
+ this.context?.logger.warn("飞书通道配置失败", { error: error instanceof Error ? error.message : String(error) });
157
+ this.json(response, 400, { error: error instanceof Error ? error.message : String(error) });
158
+ }
159
+ };
160
+ setupBegin = async (_request, response) => {
161
+ try {
162
+ const init = await this.registration("init");
163
+ if (init.error)
164
+ throw new Error(init.error_description ? `${init.error}: ${init.error_description}` : init.error);
165
+ const result = await this.registration("begin", { archetype: "PersonalAgent", auth_method: "client_secret", request_user_info: "open_id" });
166
+ if (result.error)
167
+ throw new Error(result.error_description ? `${result.error}: ${result.error_description}` : result.error);
168
+ if (typeof result.device_code !== "string" || typeof result.verification_uri_complete !== "string")
169
+ throw new Error("飞书授权响应缺少设备码或授权 URL");
170
+ const url = new URL(result.verification_uri_complete);
171
+ if (url.protocol !== "https:")
172
+ throw new Error("飞书授权 URL 必须使用 HTTPS");
173
+ 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.expire_in ?? 600) });
174
+ }
175
+ catch (error) {
176
+ this.json(response, 502, { error: error instanceof Error ? error.message : String(error) });
177
+ }
178
+ };
179
+ setupPoll = async (request, response) => {
180
+ try {
181
+ const body = await this.readJson(request);
182
+ const deviceCode = typeof body.device_code === "string" ? body.device_code.trim() : "";
183
+ if (!deviceCode)
184
+ return this.json(response, 400, { error: "device_code 必填" });
185
+ const result = await this.registration("poll", { device_code: deviceCode });
186
+ const user = result.user_info;
187
+ const appId = typeof result.client_id === "string" ? result.client_id : "";
188
+ const appSecret = typeof result.client_secret === "string" ? result.client_secret : "";
189
+ if (appId && appSecret) {
190
+ this.config = { ...this.config, app_id: appId, app_secret: appSecret };
191
+ await this.persist();
192
+ return this.json(response, 200, { status: "completed", app_id: appId, owner_open_id: user?.open_id });
193
+ }
194
+ const errorCode = typeof result.error === "string" ? result.error : "";
195
+ const status = ["authorization_pending", "slow_down"].includes(errorCode) ? "pending" : errorCode === "access_denied" ? "denied" : errorCode === "expired_token" ? "expired" : "error";
196
+ return this.json(response, 200, { status, ...(status === "error" ? { error: result.error_description || errorCode || "飞书授权响应无效" } : {}), ...(errorCode === "slow_down" ? { slow_down: true } : {}) });
197
+ }
198
+ catch (error) {
199
+ this.json(response, 502, { error: error instanceof Error ? error.message : String(error) });
200
+ }
201
+ };
202
+ 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)
203
+ throw new Error(`飞书授权服务失败:HTTP ${response.status}`); return await response.json(); }
204
+ async readJson(request) { let raw = ""; for await (const chunk of request)
205
+ raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); const body = JSON.parse(raw || "{}"); return body && typeof body === "object" && !Array.isArray(body) ? body : {}; }
206
+ json(response, status, value) { response.statusCode = status; response.setHeader("content-type", "application/json; charset=utf-8"); response.end(JSON.stringify(value)); }
207
+ }
208
+ exports.FeishuPlugin = FeishuPlugin;
209
+ exports.default = FeishuPlugin;
@@ -0,0 +1,86 @@
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 binding = find("#binding");
11
+ const settings = find("#settings");
12
+ let pollTimer;
13
+ let setup = 0;
14
+
15
+ async function request(path, method = "GET", body) {
16
+ const token = localStorage.getItem("mab-login-token");
17
+ const response = await fetch(path, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
18
+ const result = await response.json();
19
+ if (!response.ok) throw new Error(result.error || `请求失败:${response.status}`);
20
+ return result;
21
+ }
22
+ function show(message, failed = false) { notice.textContent = message; notice.className = failed ? "error" : ""; }
23
+ async function load() {
24
+ try {
25
+ const [state, plugins] = await Promise.all([request("/api/plugins/feishu/setup/status"), request("/api/plugins")]);
26
+ const agents = (plugins.plugins || []).filter((plugin) => plugin.type === "agent" && plugin.status === "ready");
27
+ const options = agents.map((agent) => new Option(agent.displayName || agent.id, agent.id));
28
+ binding.elements.pluginId.replaceChildren(new Option("选择 Agent", ""), ...options.map((option) => option.cloneNode(true)));
29
+ settings.elements.agent_plugin.replaceChildren(new Option("默认 Agent", ""), ...options);
30
+ settings.elements.agent_plugin.value = state.agent_plugin || "";
31
+ settings.elements.allow_from.value = state.allow_from || "";
32
+ settings.elements.allow_chats.value = state.allow_chats || "";
33
+ credentials.elements.app_id.value = state.app_id || "";
34
+ credentials.elements.app_secret.value = "";
35
+ find("#connection").textContent = state.message || state.status;
36
+ find("#app-id").textContent = state.app_id || "未配置";
37
+ const bound = state.bindings?.[state.app_id] || "";
38
+ find("#bound-agent").textContent = bound || state.agent_plugin || "未关联";
39
+ binding.elements.pluginId.value = bound;
40
+ show(state.configured ? "机器人已配置" : "请授权或手动配置机器人凭据");
41
+ } catch (error) { show(String(error), true); }
42
+ }
43
+ async function reconnect() {
44
+ await request("/api/plugins/feishu/reload", "POST");
45
+ await load();
46
+ }
47
+ async function poll(deviceCode, interval, deadline, currentSetup) {
48
+ if (currentSetup !== setup) return;
49
+ if (Date.now() >= deadline) { authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
50
+ try {
51
+ const result = await request("/api/plugins/feishu/setup/poll", "POST", { device_code: deviceCode });
52
+ if (currentSetup !== setup) return;
53
+ if (result.status === "completed") { authState.textContent = "授权完成,正在连接"; await reconnect(); if (currentSetup === setup) { authState.textContent = "授权完成"; find("#begin").disabled = false; } return; }
54
+ if (result.status === "denied") { authState.textContent = "授权已拒绝"; find("#begin").disabled = false; return; }
55
+ if (result.status === "expired") { authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
56
+ if (result.status === "error") { authState.textContent = `授权失败:${result.error || "未知错误"}`; find("#begin").disabled = false; return; }
57
+ authState.textContent = "等待飞书授权";
58
+ const nextInterval = interval + (result.slow_down ? 5000 : 0);
59
+ pollTimer = setTimeout(() => void poll(deviceCode, nextInterval, deadline, currentSetup), nextInterval);
60
+ } catch (error) { if (currentSetup === setup) { authState.textContent = `授权检查失败:${String(error)}`; find("#begin").disabled = false; } }
61
+ }
62
+ find("#begin").onclick = async () => {
63
+ const currentSetup = ++setup;
64
+ find("#begin").disabled = true;
65
+ clearTimeout(pollTimer); authLink.hidden = true; authDetails.hidden = true; authState.textContent = "正在申请设备授权";
66
+ try {
67
+ const result = await request("/api/plugins/feishu/setup/begin", "POST");
68
+ if (currentSetup !== setup) return;
69
+ const url = new URL(result.url);
70
+ if (url.protocol !== "https:") throw new Error("飞书授权地址无效");
71
+ authLink.href = url.href; authLink.hidden = false;
72
+ authQr.src = result.qr_image;
73
+ authUrl.textContent = url.href;
74
+ authDetails.hidden = false;
75
+ authState.textContent = "等待飞书授权";
76
+ const interval = Math.max(1, Number(result.interval) || 5) * 1000;
77
+ pollTimer = setTimeout(() => void poll(result.device_code, interval, Date.now() + (Number(result.expires_in) || 600) * 1000, currentSetup), interval);
78
+ } catch (error) { if (currentSetup === setup) { authState.textContent = `启动授权失败:${String(error)}`; find("#begin").disabled = false; } }
79
+ };
80
+ credentials.onsubmit = (event) => { event.preventDefault(); void request("/api/plugins/feishu/bot", "POST", { app_id: credentials.elements.app_id.value.trim(), app_secret: credentials.elements.app_secret.value }).then(reconnect).then(() => show("凭据已保存,正在连接")).catch((error) => show(String(error), true)); };
81
+ binding.onsubmit = (event) => { event.preventDefault(); void request("/api/plugins/feishu/bind", "POST", { pluginId: binding.elements.pluginId.value }).then(load).then(() => show("Agent 已关联")).catch((error) => show(String(error), true)); };
82
+ 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)); };
83
+ find("#refresh").onclick = () => void load();
84
+ window.addEventListener("pagehide", () => { setup++; clearTimeout(pollTimer); });
85
+ void load();
86
+ })();
@@ -0,0 +1,16 @@
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="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>
10
+ <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>
11
+ <form id="binding"><h2>关联 Agent</h2><div class="fields"><label>Agent<select name="pluginId" required></select></label></div><button type="submit">关联</button></form>
12
+ <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>
13
+ </main>
14
+ <script src="/abweb/channels/feishu/app.js"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,25 @@
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; } }
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "agent-bridge-feishu",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "agent-bridge 的飞书通道插件。",
5
+ "keywords": ["mu-agent-bridge-plugin", "mu-agent-bridge-channel"],
6
+ "main": "dist/src-ts/plugins/feishu/index.js",
7
+ "files": ["dist/", "plugin.json", "config.schema.json", "README.md"],
8
+ "agentBridge": { "plugin": "./plugin.json", "type": "channel" },
9
+ "engines": { "node": ">=22" },
10
+ "dependencies": { "qrcode": "^1.5.4" },
11
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" }
12
+ }
package/plugin.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "id": "feishu",
3
+ "version": "1.0.1",
4
+ "type": "channel",
5
+ "displayName": "飞书通道",
6
+ "entry": "dist/src-ts/plugins/feishu/index.js",
7
+ "apiVersion": "1",
8
+ "process": "in-process",
9
+ "capabilities": ["channel", "channel.bot.setup", "channel.receive", "channel.send", "health"],
10
+ "managementPage": "/abweb/channels/feishu",
11
+ "configSchema": "config.schema.json"
12
+ }