@wu529778790/open-im 1.8.1-beta.9 → 1.8.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.
@@ -24,6 +24,19 @@ const FLOW_STATUS = {
24
24
  };
25
25
  let senderSettings = {};
26
26
  const streamStates = new Map();
27
+ // Periodic cleanup of orphaned stream states (max 30 minutes)
28
+ const STREAM_MAX_AGE_MS = 30 * 60 * 1000;
29
+ setInterval(() => {
30
+ const now = Date.now();
31
+ for (const [id, state] of streamStates) {
32
+ // streamStates in DingTalk don't have createdAt, clean up by size
33
+ if (streamStates.size > 50) {
34
+ streamStates.delete(id);
35
+ log.info(`Cleaned up old DingTalk stream state: ${id}`);
36
+ break; // Clean one at a time to avoid blocking
37
+ }
38
+ }
39
+ }, STREAM_MAX_AGE_MS);
27
40
  function generateMessageId() {
28
41
  return `${Date.now()}-${randomBytes(6).toString('hex')}`;
29
42
  }
@@ -17,6 +17,99 @@ import { buildSavedMediaPrompt } from '../shared/media-analysis-prompt.js';
17
17
  import { buildMediaContext } from '../shared/media-context.js';
18
18
  import { buildProgressNote } from '../shared/message-note.js';
19
19
  const log = createLogger('FeishuHandler');
