@xmanrui/dsh-im 1.5.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 (80) hide show
  1. package/README.en.md +16 -4
  2. package/README.md +16 -4
  3. package/lib/index.js +208 -178
  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 +2 -1
  8. package/src/channels/dingtalk/dingtalk-bridge.mjs +45 -40
  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 +131 -123
  17. package/src/channels/feishu/feishu-cards.mjs +62 -54
  18. package/src/channels/feishu/feishu-channel.mjs +5 -4
  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 +57 -52
  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 +1 -1
  56. package/src/channels/shared/semantic/delivery.mjs +38 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +130 -63
  58. package/src/channels/shared/token-bot-controller.mjs +32 -11
  59. package/src/channels/shared/workspace-command.mjs +69 -58
  60. package/src/channels/slack/config-store.mjs +3 -1
  61. package/src/channels/slack/slack-api.mjs +6 -5
  62. package/src/channels/slack/slack-controller.mjs +10 -9
  63. package/src/channels/slack/slack-runtime.mjs +16 -4
  64. package/src/channels/telegram/config-store.mjs +2 -1
  65. package/src/channels/telegram/telegram-api.mjs +72 -6
  66. package/src/channels/telegram/telegram-rich-message.mjs +147 -0
  67. package/src/channels/telegram/telegram-runtime.mjs +373 -5
  68. package/src/channels/wecom/config-store.mjs +3 -1
  69. package/src/channels/wecom/wecom-bridge.mjs +59 -55
  70. package/src/channels/wecom/wecom-controller.mjs +20 -19
  71. package/src/channels/wecom/wecom-runtime.mjs +2 -1
  72. package/src/channels/weixin/config-store.mjs +2 -1
  73. package/src/channels/weixin/weixin-bridge.mjs +50 -49
  74. package/src/channels/weixin/weixin-controller.mjs +22 -21
  75. package/src/channels/weixin/weixin-runtime.mjs +3 -2
  76. package/src/channels/whatsapp/config-store.mjs +3 -1
  77. package/src/channels/whatsapp/whatsapp-bridge.mjs +3 -1
  78. package/src/channels/whatsapp/whatsapp-controller.mjs +15 -14
  79. package/src/channels/whatsapp/whatsapp-runtime.mjs +5 -4
  80. package/src/channels/whatsapp/whatsapp-web-session.mjs +3 -1
@@ -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) {