@keyou007/agent-bot 0.1.1 → 0.1.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/CHANGELOG.md +6 -0
- package/README.md +4 -0
- package/README.zh.md +4 -0
- package/config.example.yaml +0 -2
- package/dist/cli/Initializer.js +155 -11
- package/dist/cli/Initializer.js.map +1 -1
- package/dist/cli/LocalControlClient.js +14 -1
- package/dist/cli/LocalControlClient.js.map +1 -1
- package/dist/cli.js +70 -50
- package/dist/cli.js.map +1 -1
- package/dist/config/loadConfig.js +0 -2
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +1 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +79 -13
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/transport.js +4 -11
- package/dist/feishu/transport.js.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/startup/startFeishu.js +2 -1
- package/dist/startup/startFeishu.js.map +1 -1
- package/dist/supervisor.js +7 -4
- package/dist/supervisor.js.map +1 -1
- package/docs/technical-reference.md +6 -10
- package/docs/technical-reference.zh.md +6 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +1 -1
- package/src/cli/Initializer.ts +164 -11
- package/src/cli/LocalControlClient.ts +12 -1
- package/src/cli.ts +77 -46
- package/src/config/loadConfig.ts +0 -2
- package/src/config/schema.ts +1 -1
- package/src/feishu/FeishuConnector.ts +89 -12
- package/src/feishu/transport.ts +7 -16
- package/src/index.ts +14 -3
- package/src/startup/startFeishu.ts +2 -0
- package/src/supervisor.ts +7 -4
|
@@ -60,10 +60,12 @@ Agent Bot 是基于 Node.js 22+、ESM 和 TypeScript 的应用,主要组件如
|
|
|
60
60
|
- 进程环境变量优先于 `~/.agent-bot/.env`
|
|
61
61
|
- `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 必须同时存在
|
|
62
62
|
- 除非使用 `--reconfigure-feishu`,完整凭据不会被替换
|
|
63
|
-
-
|
|
64
|
-
-
|
|
63
|
+
- 缺少凭据或只有一项时会重新创建应用
|
|
64
|
+
- 新创建的凭据经过 fsync、原子替换写入 `.env`,并在配置权限前读回校验
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
初始化期间会持有 `~/.agent-bot/init.lock`,避免使用不同配置路径的并发命令重复创建应用。进程异常退出遗留的锁会在下次运行时恢复,凭据写入中断留下的临时文件也会被清理。
|
|
67
|
+
|
|
68
|
+
没有完整凭据时,初始化会启动飞书一键创建流程,并以文本和二维码显示验证链接。注册中的设备码不会恢复:如果进程在完整凭据持久化前退出,下次会重新创建应用。凭据持久化后会检查应用当前已发布版本;这一权限审计阶段中断后可以安全继续。
|
|
67
69
|
|
|
68
70
|
缺失配置分两阶段处理:
|
|
69
71
|
|
|
@@ -103,10 +105,8 @@ Agent Bot 是基于 Node.js 22+、ESM 和 TypeScript 的应用,主要组件如
|
|
|
103
105
|
|
|
104
106
|
```yaml
|
|
105
107
|
feishu:
|
|
106
|
-
transport: "auto"
|
|
107
108
|
appId: "${FEISHU_APP_ID}"
|
|
108
109
|
appSecret: "${FEISHU_APP_SECRET}"
|
|
109
|
-
useConsoleWhenMissingCredentials: true
|
|
110
110
|
|
|
111
111
|
console:
|
|
112
112
|
enabled: true
|
|
@@ -131,11 +131,7 @@ logging:
|
|
|
131
131
|
path: "./logs/agent-bot.log"
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
- `auto`:存在凭据时使用 SDK,否则按 `useConsoleWhenMissingCredentials` 决定是否回退 Console
|
|
137
|
-
- `sdk`:要求飞书凭据并启动长连接客户端
|
|
138
|
-
- `console`:禁用飞书传输
|
|
134
|
+
`agent-bot server start` 要求同时配置飞书 `appId` 和 `appSecret`。命令会等待 SDK 的 WebSocket 长连接建立后才报告 Server 就绪;缺少凭据时启动失败并提示先初始化。`agent-bot console` 是明确的纯本地入口,不需要飞书凭据。
|
|
139
135
|
|
|
140
136
|
必须至少配置一个 Agent,`defaults.agent` 必须指向已配置的 Agent。
|
|
141
137
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keyou007/agent-bot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@keyou007/agent-bot",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.2",
|
|
10
10
|
"license": "GPL-3.0-only",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@agentclientprotocol/sdk": "1.2.1",
|
package/package.json
CHANGED
|
@@ -50,7 +50,7 @@ Initialization creates the Agent Bot home, `config.yaml`, `.env`, `data/`, and `
|
|
|
50
50
|
|
|
51
51
|
When running `init` on the user's behalf, relay every exact authorization or incremental-configuration URL and let the user complete it in their own browser. The command waits only for core message receipt and delivery capabilities. Optional capabilities such as group creation, group-title synchronization, reactions, images, and card actions do not block initialization; relay the final affected-feature warnings when they remain unavailable. Neither URL contains the app secret, and the secret itself is never printed.
|
|
52
52
|
|
|
53
|
-
Existing complete Feishu credentials are preserved but still audited and repaired when needed. Use `agent-bot init --skip-feishu` only for console-only initialization because it skips both registration and configuration auditing. Use `agent-bot init --reconfigure-feishu` only when the user explicitly wants to replace existing credentials. The command respects `AGENT_BOT_HOME`, `AGENT_BOT_CONFIG`, and `--config <path>`. Use `agent-bot init --json` when structured final output helps.
|
|
53
|
+
Existing complete Feishu credentials are preserved but still audited and repaired when needed. Missing or incomplete credentials mean app creation did not complete, so a later `init` starts a new registration instead of resuming the previous device code. Once both credentials are durably saved, an interrupted permission audit resumes against the same remote app. `init` also holds a local lock to prevent concurrent registrations. Use `agent-bot init --skip-feishu` only for console-only initialization because it skips both registration and configuration auditing. Use `agent-bot init --reconfigure-feishu` only when the user explicitly wants to replace existing complete credentials. The command respects `AGENT_BOT_HOME`, `AGENT_BOT_CONFIG`, and `--config <path>`. Use `agent-bot init --json` when structured final output helps.
|
|
54
54
|
|
|
55
55
|
## Start with inspection
|
|
56
56
|
|
package/src/cli/Initializer.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { parse as parseDotEnv } from "dotenv";
|
|
@@ -44,8 +46,15 @@ export interface FeishuCredentials {
|
|
|
44
46
|
appSecret: string;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
export interface InitializationLock {
|
|
50
|
+
path: string;
|
|
51
|
+
release(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
const DEFAULT_CONFIG_TEMPLATE = fileURLToPath(new URL("../../config.example.yaml", import.meta.url));
|
|
48
55
|
const DEFAULT_ENV_TEMPLATE = fileURLToPath(new URL("../../.env.example", import.meta.url));
|
|
56
|
+
const INITIALIZATION_LOCK_NAME = "init.lock";
|
|
57
|
+
const MAX_INITIALIZATION_LOCK_AGE_MS = 30 * 60_000;
|
|
49
58
|
|
|
50
59
|
export function initializeAgentBot(options: InitializationOptions = {}): InitializationResult {
|
|
51
60
|
const env = options.env ?? process.env;
|
|
@@ -63,14 +72,7 @@ export function initializeAgentBot(options: InitializationOptions = {}): Initial
|
|
|
63
72
|
const home = ensureDirectory(homePath);
|
|
64
73
|
fs.mkdirSync(configDirectory, { recursive: true });
|
|
65
74
|
|
|
66
|
-
|
|
67
|
-
if (fs.existsSync(configPath)) {
|
|
68
|
-
config = { path: configPath, status: "existing" };
|
|
69
|
-
} else {
|
|
70
|
-
fs.copyFileSync(configTemplatePath, configPath, fs.constants.COPYFILE_EXCL);
|
|
71
|
-
config = { path: configPath, status: "created" };
|
|
72
|
-
}
|
|
73
|
-
|
|
75
|
+
const config = copyIfMissing(configTemplatePath, configPath);
|
|
74
76
|
const envFile = copyIfMissing(envTemplatePath, envPath);
|
|
75
77
|
restrictFilePermissions(envFile.path);
|
|
76
78
|
const data = ensureDirectory(path.join(configDirectory, "data"));
|
|
@@ -97,15 +99,93 @@ export function writeFeishuCredentials(envPath: string, credentials: FeishuCrede
|
|
|
97
99
|
|
|
98
100
|
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
|
99
101
|
const temporaryPath = `${envPath}.${process.pid}.${Date.now()}.tmp`;
|
|
102
|
+
let temporaryFile: number | undefined;
|
|
100
103
|
try {
|
|
101
|
-
fs.
|
|
104
|
+
temporaryFile = fs.openSync(temporaryPath, "wx", 0o600);
|
|
105
|
+
fs.writeFileSync(temporaryFile, updated, "utf8");
|
|
106
|
+
fs.fsyncSync(temporaryFile);
|
|
107
|
+
fs.closeSync(temporaryFile);
|
|
108
|
+
temporaryFile = undefined;
|
|
102
109
|
fs.renameSync(temporaryPath, envPath);
|
|
110
|
+
syncDirectory(path.dirname(envPath));
|
|
103
111
|
restrictFilePermissions(envPath);
|
|
112
|
+
const persisted = readFeishuCredentials(envPath, {});
|
|
113
|
+
if (
|
|
114
|
+
persisted.status !== "configured"
|
|
115
|
+
|| persisted.appId !== credentials.appId
|
|
116
|
+
|| persisted.appSecret !== credentials.appSecret
|
|
117
|
+
) {
|
|
118
|
+
throw new Error("飞书凭据写入后校验失败。");
|
|
119
|
+
}
|
|
104
120
|
} finally {
|
|
121
|
+
if (temporaryFile !== undefined) fs.closeSync(temporaryFile);
|
|
105
122
|
fs.rmSync(temporaryPath, { force: true });
|
|
106
123
|
}
|
|
107
124
|
}
|
|
108
125
|
|
|
126
|
+
export function shouldCreateFeishuApp(
|
|
127
|
+
credentials: FeishuCredentialState,
|
|
128
|
+
reconfigureFeishu: boolean,
|
|
129
|
+
): boolean {
|
|
130
|
+
return reconfigureFeishu || credentials.status !== "configured";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function acquireInitializationLock(lockDirectory: string): InitializationLock {
|
|
134
|
+
fs.mkdirSync(lockDirectory, { recursive: true });
|
|
135
|
+
const lockPath = path.join(lockDirectory, INITIALIZATION_LOCK_NAME);
|
|
136
|
+
const owner = {
|
|
137
|
+
token: randomUUID(),
|
|
138
|
+
pid: process.pid,
|
|
139
|
+
hostname: os.hostname(),
|
|
140
|
+
startedAt: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
144
|
+
let lockFile: number | undefined;
|
|
145
|
+
let created = false;
|
|
146
|
+
try {
|
|
147
|
+
lockFile = fs.openSync(lockPath, "wx", 0o600);
|
|
148
|
+
created = true;
|
|
149
|
+
fs.writeFileSync(lockFile, `${JSON.stringify(owner)}\n`, "utf8");
|
|
150
|
+
fs.fsyncSync(lockFile);
|
|
151
|
+
fs.closeSync(lockFile);
|
|
152
|
+
lockFile = undefined;
|
|
153
|
+
return {
|
|
154
|
+
path: lockPath,
|
|
155
|
+
release: () => {
|
|
156
|
+
const current = readInitializationLock(lockPath);
|
|
157
|
+
if (current?.token === owner.token) fs.rmSync(lockPath, { force: true });
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (lockFile !== undefined) fs.closeSync(lockFile);
|
|
162
|
+
if (created) fs.rmSync(lockPath, { force: true });
|
|
163
|
+
if (!isFileExistsError(error)) throw error;
|
|
164
|
+
if (!isStaleInitializationLock(lockPath)) {
|
|
165
|
+
throw new Error(`另一个 agent-bot init 正在运行(锁文件:${lockPath})。`);
|
|
166
|
+
}
|
|
167
|
+
fs.rmSync(lockPath, { force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new Error(`无法获取初始化锁:${lockPath}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function cleanupFeishuCredentialTemporaryFiles(envPath: string): number {
|
|
175
|
+
const directory = path.dirname(envPath);
|
|
176
|
+
if (!fs.existsSync(directory)) return 0;
|
|
177
|
+
const prefix = `${path.basename(envPath)}.`;
|
|
178
|
+
let removed = 0;
|
|
179
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
180
|
+
if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(".tmp")) continue;
|
|
181
|
+
const middle = entry.name.slice(prefix.length, -".tmp".length);
|
|
182
|
+
if (!/^\d+\.\d+$/.test(middle)) continue;
|
|
183
|
+
fs.rmSync(path.join(directory, entry.name), { force: true });
|
|
184
|
+
removed += 1;
|
|
185
|
+
}
|
|
186
|
+
return removed;
|
|
187
|
+
}
|
|
188
|
+
|
|
109
189
|
function assertTemplate(templatePath: string): void {
|
|
110
190
|
if (!fs.statSync(templatePath, { throwIfNoEntry: false })?.isFile()) {
|
|
111
191
|
throw new Error(`Initialization template does not exist: ${templatePath}`);
|
|
@@ -121,8 +201,13 @@ function ensureDirectory(directoryPath: string): InitializedPath {
|
|
|
121
201
|
function copyIfMissing(sourcePath: string, targetPath: string): InitializedPath {
|
|
122
202
|
if (fs.existsSync(targetPath)) return { path: targetPath, status: "existing" };
|
|
123
203
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
124
|
-
|
|
125
|
-
|
|
204
|
+
try {
|
|
205
|
+
fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL);
|
|
206
|
+
return { path: targetPath, status: "created" };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (isFileExistsError(error)) return { path: targetPath, status: "existing" };
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
126
211
|
}
|
|
127
212
|
|
|
128
213
|
function upsertDotEnvValue(contents: string, key: string, value: string): string {
|
|
@@ -154,6 +239,74 @@ function restrictFilePermissions(filePath: string): void {
|
|
|
154
239
|
}
|
|
155
240
|
}
|
|
156
241
|
|
|
242
|
+
function syncDirectory(directoryPath: string): void {
|
|
243
|
+
let directory: number | undefined;
|
|
244
|
+
try {
|
|
245
|
+
directory = fs.openSync(directoryPath, "r");
|
|
246
|
+
fs.fsyncSync(directory);
|
|
247
|
+
} catch {
|
|
248
|
+
// Directory fsync is not supported on every Windows or mounted filesystem.
|
|
249
|
+
} finally {
|
|
250
|
+
if (directory !== undefined) fs.closeSync(directory);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface InitializationLockOwner {
|
|
255
|
+
token: string;
|
|
256
|
+
pid: number;
|
|
257
|
+
hostname: string;
|
|
258
|
+
startedAt: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readInitializationLock(lockPath: string): InitializationLockOwner | undefined {
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as unknown;
|
|
264
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
265
|
+
const value = parsed as Record<string, unknown>;
|
|
266
|
+
if (
|
|
267
|
+
typeof value.token !== "string"
|
|
268
|
+
|| typeof value.pid !== "number"
|
|
269
|
+
|| typeof value.hostname !== "string"
|
|
270
|
+
|| typeof value.startedAt !== "string"
|
|
271
|
+
) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
token: value.token,
|
|
276
|
+
pid: value.pid,
|
|
277
|
+
hostname: value.hostname,
|
|
278
|
+
startedAt: value.startedAt,
|
|
279
|
+
};
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isStaleInitializationLock(lockPath: string): boolean {
|
|
286
|
+
const owner = readInitializationLock(lockPath);
|
|
287
|
+
const modifiedAt = fs.statSync(lockPath, { throwIfNoEntry: false })?.mtimeMs ?? Date.now();
|
|
288
|
+
const ageMs = Date.now() - modifiedAt;
|
|
289
|
+
if (!owner) return ageMs >= 5_000;
|
|
290
|
+
if (owner.hostname === os.hostname()) return !isProcessRunning(owner.pid);
|
|
291
|
+
const startedAt = Date.parse(owner.startedAt);
|
|
292
|
+
const ownerAgeMs = Number.isFinite(startedAt) ? Date.now() - startedAt : ageMs;
|
|
293
|
+
return ownerAgeMs >= MAX_INITIALIZATION_LOCK_AGE_MS;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isProcessRunning(pid: number): boolean {
|
|
297
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
298
|
+
try {
|
|
299
|
+
process.kill(pid, 0);
|
|
300
|
+
return true;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isFileExistsError(error: unknown): boolean {
|
|
307
|
+
return (error as NodeJS.ErrnoException)?.code === "EEXIST";
|
|
308
|
+
}
|
|
309
|
+
|
|
157
310
|
function firstNonBlank(...values: Array<string | undefined>): string | undefined {
|
|
158
311
|
return values.find((value) => value !== undefined && value.trim().length > 0);
|
|
159
312
|
}
|
|
@@ -41,7 +41,18 @@ export async function sendControlRequest(
|
|
|
41
41
|
export async function isServerRunning(endpoint: string): Promise<boolean> {
|
|
42
42
|
try {
|
|
43
43
|
const response = await sendControlRequest(endpoint, { action: "health" }, 1_000);
|
|
44
|
-
|
|
44
|
+
if (!response.ok) return false;
|
|
45
|
+
if (!response.data || typeof response.data !== "object") return true;
|
|
46
|
+
return (response.data as Record<string, unknown>).ready !== false;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function isServerReachable(endpoint: string): Promise<boolean> {
|
|
53
|
+
try {
|
|
54
|
+
await sendControlRequest(endpoint, { action: "health" }, 1_000);
|
|
55
|
+
return true;
|
|
45
56
|
} catch {
|
|
46
57
|
return false;
|
|
47
58
|
}
|
package/src/cli.ts
CHANGED
|
@@ -3,15 +3,18 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import qrcode from "qrcode-terminal";
|
|
5
5
|
import { loadConfig } from "./config/loadConfig.js";
|
|
6
|
-
import { sendControlRequest, isServerRunning } from "./cli/LocalControlClient.js";
|
|
6
|
+
import { sendControlRequest, isServerReachable, isServerRunning } from "./cli/LocalControlClient.js";
|
|
7
7
|
import {
|
|
8
8
|
controlEndpoint,
|
|
9
9
|
type ControlResponse,
|
|
10
10
|
type TaskStatusControlData,
|
|
11
11
|
} from "./cli/controlProtocol.js";
|
|
12
12
|
import {
|
|
13
|
+
acquireInitializationLock,
|
|
14
|
+
cleanupFeishuCredentialTemporaryFiles,
|
|
13
15
|
initializeAgentBot,
|
|
14
16
|
readFeishuCredentials,
|
|
17
|
+
shouldCreateFeishuApp,
|
|
15
18
|
writeFeishuCredentials,
|
|
16
19
|
type InitializationResult,
|
|
17
20
|
type InitializationStatus,
|
|
@@ -27,6 +30,7 @@ import {
|
|
|
27
30
|
type FeishuConfigurationChallenge,
|
|
28
31
|
} from "./cli/FeishuAppConfiguration.js";
|
|
29
32
|
import { renderCliHelp } from "./cli/help.js";
|
|
33
|
+
import { requireServerFeishuTransport } from "./feishu/transport.js";
|
|
30
34
|
import { resolveSystemSkillsRoot, SkillRegistry, type SkillRegistrationStatus } from "./cli/SkillRegistry.js";
|
|
31
35
|
import { readPackageVersion } from "./cli/packageVersion.js";
|
|
32
36
|
import { taskChatRoute } from "./cli/taskChatRoute.js";
|
|
@@ -97,57 +101,73 @@ async function initCommand(input: string[], configPath?: string): Promise<void>
|
|
|
97
101
|
|
|
98
102
|
const paths = initializeAgentBot({ configPath });
|
|
99
103
|
if (!json) printInitializationPaths(paths);
|
|
104
|
+
const initializationLock = acquireInitializationLock(paths.home.path);
|
|
100
105
|
|
|
106
|
+
try {
|
|
107
|
+
cleanupFeishuCredentialTemporaryFiles(paths.env.path);
|
|
108
|
+
const feishu = await initializeFeishu(
|
|
109
|
+
paths,
|
|
110
|
+
{ json, skipFeishu, reconfigureFeishu },
|
|
111
|
+
);
|
|
112
|
+
const result: InitCommandResult = { ...paths, feishu };
|
|
113
|
+
if (json) printJson(result);
|
|
114
|
+
else printInitializationResult(result);
|
|
115
|
+
} finally {
|
|
116
|
+
initializationLock.release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function initializeFeishu(
|
|
121
|
+
paths: InitializationResult,
|
|
122
|
+
options: { json: boolean; skipFeishu: boolean; reconfigureFeishu: boolean },
|
|
123
|
+
): Promise<InitCommandResult["feishu"]> {
|
|
101
124
|
const existing = readFeishuCredentials(paths.env.path);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
feishu = {
|
|
125
|
+
if (options.skipFeishu) {
|
|
126
|
+
return {
|
|
105
127
|
status: "skipped",
|
|
106
128
|
...(existing.appId ? { appId: existing.appId } : {}),
|
|
107
129
|
};
|
|
108
|
-
}
|
|
109
|
-
const controller = new AbortController();
|
|
110
|
-
const onInterrupt = (): void => controller.abort(new Error("已取消初始化。"));
|
|
111
|
-
process.once("SIGINT", onInterrupt);
|
|
112
|
-
try {
|
|
113
|
-
let credentials: FeishuAppCredentials;
|
|
114
|
-
let status: Exclude<FeishuInitializationStatus, "skipped">;
|
|
115
|
-
if (existing.status === "configured" && !reconfigureFeishu) {
|
|
116
|
-
credentials = {
|
|
117
|
-
appId: existing.appId!,
|
|
118
|
-
appSecret: existing.appSecret!,
|
|
119
|
-
};
|
|
120
|
-
status = "existing";
|
|
121
|
-
} else {
|
|
122
|
-
if (existing.status === "incomplete" && !reconfigureFeishu) {
|
|
123
|
-
throw new Error(`检测到不完整的飞书凭证:请补全 ${paths.env.path},或使用 --reconfigure-feishu 重新创建应用。`);
|
|
124
|
-
}
|
|
125
|
-
credentials = await registerFeishuApp({
|
|
126
|
-
signal: controller.signal,
|
|
127
|
-
onVerification: (challenge) => printFeishuVerification(challenge, json),
|
|
128
|
-
});
|
|
129
|
-
writeFeishuCredentials(paths.env.path, credentials);
|
|
130
|
-
status = "created";
|
|
131
|
-
}
|
|
130
|
+
}
|
|
132
131
|
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const onInterrupt = (): void => controller.abort(new Error("已取消初始化。"));
|
|
134
|
+
process.once("SIGINT", onInterrupt);
|
|
135
|
+
process.once("SIGTERM", onInterrupt);
|
|
136
|
+
try {
|
|
137
|
+
let credentials: FeishuAppCredentials;
|
|
138
|
+
let status: Exclude<FeishuInitializationStatus, "skipped">;
|
|
139
|
+
if (!shouldCreateFeishuApp(existing, options.reconfigureFeishu)) {
|
|
140
|
+
credentials = {
|
|
141
|
+
appId: existing.appId!,
|
|
142
|
+
appSecret: existing.appSecret!,
|
|
143
|
+
};
|
|
144
|
+
status = "existing";
|
|
145
|
+
} else {
|
|
146
|
+
if (existing.status === "incomplete" && !options.json) {
|
|
147
|
+
process.stdout.write("\n检测到未完整保存的飞书凭据,将重新创建机器人。\n");
|
|
148
|
+
}
|
|
149
|
+
credentials = await registerFeishuApp({
|
|
135
150
|
signal: controller.signal,
|
|
136
|
-
onVerification: (challenge) =>
|
|
151
|
+
onVerification: (challenge) => printFeishuVerification(challenge, options.json),
|
|
137
152
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
appId: credentials.appId,
|
|
141
|
-
configuration,
|
|
142
|
-
};
|
|
143
|
-
} finally {
|
|
144
|
-
process.removeListener("SIGINT", onInterrupt);
|
|
153
|
+
writeFeishuCredentials(paths.env.path, credentials);
|
|
154
|
+
status = "created";
|
|
145
155
|
}
|
|
146
|
-
}
|
|
147
156
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
if (!options.json) process.stdout.write("\n正在检查飞书应用权限、事件和回调配置...\n");
|
|
158
|
+
const configuration = await ensureFeishuAppConfiguration(credentials, {
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
onVerification: (challenge) => printFeishuConfigurationVerification(challenge, options.json),
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
status,
|
|
164
|
+
appId: credentials.appId,
|
|
165
|
+
configuration,
|
|
166
|
+
};
|
|
167
|
+
} finally {
|
|
168
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
169
|
+
process.removeListener("SIGTERM", onInterrupt);
|
|
170
|
+
}
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
function skillsCommand(input: string[]): void {
|
|
@@ -196,7 +216,7 @@ async function consoleCommand(input: string[]): Promise<void> {
|
|
|
196
216
|
const config = loadConfig();
|
|
197
217
|
const endpoint = controlEndpoint(config.storage.sqlitePath);
|
|
198
218
|
const force = input.includes("--force");
|
|
199
|
-
if (!force && await
|
|
219
|
+
if (!force && await isServerReachable(endpoint)) {
|
|
200
220
|
throw new Error("agent-bot server 正在运行。为避免争用同一任务状态,请先停止 server,或明确使用 --force。");
|
|
201
221
|
}
|
|
202
222
|
const entry = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
@@ -227,10 +247,17 @@ async function serverCommand(input: string[]): Promise<void> {
|
|
|
227
247
|
return;
|
|
228
248
|
}
|
|
229
249
|
if (action === "start") {
|
|
250
|
+
requireServerFeishuTransport(config.feishu);
|
|
230
251
|
if (await isServerRunning(endpoint)) {
|
|
231
252
|
process.stdout.write("agent-bot server 已在运行。\n");
|
|
232
253
|
return;
|
|
233
254
|
}
|
|
255
|
+
if (await isServerReachable(endpoint)) {
|
|
256
|
+
const running = await waitForServer(endpoint, 45_000);
|
|
257
|
+
if (!running) throw new Error("agent-bot server 已启动,但未能连接飞书机器人。请检查日志。");
|
|
258
|
+
process.stdout.write("agent-bot server 已启动。\n");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
234
261
|
const entry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
|
|
235
262
|
const child = spawn(process.execPath, [entry], {
|
|
236
263
|
cwd: process.cwd(),
|
|
@@ -240,8 +267,8 @@ async function serverCommand(input: string[]): Promise<void> {
|
|
|
240
267
|
env: { ...process.env, AGENT_BOT_RESTART_REASON: "通过 agent-bot CLI 启动" },
|
|
241
268
|
});
|
|
242
269
|
child.unref();
|
|
243
|
-
const running = await waitForServer(endpoint,
|
|
244
|
-
if (!running) throw new Error("已启动 Supervisor,但 server 未在
|
|
270
|
+
const running = await waitForServer(endpoint, 45_000);
|
|
271
|
+
if (!running) throw new Error("已启动 Supervisor,但 server 未在 45 秒内连接飞书机器人。请检查日志。");
|
|
245
272
|
process.stdout.write("agent-bot server 已启动。\n");
|
|
246
273
|
return;
|
|
247
274
|
}
|
|
@@ -422,7 +449,11 @@ function printServerStatus(data: unknown): void {
|
|
|
422
449
|
const activity = value.activity && typeof value.activity === "object"
|
|
423
450
|
? value.activity as Record<string, unknown>
|
|
424
451
|
: {};
|
|
425
|
-
process.stdout.write(
|
|
452
|
+
process.stdout.write(
|
|
453
|
+
value.ready === false
|
|
454
|
+
? "agent-bot server:启动中(正在连接飞书机器人)\n"
|
|
455
|
+
: "agent-bot server:运行中\n",
|
|
456
|
+
);
|
|
426
457
|
process.stdout.write(`PID:${value.pid ?? "-"}\n`);
|
|
427
458
|
process.stdout.write(`启动时间:${value.startedAt ?? "-"}\n`);
|
|
428
459
|
process.stdout.write(`Supervisor:${value.supervised ? "已启用" : "未启用"}\n`);
|
package/src/config/loadConfig.ts
CHANGED
|
@@ -7,10 +7,8 @@ import { defaultConfigPath, defaultDotEnvPath, resolveUserPath } from "./paths.j
|
|
|
7
7
|
|
|
8
8
|
const ENV_PATTERN = /\$\{([A-Z0-9_]+)\}/gi;
|
|
9
9
|
const DEFAULT_CONFIG = `feishu:
|
|
10
|
-
transport: "auto"
|
|
11
10
|
appId: "\${FEISHU_APP_ID}"
|
|
12
11
|
appSecret: "\${FEISHU_APP_SECRET}"
|
|
13
|
-
useConsoleWhenMissingCredentials: true
|
|
14
12
|
|
|
15
13
|
console:
|
|
16
14
|
enabled: true
|
package/src/config/schema.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const appConfigSchema = z.object({
|
|
|
21
21
|
transport: z.enum(["auto", "sdk", "console"]).default("auto"),
|
|
22
22
|
appId: z.string().optional(),
|
|
23
23
|
appSecret: z.string().optional(),
|
|
24
|
-
useConsoleWhenMissingCredentials: z.boolean().default(
|
|
24
|
+
useConsoleWhenMissingCredentials: z.boolean().default(false),
|
|
25
25
|
}),
|
|
26
26
|
agents: z.record(z.string().min(1), agentConfigSchema).refine(
|
|
27
27
|
(agents) => Object.keys(agents).length > 0,
|
|
@@ -3,11 +3,18 @@ import type { AppConfig } from "../config/schema.js";
|
|
|
3
3
|
import { threadContextKey } from "./contextKey.js";
|
|
4
4
|
import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
|
|
5
5
|
|
|
6
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
interface FeishuConnectorOptions {
|
|
9
|
+
connectTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export class FeishuConnector {
|
|
7
13
|
constructor(
|
|
8
14
|
private readonly config: AppConfig,
|
|
9
15
|
private readonly handler: FeishuEventHandler,
|
|
10
16
|
private readonly logger: Logger,
|
|
17
|
+
private readonly options: FeishuConnectorOptions = {},
|
|
11
18
|
) {}
|
|
12
19
|
|
|
13
20
|
async start(): Promise<void> {
|
|
@@ -25,7 +32,6 @@ export class FeishuConnector {
|
|
|
25
32
|
|
|
26
33
|
private async startFeishuWs(appId: string, appSecret: string): Promise<void> {
|
|
27
34
|
const lark = (await import("@larksuiteoapi/node-sdk")) as Record<string, any>;
|
|
28
|
-
const wsClient = new lark.WSClient({ appId, appSecret });
|
|
29
35
|
const eventDispatcher = new lark.EventDispatcher({}).register({
|
|
30
36
|
"im.message.receive_v1": async (data: unknown) => {
|
|
31
37
|
const message = toIncomingMessage(data);
|
|
@@ -77,21 +83,94 @@ export class FeishuConnector {
|
|
|
77
83
|
};
|
|
78
84
|
},
|
|
79
85
|
});
|
|
86
|
+
const connected = createConnectionWaiter(this.logger, this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
|
87
|
+
try {
|
|
88
|
+
const wsClient = new lark.WSClient({
|
|
89
|
+
appId,
|
|
90
|
+
appSecret,
|
|
91
|
+
logger: connected.sdkLogger,
|
|
92
|
+
loggerLevel: lark.LoggerLevel.debug,
|
|
93
|
+
});
|
|
94
|
+
await wsClient.start({ eventDispatcher });
|
|
95
|
+
await connected.promise;
|
|
96
|
+
this.logger.info("Feishu WebSocket connector connected.");
|
|
97
|
+
} finally {
|
|
98
|
+
connected.dispose();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
|
|
103
|
+
function createConnectionWaiter(
|
|
104
|
+
logger: Logger,
|
|
105
|
+
timeoutMs: number,
|
|
106
|
+
): {
|
|
107
|
+
promise: Promise<void>;
|
|
108
|
+
sdkLogger: {
|
|
109
|
+
error: (...messages: unknown[]) => void;
|
|
110
|
+
warn: (...messages: unknown[]) => void;
|
|
111
|
+
info: (...messages: unknown[]) => void;
|
|
112
|
+
debug: (...messages: unknown[]) => void;
|
|
113
|
+
trace: (...messages: unknown[]) => void;
|
|
114
|
+
};
|
|
115
|
+
dispose: () => void;
|
|
116
|
+
} {
|
|
117
|
+
let resolveConnected!: () => void;
|
|
118
|
+
let rejectConnected!: (error: Error) => void;
|
|
119
|
+
let lastError: string | undefined;
|
|
120
|
+
let settled = false;
|
|
121
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
122
|
+
resolveConnected = resolve;
|
|
123
|
+
rejectConnected = reject;
|
|
124
|
+
});
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
const detail = lastError ? ` Last SDK error: ${lastError}` : "";
|
|
129
|
+
rejectConnected(new Error(`Timed out after ${timeoutMs}ms waiting for the Feishu WebSocket connection.${detail}`));
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
const logValues = (messages: unknown[]): string => messages.map(formatSdkLogValue).join(" ");
|
|
132
|
+
const sdkLogger = {
|
|
133
|
+
error: (...messages: unknown[]) => {
|
|
134
|
+
lastError = logValues(messages);
|
|
135
|
+
logger.error({ component: "feishu-websocket", sdkMessage: lastError }, "Feishu SDK error.");
|
|
136
|
+
},
|
|
137
|
+
warn: (...messages: unknown[]) => {
|
|
138
|
+
logger.warn({ component: "feishu-websocket", sdkMessage: logValues(messages) }, "Feishu SDK warning.");
|
|
139
|
+
},
|
|
140
|
+
info: (...messages: unknown[]) => {
|
|
141
|
+
logger.debug({ component: "feishu-websocket", sdkMessage: logValues(messages) }, "Feishu SDK status.");
|
|
142
|
+
},
|
|
143
|
+
debug: (...messages: unknown[]) => {
|
|
144
|
+
if (!messages.some((message) => message === "ws connect success")) return;
|
|
145
|
+
if (settled) return;
|
|
146
|
+
settled = true;
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
resolveConnected();
|
|
149
|
+
},
|
|
150
|
+
trace: () => undefined,
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
promise,
|
|
154
|
+
sdkLogger,
|
|
155
|
+
dispose: () => clearTimeout(timer),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatSdkLogValue(value: unknown): string {
|
|
160
|
+
if (typeof value === "string") return value;
|
|
161
|
+
if (value instanceof Error) return value.message;
|
|
162
|
+
try {
|
|
163
|
+
return JSON.stringify(value);
|
|
164
|
+
} catch {
|
|
165
|
+
return String(value);
|
|
83
166
|
}
|
|
84
167
|
}
|
|
85
168
|
|
|
86
169
|
function toChatUpdatedEvent(data: unknown): ChatUpdatedEvent | undefined {
|
|
87
170
|
const event = getFeishuEvent(data);
|
|
88
171
|
const chatId = typeof event?.chat_id === "string" ? event.chat_id : undefined;
|
|
89
|
-
const beforeName = typeof event?.before_change?.name === "string"
|
|
90
|
-
|
|
91
|
-
: undefined;
|
|
92
|
-
const afterName = typeof event?.after_change?.name === "string"
|
|
93
|
-
? event.after_change.name
|
|
94
|
-
: undefined;
|
|
172
|
+
const beforeName = typeof event?.before_change?.name === "string" ? event.before_change.name : undefined;
|
|
173
|
+
const afterName = typeof event?.after_change?.name === "string" ? event.after_change.name : undefined;
|
|
95
174
|
if (!chatId || !afterName || beforeName === afterName) return undefined;
|
|
96
175
|
return {
|
|
97
176
|
chatId,
|
|
@@ -118,9 +197,7 @@ function toIncomingMessage(data: unknown): IncomingMessage | undefined {
|
|
|
118
197
|
const parentMessageId = typeof message.parent_id === "string" && message.parent_id ? message.parent_id : undefined;
|
|
119
198
|
const threadContext = Boolean(chatId && threadId);
|
|
120
199
|
const senderId =
|
|
121
|
-
event.sender?.sender_id?.open_id ??
|
|
122
|
-
event.sender?.sender_id?.user_id ??
|
|
123
|
-
event.sender?.sender_id?.union_id;
|
|
200
|
+
event.sender?.sender_id?.open_id ?? event.sender?.sender_id?.user_id ?? event.sender?.sender_id?.union_id;
|
|
124
201
|
|
|
125
202
|
if (!chatId && !senderId) {
|
|
126
203
|
return undefined;
|