@xmanrui/dsh-im 4.20.2 → 4.21.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 (51) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +108 -21
  4. package/lib/index.js +276 -276
  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/modern-harness-api.mjs +91 -3
  17. package/scripts/verify-model-setting.mjs +4 -1
  18. package/scripts/verify-package.mjs +3 -1
  19. package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
  20. package/src/channels/discord/discord-runtime.mjs +4 -1
  21. package/src/channels/feishu/bridge.mjs +9 -1
  22. package/src/channels/feishu/feishu-channel.mjs +1 -1
  23. package/src/channels/qq/qq-bridge.mjs +9 -1
  24. package/src/channels/qq/qq-controller.mjs +11 -5
  25. package/src/channels/qq/state-error.mjs +17 -0
  26. package/src/channels/qq/state-store.mjs +35 -9
  27. package/src/channels/shared/batch-input.mjs +22 -2
  28. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  29. package/src/channels/shared/config-read-error.mjs +24 -0
  30. package/src/channels/shared/harness-client.mjs +13 -12
  31. package/src/channels/shared/harness-question.mjs +10 -2
  32. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  33. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  34. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  35. package/src/channels/shared/semantic/artifact.mjs +1 -1
  36. package/src/channels/shared/text-harness-bridge.mjs +198 -3
  37. package/src/channels/shared/token-config-store.mjs +23 -8
  38. package/src/channels/shared/workspace-session.mjs +7 -1
  39. package/src/channels/slack/slack-runtime.mjs +3 -1
  40. package/src/channels/telegram/telegram-api.mjs +62 -2
  41. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  42. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  43. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  44. package/src/channels/wecom/wecom-bridge.mjs +4 -0
  45. package/src/channels/wecom-app/config-store.mjs +3 -1
  46. package/src/channels/wecom-app/wecom-app-bridge.mjs +1 -0
  47. package/src/channels/weixin/config-store.mjs +24 -15
  48. package/src/channels/weixin/connection-error.en.mjs +21 -0
  49. package/src/channels/weixin/connection-error.mjs +21 -6
  50. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  51. package/src/channels/weixin/weixin-bridge.mjs +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.20.2",
3
+ "version": "4.21.0",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -64,6 +64,22 @@
64
64
  {
65
65
  "name": "lyzhu86",
66
66
  "url": "https://github.com/lyzhu86"
67
+ },
68
+ {
69
+ "name": "Librazy",
70
+ "url": "https://github.com/Librazy"
71
+ },
72
+ {
73
+ "name": "Johnslee1207",
74
+ "url": "https://github.com/johnslee1207-commits"
75
+ },
76
+ {
77
+ "name": "Dong09",
78
+ "url": "https://github.com/Dong09"
79
+ },
80
+ {
81
+ "name": "wings1848",
82
+ "url": "https://github.com/wings1848"
67
83
  }
68
84
  ],
69
85
  "license": "MIT",
