@wu529778790/open-im 1.10.2-beta.0 → 1.10.2-beta.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.
@@ -294,7 +294,7 @@ export function runCodeBuddy(cliPath, prompt, sessionId, workDir, callbacks, opt
294
294
  handlePayload(JSON.parse(payload));
295
295
  }
296
296
  catch {
297
- // Ignore trailing partial payloads.
297
+ log.debug(`Ignoring trailing partial CodeBuddy payload on close`);
298
298
  }
299
299
  }
300
300
  if (completed)
@@ -117,7 +117,8 @@ function extractCodexJsFromCmdShim(cmdPath) {
117
117
  const relativeJsPath = match[1].replace(/\\/g, '/');
118
118
  return join(dirname(cmdPath), relativeJsPath);
119
119
  }
120
- catch {
120
+ catch (err) {
121
+ log.debug(`Failed to extract Codex JS path from cmd shim ${cmdPath}:`, err);
121
122
  return null;
122
123
  }
123
124
  }
@@ -152,7 +153,8 @@ function resolveWindowsCodexLaunch(cliPath, args) {
152
153
  windowsCodexLaunchCache.set(cliPath, resolved);
153
154
  return { command: resolved.command, args: [...resolved.args, ...args] };
154
155
  }
155
- catch {
156
+ catch (err) {
157
+ log.debug(`Failed to resolve Windows Codex launch for ${cliPath}:`, err);
156
158
  windowsCodexLaunchCache.set(cliPath, null);
157
159
  return null;
158
160
  }
@@ -1,6 +1,8 @@
1
1
  import { resolvePlatformAiCommand } from '../config.js';
2
2
  import { escapePathForMarkdown } from '../shared/utils.js';
3
3
  import { TERMINAL_ONLY_COMMANDS } from '../constants.js';
4
+ import { createLogger } from '../logger.js';
5
+ const log = createLogger('Commands');
4
6
  import { execFile } from 'node:child_process';
5
7
  import { readdirSync } from 'node:fs';
6
8
  import { dirname, join } from 'node:path';
@@ -136,8 +138,8 @@ export function listDirectories(basePath) {
136
138
  .sort((a, b) => a.name.localeCompare(b.name)); // 按名称排序
137
139
  dirs.push(...subDirs);
138
140
  }
139
- catch {
140
- // 忽略错误
141
+ catch (err) {
142
+ log.debug('Failed to list subdirectories:', err);
141
143
  }
142
144
  return dirs;
143
145
  }
@@ -108,8 +108,7 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
108
108
  [
109
109
  "headerValidateButton",
110
110
  "headerSaveButton",
111
- "headerStartButton",
112
- "headerStopButton",
111
+ "headerToggleServiceButton",
113
112
  "langButton",
114
113
  ].forEach((id) => {
115
114
  const node = el(id);
@@ -233,8 +232,7 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
233
232
  buttons: [
234
233
  { id: "headerValidateButton", key: "validate" },
235
234
  { id: "headerSaveButton", key: "save" },
236
- { id: "headerStartButton", key: "start" },
237
- { id: "headerStopButton", key: "stop" },
235
+ { id: "headerToggleServiceButton", key: "start" },
238
236
  ],
239
237
  testButtons: [
240
238
  { prefix: "test-", key: "test" },
@@ -623,6 +621,12 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
623
621
  serviceState.textContent = serviceStateText;
624
622
  serviceState.className = "badge " + (data.running ? "badge-success" : "badge-default");
625
623
  }
624
+ // Update toggle button text and style
625
+ const toggleBtn = el("headerToggleServiceButton");
626
+ if (toggleBtn) {
627
+ toggleBtn.textContent = data.running ? t("stop") : t("start");
628
+ toggleBtn.className = "btn btn-sm " + (data.running ? "btn-danger" : "btn-primary");
629
+ }
626
630
  return data;
627
631
  }
628
632
 
@@ -750,8 +754,14 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
750
754
  };
751
755
  el("headerValidateButton").onclick = validate;
752
756
  el("headerSaveButton").onclick = onSaveClick;
753
- el("headerStartButton").onclick = onStartClick;
754
- el("headerStopButton").onclick = stopService;
757
+ el("headerToggleServiceButton").onclick = async () => {
758
+ const status = await refreshStatus();
759
+ if (status.running) {
760
+ await stopService();
761
+ } else {
762
+ await onStartClick();
763
+ }
764
+ };
755
765
 
756
766
  // Platform test buttons
757
767
  platformKeys.forEach((platform) => {
@@ -866,8 +866,7 @@ export const PAGE_HTML_PREFIX = String.raw `<!doctype html>
866
866
  <div class="main-header-toolbar" id="headerToolbar" role="toolbar" aria-label="Bridge controls">
867
867
  <button type="button" id="headerValidateButton" class="btn btn-warning btn-sm">Validate</button>
868
868
  <button type="button" id="headerSaveButton" class="btn btn-secondary btn-sm">Save config</button>
869
- <button type="button" id="headerStartButton" class="btn btn-primary btn-sm">Start bridge</button>
870
- <button type="button" id="headerStopButton" class="btn btn-danger btn-sm">Stop bridge</button>
869
+ <button type="button" id="headerToggleServiceButton" class="btn btn-primary btn-sm">Start bridge</button>
871
870
  </div>
872
871
  </header>
873
872
 
@@ -894,67 +894,7 @@ export async function startWebConfigServer(options) {
894
894
  }
895
895
  if (request.method === "GET" && requestUrl.pathname === "/api/health") {
896
896
  const file = loadFileConfig();
897
- const fileTelegram = file.platforms?.telegram;
898
- const fileFeishu = file.platforms?.feishu;
899
- const fileQQ = file.platforms?.qq;
900
- const fileWework = file.platforms?.wework;
901
- const fileDingtalk = file.platforms?.dingtalk;
902
- const fileWorkbuddy = file.platforms?.workbuddy;
903
- const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN ?? fileTelegram?.botToken ?? file.telegramBotToken;
904
- const feishuAppId = process.env.FEISHU_APP_ID ?? fileFeishu?.appId ?? file.feishuAppId;
905
- const feishuAppSecret = process.env.FEISHU_APP_SECRET ?? fileFeishu?.appSecret ?? file.feishuAppSecret;
906
- const qqAppId = process.env.QQ_BOT_APPID ?? process.env.QQ_APP_ID ?? fileQQ?.appId;
907
- const qqSecret = process.env.QQ_BOT_SECRET ?? process.env.QQ_SECRET ?? fileQQ?.secret;
908
- const weworkCorpId = process.env.WEWORK_CORP_ID ?? fileWework?.corpId;
909
- const weworkSecret = process.env.WEWORK_SECRET ?? fileWework?.secret;
910
- const dingtalkClientId = process.env.DINGTALK_CLIENT_ID ?? fileDingtalk?.clientId;
911
- const dingtalkClientSecret = process.env.DINGTALK_CLIENT_SECRET ?? fileDingtalk?.clientSecret;
912
- const workbuddyAccessToken = fileWorkbuddy?.accessToken;
913
- const workbuddyRefreshToken = fileWorkbuddy?.refreshToken;
914
- const workbuddyUserId = fileWorkbuddy?.userId;
915
- const platforms = {};
916
- // 检查 Telegram
917
- platforms.telegram = {
918
- configured: !!telegramBotToken,
919
- enabled: !!telegramBotToken && fileTelegram?.enabled !== false,
920
- healthy: !!telegramBotToken,
921
- message: telegramBotToken ? "Token configured" : "Token not configured"
922
- };
923
- // 检查 Feishu
924
- platforms.feishu = {
925
- configured: !!(feishuAppId && feishuAppSecret),
926
- enabled: !!(feishuAppId && feishuAppSecret) && fileFeishu?.enabled !== false,
927
- healthy: !!(feishuAppId && feishuAppSecret),
928
- message: (feishuAppId && feishuAppSecret) ? "App ID and Secret configured" : "Missing credentials"
929
- };
930
- // 检查 QQ
931
- platforms.qq = {
932
- configured: !!(qqAppId && qqSecret),
933
- enabled: !!(qqAppId && qqSecret) && fileQQ?.enabled !== false,
934
- healthy: !!(qqAppId && qqSecret),
935
- message: (qqAppId && qqSecret) ? "App ID and Secret configured" : "Missing credentials"
936
- };
937
- // 检查 WeWork
938
- platforms.wework = {
939
- configured: !!(weworkCorpId && weworkSecret),
940
- enabled: !!(weworkCorpId && weworkSecret) && fileWework?.enabled !== false,
941
- healthy: !!(weworkCorpId && weworkSecret),
942
- message: (weworkCorpId && weworkSecret) ? "Corp ID and Secret configured" : "Missing credentials"
943
- };
944
- // 检查 DingTalk
945
- platforms.dingtalk = {
946
- configured: !!(dingtalkClientId && dingtalkClientSecret),
947
- enabled: !!(dingtalkClientId && dingtalkClientSecret) && fileDingtalk?.enabled !== false,
948
- healthy: !!(dingtalkClientId && dingtalkClientSecret),
949
- message: (dingtalkClientId && dingtalkClientSecret) ? "Client ID and Secret configured" : "Missing credentials"
950
- };
951
- // 检查 WorkBuddy
952
- platforms.workbuddy = {
953
- configured: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
954
- enabled: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) && fileWorkbuddy?.enabled !== false,
955
- healthy: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
956
- message: (workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) ? "OAuth credentials configured" : "Missing credentials"
957
- };
897
+ const platforms = getHealthPlatformSnapshot(file);
958
898
  json(response, 200, { platforms, serviceStatus: getServiceStatus() });
959
899
  return;
960
900
  }
@@ -2,6 +2,8 @@ import { configureDingTalkMessageSender, sendThinkingMessage, updateMessage, sen
2
2
  import { ackMessage, downloadRobotMessageFile, registerSessionWebhook } from './client.js';
3
3
  import { createPlatformEventContext } from '../platform/create-event-context.js';
4
4
  import { createPlatformAIRequestHandler } from '../platform/handle-ai-request.js';
5
+ import { handleTextFlow } from '../platform/handle-text-flow.js';
6
+ import { handleEnqueueResult } from '../shared/utils.js';
5
7
  import { setActiveChatId, setDingTalkActiveTarget } from '../shared/active-chats.js';
6
8
  import { setChatUser } from '../shared/chat-user-map.js';
7
9
  import { createLogger } from '../logger.js';
@@ -246,12 +248,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
246
248
  return;
247
249
  }
248
250
  const enqueueResult = await enqueuePrompt(userId, chatId, prompt, dingtalkTarget);
249
- if (enqueueResult === 'rejected') {
250
- await sendTextReply(chatId, '请求队列已满,请稍后再试。');
251
- }
252
- else if (enqueueResult === 'queued') {
253
- await sendTextReply(chatId, '您的请求已排队等待。');
254
- }
251
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text));
255
252
  ackMessage(callbackId, { queued: enqueueResult, kind });
256
253
  return;
257
254
  }
@@ -259,26 +256,29 @@ export function setupDingTalkHandlers(config, sessionManager) {
259
256
  ackMessage(callbackId, { ignored: 'empty text' });
260
257
  return;
261
258
  }
262
- try {
263
- const handled = await commandHandler.dispatch(text, chatId, userId, 'dingtalk', (userId, chatId, prompt, workDir, convId) => {
264
- return handleAIRequest({ userId, chatId, prompt, workDir, convId });
265
- });
266
- if (handled) {
267
- ackMessage(callbackId, { handled: true });
268
- return;
269
- }
270
- }
271
- catch (err) {
272
- log.error('Error in commandHandler.dispatch:', err);
273
- }
274
- const enqueueResult = await enqueuePrompt(userId, chatId, text, dingtalkTarget);
275
- if (enqueueResult === 'rejected') {
276
- await sendTextReply(chatId, '请求队列已满,请稍后再试。');
277
- }
278
- else if (enqueueResult === 'queued') {
279
- await sendTextReply(chatId, '您的请求已排队等待。');
259
+ // Use shared text flow with customEnqueue to carry dingtalkTarget
260
+ const processed = await handleTextFlow({
261
+ platform: 'dingtalk',
262
+ userId,
263
+ chatId,
264
+ text,
265
+ ctx,
266
+ handleAIRequest,
267
+ sendTextReply,
268
+ workDir: sessionManager.getWorkDir(userId),
269
+ convId: sessionManager.getConvId(userId),
270
+ queueFullMessage: '请求队列已满,请稍后再试。',
271
+ queuedMessage: '您的请求已排队等待。',
272
+ customEnqueue: (prompt) => {
273
+ return enqueuePrompt(userId, chatId, prompt, dingtalkTarget);
274
+ },
275
+ });
276
+ if (!processed) {
277
+ // Access denied
278
+ ackMessage(callbackId, { denied: true });
279
+ return;
280
280
  }
281
- ackMessage(callbackId, { queued: enqueueResult });
281
+ ackMessage(callbackId, { handled: true });
282
282
  }
283
283
  return {
284
284
  stop: () => { },
@@ -103,7 +103,7 @@ export async function initFeishu(config, eventHandler) {
103
103
  app_secret: config.feishuAppSecret,
104
104
  },
105
105
  });
106
- if (tokenResp.code !== 0 || !tokenResp.data) {
106
+ if (tokenResp.code !== 0) {
107
107
  throw new Error(`Feishu credentials invalid: ${tokenResp.msg} (code: ${tokenResp.code})`);
108
108
  }
109
109
  log.info('Feishu credentials validated successfully');
@@ -11,6 +11,7 @@ import { buildProgressNote } from '../shared/message-note.js';
11
11
  import { createPlatformEventContext } from '../platform/create-event-context.js';
12
12
  import { createPlatformAIRequestHandler } from '../platform/handle-ai-request.js';
13
13
  import { handleTextFlow } from '../platform/handle-text-flow.js';
14
+ import { handleEnqueueResult } from '../shared/utils.js';
14
15
  import { isPermissionError, handlePermissionError } from './permission.js';
15
16
  const log = createLogger('FeishuHandler');
16
17
  async function downloadFeishuMessageResource(client, messageId, fileKey, type, options) {
@@ -188,13 +189,12 @@ export function setupFeishuHandlers(config, sessionManager) {
188
189
  const messageId = message.message_id ?? '';
189
190
  const msgType = message.message_type;
190
191
  const contentStr = message.content ?? '{}';
191
- log.info(`[handleEvent] Parsed: chatId=${chatId}, msgType=${msgType}`);
192
192
  log.info(`Message: chatId=${chatId}, messageId=${messageId}, msgType=${msgType}`);
193
193
  // Parse message content
194
194
  let content;
195
195
  try {
196
196
  content = JSON.parse(contentStr);
197
- log.info(`Parsed content:`, JSON.stringify(content).slice(0, 200));
197
+ log.debug(`Parsed content:`, JSON.stringify(content).slice(0, 200));
198
198
  }
199
199
  catch (err) {
200
200
  log.error('Failed to parse message content:', err);
@@ -223,7 +223,7 @@ export function setupFeishuHandlers(config, sessionManager) {
223
223
  text = text.replace(/&nbsp;/gi, ' ');
224
224
  // 最后做一次首尾 trim,但不动中间的空格
225
225
  text = text.trim();
226
- log.info(`[MSG] Type=text, User=${senderId}, Length=${text.length}, Content="${text}"`);
226
+ log.debug(`[MSG] Type=text, User=${senderId}, Length=${text.length}, Content="${text}"`);
227
227
  }
228
228
  else if (msgType === 'post') {
229
229
  // Feishu rich text/post messages - extract text content
@@ -252,7 +252,7 @@ export function setupFeishuHandlers(config, sessionManager) {
252
252
  return '';
253
253
  }
254
254
  if (rawContent && Array.isArray(rawContent)) {
255
- log.info(`[MSG] Post content structure:`, JSON.stringify(rawContent).slice(0, 500));
255
+ log.debug(`[MSG] Post content structure:`, JSON.stringify(rawContent).slice(0, 500));
256
256
  for (const section of rawContent) {
257
257
  if (Array.isArray(section)) {
258
258
  // 二维数组:段落内多个元素
@@ -266,7 +266,7 @@ export function setupFeishuHandlers(config, sessionManager) {
266
266
  }
267
267
  }
268
268
  text = text.trim();
269
- log.info(`[MSG] Type=post, User=${senderId}, Length=${text.length}, Content="${text}"`);
269
+ log.debug(`[MSG] Type=post, User=${senderId}, Length=${text.length}, Content="${text}"`);
270
270
  }
271
271
  if (!text) {
272
272
  log.warn(`[MSG] ${msgType} message has no extractable text content`);
@@ -321,15 +321,11 @@ export function setupFeishuHandlers(config, sessionManager) {
321
321
  const enqueueResult = ctx.requestQueue.enqueue(senderId, convId, prompt, async (p, signal) => {
322
322
  await handleAIRequest({ userId: senderId, chatId, prompt: p, workDir: work, convId, replyToMessageId: messageId, signal });
323
323
  });
324
- if (enqueueResult === 'rejected') {
325
- sendTextReply(chatId, '请求队列已满,请稍后再试。').catch((sendErr) => {
326
- log.warn('[feishu] Failed to send queue full message for image:', sendErr);
327
- });
324
+ try {
325
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text));
328
326
  }
329
- else if (enqueueResult === 'queued') {
330
- sendTextReply(chatId, '您的请求已排队等待。').catch((sendErr) => {
331
- log.warn('[feishu] Failed to send queued message for image:', sendErr);
332
- });
327
+ catch (sendErr) {
328
+ log.warn('[feishu] Failed to send enqueue result message for image:', sendErr);
333
329
  }
334
330
  }
335
331
  catch (err) {
@@ -369,15 +365,11 @@ export function setupFeishuHandlers(config, sessionManager) {
369
365
  const enqueueResult = ctx.requestQueue.enqueue(senderId, convId, prompt, async (p, signal) => {
370
366
  await handleAIRequest({ userId: senderId, chatId, prompt: p, workDir, convId, replyToMessageId: messageId, signal });
371
367
  });
372
- if (enqueueResult === 'rejected') {
373
- sendTextReply(chatId, '请求队列已满,请稍后再试。').catch((sendErr) => {
374
- log.warn(`[feishu] Failed to send queue full message for ${msgType}:`, sendErr);
375
- });
368
+ try {
369
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text));
376
370
  }
377
- else if (enqueueResult === 'queued') {
378
- sendTextReply(chatId, '您的请求已排队等待。').catch((sendErr) => {
379
- log.warn(`[feishu] Failed to send queued message for ${msgType}:`, sendErr);
380
- });
371
+ catch (sendErr) {
372
+ log.warn(`[feishu] Failed to send enqueue result message for ${msgType}:`, sendErr);
381
373
  }
382
374
  }
383
375
  catch (err) {
@@ -389,7 +381,7 @@ export function setupFeishuHandlers(config, sessionManager) {
389
381
  }
390
382
  else {
391
383
  log.warn(`[MSG] Unsupported message type: ${msgType}, senderId=${senderId}`);
392
- log.info(`[MSG] Content structure:`, JSON.stringify(content).slice(0, 500));
384
+ log.debug(`[MSG] Content structure:`, JSON.stringify(content).slice(0, 500));
393
385
  }
394
386
  }
395
387
  }
@@ -76,7 +76,7 @@ async function getTenantAccessToken() {
76
76
  app_secret: client.appSecret,
77
77
  },
78
78
  });
79
- if (resp.code !== 0 || !resp.data) {
79
+ if (resp.code !== 0) {
80
80
  throw new Error(`Failed to get tenant access token: ${resp.msg}`);
81
81
  }
82
82
  return resp.data.tenant_access_token;
package/dist/index.js CHANGED
@@ -296,7 +296,7 @@ export async function main() {
296
296
  // WebSocket "not open" errors are transient — the connection will auto-reconnect.
297
297
  // Exiting would take down all platforms, so just log and continue.
298
298
  if (msg.includes("WebSocket is not open") || msg.includes("readyState")) {
299
- log.warn("Transient WebSocket error (ignored, will reconnect):", err);
299
+ log.debug("Transient WebSocket error (ignored, will reconnect):", err);
300
300
  return;
301
301
  }
302
302
  log.error("Uncaught exception (process will exit):", err);
@@ -97,42 +97,26 @@ export function createPlatformAIRequestHandler(deps) {
97
97
  const mergedCallbacks = { ...defaultCallbacks };
98
98
  // Use taskCallbacksFactory if provided (has full context access)
99
99
  let factoryCallbacks;
100
- if (taskCallbacksFactory) {
101
- factoryCallbacks = taskCallbacksFactory({
100
+ const platformOverrides = taskCallbacksFactory
101
+ ? (factoryCallbacks = taskCallbacksFactory({
102
102
  chatId,
103
103
  msgId,
104
104
  taskKey,
105
105
  userId,
106
106
  toolId,
107
107
  replyToMessageId,
108
- });
109
- if (factoryCallbacks.streamUpdate) {
110
- mergedCallbacks.streamUpdate = factoryCallbacks.streamUpdate;
111
- }
112
- if (factoryCallbacks.sendComplete) {
113
- mergedCallbacks.sendComplete = factoryCallbacks.sendComplete;
114
- }
115
- if (factoryCallbacks.sendError) {
116
- mergedCallbacks.sendError = factoryCallbacks.sendError;
117
- }
118
- if (factoryCallbacks.onFirstContent) {
119
- mergedCallbacks.onFirstContent = factoryCallbacks.onFirstContent;
120
- }
121
- }
122
- else if (taskCallbacks) {
123
- // Fall back to static taskCallbacks
124
- if (taskCallbacks.streamUpdate) {
125
- mergedCallbacks.streamUpdate = taskCallbacks.streamUpdate;
126
- }
127
- if (taskCallbacks.sendComplete) {
128
- mergedCallbacks.sendComplete = taskCallbacks.sendComplete;
129
- }
130
- if (taskCallbacks.sendError) {
131
- mergedCallbacks.sendError = taskCallbacks.sendError;
132
- }
133
- if (taskCallbacks.onFirstContent) {
134
- mergedCallbacks.onFirstContent = taskCallbacks.onFirstContent;
135
- }
108
+ }))
109
+ : taskCallbacks;
110
+ if (platformOverrides) {
111
+ const { streamUpdate, sendComplete, sendError, onFirstContent } = platformOverrides;
112
+ if (streamUpdate)
113
+ mergedCallbacks.streamUpdate = streamUpdate;
114
+ if (sendComplete)
115
+ mergedCallbacks.sendComplete = sendComplete;
116
+ if (sendError)
117
+ mergedCallbacks.sendError = sendError;
118
+ if (onFirstContent)
119
+ mergedCallbacks.onFirstContent = onFirstContent;
136
120
  }
137
121
  if (onThinkingToText) {
138
122
  mergedCallbacks.onThinkingToText = onThinkingToText;
@@ -17,15 +17,12 @@
17
17
  import { setActiveChatId } from '../shared/active-chats.js';
18
18
  import { setChatUser } from '../shared/chat-user-map.js';
19
19
  import { createLogger } from '../logger.js';
20
+ import { handleEnqueueResult, DEFAULT_QUEUE_FULL_MESSAGE, DEFAULT_QUEUED_MESSAGE } from '../shared/utils.js';
20
21
  const log = createLogger('TextFlow');
21
22
  /** Default access-denied message. */
22
23
  function defaultAccessDeniedMessage(userId) {
23
24
  return `抱歉,您没有访问权限。\n您的 ID: ${userId}`;
24
25
  }
25
- /** Default queue-full message. */
26
- const DEFAULT_QUEUE_FULL_MESSAGE = '请求队列已满,请稍后再试。';
27
- /** Default queued message. */
28
- const DEFAULT_QUEUED_MESSAGE = '您的请求已排队等待。';
29
26
  /**
30
27
  * Handles the full text message flow shared across all platforms.
31
28
  *
@@ -80,12 +77,7 @@ export async function handleTextFlow(params) {
80
77
  if (customEnqueue) {
81
78
  // Platform-specific enqueue (e.g., WorkBuddy with extra context)
82
79
  const enqueueResult = await customEnqueue(text);
83
- if (enqueueResult === 'rejected') {
84
- await sendTextReply(chatId, queueFullMessage);
85
- }
86
- else if (enqueueResult === 'queued') {
87
- await sendTextReply(chatId, queuedMessage);
88
- }
80
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text), { queueFull: queueFullMessage, queued: queuedMessage });
89
81
  }
90
82
  else {
91
83
  // Standard enqueue flow
@@ -103,12 +95,7 @@ export async function handleTextFlow(params) {
103
95
  signal,
104
96
  });
105
97
  });
106
- if (enqueueResult === 'rejected') {
107
- await sendTextReply(chatId, queueFullMessage);
108
- }
109
- else if (enqueueResult === 'queued') {
110
- await sendTextReply(chatId, queuedMessage);
111
- }
98
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text), { queueFull: queueFullMessage, queued: queuedMessage });
112
99
  }
113
100
  return true;
114
101
  }
@@ -9,6 +9,7 @@ import { setChatUser } from "../shared/chat-user-map.js";
9
9
  import { createPlatformEventContext } from "../platform/create-event-context.js";
10
10
  import { createPlatformAIRequestHandler } from "../platform/handle-ai-request.js";
11
11
  import { handleTextFlow } from "../platform/handle-text-flow.js";
12
+ import { handleEnqueueResult } from "../shared/utils.js";
12
13
  const log = createLogger("QQHandler");
13
14
  const QQ_THROTTLE_MS = 1200;
14
15
  const QQ_MIN_STREAM_DELTA_CHARS = 80;
@@ -266,12 +267,7 @@ export function setupQQHandlers(config, sessionManager) {
266
267
  signal,
267
268
  });
268
269
  });
269
- if (enqueueResult === "rejected") {
270
- await sendTextReply(chatId, "请求队列已满,请稍后再试。");
271
- }
272
- else if (enqueueResult === "queued") {
273
- await sendTextReply(chatId, "您的请求已排队等待。");
274
- }
270
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text));
275
271
  log.info(`QQ message handled: user=${userId}, chat=${chatId}, attachments=${event.attachments?.length ?? 0}`);
276
272
  }
277
273
  }
@@ -1,6 +1,3 @@
1
- export declare class QueueTimeoutError extends Error {
2
- constructor(timeoutMs: number);
3
- }
4
1
  export type EnqueueResult = 'running' | 'queued' | 'rejected';
5
2
  export declare class RequestQueue {
6
3
  private queues;
@@ -1,13 +1,6 @@
1
1
  import { createLogger } from '../logger.js';
2
2
  const log = createLogger('Queue');
3
- export class QueueTimeoutError extends Error {
4
- constructor(timeoutMs) {
5
- super(`Task timed out after ${timeoutMs / 1000}s`);
6
- this.name = 'QueueTimeoutError';
7
- }
8
- }
9
3
  const MAX_QUEUE_SIZE = 3;
10
- const TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
11
4
  export class RequestQueue {
12
5
  queues = new Map();
13
6
  enqueue(userId, convId, prompt, execute) {
@@ -25,7 +18,9 @@ export class RequestQueue {
25
18
  return 'queued';
26
19
  }
27
20
  q.running = true;
28
- this.run(key, prompt, execute).catch(() => { });
21
+ this.run(key, prompt, execute).catch((err) => {
22
+ log.error(`Unhandled error in task execution for ${key}:`, err);
23
+ });
29
24
  return 'running';
30
25
  }
31
26
  /** 清除指定用户会话的所有排队任务(不中止正在运行的任务) */
@@ -42,34 +37,22 @@ export class RequestQueue {
42
37
  }
43
38
  async run(key, prompt, execute) {
44
39
  const controller = new AbortController();
45
- let timer;
46
40
  try {
47
- const timeoutPromise = new Promise((_, reject) => {
48
- timer = setTimeout(() => {
49
- controller.abort();
50
- reject(new QueueTimeoutError(TASK_TIMEOUT_MS));
51
- }, TASK_TIMEOUT_MS);
52
- });
53
- await Promise.race([execute(prompt, controller.signal), timeoutPromise]);
41
+ await execute(prompt, controller.signal);
54
42
  }
55
43
  catch (err) {
56
- if (err instanceof QueueTimeoutError) {
57
- log.error(`Timeout executing task for ${key}: ${err.message}`);
58
- }
59
- else {
60
- log.error(`Error executing task for ${key}:`, err);
61
- }
44
+ log.error(`Error executing task for ${key}:`, err);
62
45
  throw err;
63
46
  }
64
47
  finally {
65
- if (timer)
66
- clearTimeout(timer);
67
48
  const q = this.queues.get(key);
68
49
  if (!q)
69
50
  return;
70
51
  const next = q.tasks.shift();
71
52
  if (next) {
72
- setImmediate(() => this.run(key, next.prompt, next.execute).catch(() => { }));
53
+ setImmediate(() => this.run(key, next.prompt, next.execute).catch((err) => {
54
+ log.error(`Unhandled error in next task execution for ${key}:`, err);
55
+ }));
73
56
  }
74
57
  else {
75
58
  q.running = false;
@@ -67,20 +67,6 @@ describe('RequestQueue', () => {
67
67
  const queue = new RequestQueue();
68
68
  expect(queue.clear('nobody', 'noconv')).toBe(0);
69
69
  });
70
- it('times out long-running tasks after 10 minutes', async () => {
71
- vi.useFakeTimers();
72
- const queue = new RequestQueue();
73
- const execute = vi.fn().mockReturnValue(new Promise(() => { })); // never resolves
74
- queue.enqueue('user1', 'conv1', 'hello', execute);
75
- expect(execute).toHaveBeenCalledTimes(1);
76
- // Advance past 10-minute timeout
77
- vi.advanceTimersByTime(10 * 60 * 1000 + 1);
78
- // Let microtasks settle
79
- await vi.advanceTimersByTimeAsync(100);
80
- // The timed-out task should be done, queue should be cleared
81
- expect(execute).toHaveBeenCalledTimes(1);
82
- vi.useRealTimers();
83
- });
84
70
  it('handles task execution error gracefully and processes next', async () => {
85
71
  const queue = new RequestQueue();
86
72
  const execute = vi.fn()
@@ -93,22 +79,4 @@ describe('RequestQueue', () => {
93
79
  expect(execute).toHaveBeenCalledWith('first', expect.any(AbortSignal));
94
80
  expect(execute).toHaveBeenCalledWith('second', expect.any(AbortSignal));
95
81
  });
96
- it('aborts the AbortSignal on timeout', async () => {
97
- vi.useFakeTimers();
98
- const queue = new RequestQueue();
99
- let receivedSignal;
100
- const execute = vi.fn().mockImplementation(async (_prompt, signal) => {
101
- receivedSignal = signal;
102
- // Never resolve — simulates a stuck task
103
- await new Promise(() => { });
104
- });
105
- queue.enqueue('user1', 'conv1', 'hello', execute);
106
- await vi.advanceTimersByTimeAsync(10);
107
- expect(receivedSignal?.aborted).toBe(false);
108
- // Advance past timeout
109
- vi.advanceTimersByTime(10 * 60 * 1000 + 1);
110
- await vi.advanceTimersByTimeAsync(100);
111
- expect(receivedSignal?.aborted).toBe(true);
112
- vi.useRealTimers();
113
- });
114
82
  });
@@ -2,6 +2,8 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { writeFile, mkdir } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { APP_HOME } from '../constants.js';
5
+ import { createLogger } from '../logger.js';
6
+ const log = createLogger('ActiveChats');
5
7
  const ACTIVE_CHATS_FILE = join(APP_HOME, 'data', 'active-chats.json');
6
8
  let data = {};
7
9
  let saveTimer = null;
@@ -25,8 +27,8 @@ function scheduleSave() {
25
27
  await mkdir(dirname(ACTIVE_CHATS_FILE), { recursive: true });
26
28
  await writeFile(ACTIVE_CHATS_FILE, JSON.stringify(data, null, 2), 'utf-8');
27
29
  }
28
- catch {
29
- /* ignore */
30
+ catch (err) {
31
+ log.warn('Failed to save active chats:', err);
30
32
  }
31
33
  }, 500);
32
34
  }
@@ -39,7 +41,8 @@ export function loadActiveChats() {
39
41
  }
40
42
  }
41
43
  }
42
- catch {
44
+ catch (err) {
45
+ log.warn('Failed to load active chats, starting with empty state:', err);
43
46
  data = {};
44
47
  }
45
48
  }
@@ -84,7 +87,7 @@ export function flushActiveChats() {
84
87
  mkdirSync(dir, { recursive: true });
85
88
  writeFileSync(ACTIVE_CHATS_FILE, JSON.stringify(data, null, 2), 'utf-8');
86
89
  }
87
- catch {
88
- /* ignore */
90
+ catch (err) {
91
+ log.warn('Failed to flush active chats:', err);
89
92
  }
90
93
  }
@@ -198,16 +198,18 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
198
198
  }
199
199
  settled = true;
200
200
  log.error(`Task error for user ${ctx.userId}: ${error}`);
201
- if (aiCommand !== 'claude' && !isUsageLimitError(error)) {
201
+ if (isUsageLimitError(error)) {
202
+ // Usage limit errors: keep session for all tools (user can retry later)
203
+ log.warn(`Keeping ${aiCommand} session for user ${ctx.userId} after usage limit error`);
204
+ }
205
+ else if (aiCommand !== 'claude') {
206
+ // Non-CLI errors for codex/codebuddy: reset session to avoid stale state
202
207
  if (ctx.convId)
203
208
  sessionManager.clearSessionForConv(ctx.userId, ctx.convId, aiCommand);
204
209
  else
205
210
  sessionManager.clearActiveToolSession(ctx.userId, aiCommand);
206
211
  log.warn(`Session reset for user ${ctx.userId} due to ${aiCommand} task error`);
207
212
  }
208
- else if (aiCommand === 'codex' && isUsageLimitError(error)) {
209
- log.warn(`Keeping codex session for user ${ctx.userId} after usage limit error`);
210
- }
211
213
  const friendlyError = hadSessionInvalid
212
214
  ? '当前 Claude 会话已失效,已自动执行 /new 重置会话,请重新发送刚才的问题。'
213
215
  : error;
@@ -1,5 +1,18 @@
1
+ import type { EnqueueResult } from '../queue/request-queue.js';
1
2
  /** 消息头部品牌后缀,用于飞书等平台展示 */
2
3
  export declare const OPEN_IM_BRAND_SUFFIX = " \u00B7 \u901A\u8FC7 open-im \u63A7\u5236";
4
+ /** Default queue-full message. */
5
+ export declare const DEFAULT_QUEUE_FULL_MESSAGE = "\u8BF7\u6C42\u961F\u5217\u5DF2\u6EE1\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002";
6
+ /** Default queued message. */
7
+ export declare const DEFAULT_QUEUED_MESSAGE = "\u60A8\u7684\u8BF7\u6C42\u5DF2\u6392\u961F\u7B49\u5F85\u3002";
8
+ /**
9
+ * Handle enqueue result by sending the appropriate notification message.
10
+ * Centralizes the repeated rejected/queued notification pattern across all platforms.
11
+ */
12
+ export declare function handleEnqueueResult(enqueueResult: EnqueueResult, sendTextReply: (text: string) => Promise<void>, messages?: {
13
+ queueFull?: string;
14
+ queued?: string;
15
+ }): Promise<void>;
3
16
  /** 转义路径供 Markdown 显示,防止 xxx.yyy.com 被解析为链接 */
4
17
  export declare function escapePathForMarkdown(path: string): string;
5
18
  /** AI 工具显示名称映射(aiCommand -> 用户友好名称) */
@@ -1,5 +1,21 @@
1
1
  /** 消息头部品牌后缀,用于飞书等平台展示 */
2
2
  export const OPEN_IM_BRAND_SUFFIX = ' · 通过 open-im 控制';
3
+ /** Default queue-full message. */
4
+ export const DEFAULT_QUEUE_FULL_MESSAGE = '请求队列已满,请稍后再试。';
5
+ /** Default queued message. */
6
+ export const DEFAULT_QUEUED_MESSAGE = '您的请求已排队等待。';
7
+ /**
8
+ * Handle enqueue result by sending the appropriate notification message.
9
+ * Centralizes the repeated rejected/queued notification pattern across all platforms.
10
+ */
11
+ export async function handleEnqueueResult(enqueueResult, sendTextReply, messages) {
12
+ if (enqueueResult === 'rejected') {
13
+ await sendTextReply(messages?.queueFull ?? DEFAULT_QUEUE_FULL_MESSAGE);
14
+ }
15
+ else if (enqueueResult === 'queued') {
16
+ await sendTextReply(messages?.queued ?? DEFAULT_QUEUED_MESSAGE);
17
+ }
18
+ }
3
19
  /** 转义路径供 Markdown 显示,防止 xxx.yyy.com 被解析为链接 */
4
20
  export function escapePathForMarkdown(path) {
5
21
  return `\`${path.replace(/`/g, '\\`')}\``;
@@ -9,6 +9,7 @@ import { buildErrorNote, buildProgressNote } from "../shared/message-note.js";
9
9
  import { createPlatformEventContext } from "../platform/create-event-context.js";
10
10
  import { createPlatformAIRequestHandler } from "../platform/handle-ai-request.js";
11
11
  import { handleTextFlow } from "../platform/handle-text-flow.js";
12
+ import { handleEnqueueResult } from "../shared/utils.js";
12
13
  import { setActiveChatId } from "../shared/active-chats.js";
13
14
  import { setChatUser } from "../shared/chat-user-map.js";
14
15
  const log = createLogger("TgHandler");
@@ -245,6 +246,35 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
245
246
  runningTasks,
246
247
  taskCallbacksFactory: telegramTaskCallbacksFactory,
247
248
  });
