@xmanrui/dsh-im 4.8.0 → 4.9.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 (32) hide show
  1. package/README.en.md +8 -79
  2. package/README.md +8 -79
  3. package/lib/client.js +73 -20
  4. package/lib/index.js +244 -240
  5. package/package.json +10 -6
  6. package/plugin-src/client/channels/dingtalk/api.js +23 -6
  7. package/plugin-src/client/channels/dingtalk/index.js +33 -6
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -0
  9. package/plugin-src/client/i18n.js +4 -0
  10. package/plugin-src/client/styles.js +3 -2
  11. package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
  12. package/plugin-src/host/modern-harness-api.mjs +17 -8
  13. package/src/channels/dingtalk/connection-error.mjs +299 -0
  14. package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
  15. package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
  16. package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
  17. package/src/channels/feishu/bridge.mjs +174 -20
  18. package/src/channels/feishu/feishu-cards.mjs +69 -0
  19. package/src/channels/feishu/feishu-runtime.mjs +5 -0
  20. package/src/channels/feishu/slash-command-registry.mjs +3 -0
  21. package/src/channels/qq/qq-bridge.mjs +1 -0
  22. package/src/channels/shared/harness-approval.mjs +24 -1
  23. package/src/channels/shared/harness-client.mjs +45 -16
  24. package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
  25. package/src/channels/shared/i18n-en/feishu.mjs +11 -0
  26. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  27. package/src/channels/shared/semantic/reply-reference.mjs +5 -11
  28. package/src/channels/shared/text-harness-bridge.mjs +1 -0
  29. package/src/channels/shared/workspace-command.mjs +2 -2
  30. package/src/channels/telegram/telegram-runtime.mjs +6 -9
  31. package/src/channels/wecom/wecom-bridge.mjs +1 -0
  32. package/src/channels/weixin/weixin-bridge.mjs +1 -0
@@ -245,6 +245,10 @@ export class HarnessApprovalQueue {
245
245
  await this.#rejectInteraction(interaction, payload);
246
246
  return true;
247
247
  }
248
+ // Optional channel-provided renderer. When present, the approval is shown
249
+ // as an interactive card (e.g. Feishu approve/reject buttons) instead of
250
+ // plain text. Channels that don't provide one keep the text-reply path.
251
+ const render = typeof context?.render === 'function' ? context.render : null;
248
252
 
