ylib-syim 0.0.21 → 0.0.22

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.
@@ -1,7 +1,23 @@
1
1
  import http from "node:http";
2
2
 
3
3
  export function createHttpControlServer(
4
- handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
4
+ handler: (
5
+ req: http.IncomingMessage,
6
+ res: http.ServerResponse,
7
+ ) => void | Promise<void>,
5
8
  ): http.Server {
6
- return http.createServer(handler);
9
+ return http.createServer((req, res) => {
10
+ Promise.resolve(handler(req, res)).catch((err) => {
11
+ const message = err instanceof Error ? err.message : String(err);
12
+ console.error(`[http-control-server] unhandled error: ${message}`);
13
+ if (res.headersSent) {
14
+ if (!res.writableEnded) {
15
+ res.end();
16
+ }
17
+ return;
18
+ }
19
+ res.writeHead(500, { "Content-Type": "application/json" });
20
+ res.end(JSON.stringify({ ok: false, error: message }));
21
+ });
22
+ });
7
23
  }
@@ -1,15 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { writeBridgeStructuredLog } from "./logger.ts";
4
5
 
5
6
  // runtime-event-reporter 负责把 bridge 侧 lifecycle/console 信号
6
7
  // 统一包装为 main.ts 可消费的 runtime event,避免各 bridge 自行拼装上报逻辑。
7
8
  type Platform = "dingtalk" | "feishu" | "weixin";
8
9
  const runtimeEventAliasLogMemo = new Set<string>();
10
+ const runtimeEventWarnMemo = new Map<string, number>();
9
11
  // 保留原生 console 引用,避免调试日志被 console hook 二次识别。
10
12
  const runtimeEventRawConsoleLog = console.log.bind(console);
11
13
  // reporter 调试日志开关:默认关闭,排障时启用。
12
14
  const runtimeEventVerboseLog = process.env.RUNTIME_EVENT_VERBOSE_LOG === "1";
15
+ const runtimeEventWarnCooldownMs = Math.max(
16
+ 1000,
17
+ Number(process.env.RUNTIME_EVENT_WARN_COOLDOWN_MS || "60000") || 60000,
18
+ );
13
19
 
14
20
  // logRuntimeEventDebug: 受控输出 reporter 调试日志。