249
+ /**
250
+ * Shared preamble for all media handlers: access check + chat registration.
251
+ * Returns null (with early return) if user is not allowed.
252
+ */
253
+ function registerMediaChat(tgCtx) {
254
+ const chatId = String(tgCtx.chat.id);
255
+ const userId = String(tgCtx.from?.id ?? "");
256
+ if (!accessControl.isAllowed(userId))
257
+ return null;
258
+ setActiveChatId("telegram", chatId);
259
+ setChatUser(chatId, userId, "telegram");
260
+ return { chatId, userId };
261
+ }
262
+ /**
263
+ * Generic handler for file-type media (document, audio, voice, video).
264
+ * Downloads the file, builds a prompt with media context, and enqueues.
265
+ */
266
+ async function handleFileMedia(ids, downloadFn, kind, metadata, caption, errorMsg) {
267
+ try {
268
+ const contextText = buildMediaContext(metadata, caption ? `Caption: ${caption}` : undefined);
269
+ const path = await downloadFn();
270
+ const enqueueResult = await enqueueSavedMedia(ids.userId, ids.chatId, kind, path, contextText);
271
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(ids.chatId, text));
272
+ }
273
+ catch (err) {
274
+ log.error(`Failed to download ${kind}:`, err);
275
+ await sendTextReply(ids.chatId, errorMsg);
276
+ }
277
+ }
248
278
  async function enqueueSavedMedia(userId, chatId, kind, localPath, text) {
249
279
  const prompt = buildSavedMediaPrompt({
250
280
  source: "Telegram",
@@ -309,13 +339,10 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
309
339
  }
310
340
  });