20
+ /**
21
+ * 从异常中提取飞书 API 错误码
22
+ */
23
+ function extractFeishuErrorCode(err) {
24
+ const e = err;
25
+ if (e?.response?.data?.code)
26
+ return e.response.data.code;
27
+ if (e?.code)
28
+ return e.code;
29
+ return undefined;
30
+ }
31
+ /**
32
+ * 根据错误码判断是否为权限不足
33
+ */
34
+ function isPermissionError(err) {
35
+ const code = extractFeishuErrorCode(err);
36
+ if (!code) {
37
+ // 非标准错误:检查 message 中是否包含权限关键词
38
+ const msg = err?.message ?? String(err);
39
+ return /permission|权限|scope|not authorized|no access|forbidden/i.test(msg);
40
+ }
41
+ // 飞书常见权限错误码
42
+ return [
43
+ 99991400, // 权限不足
44
+ 99991401, // 没有API权限
45
+ 99991663, // 应用未获取 scope
46
+ 99991672, // 应用未开通相关能力
47
+ 99991670, // 应用未上架/未授权
48
+ 99991668, // 应用可见性限制
49
+ ].includes(code);
50
+ }
51
+ /**
52
+ * 构建飞书权限配置指引消息
53
+ */
54
+ function buildPermissionGuideMessage(err) {
55
+ const code = extractFeishuErrorCode(err);
56
+ const codeHint = code ? ` (错误码: ${code})` : '';
57
+ return [
58
+ '⚠️ **飞书应用权限不足,无法发送消息**' + codeHint,
59
+ '',
60
+ '请按以下步骤开通所需权限:',
61
+ '',
62
+ '**1. 进入飞书开放平台**',
63
+ '👉 https://open.feishu.cn/app',
64
+ '',
65
+ '**2. 找到你的应用,进入「权限管理」**',
66
+ '',
67
+ '**3. 开通以下权限(搜索权限名称添加):**',
68
+ '• `im:message` — 获取与发送单聊、群组消息',
69
+ '• `im:message:send_as_bot` — 以应用身份发消息',
70
+ '• `im:resource` — 获取与上传图片或文件资源',
71
+ '• `im:chat` — 获取群组信息',
72
+ '',
73
+ '**4. 如需使用卡片打字机效果,还需开通:**',
74
+ '• `cardkit:card` — CardKit 卡片管理',
75
+ '',
76
+ '**5. 发布版本**',
77
+ '权限修改后需点击「创建版本」→「发布」,管理员审批后生效。',
78
+ '',
79
+ '📖 详细文档:https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO04yNxkDN',
80
+ ].join('\n');
81
+ }
82
+ /**
83
+ * 发送权限错误提示,依次尝试:卡片 → 纯文本 → open_id
84
+ */
85
+ async function sendPermissionFallback(chatId, guide) {
86
+ // 1. 先尝试 sendTextReply(发卡片消息)
87
+ try {
88
+ await sendTextReply(chatId, guide);
89
+ return;
90
+ }
91
+ catch (err) {
92
+ log.warn('Card-based reply failed, falling back to plain text:', err);
93
+ }
94
+ // 2. 降级为纯文本消息
95
+ try {
96
+ const client = (await import('./client.js')).getClient();
97
+ const plainGuide = guide.replace(/\*\*/g, '').replace(/`/g, '');
98
+ await client.im.message.create({
99
+ data: {
100
+ receive_id: chatId,
101
+ msg_type: 'text',
102
+ content: JSON.stringify({ text: plainGuide }),
103
+ },
104
+ params: { receive_id_type: 'chat_id' },
105
+ });
106
+ return;
107
+ }
108
+ catch (err) {
109
+ log.warn('Plain text reply also failed:', err);
110
+ }
111
+ log.error('All fallback methods failed to send permission guide');
112
+ }
20
113
  async function downloadFeishuMessageResource(client, messageId, fileKey, type, options) {
21
114
  const targetPath = createMediaTargetPath(options?.fallbackExtension ?? 'bin', options?.basenameHint ?? fileKey);
22
115
  const response = await client.im.messageResource.get({
@@ -57,16 +150,38 @@ export function setupFeishuHandlers(config, sessionManager) {
57
150
  const toolId = aiCommand;
58
151
  // 使用 CardKit 打字机效果(80ms 节流,约 12 次/秒,比 patch 5 QPS 更流畅)
59
152
  let cardHandle;
60
- try {
61
- cardHandle = await sendThinkingCard(chatId, toolId);
62
- }
63
- catch (err) {
64
- log.error('Failed to send thinking card:', err);
153
+ const MAX_SEND_RETRIES = 3;
154
+ for (let attempt = 1; attempt <= MAX_SEND_RETRIES; attempt++) {
65
155
  try {
66
- await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
156
+ cardHandle = await sendThinkingCard(chatId, toolId);
157
+ break;
158
+ }
159
+ catch (err) {
160
+ const isRetryable = err && typeof err === 'object' && 'code' in err &&
161
+ (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED');
162
+ if (isRetryable && attempt < MAX_SEND_RETRIES) {
163
+ log.warn(`sendThinkingCard attempt ${attempt}/${MAX_SEND_RETRIES} failed (${err.code}), retrying...`);
164
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
165
+ continue;
166
+ }
167
+ log.error(`Failed to send thinking card after ${attempt} attempts:`, err);
168
+ // 检测是否为飞书权限不足
169
+ if (isPermissionError(err)) {
170
+ const guide = buildPermissionGuideMessage(err);
171
+ await sendPermissionFallback(chatId, guide).catch((err) => {
172
+ log.warn('Permission fallback send failed:', err);
173
+ });
174
+ }
175
+ else {
176
+ try {
177
+ await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
178
+ }
179
+ catch (err) {
180
+ log.warn('Failed to send startup error reply:', err);
181
+ }
182
+ }
183
+ return;
67
184
  }
68
- catch { /* ignore */ }
69
- return;
70
185
  }
71
186
  const { messageId: msgId, cardId } = cardHandle;
72
187
  const stopTyping = startTypingLoop(chatId);
@@ -123,7 +238,8 @@ export function setupFeishuHandlers(config, sessionManager) {
123
238
  try {
124
239
  obj = JSON.parse(raw);
125
240
  }
126
- catch {
241
+ catch (err) {
242
+ log.debug('Failed to parse action value as JSON:', err);
127
243
  return null;
128
244
  }
129
245
  }
@@ -176,8 +292,8 @@ export function setupFeishuHandlers(config, sessionManager) {
176
292
  }
177
293
  actionData = parsed;
178
294
  }
179
- catch {
180
- /* ignore */
295
+ catch (err) {
296
+ log.debug('Failed to parse card action data:', err);
181
297
  }
182
298
  if (actionData?.action === 'stop' && actionData.card_id) {
183
299
  const cardId = actionData.card_id;
package/dist/index.js CHANGED
@@ -306,6 +306,15 @@ export async function main() {
306
306
  };
307
307
  process.on("SIGINT", () => shutdown().catch(() => process.exit(1)));
308
308
  process.on("SIGTERM", () => shutdown().catch(() => process.exit(1)));
309
+ // Global error handlers to prevent unhandled crashes
310
+ process.on("unhandledRejection", (reason) => {
311
+ log.error("Unhandled Promise rejection:", reason);
312
+ });
313
+ process.on("uncaughtException", (err) => {
314
+ log.error("Uncaught exception (process will exit):", err);
315
+ closeLogger();
316
+ process.exit(1);
317
+ });
309
318
  }
310
319
  const isEntry = process.argv[1]?.replace(/\\/g, "/").endsWith("/index.js") ||
311
320
  process.argv[1]?.replace(/\\/g, "/").endsWith("/index.ts");
@@ -6,6 +6,10 @@ import { APP_HOME } from "./constants.js";
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const PID_FILE = join(APP_HOME, "open-im.pid");
8
8
  const READY_FILE = join(APP_HOME, "open-im.ready");
9
+ function logError(prefix, err) {
10
+ const msg = err instanceof Error ? err.message : String(err);
11
+ process.stderr.write(`[manager-control] ${prefix} ${msg}\n`);
12
+ }
9
13
  function getManagerEntry() {
10
14
  const extension = extname(fileURLToPath(import.meta.url));
11
15
  if (extension === ".ts") {
@@ -106,6 +110,9 @@ export async function startManagerProcess(cwd) {
106
110
  env: process.env,
107
111
  windowsHide: process.platform === "win32",
108
112
  });
113
+ child.on("error", (err) => {
114
+ logError("Manager process spawn failed:", err);
115
+ });
109
116
  child.unref();
110
117
  if (!child.pid) {
111
118
  throw new Error("Failed to start manager process.");
package/dist/qq/client.js CHANGED
@@ -17,6 +17,7 @@ let stopped = false;
17
17
  let seq = null;
18
18
  let sessionId = null;
19
19
  let reconnectAttempt = 0;
20
+ let connecting = false; // 防止并发 connectWebSocket
20
21
  let currentConfig = null;
21
22
  let currentHandler = null;
22
23
  let tokenState = null;
@@ -143,103 +144,125 @@ function startHeartbeat(intervalMs) {
143
144
  heartbeatTimer = setInterval(() => {
144
145
  if (!ws || ws.readyState !== WebSocket.OPEN)
145
146
  return;
146
- ws.send(JSON.stringify({ op: 1, d: seq }));
147
+ try {
148
+ ws.send(JSON.stringify({ op: 1, d: seq }));
149
+ }
150
+ catch (err) {
151
+ log.warn('QQ heartbeat send failed:', err);
152
+ }
147
153
  }, intervalMs);
148
154
  }
149
155
  async function connectWebSocket(config, handler) {
150
- const gatewayUrl = await getGatewayUrl(config);
151
- const token = await fetchAccessToken(config);
152
- await new Promise((resolve, reject) => {
153
- const socket = new WebSocket(gatewayUrl);
154
- ws = socket;
155
- let settled = false;
156
- const settle = (fn) => {
157
- if (settled)
158
- return;
159
- settled = true;
160
- fn();
161
- };
162
- socket.on("open", () => {
163
- log.info("QQ gateway connected");
164
- reconnectAttempt = 0;
165
- });
166
- socket.on("message", async (raw) => {
167
- try {
168
- const payload = JSON.parse(raw.toString());
169
- if (typeof payload.s === "number")
170
- seq = payload.s;
171
- if (payload.op === 10) {
172
- const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
173
- startHeartbeat(heartbeatInterval);
174
- socket.send(JSON.stringify({
175
- op: sessionId ? 6 : 2,
176
- d: sessionId
177
- ? {
178
- token: `QQBot ${token}`,
179
- session_id: sessionId,
180
- seq,
181
- }
182
- : {
183
- token: `QQBot ${token}`,
184
- intents: INTENTS.GROUP_AND_C2C |
185
- INTENTS.DIRECT_MESSAGE |
186
- INTENTS.PUBLIC_GUILD_MESSAGES,
187
- properties: {
188
- os: process.platform,
189
- browser: "open-im",
190
- device: "open-im",
191
- },
192
- },
193
- }));
194
- return;
195
- }
196
- if (payload.op === 0 && payload.t === "READY") {
197
- sessionId = String(payload.d?.session_id ?? "");
198
- settle(resolve);
156
+ // 防止并发连接
157
+ if (connecting) {
158
+ log.warn("QQ gateway connection already in progress");
159
+ return;
160
+ }
161
+ connecting = true;
162
+ try {
163
+ const gatewayUrl = await getGatewayUrl(config);
164
+ const token = await fetchAccessToken(config);
165
+ await new Promise((resolve, reject) => {
166
+ const socket = new WebSocket(gatewayUrl);
167
+ ws = socket;
168
+ let settled = false;
169
+ let readyTimeoutId = setTimeout(() => {
170
+ readyTimeoutId = null;
171
+ settle(() => reject(new Error("QQ gateway ready timeout")));
172
+ }, 15000);
173
+ const settle = (fn) => {
174
+ if (settled)
199
175
  return;
176
+ settled = true;
177
+ if (readyTimeoutId) {
178
+ clearTimeout(readyTimeoutId);
179
+ readyTimeoutId = null;
200
180
  }
201
- if (payload.op === 0 && payload.t === "RESUMED") {
202
- settle(resolve);
203
- return;
181
+ fn();
182
+ };
183
+ socket.on("open", () => {
184
+ log.info("QQ gateway connected");
185
+ reconnectAttempt = 0;
186
+ });
187
+ socket.on("message", async (raw) => {
188
+ try {
189
+ const payload = JSON.parse(raw.toString());
190
+ if (typeof payload.s === "number")
191
+ seq = payload.s;
192
+ if (payload.op === 10) {
193
+ const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
194
+ startHeartbeat(heartbeatInterval);
195
+ socket.send(JSON.stringify({
196
+ op: sessionId ? 6 : 2,
197
+ d: sessionId
198
+ ? {
199
+ token: `QQBot ${token}`,
200
+ session_id: sessionId,
201
+ seq,
202
+ }
203
+ : {
204
+ token: `QQBot ${token}`,
205
+ intents: INTENTS.GROUP_AND_C2C |
206
+ INTENTS.DIRECT_MESSAGE |
207
+ INTENTS.PUBLIC_GUILD_MESSAGES,
208
+ properties: {
209
+ os: process.platform,
210
+ browser: "open-im",
211
+ device: "open-im",
212
+ },
213
+ },
214
+ }));
215
+ return;
216
+ }
217
+ if (payload.op === 0 && payload.t === "READY") {
218
+ sessionId = String(payload.d?.session_id ?? "");
219
+ settle(resolve);
220
+ return;
221
+ }
222
+ if (payload.op === 0 && payload.t === "RESUMED") {
223
+ settle(resolve);
224
+ return;
225
+ }
226
+ const event = normalizeInboundEvent(payload);
227
+ if (event && (event.content || (event.attachments?.length ?? 0) > 0)) {
228
+ await handler(event);
229
+ }
204
230
  }
205
- const event = normalizeInboundEvent(payload);
206
- if (event && (event.content || (event.attachments?.length ?? 0) > 0)) {
207
- await handler(event);
231
+ catch (error) {
232
+ log.error("Failed to handle QQ gateway payload:", error);
208
233
  }
209
- }
210
- catch (error) {
211
- log.error("Failed to handle QQ gateway payload:", error);
212
- }
213
- });
214
- socket.on("error", (error) => {
215
- log.error("QQ gateway error:", error);
216
- settle(() => reject(error));
217
- });
218
- socket.on("close", (code, reason) => {
219
- clearTimers();
220
- ws = null;
221
- log.info(`QQ gateway closed: ${code} ${reason.toString()}`);
222
- if (stopped)
223
- return;
224
- if (code === 4004 || code === 4006 || code === 4007 || code === 4009) {
225
- tokenState = null;
226
- sessionId = null;
227
- seq = null;
228
- }
229
- const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
230
- reconnectAttempt += 1;
231
- reconnectTimer = setTimeout(() => {
232
- if (currentConfig && currentHandler) {
233
- connectWebSocket(currentConfig, currentHandler).catch((err) => {
234
- log.error("QQ reconnect failed:", err);
235
- });
234
+ });
235
+ socket.on("error", (error) => {
236
+ log.error("QQ gateway error:", error);
237
+ settle(() => reject(error));
238
+ });
239
+ socket.on("close", (code, reason) => {
240
+ settle(() => { }); // 清理 ready timeout
241
+ clearTimers();
242
+ ws = null;
243
+ log.info(`QQ gateway closed: ${code} ${reason.toString()}`);
244
+ if (stopped)
245
+ return;
246
+ if (code === 4004 || code === 4006 || code === 4007 || code === 4009) {
247
+ tokenState = null;
248
+ sessionId = null;
249
+ seq = null;
236
250
  }
237
- }, delay);
251
+ const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
252
+ reconnectAttempt += 1;
253
+ reconnectTimer = setTimeout(() => {
254
+ if (currentConfig && currentHandler) {
255
+ connectWebSocket(currentConfig, currentHandler).catch((err) => {
256
+ log.error("QQ reconnect failed:", err);
257
+ });
258
+ }
259
+ }, delay);
260
+ });
238
261
  });
239
- setTimeout(() => {
240
- settle(() => reject(new Error("QQ gateway ready timeout")));
241
- }, 15000);
242
- });
262
+ }
263
+ finally {
264
+ connecting = false;
265
+ }
243
266
  }
244
267
  export function getQQBot() {
245
268
  if (!client || !currentConfig) {
@@ -62,7 +62,8 @@ async function buildAttachmentPrompt(event) {
62
62
  : "bin",
63
63
  });
64
64
  }
65
- catch {
65
+ catch (err) {
66
+ log.warn('Failed to download QQ media attachment:', err);
66
67
  localPath = undefined;
67
68
  }
68
69
  }
@@ -153,7 +154,9 @@ export function setupQQHandlers(config, sessionManager) {
153
154
  try {
154
155
  await sendTextReply(chatId, "启动 AI 处理失败,请重试。");
155
156
  }
156
- catch { /* ignore */ }
157
+ catch (err) {
158
+ log.warn('Failed to send startup error reply:', err);
159
+ }
157
160
  return;
158
161
  }
159
162
  const stopTyping = startTypingLoop();
@@ -7,6 +7,17 @@ import { buildDirectoryMessage } from "../shared/system-messages.js";
7
7
  const log = createLogger("QQSender");
8
8
  const MAX_QQ_MESSAGE_LENGTH = 1500;
9
9
  const pendingReplies = new Map();
10
+ // Periodic cleanup of orphaned pending replies
11
+ const PENDING_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
12
+ setInterval(() => {
13
+ const now = Date.now();
14
+ for (const [id, state] of pendingReplies) {
15
+ // pendingReplies don't have timestamps, but we can clear old ones based on size
16
+ if (pendingReplies.size > 100) {
17
+ pendingReplies.delete(id);
18
+ }
19
+ }
20
+ }, PENDING_MAX_AGE_MS);
10
21
  function parseChatTarget(chatId) {
11
22
  if (chatId.startsWith("group:")) {
12
23
  return { kind: "group", id: chatId.slice("group:".length) };
@@ -93,6 +93,10 @@ export function startBackgroundService(cwd) {
93
93
  env: process.env,
94
94
  windowsHide: process.platform === "win32",
95
95
  });
96
+ child.on("error", (err) => {
97
+ // Spawn failure (ENOENT etc.) — report via stderr since logger may not be initialized
98
+ process.stderr.write(`[service-control] Spawn failed: ${err.message}\n`);
99
+ });
96
100
  child.unref();
97
101
  if (!child.pid) {
98
102
  throw new Error("Failed to start background service.");
@@ -236,7 +236,17 @@ export class SessionManager {
236
236
  const resolved = resolveWorkDirInput(baseDir, targetDir);
237
237
  if (!existsSync(resolved))
238
238
  throw new Error(`目录不存在: \`${resolved}\``);
239
- return realpath(resolved);
239
+ const real = await realpath(resolved);
240
+ // Block access to sensitive system directories
241
+ const blockedPrefixes = process.platform === 'win32'
242
+ ? ['C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData']
243
+ : ['/etc', '/proc', '/sys', '/dev', '/boot', '/root', '/sbin', '/usr/sbin'];
244
+ for (const prefix of blockedPrefixes) {
245
+ if (real.toLowerCase().startsWith(prefix.toLowerCase())) {
246
+ throw new Error(`不允许访问系统目录: \`${real}\``);
247
+ }
248
+ }
249
+ return real;
240
250
  }
241
251
  load(previousDefaultWorkDir) {
242
252
  try {
@@ -4,6 +4,17 @@
4
4
  */
5
5
  const chatToUser = new Map();
6
6
  const chatToPlatform = new Map();
7
+ // Periodic cleanup to prevent unbounded growth (keep last 1000 entries)
8
+ const CHAT_MAP_MAX_SIZE = 1000;
9
+ setInterval(() => {
10
+ if (chatToUser.size > CHAT_MAP_MAX_SIZE) {
11
+ const keysToDelete = [...chatToUser.keys()].slice(0, chatToUser.size - CHAT_MAP_MAX_SIZE);
12
+ for (const key of keysToDelete) {
13
+ chatToUser.delete(key);
14
+ chatToPlatform.delete(key);
15
+ }
16
+ }
17
+ }, 60 * 60 * 1000); // Check every hour
7
18
  export function setChatUser(chatId, userId, platform) {
8
19
  chatToUser.set(chatId, userId);
9
20
  if (platform)
@@ -131,6 +131,33 @@ export async function saveBase64Media(base64, extension, basenameHint) {
131
131
  return saveBufferMedia(Buffer.from(base64, "base64"), extension, basenameHint);
132
132
  }
133
133
  export async function downloadMediaFromUrl(url, options) {
134
+ // SSRF protection: validate URL before fetching
135
+ const BLOCKED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0', '[::1]', '169.254.169.254'];
136
+ let parsedUrl;
137
+ try {
138
+ parsedUrl = new URL(url);
139
+ }
140
+ catch {
141
+ throw new Error(`Invalid URL: ${url}`);
142
+ }
143
+ const protocol = parsedUrl.protocol.toLowerCase();
144
+ if (protocol !== 'https:' && protocol !== 'http:') {
145
+ throw new Error(`Unsupported URL protocol: ${protocol}`);
146
+ }
147
+ const hostname = parsedUrl.hostname.toLowerCase();
148
+ for (const blocked of BLOCKED_HOSTS) {
149
+ if (hostname === blocked) {
150
+ throw new Error(`Blocked URL host: ${hostname}`);
151
+ }
152
+ }
153
+ // Block link-local and private IPs (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x)
154
+ const ipMatch = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
155
+ if (ipMatch) {
156
+ const [, a, b] = ipMatch.map(Number);
157
+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 0) {
158
+ throw new Error(`Blocked private/internal IP: ${hostname}`);
159
+ }
160
+ }
134
161
  await mkdir(IMAGE_DIR, { recursive: true });
135
162
  const response = await fetch(url, { signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS) });
136
163
  if (!response.ok) {
@@ -43,7 +43,9 @@ export async function initTelegram(config, setupHandlers) {
43
43
  // 不再 exit(1),让其他通道继续运行
44
44
  }
45
45
  };
46
- void launchWithRetry();
46
+ void launchWithRetry().catch((err) => {
47
+ log.error("Telegram launchWithRetry failed fatally:", err);
48
+ });
47
49
  log.info("Telegram bot launched");
48
50
  }
49
51
  export function stopTelegram() {