@xmanrui/dsh-im 4.20.2 → 4.21.1

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 (61) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +116 -21
  4. package/lib/index.js +288 -287
  5. package/package.json +17 -1
  6. package/plugin-src/client/channel-logos.js +18 -5
  7. package/plugin-src/client/channels/imessage/styles.js +1 -1
  8. package/plugin-src/client/channels/slack/styles.js +1 -1
  9. package/plugin-src/client/channels/weixin/connection-error.js +4 -1
  10. package/plugin-src/client/i18n.js +8 -0
  11. package/plugin-src/client/model-setting.js +4 -2
  12. package/plugin-src/client/session-channel-logos.js +1 -2
  13. package/plugin-src/client/styles.js +3 -2
  14. package/plugin-src/host/channels/qq/production.mjs +1 -1
  15. package/plugin-src/host/channels/qq/rpc.mjs +2 -1
  16. package/plugin-src/host/index.mjs +7 -0
  17. package/plugin-src/host/injected-context.mjs +104 -0
  18. package/plugin-src/host/modern-harness-api.mjs +91 -3
  19. package/scripts/verify-model-setting.mjs +4 -1
  20. package/scripts/verify-package.mjs +3 -1
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +105 -27
  22. package/src/channels/discord/discord-runtime.mjs +4 -1
  23. package/src/channels/feishu/bridge.mjs +44 -3
  24. package/src/channels/feishu/feishu-channel.mjs +1 -1
  25. package/src/channels/qq/qq-bridge.mjs +36 -3
  26. package/src/channels/qq/qq-controller.mjs +11 -5
  27. package/src/channels/qq/state-error.mjs +17 -0
  28. package/src/channels/qq/state-store.mjs +35 -9
  29. package/src/channels/shared/batch-input.mjs +22 -2
  30. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  31. package/src/channels/shared/config-read-error.mjs +24 -0
  32. package/src/channels/shared/context-enhancement.mjs +40 -3
  33. package/src/channels/shared/control-command.mjs +8 -1
  34. package/src/channels/shared/harness-client.mjs +20 -12
  35. package/src/channels/shared/harness-question.mjs +10 -2
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
  37. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  38. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  39. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  40. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  41. package/src/channels/shared/im-source-guidance.mjs +65 -0
  42. package/src/channels/shared/injected-context.mjs +362 -0
  43. package/src/channels/shared/semantic/artifact.mjs +4 -4
  44. package/src/channels/shared/semantic/reply-reference.mjs +2 -1
  45. package/src/channels/shared/text-harness-bridge.mjs +221 -5
  46. package/src/channels/shared/token-config-store.mjs +23 -8
  47. package/src/channels/shared/workspace-session.mjs +16 -1
  48. package/src/channels/slack/slack-runtime.mjs +3 -1
  49. package/src/channels/telegram/telegram-api.mjs +62 -2
  50. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  51. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  52. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  53. package/src/channels/wecom/wecom-bridge.mjs +15 -1
  54. package/src/channels/wecom-app/config-store.mjs +3 -1
  55. package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
  56. package/src/channels/weixin/config-store.mjs +24 -15
  57. package/src/channels/weixin/connection-error.en.mjs +21 -0
  58. package/src/channels/weixin/connection-error.mjs +21 -6
  59. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  60. package/src/channels/weixin/weixin-api.mjs +52 -13
  61. package/src/channels/weixin/weixin-bridge.mjs +14 -1
@@ -30,7 +30,11 @@ import {
30
30
  } from '../shared/preset-command.mjs';