311
341
  bot.on(message("photo"), async (ctx) => {
312
- const chatId = String(ctx.chat.id);
313
- const userId = String(ctx.from.id);
314
- const caption = ctx.message.caption?.trim() || "";
315
- if (!accessControl.isAllowed(userId))
342
+ const ids = registerMediaChat(ctx);
343
+ if (!ids)
316
344
  return;
317
- setActiveChatId("telegram", chatId);
318
- setChatUser(chatId, userId, "telegram");
345
+ const caption = ctx.message.caption?.trim() || "";
319
346
  const photos = ctx.message.photo;
320
347
  const largest = photos[photos.length - 1];
321
348
  const contextText = buildMediaContext({
@@ -328,134 +355,42 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
328
355
  }
329
356
  catch (err) {
330
357
  log.error("Failed to download photo:", err);
331
- await sendTextReply(chatId, "图片下载失败。");
358
+ await sendTextReply(ids.chatId, "图片下载失败。");
332
359
  return;
333
360
  }
334
- const enqueueResult = await enqueueSavedMedia(userId, chatId, "image", imagePath, contextText);
335
- if (enqueueResult === "rejected") {
336
- await sendTextReply(chatId, "请求队列已满,请稍后再试。");
337
- }
338
- else if (enqueueResult === "queued") {
339
- await sendTextReply(chatId, "您的请求已排队等待。");
340
- }
361
+ const enqueueResult = await enqueueSavedMedia(ids.userId, ids.chatId, "image", imagePath, contextText);
362
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(ids.chatId, text));
341
363
  });