@@ -109,13 +109,26 @@ export function DiscordLogoGlyph({ size } = {}) {
109
109
  export function SlackLogoGlyph({ size } = {}) {
110
110
  return h('svg', {
111
111
  ...dimensions(size),
112
- viewBox: '0 0 24 24',
112
+ viewBox: '0 0 100 100',
113
113
  focusable: 'false',
114
114
  'aria-hidden': 'true',
115
115
  'data-im-channel-logo': 'slack',
116
- }, h('path', {
117
- fill: 'currentColor',
118
- d: 'M6 15a2 2 0 1 1-2-2h2v2Zm1 0a2 2 0 1 1 4 0v5a2 2 0 1 1-4 0v-5Zm2-8a2 2 0 1 1 2-2v2H9Zm0 1a2 2 0 1 1 0 4H4a2 2 0 1 1 0-4h5Zm8 2a2 2 0 1 1 2 2h-2v-2Zm-1 0a2 2 0 1 1-4 0V5a2 2 0 1 1 4 0v5Zm-2 8a2 2 0 1 1-2 2v-2h2Zm0-1a2 2 0 1 1 0-4h5a2 2 0 1 1 0 4h-5Z',
116
+ },
117
+ h('path', {
118
+ fill: '#36C5F0',
119
+ d: 'M36.5 0A10.5 10.5 0 0 0 36.5 21H47V10.5A10.5 10.5 0 0 0 36.5 0ZM10.5 26a10.5 10.5 0 0 0 0 21h26a10.5 10.5 0 0 0 0-21Z',
120
+ }),
121
+ h('path', {
122
+ fill: '#2EB67D',
123
+ d: 'M100 36.5A10.5 10.5 0 0 0 79 36.5V47h10.5A10.5 10.5 0 0 0 100 36.5ZM74 10.5a10.5 10.5 0 0 0-21 0v26a10.5 10.5 0 0 0 21 0Z',
124
+ }),
125
+ h('path', {
126
+ fill: '#ECB22E',
127
+ d: 'M63.5 100a10.5 10.5 0 0 0 0-21H53v10.5A10.5 10.5 0 0 0 63.5 100ZM89.5 74a10.5 10.5 0 0 0 0-21h-26a10.5 10.5 0 0 0 0 21Z',
128
+ }),
129
+ h('path', {
130
+ fill: '#E01E5A',
131
+ d: 'M0 63.5a10.5 10.5 0 0 0 21 0V53H10.5A10.5 10.5 0 0 0 0 63.5ZM26 89.5a10.5 10.5 0 0 0 21 0v-26a10.5 10.5 0 0 0-21 0Z',
119
132
  }));
120
133
  }
121
134
 
@@ -138,7 +151,7 @@ export function IMessageLogoGlyph({ size } = {}) {
138
151
  'aria-hidden': 'true', 'data-im-channel-logo': 'imessage',
139
152
  }, h('path', {
140
153
  fill: 'currentColor',
141
- d: 'M12 2.2c-5.52 0-10 3.75-10 8.38 0 2.63 1.52 4.98 3.9 6.52l-.92 3.43a.5.5 0 0 0 .72.56l3.54-1.96c.87.23 1.8.35 2.76.35 5.52 0 10-3.75 10-8.9S17.52 2.2 12 2.2Zm-3.4 8.2a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4Zm3.4 0a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4Zm3.4 0a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4Z',
154
+ d: 'M12 2.5C5.65 2.5.5 6.66.5 11.8c0 2.76 1.49 5.24 3.85 6.94.28 1.34-.35 2.76-1.6 4.01 2.1-.2 3.87-.86 5.26-1.85 1.25.4 2.6.62 3.99.62 6.35 0 11.5-4.35 11.5-9.72S18.35 2.5 12 2.5Z',
142
155
  }));
143
156
  }
144
157
 
@@ -1,7 +1,7 @@
1
1
  export const IMESSAGE_STYLE_ID = 'xmanrui-dsh-im-imessage-settings';
2
2
  const CSS = String.raw`
3
3
  .dim-pageIMessage { --ddt-accent: #32a852; --ddt-accent-deep: #248a40; --ddt-accent-wash: #edf9f0; }
4
- .dim-avatarIMessage { color: #fff; background: #32a852; }
4
+ .dim-avatarIMessage { color: #fff; background: linear-gradient(180deg, #5bf675 0%, #28d944 50%, #0fbd2c 100%); box-shadow: inset 0 1px 1px rgb(255 255 255 / 45%), 0 1px 3px rgb(31 35 41 / 12%); }
5
5
  .dim-avatarIMessage svg { display: block; }
6
6
  .dim-imessagePermissionPanel { padding: 20px; }
7
7
  .dim-imessagePermissionPanel p { color: var(--dsw-alias-label-secondary, #646a73); }
@@ -2,7 +2,7 @@ export const SLACK_STYLE_ID = 'xmanrui-dsh-im-slack-settings';
2
2
 
3
3
  const CSS = String.raw`
