agent-bridge-feishu 1.0.1 → 1.0.3
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 +4 -0
- package/config.schema.json +55 -8
- package/dist/src-ts/plugins/feishu/client.js +41 -19
- package/dist/src-ts/plugins/feishu/index.js +99 -51
- package/dist/src-ts/plugins/feishu/public/app.js +79 -18
- package/dist/src-ts/plugins/feishu/public/index.html +2 -1
- package/dist/src-ts/plugins/feishu/public/style.css +5 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -9,4 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
也可以直接配置飞书开放平台应用的 `app_id` 和 `app_secret`。通过配置中的 `agent_plugin` 或管理界面的绑定功能指定目标 Agent。
|
|
11
11
|
|
|
12
|
+
可以保存多个机器人。设置页按 App ID 显示每个机器人的连接状态和关联 Agent;每个机器人独立建立飞书长连接,消息和回复使用收到消息的同一机器人。旧版顶层 `app_id/app_secret` 会作为第一条机器人配置读取。不同机器人和 AUN 身份即使关联同一个 Agent,也使用各自的会话;通道会话列表可在 ABWeb 的「通信记录」中查看。
|
|
13
|
+
|
|
14
|
+
保存后 App Secret 以密码掩码保留在输入框,刷新或重连无需再次复制。掩码仅代表后台已保存密钥,状态接口不会返回真实密钥。更换 App ID 时需要填写对应的新密钥。扫码授权完成后自动保存两项凭据并连接;连接失败会显示具体原因,不影响已保存的凭据。
|
|
15
|
+
|
|
12
16
|
飞书应用需要开启机器人能力和接收消息事件权限。插件默认使用飞书中国区 API,可通过 `api_base` 指定其他区域地址。
|
package/config.schema.json
CHANGED
|
@@ -1,14 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "object",
|
|
3
3
|
"properties": {
|
|
4
|
-
"app_id": {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
4
|
+
"app_id": {
|
|
5
|
+
"type": "string"
|
|
6
|
+
},
|
|
7
|
+
"app_secret": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"secret": true
|
|
10
|
+
},
|
|
11
|
+
"agent_plugin": {
|
|
12
|
+
"type": "string"
|
|
13
|
+
},
|
|
14
|
+
"bindings": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": {
|
|
17
|
+
"type": "string"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"allow_from": {
|
|
21
|
+
"type": "string"
|
|
22
|
+
},
|
|
23
|
+
"allow_chats": {
|
|
24
|
+
"type": "string"
|
|
25
|
+
},
|
|
26
|
+
"ws_url": {
|
|
27
|
+
"type": "string"
|
|
28
|
+
},
|
|
29
|
+
"api_base": {
|
|
30
|
+
"type": "string"
|
|
31
|
+
},
|
|
32
|
+
"bots": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"required": [
|
|
37
|
+
"app_id",
|
|
38
|
+
"app_secret"
|
|
39
|
+
],
|
|
40
|
+
"properties": {
|
|
41
|
+
"app_id": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"minLength": 1
|
|
44
|
+
},
|
|
45
|
+
"app_secret": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"minLength": 1,
|
|
48
|
+
"secret": true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"binding_times": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": {
|
|
56
|
+
"type": "string"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
12
59
|
},
|
|
13
60
|
"additionalProperties": false
|
|
14
61
|
}
|
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
socket.onopen = () => resolve();
|
|
48
|
-
socket.
|
|
49
|
-
}
|
|
50
|
-
|
|
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,35 +5,31 @@ 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.
|
|
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
9
|
class FeishuPlugin {
|
|
10
10
|
options;
|
|
11
11
|
manifest = manifest;
|
|
12
12
|
context;
|
|
13
|
-
client;
|
|
14
13
|
ingress;
|
|
15
14
|
config = {};
|
|
16
15
|
bindings = {};
|
|
17
16
|
status = "degraded";
|
|
18
17
|
message = "尚未配置飞书机器人";
|
|
19
18
|
unregister = [];
|
|
19
|
+
bots = [];
|
|
20
|
+
clients = new Map();
|
|
21
|
+
botStates = new Map();
|
|
22
|
+
bindingTimes = {};
|
|
20
23
|
seen = new Set();
|
|
21
24
|
constructor(options = {}) {
|
|
22
25
|
this.options = options;
|
|
23
|
-
this.client = options.client;
|
|
24
26
|
this.ingress = options.ingress;
|
|
25
27
|
}
|
|
26
28
|
identity = {
|
|
27
|
-
list: async () => ({ evolcore: [], muAgentBridge: this.
|
|
29
|
+
list: async () => ({ evolcore: [], muAgentBridge: this.bots.filter((bot) => this.botStates.get(bot.app_id)?.status === "ready").map((bot) => bot.app_id), localMuAgentBridge: [] }),
|
|
28
30
|
create: async (aid) => { if (!aid.trim())
|
|
29
31
|
throw new Error("机器人标识不能为空"); return { aid, status: "请通过配置接口提供 app_id 和 app_secret" }; },
|
|
30
|
-
bind: async (pluginId, aid) => {
|
|
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(); },
|
|
32
|
+
bind: async (pluginId, aid) => { await this.bindBot(aid || String(this.config.app_id || ""), aid ? pluginId : ""); },
|
|
37
33
|
};
|
|
38
34
|
async activate(context) {
|
|
39
35
|
this.context = context;
|
|
@@ -56,36 +52,52 @@ class FeishuPlugin {
|
|
|
56
52
|
}
|
|
57
53
|
this.validate();
|
|
58
54
|
this.bindings = this.config.bindings && typeof this.config.bindings === "object" ? { ...this.config.bindings } : {};
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 });
|
|
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);
|
|
71
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 || "尚未配置飞书机器人";
|
|
72
82
|
}
|
|
73
|
-
async deactivate(_reason) {
|
|
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))
|
|
74
85
|
dispose(); this.status = "degraded"; this.context = undefined; }
|
|
75
86
|
async health() { return { status: this.status, message: this.message, details: { appId: this.config.app_id || undefined, bindings: this.bindings } }; }
|
|
76
87
|
async handle(_request) { throw new Error("飞书是入站通道插件"); }
|
|
77
|
-
async onMessage(event) {
|
|
88
|
+
async onMessage(event, appId) {
|
|
78
89
|
const allowedFrom = this.csv("allow_from");
|
|
79
90
|
const allowedChats = this.csv("allow_chats");
|
|
80
91
|
if (allowedFrom.length && !allowedFrom.includes(event.peerId) || allowedChats.length && (!event.chatId || !allowedChats.includes(event.chatId)))
|
|
81
92
|
return;
|
|
82
|
-
|
|
93
|
+
const key = JSON.stringify([appId, event.messageId]);
|
|
94
|
+
if (this.seen.has(key))
|
|
83
95
|
return;
|
|
84
|
-
this.seen.add(
|
|
96
|
+
this.seen.add(key);
|
|
85
97
|
if (this.seen.size > 10000)
|
|
86
98
|
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[
|
|
88
|
-
await this.
|
|
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); } };
|
|
89
101
|
try {
|
|
90
102
|
await this.ingress.publish(channelMessage);
|
|
91
103
|
}
|
|
@@ -97,23 +109,54 @@ class FeishuPlugin {
|
|
|
97
109
|
if (this.config[key] !== undefined && typeof this.config[key] !== "string")
|
|
98
110
|
throw new Error(`${key} 必须是字符串`); }
|
|
99
111
|
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 || "" }); };
|
|
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 || "" }); };
|
|
102
114
|
bindAgent = async (request, response) => {
|
|
103
115
|
try {
|
|
104
116
|
const body = await this.readJson(request);
|
|
105
117
|
const pluginId = typeof body.pluginId === "string" ? body.pluginId.trim() : "";
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 });
|
|
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 });
|
|
111
121
|
}
|
|
112
122
|
catch (error) {
|
|
113
123
|
this.context?.logger.warn("飞书 Agent 关联失败", { error: error instanceof Error ? error.message : String(error) });
|
|
114
124
|
this.json(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
115
125
|
}
|
|
116
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
|
+
}
|
|
117
160
|
servePage = async (request, response) => {
|
|
118
161
|
const path = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
119
162
|
const file = path.endsWith("/app.js") ? "app.js" : path.endsWith("/style.css") ? "style.css" : "index.html";
|
|
@@ -128,19 +171,23 @@ class FeishuPlugin {
|
|
|
128
171
|
this.json(response, 503, { error: "飞书设置页面资源不可用" });
|
|
129
172
|
}
|
|
130
173
|
};
|
|
131
|
-
configureBot = async (request, response) => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
};
|
|
144
191
|
configureSettings = async (request, response) => {
|
|
145
192
|
try {
|
|
146
193
|
const body = await this.readJson(request);
|
|
@@ -170,7 +217,7 @@ class FeishuPlugin {
|
|
|
170
217
|
const url = new URL(result.verification_uri_complete);
|
|
171
218
|
if (url.protocol !== "https:")
|
|
172
219
|
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) });
|
|
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) });
|
|
174
221
|
}
|
|
175
222
|
catch (error) {
|
|
176
223
|
this.json(response, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -187,8 +234,9 @@ class FeishuPlugin {
|
|
|
187
234
|
const appId = typeof result.client_id === "string" ? result.client_id : "";
|
|
188
235
|
const appSecret = typeof result.client_secret === "string" ? result.client_secret : "";
|
|
189
236
|
if (appId && appSecret) {
|
|
190
|
-
this.
|
|
237
|
+
this.saveBot(appId, appSecret);
|
|
191
238
|
await this.persist();
|
|
239
|
+
this.context?.logger.info("飞书授权凭据已自动保存", { appId });
|
|
192
240
|
return this.json(response, 200, { status: "completed", app_id: appId, owner_open_id: user?.open_id });
|
|
193
241
|
}
|
|
194
242
|
const errorCode = typeof result.error === "string" ? result.error : "";
|
|
@@ -7,57 +7,95 @@
|
|
|
7
7
|
const authQr = find("#authorization-qr");
|
|
8
8
|
const authUrl = find("#authorization-url");
|
|
9
9
|
const credentials = find("#credentials");
|
|
10
|
-
const
|
|
10
|
+
const botTable = find("#bots");
|
|
11
|
+
let bots = [];
|
|
12
|
+
let loading = false;
|
|
11
13
|
const settings = find("#settings");
|
|
12
14
|
let pollTimer;
|
|
13
15
|
let setup = 0;
|
|
16
|
+
let savedAppId = "";
|
|
17
|
+
let secretMasked = false;
|
|
18
|
+
const authorizationKey = "mab-feishu-authorization";
|
|
14
19
|
|
|
15
20
|
async function request(path, method = "GET", body) {
|
|
16
21
|
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) } : {}) });
|
|
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) } : {}) });
|
|
18
23
|
const result = await response.json();
|
|
19
24
|
if (!response.ok) throw new Error(result.error || `请求失败:${response.status}`);
|
|
20
25
|
return result;
|
|
21
26
|
}
|
|
22
27
|
function show(message, failed = false) { notice.textContent = message; notice.className = failed ? "error" : ""; }
|
|
23
|
-
async function load() {
|
|
28
|
+
async function load(refreshForm = true) {
|
|
29
|
+
if (loading) return;
|
|
30
|
+
loading = true;
|
|
24
31
|
try {
|
|
25
32
|
const [state, plugins] = await Promise.all([request("/api/plugins/feishu/setup/status"), request("/api/plugins")]);
|
|
26
33
|
const agents = (plugins.plugins || []).filter((plugin) => plugin.type === "agent" && plugin.status === "ready");
|
|
27
34
|
const options = agents.map((agent) => new Option(agent.displayName || agent.id, agent.id));
|
|
28
|
-
|
|
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) {
|
|
29
49
|
settings.elements.agent_plugin.replaceChildren(new Option("默认 Agent", ""), ...options);
|
|
30
50
|
settings.elements.agent_plugin.value = state.agent_plugin || "";
|
|
31
51
|
settings.elements.allow_from.value = state.allow_from || "";
|
|
32
52
|
settings.elements.allow_chats.value = state.allow_chats || "";
|
|
33
53
|
credentials.elements.app_id.value = state.app_id || "";
|
|
34
|
-
|
|
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
|
+
}
|
|
35
60
|
find("#connection").textContent = state.message || state.status;
|
|
36
61
|
find("#app-id").textContent = state.app_id || "未配置";
|
|
37
62
|
const bound = state.bindings?.[state.app_id] || "";
|
|
38
63
|
find("#bound-agent").textContent = bound || state.agent_plugin || "未关联";
|
|
39
|
-
|
|
40
|
-
show(state.configured ? "
|
|
41
|
-
|
|
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; }
|
|
42
68
|
}
|
|
43
69
|
async function reconnect() {
|
|
44
70
|
await request("/api/plugins/feishu/reload", "POST");
|
|
45
|
-
await load();
|
|
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 || "飞书尚未连接");
|
|
46
74
|
}
|
|
47
75
|
async function poll(deviceCode, interval, deadline, currentSetup) {
|
|
48
76
|
if (currentSetup !== setup) return;
|
|
49
|
-
if (Date.now() >= deadline) { authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
|
|
77
|
+
if (Date.now() >= deadline) { sessionStorage.removeItem(authorizationKey); authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
|
|
50
78
|
try {
|
|
51
79
|
const result = await request("/api/plugins/feishu/setup/poll", "POST", { device_code: deviceCode });
|
|
52
80
|
if (currentSetup !== setup) return;
|
|
53
|
-
if (result.status === "completed") {
|
|
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);
|
|
54
92
|
if (result.status === "denied") { authState.textContent = "授权已拒绝"; find("#begin").disabled = false; return; }
|
|
55
93
|
if (result.status === "expired") { authState.textContent = "授权已过期,请重新开始"; find("#begin").disabled = false; return; }
|
|
56
94
|
if (result.status === "error") { authState.textContent = `授权失败:${result.error || "未知错误"}`; find("#begin").disabled = false; return; }
|
|
57
95
|
authState.textContent = "等待飞书授权";
|
|
58
96
|
const nextInterval = interval + (result.slow_down ? 5000 : 0);
|
|
59
97
|
pollTimer = setTimeout(() => void poll(deviceCode, nextInterval, deadline, currentSetup), nextInterval);
|
|
60
|
-
} catch (error) { if (currentSetup === setup) { authState.textContent =
|
|
98
|
+
} catch (error) { if (currentSetup === setup) { authState.textContent = `授权检查暂时失败,将重试:${String(error)}`; pollTimer = setTimeout(() => void poll(deviceCode, interval, deadline, currentSetup), interval); } }
|
|
61
99
|
}
|
|
62
100
|
find("#begin").onclick = async () => {
|
|
63
101
|
const currentSetup = ++setup;
|
|
@@ -74,13 +112,36 @@
|
|
|
74
112
|
authDetails.hidden = false;
|
|
75
113
|
authState.textContent = "等待飞书授权";
|
|
76
114
|
const interval = Math.max(1, Number(result.interval) || 5) * 1000;
|
|
77
|
-
|
|
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);
|
|
78
118
|
} catch (error) { if (currentSetup === setup) { authState.textContent = `启动授权失败:${String(error)}`; find("#begin").disabled = false; } }
|
|
79
119
|
};
|
|
80
|
-
credentials.
|
|
81
|
-
|
|
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
|
+
|
|
82
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)); };
|
|
83
|
-
find("#refresh").onclick = () => void load();
|
|
84
|
-
window.addEventListener("pagehide", () => { setup++; clearTimeout(pollTimer); });
|
|
85
|
-
void load();
|
|
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); }
|
|
86
147
|
})();
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
<header><h1>飞书机器人</h1><button id="refresh" type="button">刷新</button></header>
|
|
7
7
|
<p id="notice" role="status"></p>
|
|
8
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>
|
|
9
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>
|
|
10
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>
|
|
11
|
-
|
|
12
|
+
|
|
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>
|
|
13
14
|
</main>
|
|
14
15
|
<script src="/abweb/channels/feishu/app.js"></script>
|
|
@@ -23,3 +23,8 @@ button:hover { background: #e5f2ea; }
|
|
|
23
23
|
a { color: #176848; }
|
|
24
24
|
.error { color: #a83427; }
|
|
25
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; }
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bridge-feishu",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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
|
}
|