342
364
  bot.on(message("document"), async (ctx) => {
343
- const chatId = String(ctx.chat.id);
344
- const userId = String(ctx.from.id);
345
- const caption = ctx.message.caption?.trim() || "";
346
- if (!accessControl.isAllowed(userId))
365
+ const ids = registerMediaChat(ctx);
366
+ if (!ids)
347
367
  return;
348
- setActiveChatId("telegram", chatId);
349
- setChatUser(chatId, userId, "telegram");
350
- try {
351
- const document = ctx.message.document;
352
- const contextText = buildMediaContext({
353
- Filename: document.file_name,
354
- MimeType: document.mime_type,
355
- Size: document.file_size,
356
- }, caption ? `Caption: ${caption}` : undefined);
357
- const path = await downloadTelegramFile(bot, document.file_id, document.file_name ?? document.file_id, "bin");
358
- const enqueueResult = await enqueueSavedMedia(userId, chatId, "document", path, contextText);
359
- if (enqueueResult === "rejected") {
360
- await sendTextReply(chatId, "请求队列已满,请稍后再试。");
361
- }
362
- else if (enqueueResult === "queued") {
363
- await sendTextReply(chatId, "您的请求已排队等待。");
364
- }
365
- }
366
- catch (err) {
367
- log.error("Failed to download document:", err);
368
- await sendTextReply(chatId, "文档下载失败。");
369
- }
368
+ const caption = ctx.message.caption?.trim() || "";
369
+ const document = ctx.message.document;
370
+ await handleFileMedia(ids, () => downloadTelegramFile(bot, document.file_id, document.file_name ?? document.file_id, "bin"), "document", { Filename: document.file_name, MimeType: document.mime_type, Size: document.file_size }, caption || undefined, "文档下载失败。");
370
371
  });
