@xmanrui/dsh-im 1.4.0 → 2.0.0

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.
Files changed (82) hide show
  1. package/README.en.md +18 -6
  2. package/README.md +18 -6
  3. package/lib/index.js +206 -176
  4. package/package.json +1 -1
  5. package/plugin-src/host/index.mjs +2 -0
  6. package/src/channels/dingtalk/config-store.mjs +5 -3
  7. package/src/channels/dingtalk/dingtalk-api.mjs +105 -82
  8. package/src/channels/dingtalk/dingtalk-bridge.mjs +79 -110
  9. package/src/channels/dingtalk/dingtalk-card-stream.mjs +3 -1
  10. package/src/channels/dingtalk/dingtalk-controller.mjs +24 -23
  11. package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -3
  12. package/src/channels/dingtalk/state-store.mjs +3 -1
  13. package/src/channels/discord/config-store.mjs +2 -1
  14. package/src/channels/discord/discord-api.mjs +25 -1
  15. package/src/channels/discord/discord-runtime.mjs +237 -11
  16. package/src/channels/feishu/bridge.mjs +169 -180
  17. package/src/channels/feishu/feishu-cards.mjs +62 -54
  18. package/src/channels/feishu/feishu-channel.mjs +57 -21
  19. package/src/channels/feishu/feishu-runtime.mjs +10 -9
  20. package/src/channels/feishu/message-utils.mjs +4 -3
  21. package/src/channels/office/office-job-executor.mjs +15 -14
  22. package/src/channels/office/office-runtime.mjs +6 -5
  23. package/src/channels/qq/config-store.mjs +3 -1
  24. package/src/channels/qq/markdown-reply.mjs +4 -2
  25. package/src/channels/qq/qq-bridge.mjs +149 -130
  26. package/src/channels/qq/qq-controller.mjs +17 -16
  27. package/src/channels/qq/qq-runtime.mjs +3 -2
  28. package/src/channels/shared/agent-preset.mjs +3 -1
  29. package/src/channels/shared/compact-command.mjs +15 -14
  30. package/src/channels/shared/connection-test.mjs +6 -4
  31. package/src/channels/shared/control-command.mjs +13 -11
  32. package/src/channels/shared/editable-message-stream.mjs +4 -2
  33. package/src/channels/shared/harness-approval.mjs +23 -21
  34. package/src/channels/shared/harness-client.mjs +3 -2
  35. package/src/channels/shared/harness-question.mjs +8 -6
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +52 -0
  37. package/src/channels/shared/i18n-en/discord.mjs +8 -0
  38. package/src/channels/shared/i18n-en/feishu.mjs +217 -0
  39. package/src/channels/shared/i18n-en/office.mjs +23 -0
  40. package/src/channels/shared/i18n-en/qq.mjs +54 -0
  41. package/src/channels/shared/i18n-en/shared-a.mjs +91 -0
  42. package/src/channels/shared/i18n-en/shared-b.mjs +215 -0
  43. package/src/channels/shared/i18n-en/shared-c.mjs +99 -0
  44. package/src/channels/shared/i18n-en/slack.mjs +27 -0
  45. package/src/channels/shared/i18n-en/telegram.mjs +21 -0
  46. package/src/channels/shared/i18n-en/wecom.mjs +38 -0
  47. package/src/channels/shared/i18n-en/weixin.mjs +80 -0
  48. package/src/channels/shared/i18n-en/whatsapp.mjs +17 -0
  49. package/src/channels/shared/i18n-en.mjs +35 -0
  50. package/src/channels/shared/i18n.mjs +35 -0
  51. package/src/channels/shared/image-prompt.mjs +17 -15
  52. package/src/channels/shared/inbound-file.mjs +5 -3
  53. package/src/channels/shared/model-command.mjs +35 -31
  54. package/src/channels/shared/preset-command.mjs +41 -38
  55. package/src/channels/shared/semantic/artifact-delivery.mjs +170 -0
  56. package/src/channels/shared/semantic/artifact.mjs +2 -2
  57. package/src/channels/shared/semantic/delivery.mjs +40 -0
  58. package/src/channels/shared/text-harness-bridge.mjs +154 -136
  59. package/src/channels/shared/token-bot-controller.mjs +32 -11
  60. package/src/channels/shared/workspace-command.mjs +69 -58
  61. package/src/channels/slack/config-store.mjs +3 -1
  62. package/src/channels/slack/slack-api.mjs +6 -5
  63. package/src/channels/slack/slack-controller.mjs +10 -9
  64. package/src/channels/slack/slack-runtime.mjs +16 -4
  65. package/src/channels/telegram/config-store.mjs +2 -1
  66. package/src/channels/telegram/telegram-api.mjs +111 -19
  67. package/src/channels/telegram/telegram-rich-message.mjs +147 -0
  68. package/src/channels/telegram/telegram-runtime.mjs +383 -5
  69. package/src/channels/wecom/config-store.mjs +3 -1
  70. package/src/channels/wecom/wecom-bridge.mjs +154 -153
  71. package/src/channels/wecom/wecom-controller.mjs +20 -19
  72. package/src/channels/wecom/wecom-runtime.mjs +2 -1
  73. package/src/channels/weixin/config-store.mjs +2 -1
  74. package/src/channels/weixin/weixin-api.mjs +140 -107
  75. package/src/channels/weixin/weixin-bridge.mjs +86 -123
  76. package/src/channels/weixin/weixin-controller.mjs +22 -21
  77. package/src/channels/weixin/weixin-runtime.mjs +3 -2
  78. package/src/channels/whatsapp/config-store.mjs +3 -1
  79. package/src/channels/whatsapp/whatsapp-bridge.mjs +3 -1
  80. package/src/channels/whatsapp/whatsapp-controller.mjs +15 -14
  81. package/src/channels/whatsapp/whatsapp-runtime.mjs +47 -13
  82. package/src/channels/whatsapp/whatsapp-web-session.mjs +3 -1