4
4
  .dsl-page { --ddt-accent: #4a154b; --ddt-accent-deep: #321033; --ddt-accent-wash: #f7eef7; }
5
- .dsl-avatar { color: #fff; background: #4a154b; }
5
+ .dsl-avatar { background: linear-gradient(145deg, #fff, #f8fafb); border: 1px solid #e5e6eb; }
6
6
  .dsl-avatar svg { display: block; }
7
7
  .dsl-setup { display: grid; gap: 18px; }
8
8
  .dsl-guide { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 18px; padding: 16px; border: 1px solid color-mix(in srgb, #4a154b 18%, var(--dsw-alias-border-l2, #e5e6eb)); border-radius: 11px; background: color-mix(in srgb, #4a154b 4%, var(--dsw-alias-bg-layer-1, #fff)); }
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { h, localizeText } from '../../i18n.js';
3
3
  import { normalizeConnectionError } from './api.js';
4
+ import { CONFIG_ISSUE_LABELS } from '../../../../src/channels/weixin/diagnostic-details.mjs';
4
5
 
5
6
  const STAGE_LABELS = {
6
7
  'startup.load': '加载微信配置', 'qr.begin': '申请二维码', 'qr.encode': '生成二维码图片', 'qr.poll': '查询扫码状态',
@@ -27,7 +28,7 @@ export function formatWeixinDiagnostic(value) {
27
28
  return [
28
29
  localizeText(error.message), details.hint ? localizeText(details.hint) : null,
29
30
  localizeText('错误码') + ': ' + error.code,
30
- ...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
31
+ ...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
31
32
  .filter(field => details[field] !== undefined)
32
33
  .map(field => `${field}: ${details[field]}`),
33
34
  ].filter(Boolean).join('\n');
@@ -48,6 +49,8 @@ export function WeixinConnectionError({ error: value, warning = false }) {
48
49
  const fields = [
49
50
  ['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
50
51
  ['底层原因', details.reason], ['HTTP 状态', details.httpStatus], ['微信返回码', details.providerCode],
52
+ ['配置文件', details.file], ['配置字段', details.field],
53
+ ['校验原因', details.issue ? localizeText(CONFIG_ISSUE_LABELS[details.issue]) : undefined],
51
54
  ['参考号', details.referenceId], ['发生时间', details.occurredAt], ['插件版本', details.pluginVersion],
52
55
  ].filter(([, text]) => text !== undefined && text !== '');
53
56
  return h('div', { className: 'dxw-summary dim-cardSummary', 'data-weixin-diagnostic': true, role: warning ? 'status' : undefined },
@@ -1020,6 +1020,14 @@ function channelName(value) {
1020
1020
  }
1021
1021
 
1022
1022
  function translateDynamic(text) {
1023
+ const configMessage = /^微信配置格式错误:(config\.json|workspaces\.json)。请查看诊断详情,修复后重启 DSH。$/.exec(text);
1024
+ if (configMessage) return EN['微信配置格式错误:{file}。请查看诊断详情,修复后重启 DSH。'].replace('{file}', configMessage[1]);
1025
+ const configHint = /^(.*?)请检查微信渠道数据目录中的 (config\.json|workspaces\.json),修复后重启 DSH;“重新读取”不会重新加载配置。\s*(字段位置中的序号从 0 开始,按文件中的条目顺序计数,不包含真实账号标识。)?$/.exec(text);
1026
+ if (configHint) return [
1027
+ localizeText(configHint[1].trim()),
1028
+ EN['请检查微信渠道数据目录中的 {file},修复后重启 DSH;“重新读取”不会重新加载配置。'].replace('{file}', configHint[2]),
1029
+ configHint[3] ? localizeText(configHint[3]) : '',
1030
+ ].filter(Boolean).join(' ');
1023
1031
  const guidanceLimit = /^增强提示词不得超过 (\d+) 个字符。$/.exec(text);
1024
1032
  if (guidanceLimit) return `Guidance must not exceed ${guidanceLimit[1]} characters.`;
1025
1033
  let match = /^(\d+) \/ (\d+) 在线$/.exec(text);
@@ -141,6 +141,8 @@ export function ModelEditor({ model = null, disabled = false, onSave }) {
141
141
  'aria-label': '查看模型设置说明', 'aria-describedby': `${id}-help` },
142
142
  h('span', { 'aria-hidden': true }, '?')),
143
143
  h('span', { id: `${id}-help`, className: 'dim-presetTooltip', role: 'tooltip' },
144
+ effortHint && !effortUnavailable
145
+ ? h('span', { id: `${id}-hint` }, effortHint, ' ') : null,
144
146
  '只影响新建会话;若当前聊天已有会话,先发送 /new,再发送普通消息生效。'))),
145
147
  saving ? h('span', { className: 'dim-presetStatus', role: 'status' }, '保存中…') : null),
146
148
  row('model', '模型', entry?.name ?? (current ? modelSelectionId(current) : localizeText('跟随默认模型'))),
@@ -163,8 +165,8 @@ export function ModelEditor({ model = null, disabled = false, onSave }) {
163
165
  ...(reasoning?.efforts ?? []).map((level) => option(`effort:${level.id}`, level.name, level.description,
164
166
  effort === level.id, { ...current, reasoningEffort: level.id })),
165
167
  ]) : null,
166
- effortHint ? h('p', { id: `${id}-hint`, className: 'dim-modelHint',
167
- role: effortUnavailable ? 'status' : undefined }, effortHint) : null,
168
+ effortHint && effortUnavailable ? h('p', { id: `${id}-hint`, className: 'dim-modelHint',
169
+ role: 'status' }, effortHint) : null,
168
170
  error || !currentAvailable ? h('p', { className: 'dim-presetError', role: error ? 'alert' : 'status' },
169
171
  error ?? '当前模型已不可用,请选择其他模型或跟随默认模型。') : null);
170
172
  }
@@ -26,8 +26,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
26
26
  const installations = new WeakMap();
27
27
 
28
28
  function hasClassPart(element, part) {
29
- const match = new RegExp(`(?:^|[_-])${part}(?:$|[_-])`, 'u');
30
- return [...element.classList].some((token) => match.test(token));
29
+ return [...element.classList].some((token) => token.split(/[_-]/u).includes(part));
31
30
  }
32
31
 
33
32
  function titleOf(row) {
@@ -126,10 +126,11 @@ const CSS = String.raw`
126
126
  .dim-logoOffice svg { width: 23px; height: 23px; }
127
127
  .dim-logoDiscord { color: white; background: #5865f2; }
128
128
  .dim-logoDiscord svg { width: 21px; height: 21px; }
129
- .dim-logoSlack { color: white; background: #4a154b; }
129
+ .dim-logoSlack { background: linear-gradient(145deg, #fff, #f8fafb); border: 1px solid #e5e6eb; }
130
130
  .dim-logoSlack svg { width: 21px; height: 21px; }
131
131
  .dim-logoWhatsapp { color: white; background: #25d366; }
132
- .dim-logoIMessage { color: white; background: #32a852; }
132
+ .dim-logoIMessage { color: white; background: linear-gradient(180deg, #5bf675 0%, #28d944 50%, #0fbd2c 100%); box-shadow: inset 0 1px 1px rgb(255 255 255 / 45%), 0 1px 3px rgb(31 35 41 / 12%); }
133
+ .dim-logoIMessage svg { width: 23px; height: 23px; }
133
134
  .dim-logoWhatsapp svg { width: 21px; height: 21px; }
134
135
  .dim-channelCopy { min-width: 0; display: grid; }
135
136
  .dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
@@ -74,7 +74,7 @@ export async function createProductionController(ctx, config = {}, internals = {
74
74
  const stateFor = async (botId) => {
75
75
  let state = stateStores.get(botId);
76
76
  if (!state) {
77
- state = await new StateStore(statePath(botId)).load();
77
+ state = await new StateStore(statePath(botId), { logger }).load();
78
78
  stateStores.set(botId, state);
79
79
  }
80
80
  return state;
@@ -9,6 +9,7 @@ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from
9
9
  import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
10
10
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
11
11
  import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
12
+ import { publicQqStateError } from '../../../../src/channels/qq/state-error.mjs';
12
13
  import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
13
14
  import { SET_MODEL_ENDPOINT, validModelPayload } from '../shared/model-setting-rpc.mjs';
14
15
 
@@ -232,7 +233,7 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
232
233
  const workspaceError = publicWorkspaceError(error);
233
234
  return signal?.aborted
234
235
  ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
235
- : { ok: false, error: workspaceError
236
+ : { ok: false, error: publicQqStateError(error) ?? workspaceError
236
237
  ?? { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
237
238
  }
238
239
  };
@@ -474,13 +474,97 @@ class ModernHarnessApi {
474
474
  #requestQuestion(request, next) {
475
475
  const owner = this.#claimableAgent(request?.agent);
476
476
  if (!owner) return next();
477
- if (request.signal?.aborted) {
477
+ const turnSignal = request.signal;
478
+ if (turnSignal?.aborted) {
478
479
  return Promise.reject(questionError(
479
480
  'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
480
481
  ));
481
482
  }
482
- return new Promise((resolve, reject) => {
483
- const pending = {
483
+ // Give this question a lifetime of its own instead of lending it the enclosing
484
+ // turn's. `dsh-agent-loop` mints one abort controller per turn and every tool
485
+ // call in that turn shares the one signal, and under PTC two consumers of it
486
+ // react to the abort EVENT rather than to `signal.aborted`: `run_code` flips its
487
+ // own program controller from an abort listener (dsh-tools `onOuterAbort`) and
488
+ // the worker code runtime retires the worker the same way
489
+ // (dsh-code-runtime-worker-thread `onAbort`). Retiring the Web card by firing
490
+ // that event therefore also killed the program that was merely waiting for this
491
+ // answer — `code run failed (abort)`. A private controller keeps the retirement
492
+ // below local: real upstream cancellation is forwarded into it, and every
493
+ // listener on this waterfall — the Remote/Web forwarder that renders the card,
494
+ // and our own IM pending — sees a lifetime that ends with this question rather
495
+ // than with the turn.
496
+ //
497
+ // Handing it downstream means replacing `request.signal`, because the Cordis
498
+ // waterfall passes the same request object to every remaining listener and
499
+ // `next()` takes no replacement arguments. The object is a per-call copy made by
500
+ // `UserQuestionService.ask`, and the signal it carried stays reachable here as
501
+ // `turnSignal`, so the caller's own view of the request is untouched.
502
+ const question = new AbortController();
503
+ const forwardTurnAbort = () => question.abort(turnSignal?.reason);
504
+ turnSignal?.addEventListener('abort', forwardTurnAbort, { once: true });
505
+ request.signal = question.signal;
506
+ // Race the IM answer against the host's own answerers (Web/CLI). Claiming the
507
+ // request exclusively used to keep the question out of Web entirely, leaving
508
+ // whoever happened to be looking at that Session with a card they could not
509
+ // answer. Racing keeps both surfaces usable: the first answer wins.
510
+ const im = this.#askThroughIm(request, owner);
511
+ let answeredByIm = false;
512
+ const native = Promise.resolve()
513
+ .then(() => next())
514
+ .catch((error) => {
515
+ // A pure-IM session has no client connected, so the host rejects with
516
+ // NO_PROVIDER — an expected state rather than a failure — and ASK_ABORTED
517
+ // is this adapter's own retire signal below. Any other error is real, so
518
+ // log it instead of swallowing it silently; the question still stays open
519
+ // for IM rather than being failed outright.
520
+ if (error?.code !== 'NO_PROVIDER' && error?.code !== 'ASK_ABORTED') {
521
+ console.warn(
522
+ '[dsh-im] the host user-question answerer failed; waiting for the IM answer:',
523
+ error?.message ?? error,
524
+ );
525
+ }
526
+ return new Promise(() => {});
527
+ });
528
+ return Promise.race([
529
+ native,
530
+ im.promise.then((value) => {
531
+ answeredByIm = true;
532
+ return value;
533
+ }),
534
+ ]).finally(() => {
535
+ // Losing side cleanup. A host answer already settled this request, so drop
536
+ // the IM pending: leaving it registered would let a late press answer a
537
+ // question the model has moved past. Settling is idempotent.
538
+ im.dismiss(new Error('another client answered this question'));
539
+ turnSignal?.removeEventListener('abort', forwardTurnAbort);
540
+ if (answeredByIm) {
541
+ // The host's own answerer (DSH Web) keeps its question card until its own
542
+ // pending settles, and a host-side adapter has no other handle on it. A Web
543
+ // client holds that pending through the lifetime this question handed
544
+ // downstream, so ending that lifetime makes the client drop a card whose
545
+ // answer was already given on IM — otherwise it keeps offering choices for a
546
+ // question the model has moved past.
547
+ //
548
+ // Ending it is `abort`, not a dispatched `abort` event: a listener that
549
+ // reads `signal.aborted` must see the same thing one that listens for the
550
+ // event does. Either way it reaches only this question's controller, so the
551
+ // turn and every other tool call in it — a waiting `run_code` program above
552
+ // all — keep running. The reason carries `ASK_ABORTED` so the host answerer
553
+ // rejecting alongside us stays an expected retirement rather than a logged
554
+ // failure.
555
+ question.abort(questionError('the question was answered on IM', 'ASK_ABORTED'));
556
+ }
557
+ });
558
+ }
559
+
560
+ /**
561
+ * Present one question to IM clients. Returns the answer promise plus a
562
+ * `dismiss` used to retire the pending when the host answers first.
563
+ */
564
+ #askThroughIm(request, owner) {
565
+ let pending = null;
566
+ const promise = new Promise((resolve, reject) => {
567
+ pending = {
484
568
  rpcId: randomUUID(),
485
569
  sessionId: owner.sessionId,
486
570
  questions: request.questions,
@@ -505,6 +589,10 @@ class ModernHarnessApi {
505
589
  request.signal?.addEventListener('abort', onAbort, { once: true });
506
590
  this.#broadcast(this.#questionFrame(pending).payload, pending.rpcId);
507
591
  });
592
+ return {
593
+ promise,
594
+ dismiss: (reason) => pending?.settle('cancelled', reason),
595
+ };
508
596
  }
509
597
 
510
598
  #approvalFrame(pending) {
@@ -22,12 +22,15 @@ const built = await build({
22
22
  define: { 'process.env.NODE_ENV': '"development"' },
23
23
  });
24
24
  const htmlPath = join(output, 'preview.html');
25
+ const scriptOpen = '<scr' + 'ipt>';
26
+ const scriptClose = '</scr' + 'ipt>';
27
+ const scriptBody = built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script');
25
28
  await writeFile(htmlPath, `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>DSH-IM Model Settings</title>
26
29
  <style>
27
30
  body{margin:0;padding:24px;background:#fff;color:#1f2329;font:14px/20px -apple-system,BlinkMacSystemFont,sans-serif}#app{max-width:760px;margin:auto}#result{max-width:760px;margin:18px auto;font:11px/17px monospace;white-space:pre-wrap;color:#687380}
28
31
  @media(max-width:600px){body{padding:12px}}
29
32
  @media(prefers-color-scheme:dark){:root{color-scheme:dark;--dsw-alias-bg-layer-1:#202125;--dsw-alias-bg-layer-3:#282a30;--dsw-specific-menu:#282a30;--dsw-alias-bg-module-platform:#25272c;--dsw-alias-label-primary:#e9ebef;--dsw-alias-label-secondary:#b1b5bf;--dsw-alias-label-tertiary:#9a9faa;--dsw-alias-border-l1:#34363c;--dsw-alias-border-l2:#41434c;--dsw-alias-border-l3:#7b9ff9;--dsw-alias-interactive-bg-hover:#353842}body{background:#202125;color:#e9ebef}}
30
- </style><body><div id="app"></div><pre id="result">Running browser checks…</pre><script>${built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script')}</script></body></html>`);
33
+ </style><body><div id="app"></div><pre id="result">Running browser checks…</pre>${scriptOpen}${scriptBody}${scriptClose}</body></html>`);
31
34
  for (const [name, size, extra, query] of [
32
35
  ['desktop', '1100,1100', [], ''],
33
36
  ['mobile', '390,1250', [], '?mobile'],
@@ -238,7 +238,9 @@ if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whi
238
238
  if (!/(?:from\s*|import\s*\()\s*["']undici["']/.test(host)) {
239
239
  throw new Error('host bundle must retain undici as an external runtime dependency');
240
240
  }
241
- if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
241
+ if (process.platform !== 'win32' && (executable.mode & 0o111) === 0) {
242
+ throw new Error('dsh-im CLI is not executable');
243
+ }
242
244
  if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
243
245
  throw new Error('built artifacts contain a test or environment secret marker');
244
246
  }
@@ -1299,6 +1299,7 @@ export class DingtalkHarnessBridge {
1299
1299
  key,
1300
1300
  text,
1301
1301
  content,
1302
+ titleText: batchSubmission?.title,
1302
1303
  contextEnhanced,
1303
1304
  createOptions: { signal: this.#signal },
1304
1305
  existsOptions: { signal: this.#signal },
@@ -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) {
@@ -1037,12 +1037,16 @@ export class FeishuHarnessBridge {
1037
1037
  if (result.kind === 'submit') {
1038
1038
  const submissionEvent = {
1039
1039
  ...event,
1040
- batchSubmission: { token: result.token },
1040
+ batchSubmission: { token: result.token, title: result.title },
1041
1041
  message: {
1042
1042
  ...event.message,
1043
1043
  message_type: 'text',
1044
1044
  content: JSON.stringify({ text: result.prompt }),
1045
1045
  mentions: [],
1046
+ // The submission is exactly the collected text, so quoting the
1047
+ // message that carried /send must not attach it to the batch.
1048
+ parent_id: undefined,
1049
+ root_id: undefined,
1046
1050
  },
1047
1051
  };
1048
1052
  return this.#enqueueMessage(
@@ -4959,6 +4963,7 @@ export class FeishuHarnessBridge {
4959
4963
  key,
4960
4964
  text,
4961
4965
  content,
4966
+ titleText: event.batchSubmission?.title,
4962
4967
  contextEnhanced,
4963
4968
  createOptions: { signal: this.#signal },
4964
4969
  existsOptions: { signal: this.#signal },
@@ -5201,6 +5206,7 @@ export class FeishuHarnessBridge {
5201
5206
  key,
5202
5207
  text,
5203
5208
  content,
5209
+ titleText: event.batchSubmission?.title,
5204
5210
  contextEnhanced,
5205
5211
  createOptions: { signal: this.#signal },
5206
5212
  existsOptions: { signal: this.#signal },
@@ -5280,6 +5286,7 @@ export class FeishuHarnessBridge {
5280
5286
  key,
5281
5287
  text,
5282
5288
  content,
5289
+ titleText: event.batchSubmission?.title,
5283
5290
  contextEnhanced,
5284
5291
  createOptions: { signal: this.#signal },
5285
5292
  existsOptions: { signal: this.#signal },
@@ -5350,6 +5357,7 @@ export class FeishuHarnessBridge {
5350
5357
  key,
5351
5358
  text,
5352
5359
  content,
5360
+ titleText: event.batchSubmission?.title,
5353
5361
  contextEnhanced,
5354
5362
  createOptions: { signal: this.#signal },
5355
5363
  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
  }
@@ -570,7 +570,14 @@ export class QqHarnessBridge {
570
570
  if (result.handled) {
571
571
  if (result.kind === 'submit') {
572
572
  return this.#enqueueMessage(
573
- { ...message, content: result.prompt, attachments: [] },
573
+ {
574
+ ...message,
575
+ content: result.prompt,
576
+ // The submission is exactly the collected text: the command
577
+ // message's own attachments and quoted message are not part of it.
578
+ attachments: [],
579
+ refMsgIdx: undefined,
580
+ },
574
581
  messageId,
575
582
  key,
576
583
  { batchSubmission: result },
@@ -1038,6 +1045,7 @@ export class QqHarnessBridge {
1038
1045
  key,
1039
1046
  text,
1040
1047
  content,
1048
+ titleText: batchSubmission?.title,
1041
1049
  contextEnhanced,
1042
1050
  createOptions: { signal: this.#signal },
1043
1051
  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
  }