371
372
  bot.on(message("audio"), async (ctx) => {
372
- const chatId = String(ctx.chat.id);
373
- const userId = String(ctx.from.id);
374
- const caption = ctx.message.caption?.trim() || "";
375
- if (!accessControl.isAllowed(userId))
373
+ const ids = registerMediaChat(ctx);
374
+ if (!ids)
376
375
  return;
377
- setActiveChatId("telegram", chatId);
378
- setChatUser(chatId, userId, "telegram");
379
- try {
380
- const audio = ctx.message.audio;
381
- const contextText = buildMediaContext({
382
- Filename: audio.file_name,
383
- Title: audio.title,
384
- Performer: audio.performer,
385
- DurationSeconds: audio.duration,
386
- MimeType: audio.mime_type,
387
- }, caption ? `Caption: ${caption}` : undefined);
388
- const path = await downloadTelegramFile(bot, audio.file_id, audio.file_name ?? audio.file_id, "mp3");
389
- const enqueueResult = await enqueueSavedMedia(userId, chatId, "audio", path, contextText);
390
- if (enqueueResult === "rejected") {
391
- await sendTextReply(chatId, "请求队列已满,请稍后再试。");
392
- }
393
- else if (enqueueResult === "queued") {
394
- await sendTextReply(chatId, "您的请求已排队等待。");
395
- }
396
- }
397
- catch (err) {
398
- log.error("Failed to download audio:", err);
399
- await sendTextReply(chatId, "音频下载失败。");
400
- }
376
+ const caption = ctx.message.caption?.trim() || "";
377
+ const audio = ctx.message.audio;
378
+ await handleFileMedia(ids, () => downloadTelegramFile(bot, audio.file_id, audio.file_name ?? audio.file_id, "mp3"), "audio", { Filename: audio.file_name, Title: audio.title, Performer: audio.performer, DurationSeconds: audio.duration, MimeType: audio.mime_type }, caption || undefined, "音频下载失败。");
401
379
  });