@@ -1,3 +1,5 @@
1
+ import { t } from '../shared/i18n.mjs';
2
+
1
3
  const DEFAULT_UPDATE_INTERVAL_MS = 500;
2
4
  const FAILURE_TEXT = '消息处理失败,请稍后重试。';
3
5
 
@@ -100,7 +102,7 @@ export function createDingTalkCardStream({
100
102
  if (!cleanupPromise) {
101
103
  cleanupPromise = api.failAiCard({
102
104
  ...cardRequest,
103
- text: FAILURE_TEXT,
105
+ text: t(FAILURE_TEXT),
104
106
  signal: AbortSignal.timeout(5_000),
105
107
  }).then(
106
108
  () => true,
@@ -10,6 +10,7 @@ import {
10
10
  connectionTestMessage,
11
11
  connectionTestTargetUnavailable,
12
12
  } from '../shared/connection-test.mjs';
13
+ import { t } from '../shared/i18n.mjs';
13
14
 
14
15
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
15
16
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@@ -84,7 +85,7 @@ function normalizePendingSender(value) {
84
85
  return {
85
86
  requestId: opaqueRequestId,
86
87
  staffId,
87
- displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户',
88
+ displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? t('钉钉用户'),
88
89
  requestedAt: cleanString(value.requestedAt),
89
90
  };
90
91
  }
@@ -114,7 +115,7 @@ function publicPendingSender(sender) {
114
115
  function publicApprovedSender(sender) {
115
116
  return {
116
117
  senderKey: sender.senderKey,
117
- displayName: cleanString(sender.displayName) ?? '钉钉用户',
118
+ displayName: cleanString(sender.displayName) ?? t('钉钉用户'),
118
119
  senderIdMasked: maskDingtalkSenderId(sender.staffId),
119
120
  approvedAt: cleanString(sender.approvedAt),
120
121
  };
@@ -206,7 +207,7 @@ export class DingtalkController {
206
207
  if (!clientSecret) {
207
208
  this.#errors.set(
208
209
  latest.botId,
209
- safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'),
210
+ safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
210
211
  );
211
212
  this.#touch();
212
213
  return;
@@ -217,7 +218,7 @@ export class DingtalkController {
217
218
  } catch {
218
219
  this.#errors.set(
219
220
  latest.botId,
220
- safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
221
+ safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
221
222
  );
222
223
  this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
223
224
  }
@@ -270,10 +271,10 @@ export class DingtalkController {
270
271
  } catch (error) {
271
272
  if (record.controller.signal.aborted || error?.name === 'AbortError') {
272
273
  record.state = 'cancelled';
273
- record.error = safeError('cancelled', '扫码接入已取消。');
274
+ record.error = safeError('cancelled', t('扫码接入已取消。'));
274
275
  } else {
275
276
  record.state = 'failed';
276
- record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。');
277
+ record.error = safeError('qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
277
278
  }
278
279
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
279
280
  this.#touch();
@@ -319,7 +320,7 @@ export class DingtalkController {
319
320
  } catch {
320
321
  this.#errors.set(
321
322
  identity.botId,
322
- safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
323
+ safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
323
324
  );
324
325
  this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
325
326
  }
@@ -337,7 +338,7 @@ export class DingtalkController {
337
338
  }
338
339
  if (nowFrom(this.#clock) >= record.expiresAt) {
339
340
  record.state = 'expired';
340
- record.error = safeError('expired', '二维码已过期,请重新生成。');
341
+ record.error = safeError('expired', t('二维码已过期,请重新生成。'));
341
342
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
342
343
  this.#touch();
343
344
  return publicAttempt(record);
@@ -360,7 +361,7 @@ export class DingtalkController {
360
361
  record.controller.abort();
361
362
  await record.pollTask?.catch(() => undefined);
362
363
  if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
363
- record.error ??= safeError('cancelled', '扫码接入已取消。');
364
+ record.error ??= safeError('cancelled', t('扫码接入已取消。'));
364
365
  }
365
366
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
366
367
  this.#touch();
@@ -380,7 +381,7 @@ export class DingtalkController {
380
381
  } catch (error) {
381
382
  this.#errors.set(
382
383
  botId,
383
- safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'),
384
+ safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
384
385
  );
385
386
  throw error;
386
387
  } finally {
@@ -396,10 +397,10 @@ export class DingtalkController {
396
397
  return this.#withBotTransition(botId, async () => {
397
398
  const runtime = this.#runtimes.get(botId);
398
399
  if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
399
- throw connectionTestTargetUnavailable('钉钉机器人');
400
+ throw connectionTestTargetUnavailable(t('钉钉机器人'));
400
401
  }
401
402
  return runtime.sendConnectionTest(connectionTestMessage(
402
- `钉钉机器人(${maskDingtalkClientId(config.clientId)})`,
403
+ t('钉钉机器人({clientId})', { clientId: maskDingtalkClientId(config.clientId) }),
403
404
  ));
404
405
  });
405
406
  }
@@ -501,14 +502,14 @@ export class DingtalkController {
501
502
  connected,
502
503
  configured: true,
503
504
  bot: {
504
- name: '钉钉机器人',
505
+ name: t('钉钉机器人'),
505
506
  clientIdMasked: maskDingtalkClientId(config.clientId),
506
507
  },
507
508
  health: {
508
509
  status: connected ? 'healthy' : accountError ? 'error' : 'offline',
509
510
  summary: connected
510
- ? '钉钉 Stream 消息连接运行正常'
511
- : accountError?.message ?? '钉钉消息连接当前离线',
511
+ ? t('钉钉 Stream 消息连接运行正常')
512
+ : accountError?.message ?? t('钉钉消息连接当前离线'),
512
513
  lastCheckedAt: currentStatus.lastCheckedAt ?? null,
513
514
  },
514
515
  stats: {
@@ -580,32 +581,32 @@ export class DingtalkController {
580
581
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
581
582
  } else if (state === 'EXPIRED') {
582
583
  record.state = 'expired';
583
- record.error = safeError('expired', '二维码已过期,请重新生成。');
584
+ record.error = safeError('expired', t('二维码已过期,请重新生成。'));
584
585
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
585
586
  } else if (state === 'FAIL') {
586
587
  record.state = 'failed';
587
- record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。');
588
+ record.error = safeError('authorization-failed', t('钉钉未完成机器人授权,请重新扫码。'));
588
589
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
589
590
  } else {
590
591
  record.state = 'pending';
591
- record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。');
592
+ record.error = safeError('poll-pending', t('钉钉授权状态暂时不可用,正在重试。'));
592
593
  }
593
594
  } catch (error) {
594
595
  if (record.controller.signal.aborted || error?.name === 'AbortError') {
595
596
  record.state = 'cancelled';
596
- record.error = safeError('cancelled', '扫码接入已取消。');
597
+ record.error = safeError('cancelled', t('扫码接入已取消。'));
597
598
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
598
599
  } else if (record.state === 'connecting') {
599
600
  record.state = 'failed';
600
601
  record.error = safeError(
601
602
  'activation-failed',
602
- '钉钉已授权,但无法安全保存接入配置。',
603
+ t('钉钉已授权,但无法安全保存接入配置。'),
603
604
  );
604
605
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
605
606
  this.#logger.error?.('[dsh-dingtalk] bot activation failed');
606
607
  } else {
607
608
  record.state = 'pending';
608
- record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。');
609
+ record.error = safeError('poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
609
610
  }
610
611
  } finally {
611
612
  this.#touch();
@@ -660,7 +661,7 @@ export class DingtalkController {
660
661
  }
661
662
  this.#errors.set(
662
663
  identity.botId,
663
- safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
664
+ safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
664
665
  );
665
666
  this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
666
667
  }
@@ -681,7 +682,7 @@ export class DingtalkController {
681
682
  await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
682
683
  this.#errors.set(
683
684
  previousConfig.botId,
684
- safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
685
+ safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
685
686
  );
686
687
  throw error;
687
688
  } finally {
@@ -4,6 +4,7 @@ import {
4
4
  DingtalkHarnessBridge,
5
5
  } from './dingtalk-bridge.mjs';
6
6
  import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
7
+ import { t } from '../shared/i18n.mjs';
7
8
 
8
9
  function nonEmptyString(value) {
9
10
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -275,7 +276,7 @@ export class DingtalkRuntime {
275
276
  ? JSON.parse(response.data)
276
277
  : response?.data;
277
278
  } catch {
278
- this.#status.lastError = '钉钉消息格式无效。';
279
+ this.#status.lastError = t('钉钉消息格式无效。');
279
280
  this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload');
280
281
  return;
281
282
  }
@@ -284,7 +285,7 @@ export class DingtalkRuntime {
284
285
  await bridge.accept(message);
285
286
  }).catch(() => {
286
287
  if (signal.aborted || this.#bridge !== bridge) return;
287
- this.#status.lastError = '钉钉消息处理失败。';
288
+ this.#status.lastError = t('钉钉消息处理失败。');
288
289
  this.#logger.error?.('[dsh-dingtalk] callback processing failed');
289
290
  }).finally(() => this.#callbackTasks.delete(task));
290
291
  this.#callbackTasks.add(task);
@@ -352,7 +353,7 @@ export class DingtalkRuntime {
352
353
  return sendRememberedConnectionTest({
353
354
  state: this.#state,
354
355
  text,
355
- channelLabel: '钉钉机器人',
356
+ channelLabel: t('钉钉机器人'),
356
357
  send: async ({ sessionWebhook }, content) => {
357
358
  if (!this.#status.ready || !this.#abortController) {
358
359
  throw new Error('DingTalk runtime is not connected');
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
 
5
+ import { t } from '../shared/i18n.mjs';
6
+
5
7
  const EMPTY_STATE = Object.freeze({
6
8
  version: 1,
7
9
  sessions: {},
@@ -14,7 +16,7 @@ function nonEmptyString(value) {
14
16
  }
15
17
 
16
18
  function displayName(value) {
17
- return (nonEmptyString(value) ?? '钉钉用户').slice(0, 100);
19
+ return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
18
20
  }
19
21
 
20
22
  function normalizePendingSender(value, fallbackRequestId) {
@@ -3,6 +3,7 @@ import {
3
3
  maskPlatformId,
4
4
  TokenBotConfigStore,
5
5
  } from '../shared/token-config-store.mjs';
6
+ import { t } from '../shared/i18n.mjs';
6
7
 
7
8
  const IDENTITY_OPTIONS = Object.freeze({
8
9
  botPrefix: 'discord',
@@ -14,7 +15,7 @@ export function deriveDiscordBotIdentity(platformId) {
14
15
  }
15
16
 
16
17
  export function maskDiscordBotId(platformId) {
17
- return maskPlatformId(platformId, 'Discord机器人');
18
+ return maskPlatformId(platformId, t('Discord机器人'));
18
19
  }
19
20
 
20
21
  export class DiscordConfigStore extends TokenBotConfigStore {
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { t } from '../shared/i18n.mjs';
2
3
 
3
4
  const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
4
5
  const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
@@ -120,6 +121,29 @@ export class DiscordApi {
120
121
  return this.#request('gateway/bot', { ...options, method: 'GET' });
121
122
  }
122
123
 
124
+ getChannel({ channelId, signal } = {}) {
125
+ return this.#request(`channels/${snowflake(channelId, 'channel id')}`, {
126
+ method: 'GET',
127
+ signal,
128
+ });
129
+ }
130
+
131
+ startThreadFromMessage({ channelId, messageId, name, signal } = {}) {
132
+ const threadName = cleanString(name);
133
+ if (!threadName || [...threadName].length > 100) {
134
+ throw new TypeError('Discord thread name must contain 1-100 characters');
135
+ }
136
+ if (signal?.aborted) throw abortReason(signal);
137
+ return this.#request(
138
+ `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}/threads`,
139
+ {
140
+ method: 'POST',
141
+ signal,
142
+ body: { name: threadName },
143
+ },
144
+ );
145
+ }
146
+
123
147
  createMessage({ channelId, content, replyToMessageId, signal }) {
124
148
  return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, {
125
149
  method: 'POST',
@@ -275,7 +299,7 @@ export async function inspectDiscordToken(token, options = {}) {
275
299
  if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot');
276
300
  return {
277
301
  platformId: String(bot.id),
278
- name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? 'Discord机器人',
302
+ name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? t('Discord机器人'),
279
303
  username: cleanString(bot.username),
280
304
  };
281
305
  }
@@ -1,11 +1,19 @@
1
1
  import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
2
  import { fetchFileStream } from '../shared/file-download.mjs';
3
3
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
4
+ import { t } from '../shared/i18n.mjs';
4
5
  import { DiscordApi } from './discord-api.mjs';
5
6
  import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
6
7
 
7
- const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
8
+ const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12) | (1 << 15);
9
+ const THREAD_RECOVERY_TIMEOUT_MS = 5_000;
8
10
  const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
11
+ const GUILD_TEXT = 0;
12
+ const GUILD_ANNOUNCEMENT = 5;
13
+ const ANNOUNCEMENT_THREAD = 10;
14
+ const PUBLIC_THREAD = 11;
15
+ const PRIVATE_THREAD = 12;
16
+ const THREAD_TYPES = new Set([ANNOUNCEMENT_THREAD, PUBLIC_THREAD, PRIVATE_THREAD]);
9
17
  const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
10
18
  const IMAGE_FILE_TYPES = new Map([
11
19
  ['.jpg', 'image/jpeg'],
@@ -40,12 +48,12 @@ function eventData(event) {
40
48
 
41
49
  function gatewayCloseError(code) {
42
50
  if (code === 4004) {
43
- const error = new Error('Discord Bot Token 无效,请重新填写。');
51
+ const error = new Error(t('Discord Bot Token 无效,请重新填写。'));
44
52
  error.code = 'discord-401';
45
53
  return error;
46
54
  }
47
55
  if (code === 4013 || code === 4014) {
48
- const error = new Error('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。');
56
+ const error = new Error(t('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。'));
49
57
  error.code = 'discord-intents';
50
58
  return error;
51
59
  }
@@ -59,6 +67,94 @@ function stripBotMention(text, botId) {
59
67
  return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
60
68
  }
61
69
 
70
+ function cleanThreadName(message, botId) {
71
+ const name = stripBotMention(message?.content ?? '', botId).replace(/\s+/g, ' ').trim()
72
+ || 'DeepSeek Harness';
73
+ return [...name].slice(0, 100).join('');
74
+ }
75
+
76
+ function isThreadChannel(channel) {
77
+ return THREAD_TYPES.has(Number(channel?.type));
78
+ }
79
+
80
+ function isThreadFromMessage(channel, message) {
81
+ return isThreadChannel(channel)
82
+ && String(channel?.id ?? '') === String(message?.id ?? '')
83
+ && String(channel?.parent_id ?? '') === String(message?.channel_id ?? '');
84
+ }
85
+
86
+ function rememberChannel(channel, callback) {
87
+ if (channel?.id) callback?.(channel);
88
+ return channel;
89
+ }
90
+
91
+ function withConversationRoute(normalized, channel, botId, {
92
+ fromSourceMessage = false,
93
+ fallback = null,
94
+ notice = null,
95
+ } = {}) {
96
+ const channelId = String(channel?.id ?? normalized.conversationId);
97
+ const thread = isThreadChannel(channel);
98
+ const managed = thread && String(channel?.owner_id ?? '') === String(botId);
99
+ const parentId = thread && channel?.parent_id ? String(channel.parent_id) : channelId;
100
+ return {
101
+ ...normalized,
102
+ conversationId: channelId,
103
+ addressed: normalized.addressed || managed,
104
+ requiresMention: thread ? !managed : normalized.kind === 'group',
105
+ replyTarget: {
106
+ channelId,
107
+ ...(!fromSourceMessage && normalized.replyTarget?.replyToMessageId
108
+ ? { replyToMessageId: normalized.replyTarget.replyToMessageId }
109
+ : {}),
110
+ ...(notice ? { notice } : {}),
111
+ },
112
+ connectionTestTarget: { channelId },
113
+ conversationRoute: {
114
+ peerId: parentId,
115
+ ...(thread ? { threadId: channelId, managed } : {}),
116
+ ...(fallback ? { fallback } : {}),
117
+ },
118
+ };
119
+ }
120
+
121
+ function uncertainThreadCreate(error) {
122
+ const status = Number(error?.status);
123
+ return !Number.isInteger(status) || status >= 500;
124
+ }
125
+
126
+ function threadCreateUncertain(cause) {
127
+ const error = new Error('Discord Thread creation result is uncertain', { cause });
128
+ error.code = 'discord-thread-create-uncertain';
129
+ return error;
130
+ }
131
+
132
+ async function findCreatedThread(api, message, { signal, onChannel } = {}) {
133
+ try {
134
+ const channel = rememberChannel(await api.getChannel({
135
+ channelId: String(message.id),
136
+ signal,
137
+ }), onChannel);
138
+ return isThreadFromMessage(channel, message) ? channel : null;
139
+ } catch (error) {
140
+ if (error?.status === 404) return null;
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ async function sendThreadUncertainNotice(api, normalized, signal) {
146
+ try {
147
+ await api.createMessage({
148
+ channelId: normalized.replyTarget.channelId,
149
+ replyToMessageId: normalized.replyTarget.replyToMessageId,
150
+ content: 'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。',
151
+ signal,
152
+ });
153
+ } catch (error) {
154
+ if (signal?.aborted) throw signal.reason ?? error;
155
+ }
156
+ }
157
+
62
158
  function attachmentMediaType(attachment) {
63
159
  const value = typeof attachment?.content_type === 'string'
64
160
  ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
@@ -109,7 +205,8 @@ function discordFileSource(attachment, fetchImpl) {
109
205
  }
110
206
 
111
207
  export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
112
- if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
208
+ if (!message?.id || !message?.channel_id || !message?.author?.id
209
+ || Number(message.type) === 21) return null;
113
210
  const direct = !message.guild_id;
114
211
  const addressed = direct
115
212
  || message.mentions?.some((mention) => String(mention?.id) === String(botId));
@@ -135,9 +232,84 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
135
232
  };
136
233
  }
137
234
 
235
+ export async function resolveDiscordMessageRoute(message, botId, {
236
+ api,
237
+ channel,
238
+ fetchImpl = fetch,
239
+ signal,
240
+ onChannel,
241
+ } = {}) {
242
+ const normalized = normalizeDiscordMessage(message, botId, { fetchImpl });
243
+ if (!normalized || normalized.senderIsBot) return normalized;
244
+ signal?.throwIfAborted();
245
+ if (normalized.kind === 'direct') {
246
+ return withConversationRoute(normalized, { id: normalized.conversationId, type: 1 }, botId);
247
+ }
248
+ if (!api || typeof api.getChannel !== 'function') {
249
+ throw new TypeError('Discord route resolution requires the Discord API');
250
+ }
251
+
252
+ const sourceChannel = rememberChannel(channel ?? await api.getChannel({
253
+ channelId: normalized.conversationId,
254
+ signal,
255
+ }), onChannel);
256
+ if (isThreadChannel(sourceChannel)) {
257
+ return withConversationRoute(normalized, sourceChannel, botId);
258
+ }
259
+ if (!normalized.addressed) return withConversationRoute(normalized, sourceChannel, botId);
260
+
261
+ const sourceType = Number(sourceChannel?.type);
262
+ if (sourceType !== GUILD_TEXT && sourceType !== GUILD_ANNOUNCEMENT) {
263
+ return withConversationRoute(normalized, sourceChannel, botId, {
264
+ fallback: 'unsupported-channel',
265
+ notice: '当前频道不支持自动创建 Thread,已直接在当前频道回复。',
266
+ });
267
+ }
268
+
269
+ signal?.throwIfAborted();
270
+ let created;
271
+ try {
272
+ created = rememberChannel(await api.startThreadFromMessage({
273
+ channelId: normalized.conversationId,
274
+ messageId: normalized.messageId,
275
+ name: cleanThreadName(message, botId),
276
+ signal,
277
+ }), onChannel);
278
+ if (!isThreadFromMessage(created, message)) {
279
+ throw new Error('Discord returned an invalid thread for the source message');
280
+ }
281
+ } catch (error) {
282
+ let recovered;
283
+ try {
284
+ recovered = await findCreatedThread(api, message, {
285
+ signal: AbortSignal.timeout(THREAD_RECOVERY_TIMEOUT_MS),
286
+ onChannel,
287
+ });
288
+ } catch (recoveryError) {
289
+ if (signal?.aborted) throw signal.reason ?? error;
290
+ await sendThreadUncertainNotice(api, normalized, signal);
291
+ throw threadCreateUncertain(recoveryError);
292
+ }
293
+ if (recovered) {
294
+ return withConversationRoute(normalized, recovered, botId, { fromSourceMessage: true });
295
+ }
296
+ if (signal?.aborted) throw signal.reason ?? error;
297
+ if (uncertainThreadCreate(error)) {
298
+ await sendThreadUncertainNotice(api, normalized, signal);
299
+ throw threadCreateUncertain(error);
300
+ }
301
+ return withConversationRoute(normalized, sourceChannel, botId, {
302
+ fallback: 'thread-create-failed',
303
+ notice: '无法创建 Thread,已直接在当前频道回复。',
304
+ });
305
+ }
306
+ return withConversationRoute(normalized, created, botId, { fromSourceMessage: true });
307
+ }
308
+
138
309
  export class DiscordBotClient {
139
310
  #api;
140
311
  #signal;
312
+ #deliveredNotices = new WeakSet();
141
313
 
142
314
  constructor({ api, signal }) {
143
315
  this.#api = api;
@@ -145,7 +317,9 @@ export class DiscordBotClient {
145
317
  }
146
318
 
147
319
  async sendText(target, text) {
148
- const chunks = splitMessageText(text, 1_900);
320
+ const notice = !this.#deliveredNotices.has(target) && target?.notice
321
+ ? String(target.notice) : null;
322
+ const chunks = splitMessageText(notice ? `${notice}\n\n${text}` : text, 1_900);
149
323
  const providerMessageIds = [];
150
324
  for (const [index, chunk] of chunks.entries()) {
151
325
  const result = await this.#api.createMessage({
@@ -154,6 +328,7 @@ export class DiscordBotClient {
154
328
  replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
155
329
  signal: this.#signal,
156
330
  });
331
+ if (notice && index === 0) this.#deliveredNotices.add(target);
157
332
  if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
158
333
  }
159
334
  return { providerMessageIds };
@@ -173,21 +348,25 @@ export class DiscordBotClient {
173
348
  }
174
349
 
175
350
  async openStream(target) {
351
+ const notice = !this.#deliveredNotices.has(target) && target?.notice
352
+ ? String(target.notice) : null;
353
+ const decorate = (content) => notice ? `${notice}\n\n${content}` : content;
176
354
  const stream = createEditableMessageStream({
177
- limit: 1_900,
355
+ limit: notice ? 1_800 : 1_900,
178
356
  create: async (content) => {
179
357
  const message = await this.#api.createMessage({
180
358
  channelId: target.channelId,
181
- content,
359
+ content: decorate(content),
182
360
  replyToMessageId: target.replyToMessageId,
183
361
  signal: this.#signal,
184
362
  });
363
+ if (notice) this.#deliveredNotices.add(target);
185
364
  return message.id;
186
365
  },
187
366
  edit: (messageId, content) => this.#api.editMessage({
188
367
  channelId: target.channelId,
189
368
  messageId,
190
- content,
369
+ content: decorate(content),
191
370
  signal: this.#signal,
192
371
  }),
193
372
  sendRemainder: (content) => this.#api.createMessage({
@@ -241,6 +420,8 @@ export class DiscordRuntime {
241
420
  #generation = 0;
242
421
  #stopped = true;
243
422
  #starting = null;
423
+ #channels = new Map();
424
+ #routing = new Map();
244
425
 
245
426
  constructor({
246
427
  config,
@@ -299,6 +480,8 @@ export class DiscordRuntime {
299
480
  this.#resumeUrl = null;
300
481
  this.#sequence = null;
301
482
  this.#reconnectAttempt = 0;
483
+ this.#channels.clear();
484
+ this.#routing.clear();
302
485
  this.#status.startedAt = new Date().toISOString();
303
486
  this.#status.connectionState = 'connecting';
304
487
  this.#status.lastError = null;
@@ -432,11 +615,21 @@ export class DiscordRuntime {
432
615
  markReady();
433
616
  } else if (packet.t === 'RESUMED') {
434
617
  markReady();
618
+ } else if (packet.t === 'GUILD_CREATE') {
619
+ for (const channel of [...(packet.d?.channels ?? []), ...(packet.d?.threads ?? [])]) {
620
+ this.#rememberChannel(channel);
621
+ }
622
+ } else if (packet.t === 'CHANNEL_CREATE' || packet.t === 'CHANNEL_UPDATE'
623
+ || packet.t === 'THREAD_CREATE' || packet.t === 'THREAD_UPDATE') {
624
+ this.#rememberChannel(packet.d);
625
+ } else if (packet.t === 'CHANNEL_DELETE' || packet.t === 'THREAD_DELETE') {
626
+ if (packet.d?.id) this.#channels.delete(String(packet.d.id));
627
+ } else if (packet.t === 'THREAD_LIST_SYNC') {
628
+ for (const channel of packet.d?.threads ?? []) this.#rememberChannel(channel);
435
629
  } else if (packet.t === 'MESSAGE_CREATE') {
436
- const message = normalizeDiscordMessage(packet.d, this.#config.platformId);
437
630
  const bridge = this.#bridge;
438
- if (message && bridge) {
439
- void bridge.accept(message).catch((error) => {
631
+ if (packet.d && bridge) {
632
+ void this.#acceptMessage(packet.d, bridge).catch((error) => {
440
633
  if (generation !== this.#generation || this.#stopped) return;
441
634
  this.#logger.error?.(
442
635
  `[dsh-im:discord] bot ${this.#config.botId} message handling failed:`,
@@ -475,6 +668,38 @@ export class DiscordRuntime {
475
668
  });
476
669
  }
477
670
 
671
+ #rememberChannel(channel) {
672
+ if (!channel?.id) return;
673
+ this.#channels.set(String(channel.id), channel);
674
+ }
675
+
676
+ async #acceptMessage(message, bridge) {
677
+ const messageId = String(message?.id ?? '');
678
+ if (!messageId || this.#state.hasSeen(messageId)) return;
679
+ let route = this.#routing.get(messageId);
680
+ if (!route) {
681
+ route = resolveDiscordMessageRoute(message, this.#config.platformId, {
682
+ api: this.#api,
683
+ channel: this.#channels.get(String(message.channel_id)),
684
+ signal: this.#abortController?.signal,
685
+ onChannel: (resolved) => this.#rememberChannel(resolved),
686
+ });
687
+ this.#routing.set(messageId, route);
688
+ void route.finally(() => {
689
+ if (this.#routing.get(messageId) === route) this.#routing.delete(messageId);
690
+ }).catch(() => undefined);
691
+ }
692
+ try {
693
+ const normalized = await route;
694
+ if (normalized) await bridge.accept(normalized);
695
+ } catch (error) {
696
+ if (error?.code === 'discord-thread-create-uncertain') {
697
+ await this.#state.markSeen(messageId);
698
+ }
699
+ throw error;
700
+ }
701
+ }
702
+
478
703
  #sendGateway(socket, payload) {
479
704
  if (socket.readyState !== 1) return;
480
705
  socket.send(JSON.stringify(payload));
@@ -541,6 +766,7 @@ export class DiscordRuntime {
541
766
  this.#socket = null;
542
767
  this.#bridge = null;
543
768
  this.#api = null;
769
+ this.#routing.clear();
544
770
  try {
545
771
  if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
546
772
  } catch (error) {