agent-bridge-feishu 1.0.1 → 1.0.2

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
@@ -9,4 +9,6 @@
9
9
 
10
10
  也可以直接配置飞书开放平台应用的 `app_id` 和 `app_secret`。通过配置中的 `agent_plugin` 或管理界面的绑定功能指定目标 Agent。
11
11
 
12
+ 保存后 App Secret 以密码掩码保留在输入框,刷新或重连无需再次复制。掩码仅代表后台已保存密钥,状态接口不会返回真实密钥。更换 App ID 时需要填写对应的新密钥。扫码授权完成后自动保存两项凭据并连接;连接失败会显示具体原因,不影响已保存的凭据。
13
+
12
14
  飞书应用需要开启机器人能力和接收消息事件权限。插件默认使用飞书中国区 API,可通过 `api_base` 指定其他区域地址。
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FeishuClient = void 0;
4
4
  exports.parseFeishuEvent = parseFeishuEvent;
5
+ const node_sdk_1 = require("@larksuiteoapi/node-sdk");
5
6
  const defaultTransport = { fetch, WebSocket };
6
7
  function parseFeishuEvent(input) {
7
8
  if (!input || typeof input !== "object")
@@ -33,6 +34,7 @@ class FeishuClient {
33
34
  config;
34
35
  transport;
35
36
  socket;
37
+ sdkClient;
36
38
  token = "";
37
39
  tokenExpires = 0;
38
40
  constructor(config, transport = defaultTransport) {
@@ -40,17 +42,47 @@ class FeishuClient {
40
42
  this.transport = transport;
41
43
  }
42
44
  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); };
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
+ }
51
55
  signal?.addEventListener("abort", () => this.close(), { once: true });
52
56
  }
53
- close() { this.socket?.close(); this.socket = undefined; }
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
+ }
54
86
  async send(peerId, text, threadId) {
55
87
  const token = await this.accessToken();
56
88
  const body = { receive_id: peerId, msg_type: "text", content: JSON.stringify({ text }) };
@@ -83,16 +115,6 @@ class FeishuClient {
83
115
  if (event)
84
116
  await onMessage(event);
85
117
  }
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
118
  async accessToken() {
97
119
  if (this.token && Date.now() < this.tokenExpires)
98
120
  return this.token;
@@ -5,7 +5,7 @@ const promises_1 = require("node:fs/promises");
5
5
  const node_path_1 = require("node:path");
6
6
  const qrcode_1 = require("qrcode");
7
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" };
8
+ const manifest = { id: "feishu", version: "1.0.2", 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
9
  class FeishuPlugin {
10
10
  options;
11
11
  manifest = manifest;
@@ -58,7 +58,7 @@ class FeishuPlugin {
58
58
  this.bindings = this.config.bindings && typeof this.config.bindings === "object" ? { ...this.config.bindings } : {};
59
59
  if (!this.config.app_id || !this.config.app_secret)
60
60
  return;
61
- this.client ??= new client_1.FeishuClient(this.config);
61
+ this.client = this.options.client ?? new client_1.FeishuClient(this.config);
62
62
  try {
63
63
  await this.client.start((event) => this.onMessage(event));
64
64
  this.status = "ready";
@@ -128,19 +128,23 @@ class FeishuPlugin {
128
128
  this.json(response, 503, { error: "飞书设置页面资源不可用" });
129
129
  }
130
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: "已保存,重载插件后连接" })); };
131
+ configureBot = async (request, response) => {
132
+ try {
133
+ const body = await this.readJson(request);
134
+ const appId = typeof body.app_id === "string" ? body.app_id.trim() : "";
135
+ const providedSecret = typeof body.app_secret === "string" ? body.app_secret.trim() : "";
136
+ const appSecret = providedSecret || (appId === this.config.app_id ? this.config.app_secret : "");
137
+ if (!appId || !appSecret)
138
+ return this.json(response, 400, { error: "新机器人需要同时提供 App ID 和 App Secret" });
139
+ this.config = { ...this.config, app_id: appId, app_secret: appSecret };
140
+ await this.persist();
141
+ this.context?.logger.info("飞书机器人凭据已保存", { appId });
142
+ this.json(response, 200, { app_id: appId, status: "已保存,重载插件后连接" });
143
+ }
144
+ catch (error) {
145
+ this.json(response, 400, { error: error instanceof Error ? error.message : String(error) });
146
+ }
147
+ };
144
148
  configureSettings = async (request, response) => {
145
149
  try {
146
150
  const body = await this.readJson(request);
@@ -170,7 +174,7 @@ class FeishuPlugin {
170
174
  const url = new URL(result.verification_uri_complete);
171
175
  if (url.protocol !== "https:")
172
176
  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) });
177
+ 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) });
174
178
  }