402
380
  bot.on(message("voice"), async (ctx) => {
403
- const chatId = String(ctx.chat.id);
404
- const userId = String(ctx.from.id);
405
- if (!accessControl.isAllowed(userId))
381
+ const ids = registerMediaChat(ctx);
382
+ if (!ids)
406
383
  return;
407
- setActiveChatId("telegram", chatId);
408
- setChatUser(chatId, userId, "telegram");
409
- try {
410
- const voice = ctx.message.voice;
411
- const contextText = buildMediaContext({
412
- DurationSeconds: voice.duration,
413
- MimeType: voice.mime_type,
414
- });
415
- const path = await downloadTelegramFile(bot, voice.file_id, voice.file_unique_id ?? voice.file_id, "ogg");
416
- const enqueueResult = await enqueueSavedMedia(userId, chatId, "voice", path, contextText);
417
- if (enqueueResult === "rejected") {
418
- await sendTextReply(chatId, "请求队列已满,请稍后再试。");
419
- }
420
- else if (enqueueResult === "queued") {
421
- await sendTextReply(chatId, "您的请求已排队等待。");
422
- }
423
- }
424
- catch (err) {
425
- log.error("Failed to download voice message:", err);
426
- await sendTextReply(chatId, "语音下载失败。");
427
- }
384
+ const voice = ctx.message.voice;
385
+ await handleFileMedia(ids, () => downloadTelegramFile(bot, voice.file_id, voice.file_unique_id ?? voice.file_id, "ogg"), "voice", { DurationSeconds: voice.duration, MimeType: voice.mime_type }, undefined, "语音下载失败。");
428
386
  });
