@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.
@@ -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
- - 新创建的凭据以原子方式写入 `.env`
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
- `feishu.transport` 支持:
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
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Using Codex in Lark/Feishu feels just like using native Codex.",
6
6
  "license": "GPL-3.0-only",
@@ -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
 
@@ -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
- let config: InitializedPath;
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.writeFileSync(temporaryPath, updated, { encoding: "utf8", mode: 0o600 });
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
- fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL);
125
- return { path: targetPath, status: "created" };
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
- return response.ok;
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
- let feishu: InitCommandResult["feishu"];
103
- if (skipFeishu) {
104
- feishu = {
125
+ if (options.skipFeishu) {
126
+ return {
105
127
  status: "skipped",
106
128
  ...(existing.appId ? { appId: existing.appId } : {}),
107
129
  };
108
- } else {
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
- if (!json) process.stdout.write("\n正在检查飞书应用权限、事件和回调配置...\n");
134
- const configuration = await ensureFeishuAppConfiguration(credentials, {
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) => printFeishuConfigurationVerification(challenge, json),
151
+ onVerification: (challenge) => printFeishuVerification(challenge, options.json),
137
152
  });
138
- feishu = {
139
- status,
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
- const result: InitCommandResult = { ...paths, feishu };
149
- if (json) printJson(result);
150
- else printInitializationResult(result);
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 isServerRunning(endpoint)) {
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, 30_000);
244
- if (!running) throw new Error("已启动 Supervisor,但 server 未在 30 秒内就绪。请检查日志。");
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("agent-bot server:运行中\n");
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`);
@@ -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
@@ -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(true),
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
- wsClient.start({ eventDispatcher });
82
- this.logger.info("Feishu WebSocket connector started.");
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
- ? event.before_change.name
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;