31
31
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
32
32
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
33
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
33
+ import {
34
+ captureContextEnhancement,
35
+ captureContextEnhancementSource,
36
+ enhanceContextContent,
37
+ } from '../shared/context-enhancement.mjs';
34
38
  import {
35
39
  BatchInputManager,
36
40
  batchInputBusyMessage,
@@ -43,6 +47,7 @@ import {
43
47
  imagePromptUserMessage,
44
48
  } from '../shared/image-prompt.mjs';
45
49
  import {
50
+ InboundFileError,
46
51
  hasInboundFiles,
47
52
  inboundFileUserMessage,
48
53
  prefetchInboundFiles,
@@ -195,6 +200,13 @@ function downloadCodeFor(value) {
195
200
  return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
196
201
  }
197
202
 
203
+ function dingtalkImageEntries(msgtype, content) {
204
+ if (msgtype === 'picture') return [content];
205
+ if (msgtype !== 'richtext') return [];
206
+ return richTextEntries(content)
207
+ .filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture');
208
+ }
209
+
198
210
  function dingtalkTimestampMs(value) {
199
211
  const number = typeof value === 'string' && value.trim() ? Number(value) : value;
200
212
  if (!Number.isFinite(number) || number < 0) return null;
@@ -206,24 +218,36 @@ function usefulReplyText(value) {
206
218
  return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
207
219
  }
208
220
 
209
- function dingtalkReplyReference(message, options) {
210
- const replyEnvelope = message?.text;
211
- if (replyEnvelope?.isReplyMsg !== true) return null;
212
- const replied = replyEnvelope?.repliedMsg;
213
- if (!replied || typeof replied !== 'object') {
214
- return { unavailableReason: 'not-delivered' };
215
- }
216
-
221
+ function dingtalkReplyMessage(message) {
222
+ if (message?.text?.isReplyMsg !== true) return null;
223
+ const replied = message.text.repliedMsg;
224
+ if (!replied || typeof replied !== 'object') return null;
217
225
  const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
218
226
  const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
219
- const pseudoMessage = {
227
+ // Quoted richText entries use msgType instead of the direct callback's type.
228
+ const content = msgtype === 'richtext' ? {
229
+ ...repliedContent,
230
+ richText: richTextEntries(repliedContent).map((entry) => ({
231
+ ...entry, type: entry?.type ?? entry?.msgType ?? entry?.msgtype,
232
+ })),
233
+ } : repliedContent;
234
+ return {
220
235
  msgtype,
236
+ robotCode: message.robotCode,
221
237
  text: {
222
238
  content: nonEmptyString(repliedContent.text)
223
239
  ?? (typeof replied.content === 'string' ? replied.content : ''),
224
240
  },
225
- content: repliedContent,
241
+ content,
226
242
  };
243
+ }
244
+
245
+ function dingtalkReplyReference(message, options) {
246
+ if (message?.text?.isReplyMsg !== true) return null;
247
+ const pseudoMessage = dingtalkReplyMessage(message);
248
+ if (!pseudoMessage) return { unavailableReason: 'not-delivered' };
249
+ const replied = message.text.repliedMsg;
250
+ const { msgtype, content: repliedContent } = pseudoMessage;
227
251
  const normalized = dingtalkInboundMessage(pseudoMessage, options);
228
252
  let attachments = [];
229
253
  if (msgtype === 'picture') {
@@ -232,8 +256,7 @@ function dingtalkReplyReference(message, options) {
232
256
  const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
233
257
  attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
234
258
  } else if (msgtype === 'richtext') {
235
- attachments = richTextEntries(repliedContent)
236
- .filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
259
+ attachments = dingtalkImageEntries(msgtype, repliedContent)
237
260
  .map(() => ({ kind: 'image' }));
238
261
  } else if (msgtype === 'voice' || msgtype === 'audio') {
239
262
  attachments = [{ kind: 'audio' }];
@@ -278,6 +301,43 @@ function dingtalkReplyReference(message, options) {
278
301
  };
279
302
  }
280
303
 
304
+ // Resolve only the immediate quote, after command/interaction routing has finished.
305
+ function prepareDingtalkReplyAttachments(message, promptMessage, options) {
306
+ const quoted = dingtalkReplyMessage(message);
307
+ if (!quoted) return promptMessage;
308
+ if (['audio', 'voice', 'video'].includes(quoted.msgtype)) quoted.msgtype = 'file';
309
+ const imageCodes = dingtalkImageEntries(quoted.msgtype, quoted.content).map(downloadCodeFor);
310
+ const fileCodes = quoted.msgtype === 'file' ? [downloadCodeFor(quoted.content)] : [];
311
+ if ([...imageCodes, ...fileCodes].some((code) => !code)) {
312
+ throw new InboundFileError(
313
+ 'dingtalk-quoted-attachment-unavailable',
314
+ 'DingTalk did not deliver a download reference for the quoted attachment.',
315
+ t('钉钉未提供引用附件的下载信息,无法读取原附件。请直接重新发送附件后再提问。'),
316
+ );
317
+ }
318
+ if (imageCodes.length === 0 && fileCodes.length === 0) return promptMessage;
319
+ const currentType = String(message?.msgtype ?? '').toLowerCase();
320
+ const currentContent = parsedMessageContent(message);
321
+ const seen = new Set([
322
+ ...dingtalkImageEntries(currentType, currentContent).map(downloadCodeFor),
323
+ ...(currentType === 'file' ? [downloadCodeFor(currentContent)] : []),
324
+ ].filter(Boolean));
325
+ const take = (code) => {
326
+ if (seen.has(code)) return false;
327
+ seen.add(code);
328
+ return true;
329
+ };
330
+ const normalized = dingtalkInboundMessage(quoted, options);
331
+ const images = normalized.images.filter((_, index) => take(imageCodes[index]));
332
+ const files = normalized.files.filter((_, index) => take(fileCodes[index]));
333
+ const prefetched = prefetchInboundFiles({ files }, { signal: options.signal });
334
+ return {
335
+ ...promptMessage,
336
+ images: [...promptMessage.images, ...images],
337
+ files: [...promptMessage.files, ...prefetched.files],
338
+ };
339
+ }
340
+
281
341
  /** Normalize DingTalk picture and richText callbacks into lazy image references. */
282
342
  export function dingtalkInboundMessage(message, {
283
343
  api,
@@ -291,17 +351,7 @@ export function dingtalkInboundMessage(message, {
291
351
  const text = msgtype === 'text'
292
352
  ? nonEmptyString(message?.text?.content) ?? ''
293
353
  : richEntries.map(richTextEntryText).filter(Boolean).join('\n');
294
- const imageCodes = [];
295
- if (msgtype === 'picture') {
296
- const code = downloadCodeFor(content);
297
- if (code) imageCodes.push(code);
298
- } else if (msgtype === 'richtext') {
299
- for (const entry of richEntries) {
300
- if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
301
- const code = downloadCodeFor(entry);
302
- if (code) imageCodes.push(code);
303
- }
304
- }
354
+ const imageCodes = dingtalkImageEntries(msgtype, content).map(downloadCodeFor).filter(Boolean);
305
355
  const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
306
356
  const replyTo = dingtalkReplyReference(message, {
307
357
  api,
@@ -838,6 +888,17 @@ export class DingtalkHarnessBridge {
838
888
  signal: this.#signal, isDirect: String(message.conversationType) === '1',
839
889
  pendingInteraction: this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
840
890
  control: { owner: this, key }, deferredDelivery: this.#deferred,
891
+ enhancement: captureContextEnhancementSource(
892
+ this.#contextEnhancement,
893
+ String(message.conversationType) === '1' ? 'direct' : 'group',
894
+ () => ({
895
+ channel: 'dingtalk',
896
+ senderId: senderStaffId(message),
897
+ senderName: message.senderNick,
898
+ conversationTitle: message.conversationTitle,
899
+ chatId: message.conversationId,
900
+ }),
901
+ ),
841
902
  };
842
903
  const access = evaluateInboundAccess(this.#accessPolicy, {
843
904
  conversationType: options.isDirect ? 'direct' : 'group',
@@ -1102,6 +1163,17 @@ export class DingtalkHarnessBridge {
1102
1163
  || this.#approvals.hasPending(key),
1103
1164
  control: { owner: this, key },
1104
1165
  deferredDelivery: this.#deferred,
1166
+ enhancement: captureContextEnhancementSource(
1167
+ this.#contextEnhancement,
1168
+ String(message.conversationType) === '1' ? 'direct' : 'group',
1169
+ () => ({
1170
+ channel: 'dingtalk',
1171
+ senderId: senderStaffId(message),
1172
+ senderName: message.senderNick,
1173
+ conversationTitle: message.conversationTitle,
1174
+ chatId: message.conversationId,
1175
+ }),
1176
+ ),
1105
1177
  },
1106
1178
  );
1107
1179
  if (result?.stopped) {
@@ -1261,8 +1333,12 @@ export class DingtalkHarnessBridge {
1261
1333
  return;
1262
1334
  }
1263
1335
 
1264
- let content = hasImages || hasReply
1265
- ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
1336
+ const modelMessage = prepareDingtalkReplyAttachments(message, promptMessage, {
1337
+ api: this.#api, clientId: this.#clientId, clientSecret: this.#clientSecret,
1338
+ signal: this.#signal,
1339
+ });
1340
+ let content = hasInboundImages(modelMessage) || hasReply
1341
+ ? await promptContentForInboundMessage(modelMessage, { signal: this.#signal })
1266
1342
  : undefined;
1267
1343
  const snapshot = this.#acceptedMessageIds.get(messageId);
1268
1344
  let contextEnhanced = false;
@@ -1299,6 +1375,8 @@ export class DingtalkHarnessBridge {
1299
1375
  key,
1300
1376
  text,
1301
1377
  content,
1378
+ titleText: batchSubmission?.title,
1379
+ sourceGuidance: snapshot?.config?.guidance,
1302
1380
  contextEnhanced,
1303
1381
  createOptions: { signal: this.#signal },
1304
1382
  existsOptions: { signal: this.#signal },
@@ -1316,7 +1394,7 @@ export class DingtalkHarnessBridge {
1316
1394
  requiresMention: String(message.conversationType) === '2',
1317
1395
  }),
1318
1396
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
1319
- files: promptMessage.files,
1397
+ files: modelMessage.files,
1320
1398
  },
1321
1399
  });
1322
1400
  if (batchSubmission) {
@@ -66,7 +66,10 @@ function gatewayCloseError(code) {
66
66
 
67
67
  function stripBotMention(text, botId) {
68
68
  if (typeof text !== 'string') return '';
69
- return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
69
+ return text
70
+ .split(`<@${botId}>`).join('')
71
+ .split(`<@!${botId}>`).join('')
72
+ .trim();
70
73
  }
71
74
 
72
75
  function cleanThreadName(message, botId) {
@@ -60,7 +60,11 @@ import {
60
60
  } from '../shared/workspace-command.mjs';
61
61
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
62
62
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
63
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
63
+ import {
64
+ captureContextEnhancement,
65
+ captureContextEnhancementSource,
66
+ enhanceContextContent,
67
+ } from '../shared/context-enhancement.mjs';
64
68
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
65
69
  import {
66
70
  createDeliveryReceipt,
@@ -1037,12 +1041,16 @@ export class FeishuHarnessBridge {
1037
1041
  if (result.kind === 'submit') {
1038
1042
  const submissionEvent = {
1039
1043
  ...event,
1040
- batchSubmission: { token: result.token },
1044
+ batchSubmission: { token: result.token, title: result.title },
1041
1045
  message: {
1042
1046
  ...event.message,
1043
1047
  message_type: 'text',
1044
1048
  content: JSON.stringify({ text: result.prompt }),
1045
1049
  mentions: [],
1050
+ // The submission is exactly the collected text, so quoting the
1051
+ // message that carried /send must not attach it to the batch.
1052
+ parent_id: undefined,
1053
+ root_id: undefined,
1046
1054
  },
1047
1055
  };
1048
1056
  return this.#enqueueMessage(
@@ -1412,6 +1420,16 @@ export class FeishuHarnessBridge {
1412
1420
  hasFiles: hasInboundFiles(message),
1413
1421
  pendingInteraction: this.#hasPendingInteraction(key),
1414
1422
  control: { owner: this, key },
1423
+ enhancement: captureContextEnhancementSource(
1424
+ this.#contextEnhancement,
1425
+ event.message.chat_type === 'p2p' ? 'direct' : 'group',
1426
+ () => ({
1427
+ channel: 'feishu',
1428
+ senderId: senderOpenId(event),
1429
+ chatId: event.message.chat_id,
1430
+ threadId: event.message.thread_id,
1431
+ }),
1432
+ ),
1415
1433
  },
1416
1434
  );
1417
1435
  if (result?.stopped) {
@@ -2471,7 +2489,7 @@ export class FeishuHarnessBridge {
2471
2489
  await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId, replyTo: messageId });
2472
2490
  return;
2473
2491
  }
2474
- await this.#sendSteer({ key, chatId, messageId }, raw);
2492
+ await this.#sendSteer({ key, chatId, messageId, actor }, raw);
2475
2493
  return;
2476
2494
  }
2477
2495
  if (action === 'presets') {
@@ -3197,11 +3215,26 @@ export class FeishuHarnessBridge {
3197
3215
  */
3198
3216
  async #sendSteer(entry, text) {
3199
3217
  const { key, chatId } = entry;
3218
+ // Card routes carry the conversation key, not the raw event, so the topic
3219
+ // id is recovered from the key the channel itself minted.
3220
+ const threadId = typeof key === 'string'
3221
+ ? /(?:^|:)thread:(.+)$/u.exec(key)?.[1]
3222
+ : undefined;
3200
3223
  const result = await runControlCommand(
3201
3224
  `/steer ${text}`, this.#harness, this.#state, key, {
3202
3225
  signal: this.#signal,
3203
3226
  pendingInteraction: this.#hasPendingInteraction(key),
3204
3227
  control: { owner: this, key },
3228
+ enhancement: captureContextEnhancementSource(
3229
+ this.#contextEnhancement,
3230
+ typeof key === 'string' && key.startsWith('p2p:') ? 'direct' : 'group',
3231
+ () => ({
3232
+ channel: 'feishu',
3233
+ senderId: entry.actor ?? entry.operatorOpenId,
3234
+ chatId,
3235
+ threadId,
3236
+ }),
3237
+ ),
3205
3238
  },
3206
3239
  );
3207
3240
  await this.#send(chatId, result?.message || t('已提交补充指令。'), { replyTo: entry.messageId ?? null });
@@ -4959,6 +4992,8 @@ export class FeishuHarnessBridge {
4959
4992
  key,
4960
4993
  text,
4961
4994
  content,
4995
+ titleText: event.batchSubmission?.title,
4996
+ sourceGuidance: snapshot?.config?.guidance,
4962
4997
  contextEnhanced,
4963
4998
  createOptions: { signal: this.#signal },
4964
4999
  existsOptions: { signal: this.#signal },
@@ -5201,6 +5236,8 @@ export class FeishuHarnessBridge {
5201
5236
  key,
5202
5237
  text,
5203
5238
  content,
5239
+ titleText: event.batchSubmission?.title,
5240
+ sourceGuidance: snapshot?.config?.guidance,
5204
5241
  contextEnhanced,
5205
5242
  createOptions: { signal: this.#signal },
5206
5243
  existsOptions: { signal: this.#signal },
@@ -5280,6 +5317,8 @@ export class FeishuHarnessBridge {
5280
5317
  key,
5281
5318
  text,
5282
5319
  content,
5320
+ titleText: event.batchSubmission?.title,
5321
+ sourceGuidance: snapshot?.config?.guidance,
5283
5322
  contextEnhanced,
5284
5323
  createOptions: { signal: this.#signal },
5285
5324
  existsOptions: { signal: this.#signal },
@@ -5350,6 +5389,8 @@ export class FeishuHarnessBridge {
5350
5389
  key,
5351
5390
  text,
5352
5391
  content,
5392
+ titleText: event.batchSubmission?.title,
5393
+ sourceGuidance: snapshot?.config?.guidance,
5353
5394
  contextEnhanced,
5354
5395
  createOptions: { signal: this.#signal },
5355
5396
  existsOptions: { signal: this.#signal },
@@ -576,7 +576,7 @@ export class VerifiedFeishuChannel {
576
576
  assertApiSuccess('Feishu message delete', response);
577
577
  return true;
578
578
  } catch (error) {
579
- console.warn(`[bridge] unable to recall ${label}:`, error.message);
579
+ console.warn('[bridge] unable to recall message:', label, error.message);
580
580
  return false;
581
581
  }
582
582
  }
@@ -22,7 +22,11 @@ import {
22
22
  runPresetCommand,
23
23
  } from '../shared/preset-command.mjs';
24
24
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
25
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
25
+ import {
26
+ captureContextEnhancement,
27
+ captureContextEnhancementSource,
28
+ enhanceContextContent,
29
+ } from '../shared/context-enhancement.mjs';
26
30
  import {
27
31
  BatchInputManager,
28
32
  batchInputBusyMessage,
@@ -570,7 +574,14 @@ export class QqHarnessBridge {
570
574
  if (result.handled) {
571
575
  if (result.kind === 'submit') {
572
576
  return this.#enqueueMessage(
573
- { ...message, content: result.prompt, attachments: [] },
577
+ {
578
+ ...message,
579
+ content: result.prompt,
580
+ // The submission is exactly the collected text: the command
581
+ // message's own attachments and quoted message are not part of it.
582
+ attachments: [],
583
+ refMsgIdx: undefined,
584
+ },
574
585
  messageId,
575
586
  key,
576
587
  { batchSubmission: result },
@@ -752,7 +763,17 @@ export class QqHarnessBridge {
752
763
  return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
753
764
  }
754
765
  const options = { signal: this.#signal, isDirect: message.kind === 'c2c', pendingInteraction,
755
- control: { owner: this, key }, deferredDelivery: this.#deferred };
766
+ control: { owner: this, key }, deferredDelivery: this.#deferred,
767
+ enhancement: captureContextEnhancementSource(
768
+ this.#contextEnhancement,
769
+ message.kind === 'c2c' ? 'direct' : 'group',
770
+ () => ({
771
+ channel: 'qq',
772
+ senderId: nonEmptyString(message.senderId),
773
+ senderName: message.kind === 'group' ? message.senderName : undefined,
774
+ chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
775
+ }),
776
+ ) };
756
777
  // Existing runners own all Host mutations and control authorization.
757
778
  const execute = async () => {
758
779
  this.#signal?.throwIfAborted();
@@ -817,6 +838,16 @@ export class QqHarnessBridge {
817
838
  || this.#approvals.hasPending(key),
818
839
  control: { owner: this, key },
819
840
  deferredDelivery: this.#deferred,
841
+ enhancement: captureContextEnhancementSource(
842
+ this.#contextEnhancement,
843
+ message.kind === 'c2c' ? 'direct' : 'group',
844
+ () => ({
845
+ channel: 'qq',
846
+ senderId: nonEmptyString(message.senderId),
847
+ senderName: message.kind === 'group' ? message.senderName : undefined,
848
+ chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
849
+ }),
850
+ ),
820
851
  });
821
852
  if (result?.stopped) {
822
853
  await Promise.allSettled([
@@ -1038,6 +1069,8 @@ export class QqHarnessBridge {
1038
1069
  key,
1039
1070
  text,
1040
1071
  content,
1072
+ titleText: batchSubmission?.title,
1073
+ sourceGuidance: snapshot?.config?.guidance,
1041
1074
  contextEnhanced,
1042
1075
  createOptions: { signal: this.#signal },
1043
1076
  existsOptions: { signal: this.#signal },
@@ -4,6 +4,7 @@ import { connectionTestMessage } from '../shared/connection-test.mjs';
4
4
  import { t } from '../shared/i18n.mjs';
5
5
  import { publicMessageFailure } from '../shared/message-failure.mjs';
6
6
  import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
7
+ import { publicQqStateError } from './state-error.mjs';
7
8
 
8
9
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
9
10
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
@@ -17,6 +18,10 @@ function safeError(code, message) {
17
18
  return Object.freeze({ code, message });
18
19
  }
19
20
 
21
+ function connectionError(error, message) {
22
+ return publicQqStateError(error) ?? safeError('connection-failed', message);
23
+ }
24
+
20
25
  function publicAttempt(record) {
21
26
  if (!record) return null;
22
27
  return {
@@ -86,7 +91,7 @@ export class QqController {
86
91
  await this.#startRuntime(config, appSecret);
87
92
  this.#errors.delete(config.botId);
88
93
  } catch (error) {
89
- this.#errors.set(config.botId, safeError('connection-failed', t('QQ 连接未就绪,插件会自动重试。')));
94
+ this.#errors.set(config.botId, connectionError(error, t('QQ 连接未就绪,插件会自动重试。')));
90
95
  this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error);
91
96
  } finally {
92
97
  this.#touch();
@@ -209,7 +214,7 @@ export class QqController {
209
214
  await this.#startRuntime(config, normalizedSecret);
210
215
  this.#errors.delete(identity.botId);
211
216
  } catch (error) {
212
- this.#errors.set(identity.botId, safeError('connection-failed', t('QQ 机器人已绑定,消息连接暂未就绪。')));
217
+ this.#errors.set(identity.botId, connectionError(error, t('QQ 机器人已绑定,消息连接暂未就绪。')));
213
218
  this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
214
219
  }
215
220
  this.#touch();
@@ -242,7 +247,7 @@ export class QqController {
242
247
  await this.#startRuntime(config, secret);
243
248
  this.#errors.delete(botId);
244
249
  } catch (error) {
245
- this.#errors.set(botId, safeError('connection-failed', t('QQ 连接仍未就绪,请稍后重试。')));
250
+ this.#errors.set(botId, connectionError(error, t('QQ 连接仍未就绪,请稍后重试。')));
246
251
  throw error;
247
252
  } finally {
248
253
  this.#touch();
@@ -326,7 +331,8 @@ export class QqController {
326
331
  health: {
327
332
  status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
328
333
  summary: connected ? t('QQ WebSocket 长连接运行正常')
329
- : state === 'error' ? t('QQ 连接未就绪,插件会自动重试') : t('QQ 连接当前离线'),
334
+ : this.#errors.get(config.botId)?.message
335
+ ?? (state === 'error' ? t('QQ 连接未就绪,插件会自动重试') : t('QQ 连接当前离线')),
330
336
  lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
331
337
  lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
332
338
  },
@@ -434,7 +440,7 @@ export class QqController {
434
440
  await this.#restoreCredential(identity.secretRef, previousSecret);
435
441
  throw error;
436
442
  }
437
- this.#errors.set(identity.botId, safeError('connection-failed', t('QQ 机器人已绑定,消息连接暂未就绪。')));
443
+ this.#errors.set(identity.botId, connectionError(error, t('QQ 机器人已绑定,消息连接暂未就绪。')));
438
444
  this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error);
439
445
  }
440
446
  this.#touch();
@@ -0,0 +1,17 @@
1
+ import { t } from '../shared/i18n.mjs';
2
+
3
+ const MESSAGES = Object.freeze({
4
+ 'state-read-failed': '无法读取 QQ 本地状态,请检查数据目录及访问权限。',
5
+ 'state-backup-failed': 'QQ 本地状态已损坏,但无法备份,原文件已保留。',
6
+ 'state-write-failed': '无法保存 QQ 本地状态,请检查磁盘空间及目录写入权限。',
7
+ });
8
+
9
+ export function qqStateError(code, cause) {
10
+ return Object.assign(new Error('QQ state operation failed', { cause }), { code });
11
+ }
12
+
13
+ export function publicQqStateError(error) {
14
+ return Object.hasOwn(MESSAGES, error?.code ?? '')
15
+ ? { code: error.code, message: t(MESSAGES[error.code]) }
16
+ : null;
17
+ }
@@ -1,6 +1,8 @@
1
1
  import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
2
- import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs/promises';
3
4
  import { dirname } from 'node:path';
5
+ import { qqStateError } from './state-error.mjs';
4
6
 
5
7
  const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
6
8
 
@@ -27,22 +29,46 @@ function normalizeState(value) {
27
29
 
28
30
  export class QqStateStore {
29
31
  #path;
32
+ #logger;
30
33
  #state = structuredClone(EMPTY_STATE);
31
34
  #writeQueue = Promise.resolve();
32
35
  #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
33
36
 
34
- constructor(path) {
37
+ constructor(path, { logger = console } = {}) {
35
38
  this.#path = path;
39
+ this.#logger = logger;
36
40
  }
37
41
 
38
42
  async load() {
43
+ let bytes;
39
44
  try {
40
- this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
45
+ bytes = await fs.readFile(this.#path);
41
46
  } catch (error) {
42
- if (error?.code !== 'ENOENT') throw error;
47
+ if (error?.code !== 'ENOENT') throw qqStateError('state-read-failed', error);
48
+ this.#state = structuredClone(EMPTY_STATE);
49
+ await this.#persist();
50
+ return this;
51
+ }
52
+
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(bytes.toString('utf8'));
56
+ } catch (error) {
57
+ if (!(error instanceof SyntaxError)) throw error;
58
+ const backup = `${this.#path}.corrupt-${Date.now()}-${randomUUID()}`;
59
+ try {
60
+ // Copy the exact bytes before replacing anything. A failed rebuild
61
+ // leaves both the original and this exclusive backup available.
62
+ await fs.writeFile(backup, bytes, { flag: 'wx', mode: 0o600 });
63
+ } catch (cause) {
64
+ throw qqStateError('state-backup-failed', cause);
65
+ }
43
66
  this.#state = structuredClone(EMPTY_STATE);
44
67
  await this.#persist();
68
+ this.#logger.warn?.(`[dsh-im:qq] state-recovered: ${this.#path}; backup: ${backup}; conversation mappings, message deduplication and deferred deliveries have been reset.`);
69
+ return this;
45
70
  }
71
+ this.#state = normalizeState(parsed);
46
72
  return this;
47
73
  }
48
74
 
@@ -96,7 +122,7 @@ export class QqStateStore {
96
122
 
97
123
  async remove() {
98
124
  try {
99
- await unlink(this.#path);
125
+ await fs.unlink(this.#path);
100
126
  } catch (error) {
101
127
  if (error?.code !== 'ENOENT') throw error;
102
128
  }
@@ -106,12 +132,12 @@ export class QqStateStore {
106
132
  async #persist() {
107
133
  const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
108
134
  const operation = this.#writeQueue.then(async () => {
109
- await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
135
+ await fs.mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
110
136
  const temporary = `${this.#path}.tmp`;
111
- await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
112
- await rename(temporary, this.#path);
137
+ await fs.writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
138
+ await fs.rename(temporary, this.#path);
113
139
  });
114
140
  this.#writeQueue = operation.then(() => undefined, () => undefined);
115
- await operation;
141
+ await operation.catch(error => { throw qqStateError('state-write-failed', error); });
116
142
  }
117
143
  }
@@ -33,6 +33,18 @@ function submissionPrompt(messages) {
33
33
  ].join('\n\n');
34
34
  }
35
35
 
36
+ /**
37
+ * Session title for one submission.
38
+ *
39
+ * The submitted prompt is dsh-im's own composition -- a framing sentence plus
40
+ * `[消息 N]` labels -- so using it as the conversation title would put plugin
41
+ * text where the user's own words belong. The first collected message is what
42
+ * the user actually said first.
43
+ */
44
+ function submissionTitle(messages) {
45
+ return messages.find((message) => message.trim()) ?? '';
46
+ }
47
+
36
48
  export function isBatchInputCommand(text) {
37
49
  return commandName(text) !== null;
38
50
  }
@@ -73,7 +85,10 @@ export class BatchInputManager {
73
85
 
74
86
  if (!batch) {
75
87
  if (!name) return { handled: false };
76
- if (!plainText) {
88
+ // Only starting a batch requires the plain-text command itself; /send and
89
+ // /cancel carry no content, so they must answer with their own state
90
+ // message instead of being refused as uncollectable content.
91
+ if (!plainText && name === 'batch') {
77
92
  return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片、文件或引用消息后重试。'));
78
93
  }
79
94
  if (name === 'send') {
@@ -90,7 +105,11 @@ export class BatchInputManager {
90
105
  });
91
106
  }
92
107
 
93
- if (!plainText && (batch.phase === 'collecting' || name)) {
108
+ // A command is never collected content: /send, /cancel and a repeated
109
+ // /batch must keep working even when the message that carries them is a
110
+ // quoted reply, an image caption or a file. Only the collected text itself
111
+ // has to be plain.
112
+ if (!plainText && batch.phase === 'collecting' && !name) {
94
113
  return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
95
114
  请继续发送文字,或使用 /send、/cancel。`), {
96
115
  count: batch.messages.length,
@@ -141,6 +160,7 @@ export class BatchInputManager {
141
160
  token,
142
161
  messages,
143
162
  prompt: submissionPrompt(messages),
163
+ title: submissionTitle(messages),
144
164
  count: messages.length,
145
165
  });
146
166
  }