429
387
  bot.on(message("video"), async (ctx) => {
430
- const chatId = String(ctx.chat.id);
431
- const userId = String(ctx.from.id);
432
- const caption = ctx.message.caption?.trim() || "";
433
- if (!accessControl.isAllowed(userId))
388
+ const ids = registerMediaChat(ctx);
389
+ if (!ids)
434
390
  return;
435
- setActiveChatId("telegram", chatId);
436
- setChatUser(chatId, userId, "telegram");
437
- try {
438
- const video = ctx.message.video;
439
- const contextText = buildMediaContext({
440
- Filename: video.file_name,
441
- DurationSeconds: video.duration,
442
- Width: video.width,
443
- Height: video.height,
444
- MimeType: video.mime_type,
445
- }, caption ? `Caption: ${caption}` : undefined);
446
- const path = await downloadTelegramFile(bot, video.file_id, video.file_name ?? video.file_unique_id ?? video.file_id, "mp4");
447
- const enqueueResult = await enqueueSavedMedia(userId, chatId, "video", path, contextText);
448
- if (enqueueResult === "rejected") {
449
- await sendTextReply(chatId, "请求队列已满,请稍后再试。");
450
- }
451
- else if (enqueueResult === "queued") {
452
- await sendTextReply(chatId, "您的请求已排队等待。");
453
- }
454
- }
455
- catch (err) {
456
- log.error("Failed to download video:", err);
457
- await sendTextReply(chatId, "视频下载失败。");
458
- }
391
+ const caption = ctx.message.caption?.trim() || "";
392
+ const video = ctx.message.video;
393
+ await handleFileMedia(ids, () => downloadTelegramFile(bot, video.file_id, video.file_name ?? video.file_unique_id ?? video.file_id, "mp4"), "video", { Filename: video.file_name, DurationSeconds: video.duration, Width: video.width, Height: video.height, MimeType: video.mime_type }, caption || undefined, "视频下载失败。");
459
394
  });