249
253
  const text = harnessApprovalText(payload, {
250
254
  toolCall: interaction.toolCall,
@@ -267,6 +271,7 @@ export class HarnessApprovalQueue {
267
271
  actor,
268
272
  requiresMention: context.requiresMention === true,
269
273
  send,
274
+ render,
270
275
  text,
271
276
  presented: false,
272
277
  presentationTask: null,
@@ -287,6 +292,20 @@ export class HarnessApprovalQueue {
287
292
  return true;
288
293
  }
289
294
 
295
+ /**
296
+ * Submit an approval decision by id, as triggered by a channel card button
297
+ * (e.g. Feishu approve/reject). Returns false when no matching pending
298
+ * approval is found. Callers may pass the acting user to enforce that only
299
+ * the originating actor can decide.
300
+ */
301
+ async submitByApprovalId(approvalId, outcome, { actor } = {}) {
302
+ const pending = this.#byId.get(cleanText(approvalId));
303
+ if (!pending || pending.inactive || pending.resolving || pending.submitting) return false;
304
+ if (actor !== undefined && pending.actor !== actor) return false;
305
+ await this.#submit(pending, outcome);
306
+ return true;
307
+ }
308
+
290
309
  async handleResolved(resolution) {
291
310
  if (resolution?.kind !== 'approval') return false;
292
311
  const pending = this.#byId.get(cleanText(resolution.interactionId));
@@ -353,7 +372,11 @@ export class HarnessApprovalQueue {
353
372
  if (this.#routes.get(pending.key)?.items[0] !== pending
354
373
  || pending.inactive || pending.resolving || pending.presented) return;
355
374
  if (pending.presentationTask) return pending.presentationTask;
356
- const task = Promise.resolve().then(() => pending.send(pending.text));
375
+ // A channel-provided renderer shows the approval as an interactive card
376
+ // (e.g. approve/reject buttons); otherwise fall back to plain text.
377
+ const task = pending.render
378
+ ? Promise.resolve().then(() => pending.render(pending, pending.send))
379
+ : Promise.resolve().then(() => pending.send(pending.text));
357
380
  pending.presentationTask = task;
358
381
  try {
359
382
  await task;
@@ -434,6 +434,11 @@ export class HarnessReplyTracker {
434
434
  return this.#finished;
435
435
  }
436
436
 
437
+ /** The highest event seq consumed so far; advances as the turn produces events. */
438
+ get lastSeq() {
439
+ return this.#lastSeq;
440
+ }
441
+
437
442
  get answer() {
438
443
  return this.#latestText.trim();
439
444
  }
@@ -1435,8 +1440,14 @@ export class HarnessClient {
1435
1440
  promptAccepted = true;
1436
1441
 
1437
1442
  try {
1438
- const deadline = Date.now() + timeoutMs;
1439
- while (Date.now() < deadline) {
1443
+ // Treat timeoutMs as a stall window rather than a hard runtime limit.
1444
+ // Durable events are direct progress. Once a full quiet window elapses,
1445
+ // confirm the Session is still running before renewing the wait.
1446
+ // Interaction ownership is intentionally not a liveness signal: it stays
1447
+ // active until turn/end and can therefore outlive a stalled turn.
1448
+ let lastProgressAt = Date.now();
1449
+ let lastPollSeq = tracker.lastSeq;
1450
+ while (true) {
1440
1451
  await sleep(300, signal);
1441
1452
  const history = await this.rpc(
1442
1453
  'session.history',
@@ -1450,6 +1461,9 @@ export class HarnessClient {
1450
1461
  if (!wasActive && ownership.active) ownership.reconnect?.();
1451
1462
  }
1452
1463
  const updates = tracker.consumeAll(history.events ?? []);
1464
+ const seqAdvanced = tracker.lastSeq > lastPollSeq;
1465
+ lastPollSeq = tracker.lastSeq;
1466
+ if (seqAdvanced) lastProgressAt = Date.now();
1453
1467
  if (onUpdate) {
1454
1468
  const visibleUpdates = progressMode === 'all' ? updates : updates.slice(-1);
1455
1469
  for (const update of visibleUpdates) {
@@ -1460,24 +1474,39 @@ export class HarnessClient {
1460
1474
  }
1461
1475
  }
1462
1476
  }
1463
- if (!tracker.finished) continue;
1464
- turnFinished = true;
1465
- if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
1477
+ if (tracker.finished) {
1478
+ turnFinished = true;
1479
+ if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
1480
+ throw harnessTurnError(tracker.reason);
1481
+ }
1482
+ // An accepted /stop revokes attachment delivery even when Harness
1483
+ // preserved a useful partial text answer for the existing UX.
1484
+ const artifactCount = ownership?.stopRequested
1485
+ ? 0
1486
+ : await deliverArtifacts();
1487
+ if (tracker.answer) {
1488
+ return tracker.answer;
1489
+ }
1490
+ if (artifactCount > 0) return '';
1491
+ if (ownership?.stopRequested) throw turnStoppedError();
1466
1492
  throw harnessTurnError(tracker.reason);
1467
1493
  }
1468
- // An accepted /stop revokes attachment delivery even when Harness
1469
- // preserved a useful partial text answer for the existing UX.
1470
- const artifactCount = ownership?.stopRequested
1471
- ? 0
1472
- : await deliverArtifacts();
1473
- if (tracker.answer) {
1474
- return tracker.answer;
1494
+
1495
+ if (Date.now() - lastProgressAt < timeoutMs) continue;
1496
+
1497
+ let running = false;
1498
+ try {
1499
+ running = await this.isSessionRunning(sessionId, { signal });
1500
+ } catch (error) {
1501
+ if (signal?.aborted) throw signal.reason ?? error;
1502
+ // A failed liveness probe is not evidence of progress.
1503
+ }
1504
+ if (running) {
1505
+ lastProgressAt = Date.now();
1506
+ continue;
1475
1507
  }
1476
- if (artifactCount > 0) return '';
1477
- if (ownership?.stopRequested) throw turnStoppedError();
1478
- throw harnessTurnError(tracker.reason);
1508
+ throw new HarnessTurnError('harness-reply-timeout');
1479
1509
  }
1480
- throw new HarnessTurnError('harness-reply-timeout');
1481
1510
  } catch (error) {
1482
1511
  // Once cancellation was accepted, transport/poll failures and timeouts
1483
1512
  // describe the convergence of that stop, not an unrelated ask failure.
@@ -38,6 +38,29 @@ export default {
38
38
  '钉钉机器人凭据缺失,请移除后重新扫码。': 'The DingTalk bot credentials are missing. Remove the bot and scan the QR code again.',
39
39
  '钉钉连接未就绪,请稍后重试。': 'The DingTalk connection is not ready. Please try again later.',
40
40
  '钉钉连接仍未就绪,请稍后重试。': 'The DingTalk connection is still not ready. Please try again later.',
41
+ '钉钉 Stream 连接失败:检测到代理依赖 agent-base 6.0.0。': 'The DingTalk Stream connection failed because proxy dependency agent-base 6.0.0 was detected.',
42
+ '请将 DSH profile 中的 agent-base@6 固定为 6.0.2 后重新安装依赖,或升级 pnpm 后重新解析 lockfile。': 'Pin agent-base@6 to 6.0.2 in the DSH profile and reinstall dependencies, or upgrade pnpm and re-resolve the lockfile.',
43
+ '插件无法连接本机 Harness。': 'The plugin could not connect to the local Harness.',
44
+ '请确认 dsh web 正常运行,并查看 dsh web 日志中相同参考号对应的诊断信息。': 'Make sure dsh web is running, then find the matching reference in the dsh web log.',
45
+ '钉钉 Stream SDK 加载失败。': 'The DingTalk Stream SDK could not be loaded.',
46
+ '请重新安装当前 DSH profile 的插件依赖,并查看 dsh web 日志中相同参考号对应的诊断信息。': 'Reinstall plugin dependencies in the current DSH profile, then find the matching reference in the dsh web log.',
47
+ '钉钉拒绝了当前应用凭据。': 'DingTalk rejected the current app credentials.',
48
+ '请核对 Client ID、Client Secret 和机器人权限后重试。': 'Verify the app credentials and bot permissions, then try again.',
49
+ '连接钉钉 Stream 超时。': 'The DingTalk Stream connection timed out.',
50
+ '请检查网络、代理和防火墙后重试;详细原因可在 dsh web 日志中按参考号查找。': 'Check the network, proxy, and firewall, then try again. Find details in the dsh web log using the reference.',
51
+ '无法解析钉钉服务地址。': 'The DingTalk service address could not be resolved.',
52
+ '请检查 DNS、网络和代理设置;详细原因可在 dsh web 日志中按参考号查找。': 'Check DNS, network, and proxy settings. Find details in the dsh web log using the reference.',
53
+ '钉钉 Stream 的 TLS 连接校验失败。': 'TLS validation failed for the DingTalk Stream connection.',
54
+ '请检查系统证书、代理证书或 HTTPS 中间代理;详细原因可在 dsh web 日志中按参考号查找。': 'Check system certificates, proxy certificates, or the HTTPS interception proxy. Find details in the dsh web log using the reference.',
55
+ '钉钉 Stream 无法通过当前代理建立连接。': 'DingTalk Stream could not connect through the current proxy.',
56
+ '请检查 HTTP_PROXY、HTTPS_PROXY、NO_PROXY 和代理连通性;详细原因可在 dsh web 日志中按参考号查找。': 'Check HTTP_PROXY, HTTPS_PROXY, NO_PROXY, and proxy connectivity. Find details in the dsh web log using the reference.',
57
+ '钉钉 Stream 消息连接建立失败。': 'The DingTalk Stream message connection could not be established.',
58
+ '请检查网络和机器人配置;详细原因可在 dsh web 日志中按参考号查找。': 'Check the network and bot configuration. Find details in the dsh web log using the reference.',
59
+ '钉钉 Stream 消息监听初始化失败。': 'DingTalk Stream message-listener initialization failed.',
60
+ '请确认 dsh-im 与 dingtalk-stream 版本兼容,并按参考号查看 dsh web 日志。': 'Make sure dsh-im and dingtalk-stream are compatible, then inspect the dsh web log using the reference.',
61
+ '钉钉机器人运行环境初始化失败。': 'The DingTalk bot runtime could not be initialized.',
62
+ '请检查 DSH 数据目录、工作区和插件依赖,并按参考号查看 dsh web 日志。': 'Check the DSH data directory, workspace, and plugin dependencies, then inspect the dsh web log using the reference.',
63
+ '请在 dsh web 日志中查找相同参考号,以获取已脱敏的具体错误。': 'Find the matching reference in the dsh web log for the redacted error details.',
41
64
  '扫码接入已取消。': 'QR code setup has been cancelled.',
42
65
  '无法生成钉钉二维码,请稍后重试。': 'Could not generate the DingTalk QR code. Please try again later.',
43
66
  '二维码已过期,请重新生成。': 'The QR code has expired. Generate a new one.',
@@ -356,4 +356,15 @@ export default {
356
356
  '⚠️ Repair verification failed: the dedicated test card could not be sent, so card.action.trigger cannot be confirmed restored. Do not authorize again; check the bot message permission and connection status first.',
357
357
  '⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。':
358
358
  '⚠️ Repair verification interrupted: the Runtime stopped before the card.action.trigger test completed, so the repair cannot be confirmed. Do not authorize again; wait for the bot to reconnect.',
359
+
360
+ // feishu/bridge.mjs — interaction cards (approve/reject / answer buttons)
361
+ '该审批已处理或不存在,无需重复操作。':
362
+ 'This approval has already been processed or does not exist; no need to repeat the action.',
363
+ // feishu/feishu-cards.mjs — approval card
364
+ '操作参数:\n{operation}': 'Operation parameters:\n{operation}',
365
+ '✅ 批准': '✅ Approve',
366
+ '❌ 拒绝': '❌ Reject',
367
+ '🔐 工具审批': '🔐 Tool approval',
368
+ // feishu/feishu-cards.mjs — question card
369
+ '❓ 请补充信息{progress}': '❓ Please provide more information{progress}',
359
370
  };
@@ -123,6 +123,7 @@ export default {
123
123
  '/workspace 工作区序号或绝对路径 切换工作区':
124
124
  '/workspace <workspace index or absolute path> Switch workspace',
125
125
  '/workspacelist 列出工作区绝对路径': '/workspacelist List absolute workspace paths',
126
+ '/ws、/wsl、/workspaces 工作区命令别名': '/ws, /wsl, /workspaces Workspace command aliases',
126
127
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题':
127
128
  '/sessionlist [workspace index or absolute path] List session IDs and titles',
128
129
  '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题':
@@ -2,7 +2,6 @@ import { promptContentForMessage } from '../image-prompt.mjs';
2
2
 
3
3
  const REPLY_CONTENT_MAX_CODE_POINTS = 8_000;
4
4
  const REPLY_ATTACHMENTS_MAX = 20;
5
- const REPLY_ID_MAX_CODE_POINTS = 512;
6
5
  const REPLY_AUTHOR_NAME_MAX_CODE_POINTS = 256;
7
6
  const REPLY_ATTACHMENT_NAME_MAX_CODE_POINTS = 255;
8
7
 
@@ -105,8 +104,6 @@ async function resolveReference(reference, signal) {
105
104
  }
106
105
 
107
106
  function normalizeReference(reference) {
108
- const messageId = cleanString(reference.messageId, REPLY_ID_MAX_CODE_POINTS);
109
- const authorId = cleanString(reference.authorId, REPLY_ID_MAX_CODE_POINTS);
110
107
  const authorName = cleanString(reference.authorName, REPLY_AUTHOR_NAME_MAX_CODE_POINTS);
111
108
  const content = cleanString(reference.content, REPLY_CONTENT_MAX_CODE_POINTS, { multiline: true });
112
109
  const { attachments, truncated: attachmentsTruncated } = cleanAttachments(reference.attachments);
@@ -114,19 +111,16 @@ function normalizeReference(reference) {
114
111
  if (!content.value && attachments.length === 0 && !unavailableReason) {
115
112
  unavailableReason = 'not-delivered';
116
113
  }
114
+ const truncated = authorName.truncated
115
+ || content.truncated
116
+ || attachmentsTruncated;
117
117
  return {
118
118
  note: REPLY_NOTE,
119
- ...(messageId.value ? { messageId: messageId.value } : {}),
120
- ...(authorId.value ? { authorId: authorId.value } : {}),
121
119
  ...(authorName.value ? { authorName: authorName.value } : {}),
122
120
  ...(content.value ? { content: content.value } : {}),
123
- attachments,
121
+ ...(attachments.length > 0 ? { attachments } : {}),
124
122
  ...(unavailableReason ? { unavailableReason } : {}),
125
- truncated: messageId.truncated
126
- || authorId.truncated
127
- || authorName.truncated
128
- || content.truncated
129
- || attachmentsTruncated,
123
+ ...(truncated ? { truncated: true } : {}),
130
124
  };
131
125
  }
132
126
 
@@ -596,6 +596,7 @@ export class TextHarnessBridge {
596
596
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
597
597
  t('/workspace 工作区序号或绝对路径 切换工作区'),
598
598
  t('/workspacelist 列出工作区绝对路径'),
599
+ t('/ws、/wsl、/workspaces 工作区命令别名'),
599
600
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
600
601
  t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
601
602
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -4,8 +4,8 @@ import { isAbsolute, resolve } from 'node:path';
4
4
  import { t } from './i18n.mjs';
5
5
  import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
6
6
 
7
- const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
8
- const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
7
+ const WORKSPACE_COMMAND = /^\/(?:workspace|ws)(?:\s+([\s\S]+))?$/i;
8
+ const WORKSPACE_LIST_COMMAND = /^\/(?:workspacelist|workspaces|wsl)(?:\s+([\s\S]+))?$/i;
9
9
  const SESSION_LIST_COMMAND = /^\/(?:sessionlist|sessions)(?:\s+([\s\S]+))?$/i;
10
10
  const SESSION_BIND_PREFIX = /^\/session(?=$|\s)/i;
11
11
  const SESSION_BIND_COMMAND = /^\/session[ \t]+([^\s]+)$/i;
@@ -21,7 +21,10 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze([
21
21
  { command: 'new', description: '开启一个全新会话' },
22
22
  { command: 'compact', description: '压缩当前会话的较早上下文' },
23
23
  { command: 'workspace', description: '切换工作区' },
24
+ { command: 'ws', description: '切换工作区' },
24
25
  { command: 'workspacelist', description: '列出工作区绝对路径' },
26
+ { command: 'workspaces', description: '列出工作区绝对路径' },
27
+ { command: 'wsl', description: '列出工作区绝对路径' },
25
28
  { command: 'sessionlist', description: '列出会话 ID 和标题' },
26
29
  { command: 'sessions', description: '列出会话 ID 和标题' },
27
30
  { command: 'session', description: '将当前聊天绑定到指定会话' },
@@ -139,11 +142,10 @@ function telegramFileSource(message, loadFile) {
139
142
  };
140
143
  }
141
144
 
142
- function telegramReplyAttachment(kind, file, fallbackName) {
145
+ function telegramReplyAttachment(kind, file) {
143
146
  if (!file || typeof file !== 'object') return null;
144
147
  const name = typeof file.file_name === 'string' && file.file_name
145
- ? file.file_name : typeof fallbackName === 'string' && fallbackName
146
- ? fallbackName : undefined;
148
+ ? file.file_name : undefined;
147
149
  return { kind, ...(name ? { name } : {}) };
148
150
  }
149
151
 
@@ -153,11 +155,7 @@ function telegramReplyAttachments(message) {
153
155
  const largest = message.photo.reduce((best, candidate) => (
154
156
  photoScore(candidate) > photoScore(best) ? candidate : best
155
157
  ));
156
- attachments.push(telegramReplyAttachment(
157
- 'image',
158
- largest,
159
- `${largest.file_unique_id ?? largest.file_id ?? 'telegram-photo'}.jpg`,
160
- ));
158
+ attachments.push(telegramReplyAttachment('image', largest));
161
159
  } else if (message?.document) {
162
160
  attachments.push(telegramReplyAttachment(
163
161
  imageTypeForDocument(message.document) ? 'image' : 'file',
@@ -177,7 +175,6 @@ function telegramReplyAttachments(message) {
177
175
  attachments.push(telegramReplyAttachment(
178
176
  message.sticker.is_video === true ? 'video' : 'image',
179
177
  message.sticker,
180
- message.sticker.file_unique_id ?? message.sticker.file_id,
181
178
  ));
182
179
  }
183
180
  return attachments.filter(Boolean);
@@ -73,6 +73,7 @@ function helpText() {
73
73
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
74
74
  t('/workspace 工作区序号或绝对路径 切换工作区'),
75
75
  t('/workspacelist 列出工作区绝对路径'),
76
+ t('/ws、/wsl、/workspaces 工作区命令别名'),
76
77
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
77
78
  t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
78
79
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -87,6 +87,7 @@ const HELP_TEXT = () => [
87
87
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
88
88
  t('/workspace 工作区序号或绝对路径 切换工作区'),
89
89
  t('/workspacelist 列出工作区绝对路径'),
90
+ t('/ws、/wsl、/workspaces 工作区命令别名'),
90
91
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
91
92
  t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
92
93
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),