15
21
  function logRuntimeEventDebug(message: string): void {
@@ -17,6 +23,16 @@ function logRuntimeEventDebug(message: string): void {
17
23
  runtimeEventRawConsoleLog(`[runtime-event-reporter][debug] ${message}`);
18
24
  }
19
25
 
26
+ function logRuntimeEventWarning(key: string, message: string): void {
27
+ const now = Date.now();
28
+ const prevAt = runtimeEventWarnMemo.get(key) || 0;
29
+ if (now - prevAt < runtimeEventWarnCooldownMs) return;
30
+ runtimeEventWarnMemo.set(key, now);
31
+ const line = `[runtime-event-reporter][warn] ${message}`;
32
+ writeBridgeStructuredLog("warn", line);
33
+ runtimeEventRawConsoleLog(line);
34
+ }
35
+
20
36
  // logSingleAccountAlias: 记录单账号 alias 映射日志(带去重)。
21
37
  function logSingleAccountAlias(
22
38
  platform: Platform,
@@ -179,7 +195,13 @@ export async function reportRuntimeEvent(
179
195
  ): Promise<void> {
180
196
  // 统一事件上报出口:所有 bridge 生命周期与日志信号都经此入站 main.ts。
181
197
  const token = (process.env.INTERNAL_FIXED_TOKEN || "").trim();
182
- if (!token) return;
198
+ if (!token) {
199
+ logRuntimeEventWarning(
200
+ "missing_internal_token",
201
+ "skip runtime event report: INTERNAL_FIXED_TOKEN is empty",
202
+ );
203
+ return;
204
+ }
183
205
  const port = Number(process.env.INTERNAL_CONTROL_PORT || "18999");
184
206
  const emittedAt = new Date().toISOString();
185
207
  const payload = {
@@ -197,23 +219,44 @@ export async function reportRuntimeEvent(
197
219
  `report event platform=${platform} account=${accountId} status=${linkStatus} event=${String(extra?.eventName || "")}`,
198
220
  );
199
221
  try {
200
- if (typeof fetch !== "function") return;
222
+ if (typeof fetch !== "function") {
223
+ logRuntimeEventWarning(
224
+ "fetch_unavailable",
225
+ "skip runtime event report: global fetch is unavailable",
226
+ );
227
+ return;
228
+ }
201
229
  const controller = new AbortController();
202
230
  const timeout = setTimeout(() => controller.abort(), 1500);
203
231
  try {
204
- await fetch(`http://127.0.0.1:${port}/internal/runtime/events/report`, {
205
- method: "POST",
206
- headers: {
207
- Authorization: `Bearer ${token}`,
208
- "Content-Type": "application/json",
232
+ const response = await fetch(
233
+ `http://127.0.0.1:${port}/internal/runtime/events/report`,
234
+ {
235
+ method: "POST",
236
+ headers: {
237
+ Authorization: `Bearer ${token}`,
238
+ "Content-Type": "application/json",
239
+ },
240
+ body: JSON.stringify(payload),
241
+ signal: controller.signal,
209
242
  },
210
- body: JSON.stringify(payload),
211
- signal: controller.signal,
212
- });
243
+ );
244
+ if (!response.ok) {
245
+ logRuntimeEventWarning(
246
+ `report_http_status_${response.status}`,
247
+ `runtime event report response not ok status=${response.status} platform=${platform} account=${accountId} event=${String(extra?.eventName || "")}`,
248
+ );
249
+ }
213
250
  } finally {
214
251
  clearTimeout(timeout);
215
252
  }
216
- } catch {}
253
+ } catch (err) {
254
+ const errMessage = err instanceof Error ? err.message : String(err);
255
+ logRuntimeEventWarning(
256
+ "report_runtime_event_exception",
257
+ `runtime event report request failed platform=${platform} account=${accountId} event=${String(extra?.eventName || "")} err=${errMessage}`,
258
+ );
259
+ }
217
260
  }
218
261
 
219
262
  // wireBridgeLifecycleReporter: 绑定生命周期事件并上报基础状态。
@@ -52,15 +52,18 @@ process.on("unhandledRejection", (reason) => {
52
52
  | ((payload: Record<string, unknown>) => void)
53
53
  | undefined;
54
54
  const nowIso = new Date().toISOString();
55
- sink?.({
56
- platform: "weixin",
57
- bot_account_id: payload.accountId,
58
- link_status: payload.linkStatus,
59
- last_error: payload.lastError || null,
60
- status_source: "event",
61
- last_event_at: nowIso,
62
- last_heartbeat_at: nowIso,
63
- });
55
+ if (sink) {
56
+ sink({
57
+ platform: "weixin",
58
+ bot_account_id: payload.accountId,
59
+ link_status: payload.linkStatus,
60
+ last_error: payload.lastError || null,
61
+ status_source: "event",
62
+ last_event_at: nowIso,
63
+ last_heartbeat_at: nowIso,
64
+ });
65
+ return Promise.resolve({ ok: true, transport: "in_process_sink" });
66
+ }
64
67
  } catch {
65
68
  // ignore
66
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ylib-syim",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "description": "多 IM / 多 Agent 的会话路由与上下文管理(支持 /new)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
49
- "ylib-dingtalk-connector": "0.7.10-beta.13",
50
- "ylib-openclaw-lark": "2026.3.17-beta.19",
51
- "ylib-openclaw-weixin": "2.1.7-beta.5",
49
+ "ylib-dingtalk-connector": "0.7.10-beta.14",
50
+ "ylib-openclaw-lark": "2026.3.17-beta.20",
51
+ "ylib-openclaw-weixin": "2.1.7-beta.6",
52
52
  "axios": "^1.6.0",
53
53
  "dingtalk-stream": "^2.1.4",
54
54
  "fluent-ffmpeg": "^2.1.3",
@@ -62,6 +62,10 @@ function emitRuntimeEvent(
62
62
  lastError?: string | null;
63
63
  }) => Promise<unknown>)
64
64
  | undefined;
65
+ if (reporter) {
66
+ void reporter({ accountId, linkStatus, lastError });
67
+ return;
68
+ }
65
69
  const nowIso = new Date().toISOString();
66
70
  sink?.({
67
71
  platform: "dingtalk",
@@ -72,8 +76,6 @@ function emitRuntimeEvent(
72
76
  last_event_at: nowIso,
73
77
  last_heartbeat_at: nowIso,
74
78
  });
75
- if (!reporter) return;
76
- void reporter({ accountId, linkStatus, lastError });
77
79
  } catch {}
78
80
  }
79
81
 
@@ -304,14 +306,36 @@ function channelsForLog(
304
306
 
305
307
  /** 加载 syim.json,与 connector-host 一致 */
306
308
  function loadOpenClawConfig(): Record<string, unknown> | null {
309
+ const runtimeConfig = (globalThis as Record<string, unknown>)
310
+ .__IM_RUNTIME_CONFIG__;
311
+ if (
312
+ runtimeConfig &&
313
+ typeof runtimeConfig === "object" &&
314
+ !Array.isArray(runtimeConfig)
315
+ ) {
316
+ console.log(
317
+ "[dingtalk-stdio-bridge] 从 runtime 内存配置加载,跳过本地 syim fallback",
318
+ );
319
+ try {
320
+ return JSON.parse(JSON.stringify(runtimeConfig)) as Record<string, unknown>;
321
+ } catch {
322
+ return { ...(runtimeConfig as Record<string, unknown>) };
323
+ }
324
+ }
307
325
  const runtimeConfigPath = String(
308
326
  (globalThis as Record<string, unknown>).__IM_RUNTIME_CONFIG_PATH__ || "",
309
327
  ).trim();
310
- const configPaths = [
311
- runtimeConfigPath,
312
- path.join(getProjectRoot(), "syim.json"),
313
- path.join(os.homedir(), ".syim", "syim.json"),
314
- ].filter((p) => Boolean(p));
328
+ const envConfigPath = String(process.env.OPENCLAW_CONFIG_PATH || "").trim();
329
+ const configPaths = Array.from(
330
+ new Set(
331
+ [
332
+ envConfigPath,
333
+ runtimeConfigPath,
334
+ path.join(os.homedir(), ".syim", "syim.json"),
335
+ path.join(getProjectRoot(), "syim.json"),
336
+ ].filter((p) => Boolean(p)),
337
+ ),
338
+ );
315
339
  for (const configPath of configPaths) {
316
340
  if (!fs.existsSync(configPath)) continue;
317
341
  try {
@@ -507,6 +531,30 @@ const bridgeLifecycleLog = {
507
531
  warn: (msg: string) => console.warn("[DingTalk]", msg),
508
532
  error: (msg: string) => console.error("[DingTalk]", msg),
509
533
  };
534
+ const bridgeAccountControlTimeoutMs = Math.max(
535
+ 1_000,
536
+ Number(process.env.RUNTIME_ACCOUNT_CONTROL_TIMEOUT_MS || "10000") || 10000,
537
+ );
538
+
539
+ async function withBridgeControlTimeout<T>(
540
+ work: Promise<T>,
541
+ timeoutMessage: string,
542
+ ): Promise<T> {
543
+ let timer: ReturnType<typeof setTimeout> | null = null;
544
+ try {
545
+ return await Promise.race([
546
+ work,
547
+ new Promise<T>((_, reject) => {
548
+ timer = setTimeout(
549
+ () => reject(new Error(timeoutMessage)),
550
+ bridgeAccountControlTimeoutMs,
551
+ );
552
+ }),
553
+ ]);
554
+ } finally {
555
+ if (timer) clearTimeout(timer);
556
+ }
557
+ }
510
558
 
511
559
  function resolveBridgeAccount(accountId: string): ResolvedAccount | null {
512
560
  if (!bridgeCfg) return null;
@@ -623,15 +671,18 @@ async function stopSingleAccount(
623
671
 
624
672
  if (typeof bridgeStopAccount === "function" && bridgeCfg) {
625
673
  try {
626
- await Promise.resolve(
627
- bridgeStopAccount({
628
- cfg: bridgeCfg,
629
- accountId: effectiveAccountId,
630
- account,
631
- log: bridgeLifecycleLog,
632
- setStatus: (patch: Record<string, unknown>) =>
633
- applyBridgeStatusPatch(effectiveAccountId, patch),
634
- }),
674
+ await withBridgeControlTimeout(
675
+ Promise.resolve(
676
+ bridgeStopAccount({
677
+ cfg: bridgeCfg,
678
+ accountId: effectiveAccountId,
679
+ account,
680
+ log: bridgeLifecycleLog,
681
+ setStatus: (patch: Record<string, unknown>) =>
682
+ applyBridgeStatusPatch(effectiveAccountId, patch),
683
+ }),
684
+ ),
685
+ `dingtalk stop hook timeout account=${effectiveAccountId}`,
635
686
  );
636
687
  } catch (err) {
637
688
  const detail = toDetailedErrorText(err);
@@ -71,6 +71,10 @@ function emitRuntimeEvent(
71
71
  lastError?: string | null;
72
72
  }) => Promise<unknown>)
73
73
  | undefined;
74
+ if (reporter) {
75
+ void reporter({ accountId, linkStatus, lastError });
76
+ return;
77
+ }
74
78
  const nowIso = new Date().toISOString();
75
79
  sink?.({
76
80
  platform: "feishu",
@@ -81,8 +85,6 @@ function emitRuntimeEvent(
81
85
  last_event_at: nowIso,
82
86
  last_heartbeat_at: nowIso,
83
87
  });
84
- if (!reporter) return;
85
- void reporter({ accountId, linkStatus, lastError });
86
88
  } catch {}
87
89
  }
88
90
 
@@ -321,10 +323,36 @@ function channelsForLog(
321
323
  // ---------------------------------------------------------------------------
322
324
 
323
325
  function loadOpenClawConfig(): Record<string, unknown> | null {
324
- const configPaths = [
325
- path.join(getProjectRoot(), "syim.json"),
326
- path.join(os.homedir(), ".syim", "syim.json"),
327
- ];
326
+ const runtimeConfig = (globalThis as Record<string, unknown>)
327
+ .__IM_RUNTIME_CONFIG__;
328
+ if (
329
+ runtimeConfig &&
330
+ typeof runtimeConfig === "object" &&
331
+ !Array.isArray(runtimeConfig)
332
+ ) {
333
+ console.log(
334
+ "[lark-stdio-bridge] 从 runtime 内存配置加载,跳过本地 syim fallback",
335
+ );
336
+ try {
337
+ return JSON.parse(JSON.stringify(runtimeConfig)) as Record<string, unknown>;
338
+ } catch {
339
+ return { ...(runtimeConfig as Record<string, unknown>) };
340
+ }
341
+ }
342
+ const runtimeConfigPath = String(
343
+ (globalThis as Record<string, unknown>).__IM_RUNTIME_CONFIG_PATH__ || "",
344
+ ).trim();
345
+ const envConfigPath = String(process.env.OPENCLAW_CONFIG_PATH || "").trim();
346
+ const configPaths = Array.from(
347
+ new Set(
348
+ [
349
+ envConfigPath,
350
+ runtimeConfigPath,
351
+ path.join(os.homedir(), ".syim", "syim.json"),
352
+ path.join(getProjectRoot(), "syim.json"),
353
+ ].filter((p) => Boolean(p)),
354
+ ),
355
+ );
328
356
  for (const configPath of configPaths) {
329
357
  if (!fs.existsSync(configPath)) continue;
330
358
  try {
@@ -584,6 +612,30 @@ const bridgeLifecycleLog = {
584
612
  warn: (msg: string) => console.warn("[Feishu]", msg),
585
613
  error: (msg: string) => console.error("[Feishu][ERR]", msg),
586
614
  };
615
+ const bridgeAccountControlTimeoutMs = Math.max(
616
+ 1_000,
617
+ Number(process.env.RUNTIME_ACCOUNT_CONTROL_TIMEOUT_MS || "10000") || 10000,
618
+ );
619
+
620
+ async function withBridgeControlTimeout<T>(
621
+ work: Promise<T>,
622
+ timeoutMessage: string,
623
+ ): Promise<T> {
624
+ let timer: ReturnType<typeof setTimeout> | null = null;
625
+ try {
626
+ return await Promise.race([
627
+ work,
628
+ new Promise<T>((_, reject) => {
629
+ timer = setTimeout(
630
+ () => reject(new Error(timeoutMessage)),
631
+ bridgeAccountControlTimeoutMs,
632
+ );
633
+ }),
634
+ ]);
635
+ } finally {
636
+ if (timer) clearTimeout(timer);
637
+ }
638
+ }
587
639
 
588
640
  function ensureGatewayBaseOnCfg(
589
641
  cfg: Record<string, unknown>,
@@ -788,16 +840,19 @@ async function stopSingleAccount(
788
840
 
789
841
  if (typeof bridgeStopAccount === "function" && currentCfg) {
790
842
  try {
791
- await Promise.resolve(
792
- bridgeStopAccount({
793
- cfg: currentCfg,
794
- accountId: effectiveAccountId,
795
- account,
796
- runtime: currentRuntime,
797
- log: bridgeLifecycleLog,
798
- setStatus: (patch: Record<string, unknown>) =>
799
- applyBridgeStatusPatch(effectiveAccountId, patch),
800
- }),
843
+ await withBridgeControlTimeout(
844
+ Promise.resolve(
845
+ bridgeStopAccount({
846
+ cfg: currentCfg,
847
+ accountId: effectiveAccountId,
848
+ account,
849
+ runtime: currentRuntime,
850
+ log: bridgeLifecycleLog,
851
+ setStatus: (patch: Record<string, unknown>) =>
852
+ applyBridgeStatusPatch(effectiveAccountId, patch),
853
+ }),
854
+ ),
855
+ `feishu stop hook timeout account=${effectiveAccountId}`,
801
856
  );
802
857
  } catch (err) {
803
858
  const detail = toDetailedErrorText(err);
@@ -64,6 +64,10 @@ function emitRuntimeEvent(
64
64
  lastError?: string | null;
65
65
  }) => Promise<unknown>)
66
66
  | undefined;
67
+ if (reporter) {
68
+ void reporter({ accountId, linkStatus, lastError });
69
+ return;
70
+ }
67
71
  const nowIso = new Date().toISOString();
68
72
  sink?.({
69
73
  platform: "weixin",
@@ -74,8 +78,6 @@ function emitRuntimeEvent(
74
78
  last_event_at: nowIso,
75
79
  last_heartbeat_at: nowIso,
76
80
  });
77
- if (!reporter) return;
78
- void reporter({ accountId, linkStatus, lastError });
79
81
  } catch {
80
82
  // ignore
81
83
  }
@@ -278,14 +280,36 @@ function normalizeConfiguredAccountIds(ids: string[]): string[] {
278
280
  }
279
281
 
280
282
  function loadOpenClawConfig(): Record<string, unknown> | null {
283
+ const runtimeConfig = (globalThis as Record<string, unknown>)
284
+ .__IM_RUNTIME_CONFIG__;
285
+ if (
286
+ runtimeConfig &&
287
+ typeof runtimeConfig === "object" &&
288
+ !Array.isArray(runtimeConfig)
289
+ ) {
290
+ console.log(
291
+ "[weixin-stdio-bridge] 从 runtime 内存配置加载,跳过本地 syim fallback",
292
+ );
293
+ try {
294
+ return JSON.parse(JSON.stringify(runtimeConfig)) as Record<string, unknown>;
295
+ } catch {
296
+ return { ...(runtimeConfig as Record<string, unknown>) };
297
+ }
298
+ }
281
299
  const runtimeConfigPath = String(
282
300
  (globalThis as Record<string, unknown>).__IM_RUNTIME_CONFIG_PATH__ || "",
283
301
  ).trim();
284
- const configPaths = [
285
- runtimeConfigPath,
286
- path.join(getProjectRoot(), "syim.json"),
287
- path.join(os.homedir(), ".syim", "syim.json"),
288
- ].filter(Boolean);
302
+ const envConfigPath = String(process.env.OPENCLAW_CONFIG_PATH || "").trim();
303
+ const configPaths = Array.from(
304
+ new Set(
305
+ [
306
+ envConfigPath,
307
+ runtimeConfigPath,
308
+ path.join(os.homedir(), ".syim", "syim.json"),
309
+ path.join(getProjectRoot(), "syim.json"),
310
+ ].filter((p) => Boolean(p)),
311
+ ),
312
+ );
289
313
 
290
314
  for (const configPath of configPaths) {
291
315
  if (!fs.existsSync(configPath)) continue;
@@ -512,6 +536,30 @@ const bridgeLifecycleLog = {
512
536
  warn: (msg: string) => console.warn("[Weixin]", msg),
513
537
  error: (msg: string) => console.error("[Weixin][ERR]", msg),
514
538
  };
539
+ const bridgeAccountControlTimeoutMs = Math.max(
540
+ 1_000,
541
+ Number(process.env.RUNTIME_ACCOUNT_CONTROL_TIMEOUT_MS || "10000") || 10000,
542
+ );
543
+
544
+ async function withBridgeControlTimeout<T>(
545
+ work: Promise<T>,
546
+ timeoutMessage: string,
547
+ ): Promise<T> {
548
+ let timer: ReturnType<typeof setTimeout> | null = null;
549
+ try {
550
+ return await Promise.race([
551
+ work,
552
+ new Promise<T>((_, reject) => {
553
+ timer = setTimeout(
554
+ () => reject(new Error(timeoutMessage)),
555
+ bridgeAccountControlTimeoutMs,
556
+ );
557
+ }),
558
+ ]);
559
+ } finally {
560
+ if (timer) clearTimeout(timer);
561
+ }
562
+ }
515
563
 
516
564
  function resolveBridgeAccount(accountId: string): ResolvedAccount | null {
517
565
  if (!bridgeCfg) return null;
@@ -600,7 +648,7 @@ async function startSingleAccount(
600
648
  account,
601
649
  abortSignal: abort.signal,
602
650
  log: bridgeLifecycleLog,
603
- runtime: bridgeLifecycleLog,
651
+ runtime: bridgeRuntimeForGateway || bridgeLifecycleLog,
604
652
  setStatus: (patch: Record<string, unknown>) =>
605
653
  applyBridgeStatusPatch(account.accountId, patch),
606
654
  })
@@ -634,15 +682,18 @@ async function stopSingleAccount(
634
682
 
635
683
  if (typeof bridgeStopAccount === "function" && bridgeCfg) {
636
684
  try {
637
- await Promise.resolve(
638
- bridgeStopAccount({
639
- cfg: bridgeCfg,
640
- accountId: effectiveAccountId,
641
- account,
642
- log: bridgeLifecycleLog,
643
- setStatus: (patch: Record<string, unknown>) =>
644
- applyBridgeStatusPatch(effectiveAccountId, patch),
645
- }),
685
+ await withBridgeControlTimeout(
686
+ Promise.resolve(
687
+ bridgeStopAccount({
688
+ cfg: bridgeCfg,
689
+ accountId: effectiveAccountId,
690
+ account,
691
+ log: bridgeLifecycleLog,
692
+ setStatus: (patch: Record<string, unknown>) =>
693
+ applyBridgeStatusPatch(effectiveAccountId, patch),
694
+ }),
695
+ ),
696
+ `weixin stop hook timeout account=${effectiveAccountId}`,
646
697
  );
647
698
  } catch (err) {
648
699
  const detail = toDetailedErrorText(err);