175
179
  catch (error) {
176
180
  this.json(response, 502, { error: error instanceof Error ? error.message : String(error) });
@@ -189,6 +193,7 @@ class FeishuPlugin {
189
193
  if (appId && appSecret) {
190
194
  this.config = { ...this.config, app_id: appId, app_secret: appSecret };
191
195
  await this.persist();
196
+ this.context?.logger.info("飞书授权凭据已自动保存", { appId });
192
197
  return this.json(response, 200, { status: "completed", app_id: appId, owner_open_id: user?.open_id });
193
198
  }
194
199
  const errorCode = typeof result.error === "string" ? result.error : "";
@@ -11,10 +11,13 @@
11
11
  const settings = find("#settings");
12
12
  let pollTimer;
13
13
  let setup = 0;
14
+ let savedAppId = "";
15
+ let secretMasked = false;
16
+ const authorizationKey = "mab-feishu-authorization";
14
17
 
15
18
  async function request(path, method = "GET", body) {
16
19
  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) } : {}) });
20
+ 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) } : {}) });
18
21
  const result = await response.json();
19
22
  if (!response.ok) throw new Error(result.error || `请求失败:${response.status}`);
20
23
  return result;
@@ -31,33 +34,49 @@
31
34
  settings.elements.allow_from.value = state.allow_from || "";
32
35
  settings.elements.allow_chats.value = state.allow_chats || "";
33
36
  credentials.elements.app_id.value = state.app_id || "";
34
- credentials.elements.app_secret.value = "";
37
+ savedAppId = state.configured ? state.app_id : "";
38
+ secretMasked = state.configured;
39
+ credentials.elements.app_secret.value = secretMasked ? "********" : "";
40
+ credentials.elements.app_secret.required = !state.configured;
41
+ credentials.elements.app_secret.placeholder = state.configured ? "已保存,留空使用已有凭据" : "App Secret 必填";
35
42
  find("#connection").textContent = state.message || state.status;
36
43
  find("#app-id").textContent = state.app_id || "未配置";
37
44
  const bound = state.bindings?.[state.app_id] || "";
38
45
  find("#bound-agent").textContent = bound || state.agent_plugin || "未关联";
39
46
  binding.elements.pluginId.value = bound;
40
- show(state.configured ? "机器人已配置" : "请授权或手动配置机器人凭据");
41
- } catch (error) { show(String(error), true); }
47
+ show(state.configured ? state.status === "ready" ? "机器人已连接" : `凭据已保存,但连接失败:${state.message || state.status}` : "请授权或手动配置机器人凭据", state.configured && state.status !== "ready");
48
+ return state;
49
+ } catch (error) { show(String(error), true); throw error; }
42
50
  }
43
51
  async function reconnect() {
44
52
  await request("/api/plugins/feishu/reload", "POST");
45
- await load();
53
+ const state = await load();
54
+ if (state.status !== "ready") throw new Error(state.message || "飞书尚未连接");
46
55
  }
