agent-loft 0.0.0-stage → 0.2.8
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/LICENSE +21 -0
- package/README.md +110 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/account.ts +122 -0
- package/src/agents/claude/driver.ts +216 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +260 -0
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +487 -0
- package/src/agents/codex/account.ts +122 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +210 -0
- package/src/agents/codex/errors.ts +158 -0
- package/src/agents/codex/protocol.ts +284 -0
- package/src/agents/codex/refusals.ts +62 -0
- package/src/agents/codex/session.ts +773 -0
- package/src/channels/feishu/bot.ts +669 -0
- package/src/channels/feishu/cards.ts +360 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +117 -0
- package/src/channels/feishu/index.ts +62 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +645 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/push.ts +218 -0
- package/src/channels/web/server.ts +446 -0
- package/src/cli/doctor.ts +39 -0
- package/src/cli/import.ts +155 -0
- package/src/cli/pair.ts +35 -0
- package/src/cli/passwd.ts +15 -0
- package/src/cli/service.ts +137 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +104 -0
- package/src/core/config.ts +285 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/footers.ts +88 -0
- package/src/core/hub.ts +1152 -0
- package/src/core/interactions.ts +273 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +178 -0
- package/src/core/types.ts +213 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +43 -0
- package/src/main.ts +68 -0
- package/web/app.js +3731 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-maskable.svg +13 -0
- package/web/icon.svg +12 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +15 -0
- package/web/style.css +2604 -0
- package/web/sw.js +76 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// 一个飞书机器人 = 一条长连接 + 一组会话。
|
|
2
|
+
//
|
|
3
|
+
// 它是 hub 的一个渠道:把飞书消息翻成 hub.send / hub.command,把 hub 的事件和
|
|
4
|
+
// 交互请求画成飞书卡片。同一条会话在 Web 上被操作时,这里照样收到事件 ——
|
|
5
|
+
// 所以 Web 上说的话、点的授权,飞书这边都会同步显示。
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
10
|
+
import { config, type FeishuBotConfig } from "../../core/config.js";
|
|
11
|
+
import { hub, type CommandReply, type EventEnvelope } from "../../core/hub.js";
|
|
12
|
+
import { interactions, type Interaction } from "../../core/interactions.js";
|
|
13
|
+
import { parseCommand } from "../../core/commands.js";
|
|
14
|
+
import { Dedup } from "../../core/dedup.js";
|
|
15
|
+
import { isPaired, requestPairing } from "../../core/pairing.js";
|
|
16
|
+
import { getConversation, updateConversation } from "../../core/store.js";
|
|
17
|
+
import { adoptFile, KIND_LABEL } from "../../core/uploads.js";
|
|
18
|
+
import type { SavedAttachment } from "../../core/types.js";
|
|
19
|
+
import { FeishuClient } from "./client.js";
|
|
20
|
+
import { SlashSync } from "./slash-sync.js";
|
|
21
|
+
import type { CommandEntry } from "../../core/command-catalog.js";
|
|
22
|
+
import {
|
|
23
|
+
agentPickerCard,
|
|
24
|
+
approvalCard,
|
|
25
|
+
commandListCard,
|
|
26
|
+
modelPickerCard,
|
|
27
|
+
progressCard,
|
|
28
|
+
questionCard,
|
|
29
|
+
replyCard,
|
|
30
|
+
sessionListCard,
|
|
31
|
+
settledCard,
|
|
32
|
+
} from "./cards.js";
|
|
33
|
+
import { decodeEnvelope, DECODE_FAILURE_TEXT, type CardCtx, type CardEnvelope } from "./envelope.js";
|
|
34
|
+
import { parseMessage, unsupportedTypeLabel, type AttachmentRef } from "./message.js";
|
|
35
|
+
import { TurnStream } from "./turn-stream.js";
|
|
36
|
+
|
|
37
|
+
type CardResponse = {
|
|
38
|
+
toast?: { type: "info" | "success" | "error" | "warning"; content: string };
|
|
39
|
+
card?: { type: "raw"; data: unknown };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type BotStatus = { state: string; since: number; error?: string };
|
|
43
|
+
|
|
44
|
+
export class FeishuBot {
|
|
45
|
+
readonly client: FeishuClient;
|
|
46
|
+
private ws: Lark.WSClient | null = null;
|
|
47
|
+
private dedup = new Dedup();
|
|
48
|
+
private streams = new Map<string, TurnStream>();
|
|
49
|
+
private typing = new Map<string, { messageId: string; reactionId: string }>();
|
|
50
|
+
/** 交互 id → 飞书卡片的 message_id */
|
|
51
|
+
private cards = new Map<string, string>();
|
|
52
|
+
/** 会话键 → 最后一个在飞书上说话的人。卡片默认只让他点 */
|
|
53
|
+
private operators = new Map<string, string>();
|
|
54
|
+
/** 附件要先下载才能送,紧跟着的文字不能抢在前面 —— 每条会话一条串行链 */
|
|
55
|
+
private chains = new Map<string, Promise<void>>();
|
|
56
|
+
private offs: (() => void)[] = [];
|
|
57
|
+
private watchdog: NodeJS.Timeout | null = null;
|
|
58
|
+
private downSince: number | undefined;
|
|
59
|
+
private stopped = false;
|
|
60
|
+
status: BotStatus = { state: "stopped", since: Date.now() };
|
|
61
|
+
|
|
62
|
+
/** 飞书输入框里「/」弹出的指令面板 */
|
|
63
|
+
readonly slash: SlashSync;
|
|
64
|
+
private slashTimer: NodeJS.Timeout | null = null;
|
|
65
|
+
|
|
66
|
+
constructor(readonly cfg: FeishuBotConfig) {
|
|
67
|
+
this.client = new FeishuClient(cfg.appId, cfg.appSecret, cfg.domain, `飞书:${cfg.name}`);
|
|
68
|
+
this.slash = new SlashSync(this.client.lark, () => this.slashCommands(), `[飞书:${cfg.name}]`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 这个机器人该注册的命令:默认 agent 的命令表在前,其它可用 agent 的原生命令接在后面。
|
|
73
|
+
* 飞书的指令面板是整个应用一份,不分会话,没法跟着每个会话当前的 agent 变。
|
|
74
|
+
*/
|
|
75
|
+
private async slashCommands(): Promise<CommandEntry[]> {
|
|
76
|
+
const agents = [this.cfg.defaultAgent, ...this.cfg.agents.filter((a) => a !== this.cfg.defaultAgent)];
|
|
77
|
+
const out: CommandEntry[] = [];
|
|
78
|
+
for (const agent of agents) {
|
|
79
|
+
if (!hub.driver(agent)) continue;
|
|
80
|
+
out.push(...(await hub.commandCatalog(agent, { allowedAgents: this.cfg.agents })));
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get id(): string {
|
|
86
|
+
return this.cfg.id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private get tag(): string {
|
|
90
|
+
return `[飞书:${this.cfg.name}]`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private get prefix(): string {
|
|
94
|
+
return `feishu:${this.cfg.id}:`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
keyOf(chatId: string, threadId?: string): string {
|
|
98
|
+
return `${this.prefix}${chatId}${threadId ? `:${threadId}` : ""}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private chatIdOf(key: string): string {
|
|
102
|
+
return key.slice(this.prefix.length).split(":")[0]!;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private mine(key: string): boolean {
|
|
106
|
+
return key.startsWith(this.prefix);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 这个机器人能用的 agent:配置里勾选的 ∩ 已启用的 */
|
|
110
|
+
allowedAgents(): string[] {
|
|
111
|
+
const enabled = new Set(hub.enabledAgents().map((d) => d.id));
|
|
112
|
+
return this.cfg.agents.filter((a) => enabled.has(a));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
isAuthorized(openId: string | undefined): boolean {
|
|
116
|
+
if (!openId) return false;
|
|
117
|
+
const id = openId.trim().toLowerCase();
|
|
118
|
+
return this.cfg.allowFrom.includes(id) || isPaired(this.cfg.id, id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------- 生命周期 ----------
|
|
122
|
+
|
|
123
|
+
start(): void {
|
|
124
|
+
this.stopped = false;
|
|
125
|
+
const onEvent = (env: EventEnvelope) => {
|
|
126
|
+
if (this.mine(env.key)) this.render(env);
|
|
127
|
+
};
|
|
128
|
+
hub.on("event", onEvent);
|
|
129
|
+
this.offs.push(() => hub.off("event", onEvent));
|
|
130
|
+
|
|
131
|
+
const onCreated = (i: Interaction) => this.mine(i.convKey) && void this.showInteraction(i);
|
|
132
|
+
const onUpdated = (i: Interaction) => this.mine(i.convKey) && void this.refreshInteraction(i);
|
|
133
|
+
const onSettled = (i: Interaction) => this.mine(i.convKey) && void this.refreshInteraction(i);
|
|
134
|
+
interactions.on("created", onCreated);
|
|
135
|
+
interactions.on("updated", onUpdated);
|
|
136
|
+
interactions.on("settled", onSettled);
|
|
137
|
+
this.offs.push(() => {
|
|
138
|
+
interactions.off("created", onCreated);
|
|
139
|
+
interactions.off("updated", onUpdated);
|
|
140
|
+
interactions.off("settled", onSettled);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
this.connect();
|
|
144
|
+
this.startWatchdog();
|
|
145
|
+
// 等 agent 起来再同步(Claude 的原生命令要起进程读);之后每 6 小时对一次账,新装的 skill 会跟上
|
|
146
|
+
this.slash.request(15_000);
|
|
147
|
+
this.slashTimer = setInterval(() => this.slash.request(0), 6 * 3600_000);
|
|
148
|
+
this.slashTimer.unref?.();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
stop(): void {
|
|
152
|
+
this.stopped = true;
|
|
153
|
+
this.slash.stop();
|
|
154
|
+
if (this.slashTimer) clearInterval(this.slashTimer);
|
|
155
|
+
this.slashTimer = null;
|
|
156
|
+
for (const off of this.offs.splice(0)) off();
|
|
157
|
+
if (this.watchdog) clearInterval(this.watchdog);
|
|
158
|
+
this.watchdog = null;
|
|
159
|
+
try {
|
|
160
|
+
this.ws?.close({ force: true });
|
|
161
|
+
} catch {
|
|
162
|
+
// 已经断了
|
|
163
|
+
}
|
|
164
|
+
this.ws = null;
|
|
165
|
+
this.setStatus("stopped");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private setStatus(state: string, error?: string): void {
|
|
169
|
+
if (this.status.state === state && !error) return;
|
|
170
|
+
this.status = { state, since: Date.now(), ...(error ? { error } : {}) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private connect(): void {
|
|
174
|
+
const dispatcher = new Lark.EventDispatcher({}).register({
|
|
175
|
+
"im.message.receive_v1": async (data: any) => {
|
|
176
|
+
try {
|
|
177
|
+
await this.handleMessage(data);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(`${this.tag} 处理消息出错:`, err);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"card.action.trigger": async (data: any) => {
|
|
183
|
+
try {
|
|
184
|
+
return await this.handleCardAction(data);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error(`${this.tag} 处理卡片回调出错:`, err);
|
|
187
|
+
return { toast: { type: "error", content: "处理失败,请看 agent-loft 日志。" } };
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
} as any);
|
|
191
|
+
|
|
192
|
+
this.setStatus("connecting");
|
|
193
|
+
this.ws = new Lark.WSClient({
|
|
194
|
+
appId: this.cfg.appId,
|
|
195
|
+
appSecret: this.cfg.appSecret,
|
|
196
|
+
domain: this.cfg.domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu,
|
|
197
|
+
loggerLevel: Lark.LoggerLevel.warn,
|
|
198
|
+
// 不设握手超时的话,DNS / 代理卡住时握手会无限期挂着,连重连循环都进不去
|
|
199
|
+
handshakeTimeoutMs: 30_000,
|
|
200
|
+
onReady: () => {
|
|
201
|
+
this.setStatus("connected");
|
|
202
|
+
console.log(`${this.tag} 长连接已就绪`);
|
|
203
|
+
},
|
|
204
|
+
onReconnecting: () => this.setStatus("reconnecting"),
|
|
205
|
+
onReconnected: () => this.setStatus("connected"),
|
|
206
|
+
// SDK 放弃了:它不会再自己起来。以前是整个进程退出重来,现在只重建这一条连接
|
|
207
|
+
onError: (err: Error) => {
|
|
208
|
+
this.setStatus("failed", err.message);
|
|
209
|
+
console.error(`${this.tag} 长连接彻底失败:${err.message},30 秒后重建`);
|
|
210
|
+
setTimeout(() => this.reconnect(), 30_000).unref?.();
|
|
211
|
+
},
|
|
212
|
+
} as any);
|
|
213
|
+
this.ws.start({ eventDispatcher: dispatcher });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private reconnect(): void {
|
|
217
|
+
if (this.stopped) return;
|
|
218
|
+
try {
|
|
219
|
+
this.ws?.close({ force: true });
|
|
220
|
+
} catch {
|
|
221
|
+
// ignore
|
|
222
|
+
}
|
|
223
|
+
this.ws = null;
|
|
224
|
+
this.downSince = undefined;
|
|
225
|
+
this.connect();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 看门狗:长连接放弃时只发一个 onError 就停手,而 ping 定时器还在续期,
|
|
230
|
+
* 进程活得好好的、消息却再也收不到。连续掉线超过阈值就重建连接。
|
|
231
|
+
*/
|
|
232
|
+
private startWatchdog(): void {
|
|
233
|
+
this.watchdog = setInterval(() => {
|
|
234
|
+
const limit = config().feishu.watchdogMs;
|
|
235
|
+
if (limit <= 0 || !this.ws) return;
|
|
236
|
+
const state = (this.ws as any).getConnectionStatus?.()?.state as string | undefined;
|
|
237
|
+
if (state === "connected") {
|
|
238
|
+
this.downSince = undefined;
|
|
239
|
+
if (this.status.state !== "connected") this.setStatus("connected");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.downSince ??= Date.now();
|
|
243
|
+
if (Date.now() - this.downSince >= limit) {
|
|
244
|
+
console.error(`${this.tag} 长连接已断开 ${Math.round((Date.now() - this.downSince) / 60000)} 分钟,重建`);
|
|
245
|
+
this.reconnect();
|
|
246
|
+
}
|
|
247
|
+
}, 30_000);
|
|
248
|
+
this.watchdog.unref?.();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------- 渲染 hub 事件 ----------
|
|
252
|
+
|
|
253
|
+
private stream(key: string): TurnStream {
|
|
254
|
+
let stream = this.streams.get(key);
|
|
255
|
+
if (stream) return stream;
|
|
256
|
+
const chatId = this.chatIdOf(key);
|
|
257
|
+
stream = new TurnStream({
|
|
258
|
+
sendCard: (card) => this.client.sendCard(chatId, card),
|
|
259
|
+
updateCard: (messageId, card) => this.client.updateCard(messageId, card),
|
|
260
|
+
renderProgress: (steps, done, footer) => progressCard({ steps, done, ...(footer ? { footer } : {}) }),
|
|
261
|
+
sendReply: (text) => this.client.sendCard(chatId, replyCard(text)),
|
|
262
|
+
onError: (err) => console.error(`${this.tag} 卡片更新失败:`, err),
|
|
263
|
+
});
|
|
264
|
+
this.streams.set(key, stream);
|
|
265
|
+
return stream;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private render(env: EventEnvelope): void {
|
|
269
|
+
const { key, ev } = env;
|
|
270
|
+
const chatId = this.chatIdOf(key);
|
|
271
|
+
switch (ev.type) {
|
|
272
|
+
case "user":
|
|
273
|
+
// 别的渠道(Web)说的话,同步一句过来,不然飞书这边看着没头没尾
|
|
274
|
+
if (ev.origin.channel !== "feishu" || ev.origin.channelId !== this.cfg.id) {
|
|
275
|
+
const files = ev.attachments.length ? `\n\n📎 ${ev.attachments.join("、")}` : "";
|
|
276
|
+
void this.client.sendCard(chatId, replyCard(`💻 **${ev.origin.name ?? "Web"}**:${ev.text}${files}`));
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
case "step":
|
|
280
|
+
this.stream(key).addStep(ev.text);
|
|
281
|
+
return;
|
|
282
|
+
case "text":
|
|
283
|
+
this.stream(key).addText(ev.text);
|
|
284
|
+
return;
|
|
285
|
+
case "result": {
|
|
286
|
+
const stream = this.stream(key);
|
|
287
|
+
stream.finish(ev.text, ev.footer);
|
|
288
|
+
void stream.drain().then(() => this.clearTyping(key));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case "error": {
|
|
292
|
+
const stream = this.stream(key);
|
|
293
|
+
stream.finish("", "出错");
|
|
294
|
+
void stream.drain().then(() => {
|
|
295
|
+
this.clearTyping(key);
|
|
296
|
+
void this.client.sendText(chatId, ev.message);
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
case "notice":
|
|
301
|
+
void this.client.sendCard(chatId, replyCard(ev.text));
|
|
302
|
+
return;
|
|
303
|
+
default:
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private cardCtx(key: string): CardCtx {
|
|
309
|
+
const operator = this.operators.get(key);
|
|
310
|
+
return {
|
|
311
|
+
...(operator ? { operatorOpenId: operator } : {}),
|
|
312
|
+
chatId: this.chatIdOf(key),
|
|
313
|
+
expiresAt: Date.now() + config().approvalTimeoutMs,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private interactionCard(i: Interaction, ctx?: CardCtx) {
|
|
318
|
+
if (i.settled) return settledCard(i);
|
|
319
|
+
const c = ctx ?? this.cardCtx(i.convKey);
|
|
320
|
+
if (i.spec.kind === "approval") return approvalCard(i, c);
|
|
321
|
+
return questionCard(i, c, hub.label(getConversation(i.convKey)?.agent ?? ""));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private async showInteraction(i: Interaction): Promise<void> {
|
|
325
|
+
const messageId = await this.client.sendCard(this.chatIdOf(i.convKey), this.interactionCard(i));
|
|
326
|
+
if (!messageId) return;
|
|
327
|
+
this.cards.set(i.id, messageId);
|
|
328
|
+
// 发卡的空当里可能已经在 Web 上处理掉了
|
|
329
|
+
const latest = interactions.get(i.id);
|
|
330
|
+
if (latest?.settled) await this.refreshInteraction(latest);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async refreshInteraction(i: Interaction): Promise<void> {
|
|
334
|
+
const messageId = this.cards.get(i.id);
|
|
335
|
+
if (!messageId) return;
|
|
336
|
+
if (i.settled) this.cards.delete(i.id);
|
|
337
|
+
await this.client.updateCard(messageId, this.interactionCard(i));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------- 正在输入 ----------
|
|
341
|
+
|
|
342
|
+
private clearTyping(key: string): void {
|
|
343
|
+
const typing = this.typing.get(key);
|
|
344
|
+
if (!typing) return;
|
|
345
|
+
this.typing.delete(key);
|
|
346
|
+
void this.client.removeReaction(typing.messageId, typing.reactionId);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async markTyping(key: string, messageId: string): Promise<void> {
|
|
350
|
+
this.clearTyping(key);
|
|
351
|
+
const reactionId = await this.client.addTypingReaction(messageId);
|
|
352
|
+
if (reactionId) this.typing.set(key, { messageId, reactionId });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private enqueue(key: string, task: () => Promise<void>): void {
|
|
356
|
+
const prev = this.chains.get(key) ?? Promise.resolve();
|
|
357
|
+
const next = prev
|
|
358
|
+
.then(task)
|
|
359
|
+
.catch((err) => console.error(`${this.tag} ${key} 处理消息失败:`, err))
|
|
360
|
+
.finally(() => {
|
|
361
|
+
if (this.chains.get(key) === next) this.chains.delete(key);
|
|
362
|
+
});
|
|
363
|
+
this.chains.set(key, next);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------- 收消息 ----------
|
|
367
|
+
|
|
368
|
+
private async handleMessage(data: any): Promise<void> {
|
|
369
|
+
const message = data?.message;
|
|
370
|
+
const openId: string | undefined = data?.sender?.sender_id?.open_id;
|
|
371
|
+
const chatId: string | undefined = message?.chat_id;
|
|
372
|
+
if (!message || !chatId) return;
|
|
373
|
+
if (this.dedup.isDuplicate(message.message_id)) return;
|
|
374
|
+
|
|
375
|
+
if (!this.isAuthorized(openId)) {
|
|
376
|
+
await this.handleUnknownSender(openId, chatId);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 群聊必须 @ 机器人才响应,私聊不用
|
|
381
|
+
const isGroup = message.chat_type === "group";
|
|
382
|
+
const mentioned = Array.isArray(message.mentions) && message.mentions.length > 0;
|
|
383
|
+
if (isGroup && !mentioned) return;
|
|
384
|
+
|
|
385
|
+
const parsed = parseMessage(message);
|
|
386
|
+
if (!parsed) {
|
|
387
|
+
await this.client.sendText(
|
|
388
|
+
chatId,
|
|
389
|
+
`暂不支持${unsupportedTypeLabel(message.message_type)}消息。目前支持文字、富文本、图片、文件、语音和视频。`,
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const text = parsed.text.replace(/@_user_\d+/g, "").trim();
|
|
394
|
+
const { attachments } = parsed;
|
|
395
|
+
if (!text && attachments.length === 0) return;
|
|
396
|
+
|
|
397
|
+
const allowed = this.allowedAgents();
|
|
398
|
+
if (allowed.length === 0) {
|
|
399
|
+
await this.client.sendText(chatId, "这个机器人没有可用的 agent,请到 agent-loft 设置里给它勾选至少一个。");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const key = this.keyOf(chatId, message.thread_id);
|
|
404
|
+
const conv = hub.ensure({
|
|
405
|
+
key,
|
|
406
|
+
channel: "feishu",
|
|
407
|
+
channelId: this.cfg.id,
|
|
408
|
+
defaultAgent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]!,
|
|
409
|
+
});
|
|
410
|
+
// 配置改过(这个机器人不再能用当前 agent):退回默认
|
|
411
|
+
if (!allowed.includes(conv.agent)) {
|
|
412
|
+
updateConversation(key, { agent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]! });
|
|
413
|
+
}
|
|
414
|
+
this.operators.set(key, openId!.trim().toLowerCase());
|
|
415
|
+
|
|
416
|
+
const command = attachments.length > 0 ? ({ kind: "chat", text } as const) : parseCommand(text);
|
|
417
|
+
if (command.kind !== "chat") {
|
|
418
|
+
try {
|
|
419
|
+
const reply = await hub.command(key, command, { allowedAgents: allowed, by: "飞书命令" });
|
|
420
|
+
await this.renderReply(key, reply);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
await this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`);
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
void this.markTyping(key, message.message_id);
|
|
428
|
+
const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
|
|
429
|
+
// 收到消息这一刻的代次:下载附件期间点了 /stop、或者 /cd /new 换了会话,这条就作废
|
|
430
|
+
const gen = hub.generation(key);
|
|
431
|
+
this.enqueue(key, async () => {
|
|
432
|
+
if (attachments.length === 0) {
|
|
433
|
+
hub.send(key, { text: command.text, attachments: [] }, origin, gen);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const { saved, failed } = await this.download(key, message.message_id, attachments);
|
|
437
|
+
if (failed.length > 0) {
|
|
438
|
+
await this.client.sendText(
|
|
439
|
+
chatId,
|
|
440
|
+
["有附件没能下载下来:", ...failed.map((f) => `- ${f.ref.name ?? KIND_LABEL[f.ref.kind]}:${f.reason}`)].join("\n"),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (saved.length === 0 && !command.text) {
|
|
444
|
+
this.clearTyping(key);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
hub.send(key, { text: command.text, attachments: saved }, origin, gen);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async download(
|
|
452
|
+
key: string,
|
|
453
|
+
messageId: string,
|
|
454
|
+
refs: AttachmentRef[],
|
|
455
|
+
): Promise<{ saved: SavedAttachment[]; failed: { ref: AttachmentRef; reason: string }[] }> {
|
|
456
|
+
const saved: SavedAttachment[] = [];
|
|
457
|
+
const failed: { ref: AttachmentRef; reason: string }[] = [];
|
|
458
|
+
for (const ref of refs) {
|
|
459
|
+
const tmp = path.join(os.tmpdir(), `aim-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
460
|
+
try {
|
|
461
|
+
await this.client.downloadResource(messageId, ref.key, ref.kind === "image" ? "image" : "file", tmp);
|
|
462
|
+
saved.push(
|
|
463
|
+
adoptFile({
|
|
464
|
+
key,
|
|
465
|
+
tmpPath: tmp,
|
|
466
|
+
kind: ref.kind,
|
|
467
|
+
...(ref.name ? { name: ref.name } : {}),
|
|
468
|
+
...(ref.durationMs ? { durationMs: ref.durationMs } : {}),
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
fs.rmSync(tmp, { force: true });
|
|
473
|
+
failed.push({ ref, reason: err instanceof Error ? err.message : String(err) });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return { saved, failed };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private async renderReply(key: string, reply: CommandReply): Promise<void> {
|
|
480
|
+
const chatId = this.chatIdOf(key);
|
|
481
|
+
const ctx = this.cardCtx(key);
|
|
482
|
+
switch (reply.type) {
|
|
483
|
+
case "text":
|
|
484
|
+
await this.client.sendCard(chatId, replyCard(reply.text));
|
|
485
|
+
return;
|
|
486
|
+
case "sessions":
|
|
487
|
+
await this.client.sendCard(
|
|
488
|
+
chatId,
|
|
489
|
+
sessionListCard({
|
|
490
|
+
agentLabel: hub.label(reply.agent),
|
|
491
|
+
sessions: reply.sessions,
|
|
492
|
+
...(reply.current ? { current: reply.current } : {}),
|
|
493
|
+
cwd: reply.cwd,
|
|
494
|
+
allDirs: reply.allDirs,
|
|
495
|
+
fellBack: reply.fellBack,
|
|
496
|
+
sessionKey: key,
|
|
497
|
+
ctx,
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
return;
|
|
501
|
+
case "models":
|
|
502
|
+
await this.client.sendCard(
|
|
503
|
+
chatId,
|
|
504
|
+
modelPickerCard({
|
|
505
|
+
agentLabel: hub.label(reply.agent),
|
|
506
|
+
models: reply.models,
|
|
507
|
+
...(reply.current ? { current: reply.current } : {}),
|
|
508
|
+
...(reply.currentEffort ? { currentEffort: reply.currentEffort } : {}),
|
|
509
|
+
effortSupported: reply.effortSupported,
|
|
510
|
+
sessionKey: key,
|
|
511
|
+
ctx,
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
return;
|
|
515
|
+
case "agents":
|
|
516
|
+
await this.client.sendCard(chatId, agentPickerCard({ ...reply, sessionKey: key, ctx }));
|
|
517
|
+
return;
|
|
518
|
+
case "commands":
|
|
519
|
+
await this.client.sendCard(
|
|
520
|
+
chatId,
|
|
521
|
+
commandListCard({ agentLabel: hub.label(reply.agent), commands: reply.commands, sessionKey: key, ctx }),
|
|
522
|
+
);
|
|
523
|
+
return;
|
|
524
|
+
case "none":
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 陌生人:不处理内容,只发一个配对码。同一个人在码有效期内不会被反复回复。 */
|
|
530
|
+
private async handleUnknownSender(openId: string | undefined, chatId: string): Promise<void> {
|
|
531
|
+
if (!openId) return;
|
|
532
|
+
const outcome = requestPairing(this.cfg.id, { userId: openId, chatId });
|
|
533
|
+
if (outcome.kind === "rejected_full") {
|
|
534
|
+
console.warn(`${this.tag} 待批准配对已达上限,忽略 ${openId}`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (outcome.kind !== "created") return;
|
|
538
|
+
const { code } = outcome.request;
|
|
539
|
+
console.log(`${this.tag} 新的接入请求 ${openId},配对码 ${code}(Web 设置页批准,或运行 agent-loft pair approve ${code})`);
|
|
540
|
+
await this.client.sendText(
|
|
541
|
+
chatId,
|
|
542
|
+
`你还没有被授权使用这台机器。\n\n配对码:${code}\n\n请让机器主人在 agent-loft 的 Web 设置页里批准,或在终端运行:\nagent-loft pair approve ${code}\n\n批准后我会通知你。`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** 配对被批准后通知等待中的人 */
|
|
547
|
+
notifyApproved(chatId: string): void {
|
|
548
|
+
void this.client.sendText(chatId, "已获授权,现在可以直接跟我说话了。");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---------- 卡片回调 ----------
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* 响应体里带 card 是让卡片「点完就变样」的唯一可靠途径:飞书客户端会拿它重渲这条
|
|
555
|
+
* 消息,没有 card 就把本地旧卡片渲染回去(闪一下又变回可点)。另外必须 3 秒内响应,
|
|
556
|
+
* 耗时的活儿一律先应答再后台做。
|
|
557
|
+
*/
|
|
558
|
+
private async handleCardAction(data: any): Promise<CardResponse> {
|
|
559
|
+
const operatorOpenId: string | undefined = data?.operator?.open_id;
|
|
560
|
+
const chatId: string | undefined = data?.context?.open_chat_id ?? data?.context?.chat_id;
|
|
561
|
+
const dedupKey =
|
|
562
|
+
data?.token ??
|
|
563
|
+
data?.event_id ??
|
|
564
|
+
`${operatorOpenId}:${data?.context?.open_message_id}:${JSON.stringify(data?.action?.value)}`;
|
|
565
|
+
if (this.dedup.isDuplicate(`card:${dedupKey}`)) return {};
|
|
566
|
+
|
|
567
|
+
const decoded = decodeEnvelope({
|
|
568
|
+
value: data?.action?.value,
|
|
569
|
+
operatorOpenId,
|
|
570
|
+
chatId,
|
|
571
|
+
isAllowed: (id) => this.isAuthorized(id),
|
|
572
|
+
});
|
|
573
|
+
if (!decoded.ok) return { toast: { type: "error", content: DECODE_FAILURE_TEXT[decoded.reason] } };
|
|
574
|
+
const envelope: CardEnvelope = decoded.envelope;
|
|
575
|
+
const by = "已在飞书处理";
|
|
576
|
+
const stale = (i: Interaction | undefined): CardResponse => ({
|
|
577
|
+
toast: {
|
|
578
|
+
type: "error",
|
|
579
|
+
content: i?.settled
|
|
580
|
+
? `这个请求已经处理过了(${i.settled.by})。`
|
|
581
|
+
: "这个请求已失效(超时、已处理,或 agent-loft 重启过),请重新发一次你的需求。",
|
|
582
|
+
},
|
|
583
|
+
...(i?.settled ? { card: { type: "raw", data: settledCard(i) } } : {}),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
switch (envelope.a) {
|
|
587
|
+
case "approval": {
|
|
588
|
+
const settled = interactions.decide(envelope.r, envelope.d, by);
|
|
589
|
+
if (!settled) return stale(interactions.get(envelope.r));
|
|
590
|
+
this.cards.delete(settled.id);
|
|
591
|
+
return {
|
|
592
|
+
toast: { type: "success", content: envelope.d === "deny" ? "已拒绝" : "已允许" },
|
|
593
|
+
card: { type: "raw", data: settledCard(settled) },
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
case "ask": {
|
|
597
|
+
const state = interactions.select(envelope.r, envelope.q, envelope.o, envelope.v === 1, by);
|
|
598
|
+
if (!state) return stale(interactions.get(envelope.r));
|
|
599
|
+
if (state.submitted) {
|
|
600
|
+
this.cards.delete(state.interaction.id);
|
|
601
|
+
return { toast: { type: "success", content: "已回答" }, card: { type: "raw", data: settledCard(state.interaction) } };
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
toast: { type: "info", content: "已勾选" },
|
|
605
|
+
card: { type: "raw", data: this.interactionCard(state.interaction) },
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
case "ask-submit": {
|
|
609
|
+
const result = interactions.submit(envelope.r, by);
|
|
610
|
+
if (!result) return stale(interactions.get(envelope.r));
|
|
611
|
+
if ("missing" in result) return { toast: { type: "warning", content: "还有问题没选,选完再提交。" } };
|
|
612
|
+
this.cards.delete(result.id);
|
|
613
|
+
return { toast: { type: "success", content: "已回答" }, card: { type: "raw", data: settledCard(result) } };
|
|
614
|
+
}
|
|
615
|
+
case "ask-skip": {
|
|
616
|
+
const result = interactions.skip(envelope.r, "已在飞书跳过,agent 会按合理的默认继续");
|
|
617
|
+
if (!result) return stale(interactions.get(envelope.r));
|
|
618
|
+
this.cards.delete(result.id);
|
|
619
|
+
return { toast: { type: "info", content: "已跳过" }, card: { type: "raw", data: settledCard(result) } };
|
|
620
|
+
}
|
|
621
|
+
case "resume":
|
|
622
|
+
this.background(envelope.k, () => hub.resume(envelope.k, envelope.s, envelope.c));
|
|
623
|
+
return { toast: { type: "info", content: "正在恢复该会话…" } };
|
|
624
|
+
case "model":
|
|
625
|
+
this.background(envelope.k, () => hub.setModel(envelope.k, envelope.m));
|
|
626
|
+
return { toast: { type: "success", content: `正在切换到 ${envelope.m}` } };
|
|
627
|
+
case "effort":
|
|
628
|
+
this.background(envelope.k, () => hub.setEffort(envelope.k, envelope.f));
|
|
629
|
+
return { toast: { type: "success", content: `思考程度设为 ${envelope.f}` } };
|
|
630
|
+
case "agent":
|
|
631
|
+
this.background(envelope.k, () => hub.setAgent(envelope.k, envelope.g, this.allowedAgents()));
|
|
632
|
+
return { toast: { type: "success", content: `正在切换到 ${hub.label(envelope.g)}` } };
|
|
633
|
+
case "cmd":
|
|
634
|
+
return this.runCardCommand(envelope.k, `/${envelope.c}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 命令表上点「执行」:等同于用户自己发了这条命令。
|
|
640
|
+
* agent-loft 的命令就地执行并回卡片;agent 原生命令(skills 等)当一条消息发给 agent。
|
|
641
|
+
*/
|
|
642
|
+
private runCardCommand(key: string, text: string): CardResponse {
|
|
643
|
+
if (!this.mine(key) || !getConversation(key)) {
|
|
644
|
+
return { toast: { type: "error", content: "这条会话已不存在,请重新发一次「/」。" } };
|
|
645
|
+
}
|
|
646
|
+
const command = parseCommand(text);
|
|
647
|
+
if (command.kind === "chat") {
|
|
648
|
+
const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
|
|
649
|
+
const gen = hub.generation(key);
|
|
650
|
+
this.enqueue(key, async () => hub.send(key, { text, attachments: [] }, origin, gen));
|
|
651
|
+
return { toast: { type: "success", content: `已发送 ${text}` } };
|
|
652
|
+
}
|
|
653
|
+
const chatId = this.chatIdOf(key);
|
|
654
|
+
void hub
|
|
655
|
+
.command(key, command, { allowedAgents: this.allowedAgents(), by: "飞书命令" })
|
|
656
|
+
.then((reply) => this.renderReply(key, reply))
|
|
657
|
+
.catch((err) => this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`));
|
|
658
|
+
return { toast: { type: "info", content: `正在执行 ${text}` } };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** 卡片回调里耗时的操作:先应答,做完在会话里回一句。 */
|
|
662
|
+
private background(key: string, task: () => Promise<string>): void {
|
|
663
|
+
if (!this.mine(key) || !getConversation(key)) return;
|
|
664
|
+
const chatId = this.chatIdOf(key);
|
|
665
|
+
void task()
|
|
666
|
+
.then((text) => this.client.sendCard(chatId, replyCard(text)))
|
|
667
|
+
.catch((err) => this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`));
|
|
668
|
+
}
|
|
669
|
+
}
|