460
395
  return {
461
396
  stop: () => { },
@@ -14,6 +14,7 @@ import { buildErrorNote, buildProgressNote } from '../shared/message-note.js';
14
14
  import { createPlatformEventContext } from '../platform/create-event-context.js';
15
15
  import { createPlatformAIRequestHandler } from '../platform/handle-ai-request.js';
16
16
  import { handleTextFlow } from '../platform/handle-text-flow.js';
17
+ import { handleEnqueueResult } from '../shared/utils.js';
17
18
  import { decryptAes256CbcMedia, downloadMediaFromUrl, inferExtensionFromBuffer, inferExtensionFromContentType, saveBase64Media, saveBufferMedia, } from '../shared/media-storage.js';
18
19
  const log = createLogger('WeWorkHandler');
19
20
  const WEWORK_MEDIA_TIMEOUT_MS = 60_000;
@@ -243,12 +244,7 @@ export function setupWeWorkHandlers(config, sessionManager) {
243
244
  const enqueueResult = ctx.requestQueue.enqueue(userId, convId, prompt, async (nextPrompt, signal) => {
244
245
  await handleAIRequest({ userId, chatId, prompt: nextPrompt, workDir, convId, replyToMessageId: undefined, signal });
245
246
  });
246
- if (enqueueResult === 'rejected') {
247
- await sendTextReply(chatId, '请求队列已满,请稍后再试。', reqId);
248
- }
249
- else if (enqueueResult === 'queued') {
250
- await sendTextReply(chatId, '您的请求已排队等待。', reqId);
251
- }
247
+ await handleEnqueueResult(enqueueResult, (text) => sendTextReply(chatId, text, reqId));
252
248
  }
253
249
  async function handleEvent(data) {
254
250
  log.debug('[handleEvent] Called with data:', JSON.stringify(data).slice(0, 800));
@@ -195,7 +195,14 @@ async function connect() {
195
195
  scheduleReconnect();
196
196
  },
197
197
  onError: (error) => {
198
- log.error(`WorkBuddy Centrifuge error: ${error instanceof Error ? error.message : JSON.stringify(error)}`);
198
+ const msg = error instanceof Error ? error.message : JSON.stringify(error);
199
+ // "transport closed" is a transient WebSocket disconnect, not a real error
200
+ if (msg.includes('transport closed')) {
201
+ log.debug(`WorkBuddy Centrifuge transient error: ${msg}`);
202
+ }
203
+ else {
204
+ log.error(`WorkBuddy Centrifuge error: ${msg}`);
205
+ }
199
206
  updateState('error');
200
207
  },
201
208
  onPersistentFailure: () => {
@@ -4,15 +4,12 @@
4
4
  import { sendTextReply, sendErrorReply } from './message-sender.js';
5
5
  import { startTaskCleanup } from '../shared/task-cleanup.js';
6
6
  import { WORKBUDDY_THROTTLE_MS } from '../constants.js';
7
- import { setActiveChatId } from '../shared/active-chats.js';
8
- import { setChatUser } from '../shared/chat-user-map.js';
9
7
  import { createLogger } from '../logger.js';
10
8
  import { createPlatformEventContext } from '../platform/create-event-context.js';
11
9
  import { createPlatformAIRequestHandler } from '../platform/handle-ai-request.js';
10
+ import { handleTextFlow } from '../platform/handle-text-flow.js';
12
11
  const log = createLogger('WorkBuddyHandler');
13
12
  export function setupWorkBuddyHandlers(config, sessionManager) {
14
- // WorkBuddy-specific: taskKeyByChatId map for tracking
15
- const taskKeyByChatId = new Map();
16
13
  // Create shared platform event context
17
14
  const ctx = createPlatformEventContext({
18
15
  platform: 'workbuddy',
@@ -21,7 +18,6 @@ export function setupWorkBuddyHandlers(config, sessionManager) {
21
18
  sessionManager,
22
19
  sender: {
23
20
  sendTextReply: async (chatId, text) => {
24
- // WorkBuddy needs msgId for all replies, we'll handle this per-event
25
21
  await sendTextReply(null, chatId, text, '');
26
22
  },
27
23
  },
@@ -31,157 +27,65 @@ export function setupWorkBuddyHandlers(config, sessionManager) {
31
27
  // WorkBuddy-specific sender callbacks (no thinking message needed)
32
28
  const platformSender = {
33
29
  sendThinkingMessage: async (_chatId, _replyToMessageId, _toolId) => {
34
- // WorkBuddy uses incoming msgId as thinking message ID
35
- // This is a no-op since we'll use the incoming msgId
30
+ // WorkBuddy uses incoming msgId directly; no separate thinking message
36
31
  return 'workbuddy_no_thinking';
37
32
  },
38
- sendTextReply: async (_chatId, text) => {
39
- // WorkBuddy-specific reply (needs msgId captured per event)
40
- await sendTextReply(null, _chatId, text, '');
33
+ sendTextReply: async (chatId, text) => {
34
+ await sendTextReply(null, chatId, text, '');
41
35
  },
42
36
  startTyping: (_chatId) => {
43
37
  // WorkBuddy doesn't support typing indicators
44
38
  return () => { };
45
39
  },
46
40
  };
47
- // WorkBuddy-specific callbacks (log-only streaming, no real updates)
48
- const workBuddyTaskCallbacks = {
49
- streamUpdate: async (content) => {
50
- // WorkBuddy doesn't support streaming updates via Centrifuge
51
- log.debug(`Stream update (not sent): ${content.substring(0, 50)}...`);
52
- },
53
- sendComplete: async (_content) => {
54
- // Will be handled per-event with correct msgId
55
- },
56
- sendError: async (_error) => {
57
- // Will be handled per-event with correct msgId
58
- },
59
- extraCleanup: () => {
60
- // Clean up taskKeyByChatId on completion
61
- },
62
- };
63
- // WorkBuddy-specific init to capture msgId for task tracking
64
- const extraInit = ({ chatId, taskKey }) => {
65
- taskKeyByChatId.set(chatId, taskKey);
66
- return () => {
67
- if (taskKeyByChatId.get(chatId) === taskKey) {
68
- taskKeyByChatId.delete(chatId);
69
- }
70
- };
71
- };
72
- // Create platform-specific AI request handler
73
- // Note: WorkBuddy uses a different handleAIRequest signature with msgId
74
- // We'll need to wrap the standard handler
75
- createPlatformAIRequestHandler({
76
- platform: 'workbuddy',
77
- config,
78
- sessionManager,
79
- sender: platformSender,
80
- throttleMs: WORKBUDDY_THROTTLE_MS,
81
- runningTasks: ctx.runningTasks,
82
- taskCallbacks: workBuddyTaskCallbacks,
83
- extraInit,
84
- });
85
- // WorkBuddy-specific wrapper that captures msgId
86
- async function handleAIRequest(userId, chatId, msgId, prompt, workDir, convId, signal) {
87
- log.info(`[AI_REQUEST] userId=${userId}, chatId=${chatId}, msgId=${msgId}, promptLength=${prompt.length}`);
88
- // WorkBuddy uses incoming msgId as taskKey (no thinking message needed)
89
- const taskKey = `${userId}:${msgId}`;
90
- // Directly run AI task (WorkBuddy doesn't use the standard flow)
91
- const { resolvePlatformAiCommand } = await import('../config.js');
92
- const { getAdapter } = await import('../adapters/registry.js');
93
- const { runAITask } = await import('../shared/ai-task.js');
94
- const aiCommand = resolvePlatformAiCommand(config, 'workbuddy');
95
- const toolAdapter = getAdapter(aiCommand);
96
- if (!toolAdapter) {
97
- log.error(`[handleAIRequest] No adapter found for: ${aiCommand}`);
98
- await sendErrorReply(null, chatId, `AI tool is not configured: ${aiCommand}`, msgId);
99
- return;
100
- }
101
- const sessionId = convId
102
- ? sessionManager.getSessionIdForConv(userId, convId, aiCommand)
103
- : undefined;
104
- log.info(`[handleAIRequest] Running ${aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
105
- // Set up task tracking key mapping
106
- taskKeyByChatId.set(chatId, taskKey);
107
- await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'workbuddy', taskKey, signal }, prompt, toolAdapter, {
108
- throttleMs: WORKBUDDY_THROTTLE_MS,
109
- streamUpdate: async (content) => {
110
- log.debug(`Stream update (not sent): ${content.substring(0, 50)}...`);
111
- },
112
- sendComplete: async (content) => {
113
- await sendTextReply(null, chatId, content, msgId);
114
- },
115
- sendError: async (error) => {
116
- await sendErrorReply(null, chatId, error, msgId);
117
- },
118
- extraCleanup: () => {
119
- ctx.runningTasks.delete(taskKey);
120
- if (taskKeyByChatId.get(chatId) === taskKey) {
121
- taskKeyByChatId.delete(chatId);
122
- }
123
- },
124
- onTaskReady: (state) => {
125
- ctx.runningTasks.set(taskKey, state);
126
- taskKeyByChatId.set(chatId, taskKey);
127
- },
128
- });
129
- }
130
41
  async function handleEvent(chatId, msgId, content) {
131
42
  log.info(`[handleEvent] chatId=${chatId}, msgId=${msgId}, content="${content.substring(0, 100)}"`);
132
43
  // Use chatId as userId for WorkBuddy (WeChat KF doesn't have separate userId)
133
44
  const userId = chatId;
134
45
  const text = content.trim();
135
- // Access control check
136
- if (!ctx.accessControl.isAllowed(userId)) {
137
- log.warn(`Access denied for sender: ${userId}`);
138
- await sendErrorReply(null, chatId, `抱歉,您没有访问权限。\n您的 ID: ${userId}`, msgId);
139
- return;
140
- }
141
- setActiveChatId('workbuddy', chatId);
142
- setChatUser(chatId, userId, 'workbuddy');
143
- const workDir = sessionManager.getWorkDir(userId);
144
- const convId = sessionManager.getConvId(userId);
145
- // Create a per-event sender that captures msgId
146
- const eventSender = {
46
+ // Create a per-event sender that captures msgId for all replies
47
+ const msgIdSender = {
48
+ ...platformSender,
147
49
  sendTextReply: async (c, t) => {
148
50
  await sendTextReply(null, c, t, msgId);
149
51
  },
150
52
  };
151
- // Create a per-event CommandHandler with msgId-capturing sender
152
- const { CommandHandler } = await import('../commands/handler.js');
153
- const commandHandler = new CommandHandler({
53
+ // Create per-event handleAIRequest that captures msgId for task callbacks
54
+ const handleAIRequest = createPlatformAIRequestHandler({
55
+ platform: 'workbuddy',
154
56
  config,
155
57
  sessionManager,
156
- requestQueue: ctx.requestQueue,
157
- sender: eventSender,
158
- getRunningTasksSize: () => ctx.runningTasks.size,
58
+ sender: msgIdSender,
59
+ throttleMs: WORKBUDDY_THROTTLE_MS,
60
+ runningTasks: ctx.runningTasks,
61
+ taskKeyBuilder: (userId, _msgId) => `${userId}:${msgId}`,
62
+ taskCallbacksFactory: ({ chatId: c }) => ({
63
+ streamUpdate: async () => {
64
+ // WorkBuddy doesn't support streaming updates via Centrifuge
65
+ },
66
+ sendComplete: async (content) => {
67
+ await sendTextReply(null, c, content, msgId);
68
+ },
69
+ sendError: async (error) => {
70
+ await sendErrorReply(null, c, error, msgId);
71
+ },
72
+ }),
159
73
  });
160
- // Try command handler first
161
- try {
162
- const handled = await commandHandler.dispatch(text, chatId, userId, 'workbuddy', (u, c, p, w, conv, _r, _m) => handleAIRequest(u, c, msgId, p, w, conv));
163
- if (handled) {
164
- log.info(`Command handled for message: ${text}`);
165
- return;
166
- }
167
- }
168
- catch (err) {
169
- log.error('Error in commandHandler.dispatch:', err);
170
- }
171
- // No command, proceed with AI request
172
- if (!text) {
173
- return;
174
- }
175
- const enqueueResult = ctx.requestQueue.enqueue(userId, convId, text, async (nextPrompt, signal) => {
176
- log.info(`Executing AI request for: ${nextPrompt}`);
177
- await handleAIRequest(userId, chatId, msgId, nextPrompt, workDir, convId, signal);
74
+ // Use shared text flow with customEnqueue to carry msgId through
75
+ await handleTextFlow({
76
+ platform: 'workbuddy',
77
+ userId,
78
+ chatId,
79
+ text,
80
+ ctx,
81
+ handleAIRequest,
82
+ sendTextReply: (c, t) => sendTextReply(null, c, t, msgId),
83
+ workDir: sessionManager.getWorkDir(userId),
84
+ convId: sessionManager.getConvId(userId),
85
+ accessDeniedMessage: (uid) => `抱歉,您没有访问权限。\n您的 ID: ${uid}`,
86
+ queueFullMessage: '请求队列已满,请稍后再试。',
87
+ queuedMessage: '您的请求已排队等待。',
178
88
  });
179
- if (enqueueResult === 'rejected') {
180
- await sendErrorReply(null, chatId, '请求队列已满,请稍后再试。', msgId);
181
- }
182
- else if (enqueueResult === 'queued') {
183
- await sendTextReply(null, chatId, '您的请求已排队等待。', msgId);
184
- }
185
89
  }
186
90
  return {
187
91
  stop: () => stopTaskCleanup(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.10.2-beta.0",
3
+ "version": "1.10.2-beta.2",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",