47
56
  async function poll(deviceCode, interval, deadline, currentSetup) {
48
57
  if (currentSetup !== setup) return;
49
- if (Date.now() >= deadline) { authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
58
+ if (Date.now() >= deadline) { sessionStorage.removeItem(authorizationKey); authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
50
59
  try {
51
60
  const result = await request("/api/plugins/feishu/setup/poll", "POST", { device_code: deviceCode });
52
61
  if (currentSetup !== setup) return;
53
- if (result.status === "completed") { authState.textContent = "授权完成,正在连接"; await reconnect(); if (currentSetup === setup) { authState.textContent = "授权完成"; find("#begin").disabled = false; } return; }
62
+ if (result.status === "completed") {
63
+ sessionStorage.removeItem(authorizationKey);
64
+ credentials.elements.app_id.value = result.app_id;
65
+ find("#app-id").textContent = result.app_id;
66
+ authState.textContent = "授权完成,凭据已自动保存,正在连接";
67
+ try { await reconnect(); authState.textContent = "授权完成,机器人已连接"; }
68
+ catch (error) { authState.textContent = "授权完成,凭据已自动保存,但连接失败"; show(String(error), true); }
69
+ find("#begin").disabled = false;
70
+ return;
71
+ }
72
+ if (["denied", "expired", "error"].includes(result.status)) sessionStorage.removeItem(authorizationKey);
54
73
  if (result.status === "denied") { authState.textContent = "授权已拒绝"; find("#begin").disabled = false; return; }
55
74
  if (result.status === "expired") { authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
56
75
  if (result.status === "error") { authState.textContent = `授权失败:${result.error || "未知错误"}`; find("#begin").disabled = false; return; }
57
76
  authState.textContent = "等待飞书授权";
58
77
  const nextInterval = interval + (result.slow_down ? 5000 : 0);
59
78
  pollTimer = setTimeout(() => void poll(deviceCode, nextInterval, deadline, currentSetup), nextInterval);
60
- } catch (error) { if (currentSetup === setup) { authState.textContent = `授权检查失败:${String(error)}`; find("#begin").disabled = false; } }
79
+ } catch (error) { if (currentSetup === setup) { authState.textContent = `授权检查暂时失败,将重试:${String(error)}`; pollTimer = setTimeout(() => void poll(deviceCode, interval, deadline, currentSetup), interval); } }
61
80
  }
62
81
  find("#begin").onclick = async () => {
63
82
  const currentSetup = ++setup;
@@ -74,13 +93,34 @@
74
93
  authDetails.hidden = false;
75
94
  authState.textContent = "等待飞书授权";
76
95
  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);
96
+ const deadline = Date.now() + (Number(result.expires_in) || 600) * 1000;
97
+ sessionStorage.setItem(authorizationKey, JSON.stringify({ deviceCode: result.device_code, interval, deadline }));
98
+ pollTimer = setTimeout(() => void poll(result.device_code, interval, deadline, currentSetup), interval);
78
99
  } catch (error) { if (currentSetup === setup) { authState.textContent = `启动授权失败:${String(error)}`; find("#begin").disabled = false; } }
79
100
  };
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)); };
101
+ credentials.elements.app_id.oninput = () => {
102
+ const changed = credentials.elements.app_id.value.trim() !== savedAppId;
103
+ if (changed && secretMasked) { credentials.elements.app_secret.value = ""; secretMasked = false; }
104
+ credentials.elements.app_secret.required = changed;
105
+ };
106
+ credentials.elements.app_secret.oninput = () => { secretMasked = false; };
107
+ credentials.elements.app_secret.addEventListener("invalid", () => show("首次配置或更换机器人时,App ID 和 App Secret 都需要填写", true));
108
+ credentials.onsubmit = async (event) => {
109
+ event.preventDefault(); const button = credentials.querySelector("button"); button.disabled = true; show("正在保存凭据并连接飞书");
110
+ 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("凭据已保存,机器人已连接"); }
111
+ catch (error) { show(String(error), true); }
112
+ finally { button.disabled = false; }
113
+ };
81
114
  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
115
  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();
116
+ find("#refresh").onclick = () => void load().catch(() => {});
84
117
  window.addEventListener("pagehide", () => { setup++; clearTimeout(pollTimer); });
85
- void load();
118
+ void load().catch(() => {});
119
+ try {
120
+ const pending = JSON.parse(sessionStorage.getItem(authorizationKey) || "null");
121
+ if (pending && typeof pending.deviceCode === "string" && Number.isFinite(pending.deadline) && Number.isFinite(pending.interval) && pending.interval >= 1000) {
122
+ find("#begin").disabled = true;
123
+ void poll(pending.deviceCode, pending.interval, pending.deadline, setup);
124
+ }
125
+ } catch { sessionStorage.removeItem(authorizationKey); }
86
126
  })();
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "agent-bridge-feishu",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "agent-bridge 的飞书通道插件。",
5
5
  "keywords": ["mu-agent-bridge-plugin", "mu-agent-bridge-channel"],
6
6
  "main": "dist/src-ts/plugins/feishu/index.js",
7
7
  "files": ["dist/", "plugin.json", "config.schema.json", "README.md"],
8
8
  "agentBridge": { "plugin": "./plugin.json", "type": "channel" },
9
9
  "engines": { "node": ">=22" },
10
- "dependencies": { "qrcode": "^1.5.4" },
10
+ "dependencies": { "qrcode": "^1.5.4", "@larksuiteoapi/node-sdk": "1.74.0" },
11
11
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" }
12
12
  }
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "feishu",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "channel",
5
5
  "displayName": "飞书通道",
6
6
  "entry": "dist/src-ts/plugins/feishu/index.js",