@xmanrui/dsh-im 2.2.1 → 2.4.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 (31) hide show
  1. package/README.en.md +12 -3
  2. package/README.md +12 -3
  3. package/lib/client.js +95 -52
  4. package/lib/index.js +220 -191
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/feishu/index.js +37 -27
  7. package/plugin-src/client/channels/feishu/styles.js +26 -1
  8. package/plugin-src/client/i18n.js +27 -25
  9. package/src/channels/dingtalk/dingtalk-bridge.mjs +96 -3
  10. package/src/channels/discord/discord-runtime.mjs +3 -0
  11. package/src/channels/feishu/bridge.mjs +176 -31
  12. package/src/channels/feishu/feishu-cards.mjs +18 -6
  13. package/src/channels/feishu/feishu-runtime.mjs +0 -1
  14. package/src/channels/feishu/message-utils.mjs +1 -1
  15. package/src/channels/feishu/multi-bot-controller.mjs +1 -17
  16. package/src/channels/feishu/repair-manager.mjs +7 -2
  17. package/src/channels/qq/qq-bridge.mjs +99 -8
  18. package/src/channels/shared/batch-input.mjs +209 -0
  19. package/src/channels/shared/control-command.mjs +11 -1
  20. package/src/channels/shared/i18n-en/feishu.mjs +28 -25
  21. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  22. package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
  23. package/src/channels/shared/i18n-en/shared-c.mjs +49 -0
  24. package/src/channels/shared/i18n-en/telegram.mjs +1 -0
  25. package/src/channels/shared/text-harness-bridge.mjs +112 -2
  26. package/src/channels/slack/slack-runtime.mjs +1 -0
  27. package/src/channels/telegram/telegram-runtime.mjs +5 -0
  28. package/src/channels/wecom/wecom-bridge.mjs +102 -4
  29. package/src/channels/weixin/weixin-bridge.mjs +101 -3
  30. package/src/channels/weixin/weixin-runtime.mjs +18 -1
  31. package/src/channels/whatsapp/whatsapp-runtime.mjs +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -265,7 +265,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
265
265
  ? h("img", {
266
266
  src: qrSource,
267
267
  alt: repairing
268
- ? `用于修复${botName}卡片按钮的一次性授权二维码`
268
+ ? `用于为${botName}补全权限与回调的一次性授权二维码`
269
269
  : grantingGroupMessages
270
270
  ? `用于为${botName}开通群消息权限的一次性授权二维码`
271
271
  : "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
@@ -293,31 +293,31 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
293
293
  h("div", { className: "bxf-stateLabel dim-stateLabel" },
294
294
  h("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
295
295
  h("span", null, repairing
296
- ? `正在修复「${botName}」`
296
+ ? `正在为「${botName}」补全权限与回调`
297
297
  : grantingGroupMessages
298
298
  ? `正在为「${botName}」开通群消息权限`
299
299
  : "正在添加新机器人")),
300
300
  h("h3", null, expired
301
301
  ? "刷新二维码后继续"
302
302
  : repairing
303
- ? "使用飞书扫码修复卡片按钮"
303
+ ? "使用飞书扫码补全权限"
304
304
  : grantingGroupMessages
305
305
  ? "使用飞书确认群消息权限"
306
306
  : "使用飞书扫码创建机器人"),
307
307
  h("p", null, repairing
308
- ? "扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。"
308
+ ? "扫码会更新现有飞书应用,最多增量补充卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
309
309
  : grantingGroupMessages
310
310
  ? "扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。"
311
311
  : "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
312
312
  h("ol", { className: "bxf-steps dim-steps" },
313
313
  h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
314
314
  h("li", null, repairing
315
- ? "核对现有应用名称,并确认只新增卡片回调"
315
+ ? "核对现有应用名称,并确认只新增当前缺少的上述配置"
316
316
  : grantingGroupMessages
317
317
  ? "核对现有应用,并确认“获取群组中所有消息”权限"
318
318
  : "核对应用名称与权限范围,并确认创建"),
319
319
  h("li", null, repairing
320
- ? "保持本页打开,等待卡片按钮修复完成"
320
+ ? "保持本页打开,等待权限与回调补全完成"
321
321
  : grantingGroupMessages
322
322
  ? "保持本页打开,等待权限生效并自动切换响应方式"
323
323
  : "保持本页打开,等待新机器人的长连接就绪")),
@@ -336,7 +336,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
336
336
  ? h(Button, { onClick: onRefresh, disabled: busy }, "换一个二维码")
337
337
  : null,
338
338
  h(Button, { onClick: onCancel, disabled: busy }, repairing
339
- ? "取消修复"
339
+ ? "取消补全"
340
340
  : grantingGroupMessages ? "取消授权" : "取消添加")),
341
341
  ),
342
342
  ),
@@ -354,16 +354,16 @@ function ProvisionProgress({ phase, provision, onCancel, busy }) {
354
354
  h("div", { className: "dim-spinner", "aria-hidden": "true" }),
355
355
  h("h3", null, connecting
356
356
  ? repairing
357
- ? "已确认,正在完成卡片按钮修复"
357
+ ? "已确认,正在完成权限与回调配置"
358
358
  : grantingGroupMessages
359
359
  ? "已确认,正在启用全部消息模式"
360
360
  : "已确认,正在连接新机器人"
361
361
  : repairing
362
- ? "正在准备修复二维码"
362
+ ? "正在准备权限补全二维码"
363
363
  : grantingGroupMessages ? "正在准备权限授权二维码" : "正在准备授权二维码"),
364
364
  h("p", null, connecting
365
365
  ? repairing
366
- ? "配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。"
366
+ ? "配置已提交,正在保存权限、验证卡片回调并重连此机器人;此阶段无法取消,其他机器人不会中断。"
367
367
  : grantingGroupMessages
368
368
  ? "权限配置已提交,正在保存设置并重连此机器人;此阶段无法取消,其他机器人不会中断。"
369
369
  : "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
@@ -375,7 +375,7 @@ function ProvisionProgress({ phase, provision, onCancel, busy }) {
375
375
  connecting && onCancel
376
376
  ? h("div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
377
377
  h(Button, { onClick: onCancel, disabled: busy }, repairing
378
- ? "取消修复"
378
+ ? "取消补全"
379
379
  : grantingGroupMessages ? "取消授权" : "取消添加"))
380
380
  : null,
381
381
  );
@@ -388,7 +388,7 @@ function ProvisionError({ error, provision, onRetry, onCancel, busy }) {
388
388
  h("div", { className: "bxf-inlineError dim-inlineError", role: "alert" },
389
389
  h("div", null,
390
390
  h("h3", null, repairing
391
- ? "卡片按钮没有修复完成"
391
+ ? "权限与回调没有补全完成"
392
392
  : grantingGroupMessages ? "群消息权限没有开通完成" : "新机器人没有添加完成"),
393
393
  h("p", null, error.message),
394
394
  error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
@@ -564,6 +564,7 @@ export function BotCard({
564
564
  removeButtonRef,
565
565
  }) {
566
566
  const { bot, health, state, connected } = connection;
567
+ const repairTooltipId = React.useId();
567
568
  const stateForDisplay = busy === "reconnect"
568
569
  ? "connecting"
569
570
  : state;
@@ -637,13 +638,22 @@ export function BotCard({
637
638
  disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
638
639
  "aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
639
640
  }, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
640
- h(Button, {
641
- className: "bxf-repairButton dim-cardAction",
642
- onClick: onRepairCallback,
643
- disabled: Boolean(busy) || repairDisabled,
644
- "aria-busy": busy === "callback-repair" ? "true" : undefined,
645
- "aria-label": `修复${bot.name}的卡片按钮`,
646
- }, busy === "callback-repair" ? "等待扫码…" : "修复卡片按钮"),
641
+ h("span", { className: "bxf-repairAction" },
642
+ h(Button, {
643
+ className: "bxf-repairButton dim-cardAction",
644
+ onClick: onRepairCallback,
645
+ disabled: Boolean(busy) || repairDisabled,
646
+ "aria-busy": busy === "callback-repair" ? "true" : undefined,
647
+ "aria-label": `为${bot.name}补全权限与回调`,
648
+ "aria-describedby": repairTooltipId,
649
+ }, busy === "callback-repair" ? "等待扫码…" : "补全权限"),
650
+ h("span", {
651
+ id: repairTooltipId,
652
+ className: "bxf-repairTooltip",
653
+ role: "tooltip",
654
+ },
655
+ h("strong", null, "补全范围"),
656
+ h("span", null, "最多增量添加卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;确认页只显示当前缺少项,不会创建新应用。"))),
647
657
  h(Button, {
648
658
  className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
649
659
  disabled: Boolean(busy), ref: removeButtonRef,
@@ -933,7 +943,7 @@ export function FeishuSettingsTab({ rpcCall }) {
933
943
  && (provision.operation !== operation || provision.botId !== botId)) {
934
944
  throw new Error(grantingGroupMessages
935
945
  ? "飞书服务返回了不匹配的群消息权限二维码"
936
- : "飞书服务返回了不匹配的卡片修复二维码");
946
+ : "飞书服务返回了不匹配的权限补全二维码");
937
947
  }
938
948
  const timestamp = Date.now();
939
949
  setNow(timestamp);
@@ -948,7 +958,7 @@ export function FeishuSettingsTab({ rpcCall }) {
948
958
  },
949
959
  }));
950
960
  announce(repairing
951
- ? `${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。`
961
+ ? `${botName ?? "机器人"}的权限补全二维码已生成,请使用飞书扫码。`
952
962
  : grantingGroupMessages
953
963
  ? `${botName ?? "机器人"}的群消息权限二维码已生成,请使用飞书确认。`
954
964
  : "授权二维码已生成,请使用飞书扫码。");
@@ -1032,7 +1042,7 @@ export function FeishuSettingsTab({ rpcCall }) {
1032
1042
  : current);
1033
1043
  announce(grantingGroupMessages
1034
1044
  ? "权限配置已提交,正在启用全部消息模式并重连此机器人;此阶段无法取消,其他机器人不会中断。"
1035
- : "配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。");
1045
+ : "配置已提交,正在保存权限、验证卡片回调并重连此机器人;此阶段无法取消,其他机器人不会中断。");
1036
1046
  return;
1037
1047
  }
1038
1048
  if (result.status === "connected") {
@@ -1040,7 +1050,7 @@ export function FeishuSettingsTab({ rpcCall }) {
1040
1050
  setModel((current) => ({ ...current, provisioning: null }));
1041
1051
  announce(grantingGroupMessages
1042
1052
  ? `${targetBotName}已开通群消息权限,并启用“响应所有群消息”。`
1043
- : `${targetBotName}的卡片按钮已修复。`);
1053
+ : `${targetBotName}的权限与回调已补全。`);
1044
1054
  if (activeProvision.botId) setFocusBotId(activeProvision.botId);
1045
1055
  await loadStatus({ silent: true, restoreProvisioning: false });
1046
1056
  return;
@@ -1048,7 +1058,7 @@ export function FeishuSettingsTab({ rpcCall }) {
1048
1058
  }
1049
1059
  setModel((current) => ({ ...current, provisioning: null }));
1050
1060
  announce(repairing
1051
- ? "已取消卡片按钮修复。"
1061
+ ? "已取消补全权限与回调。"
1052
1062
  : grantingGroupMessages ? "已取消群消息权限授权。" : "已取消添加机器人。");
1053
1063
  await loadStatus({ silent: true, restoreProvisioning: false });
1054
1064
  scheduleAnimationFrame(() => {
@@ -1117,7 +1127,7 @@ export function FeishuSettingsTab({ rpcCall }) {
1117
1127
  const targetBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
1118
1128
  if (!snapshot) {
1119
1129
  throw new Error(isCallbackRepair(provision)
1120
- ? "卡片按钮已更新,但暂时无法确认机器人连接状态"
1130
+ ? "权限与回调已更新,但暂时无法确认机器人连接状态"
1121
1131
  : isGroupMessagePermission(provision)
1122
1132
  ? "群消息权限已更新,但暂时无法确认机器人连接状态"
1123
1133
  : "机器人已经创建,但暂时无法确认连接状态");
@@ -1130,7 +1140,7 @@ export function FeishuSettingsTab({ rpcCall }) {
1130
1140
  }
1131
1141
  setModel((current) => ({ ...current, provisioning: null }));
1132
1142
  announce(isCallbackRepair(provision)
1133
- ? `${targetBot.bot.name}的卡片按钮已修复。`
1143
+ ? `${targetBot.bot.name}的权限与回调已补全。`
1134
1144
  : isGroupMessagePermission(provision)
1135
1145
  ? `${targetBot.bot.name}已开通群消息权限,并启用“响应所有群消息”。`
1136
1146
  : targetBot
@@ -1142,7 +1152,7 @@ export function FeishuSettingsTab({ rpcCall }) {
1142
1152
  if (result.status === "failed") {
1143
1153
  const error = new Error(result.message
1144
1154
  ?? (isCallbackRepair(provision)
1145
- ? "飞书卡片按钮修复失败"
1155
+ ? "飞书权限与回调补全失败"
1146
1156
  : isGroupMessagePermission(provision)
1147
1157
  ? "飞书群消息权限开通失败"
1148
1158
  : "飞书应用创建失败"));
@@ -450,10 +450,35 @@ const CSS = String.raw`
450
450
  .bxf-connectedFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
451
451
  .bxf-healthSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
452
452
  .bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
453
- .bxf-botActions { flex: none; width: 100%; flex-wrap: wrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
453
+ .bxf-botActions { position: relative; flex: none; width: 100%; flex-wrap: wrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
454
454
  .bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
455
455
  .bxf-botActions .bxf-repairButton { color: var(--bxf-accent); border-color: color-mix(in srgb, var(--bxf-accent) 35%, var(--dsw-alias-border-l2, #dee0e3)); }
456
456
  .bxf-botActions .bxf-repairButton:hover:not(:disabled) { background: color-mix(in srgb, var(--bxf-accent) 7%, transparent); }
457
+ .bxf-repairAction { display: inline-flex; }
458
+ .bxf-repairTooltip {
459
+ position: absolute;
460
+ right: 0;
461
+ bottom: calc(100% + 8px);
462
+ z-index: 40;
463
+ width: min(330px, 100%);
464
+ display: grid;
465
+ gap: 3px;
466
+ padding: 9px 10px;
467
+ border: 1px solid var(--dsw-alias-border-l2, #dfe1e5);
468
+ border-radius: 8px;
469
+ color: var(--dsw-alias-label-primary, #1f2329);
470
+ background: var(--dsw-alias-bg-layer-3, #fff);
471
+ box-shadow: 0 10px 28px rgb(31 35 41 / 16%);
472
+ opacity: 0;
473
+ visibility: hidden;
474
+ transform: translateY(3px);
475
+ pointer-events: none;
476
+ transition: opacity .15s ease, transform .15s ease, visibility .15s ease;
477
+ }
478
+ .bxf-repairTooltip strong { font-size: 12px; line-height: 17px; font-weight: 650; }
479
+ .bxf-repairTooltip > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; font-weight: 400; overflow-wrap: anywhere; }
480
+ .bxf-repairAction:hover .bxf-repairTooltip,
481
+ .bxf-repairAction:focus-within .bxf-repairTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
457
482
 
458
483
  .bxf-confirm {
459
484
  border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
@@ -231,7 +231,7 @@ const EN = Object.freeze({
231
231
  '保持本页打开,等待新机器人的长连接就绪': 'Keep this page open until the bot connection is ready',
232
232
  '在飞书中打开': 'Open in Feishu',
233
233
  '取消添加': 'Cancel',
234
- '使用飞书扫码修复卡片按钮': 'Scan with Feishu to repair card buttons',
234
+ '使用飞书扫码补全权限': 'Scan with Feishu to complete permissions',
235
235
  '使用飞书确认群消息权限': 'Confirm group-message permission with Feishu',
236
236
  '扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。': 'Scanning updates the existing Feishu app with only the “Read all messages in associated group chat” scope. It does not create a new app. After confirmation, “Respond to all group messages” is enabled automatically; other bots are unaffected.',
237
237
  '核对现有应用,并确认“获取群组中所有消息”权限': 'Review the existing app and confirm the “Read all messages in associated group chat” permission',
@@ -242,18 +242,20 @@ const EN = Object.freeze({
242
242
  '权限配置已提交,正在启用全部消息模式并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The permission update was submitted. Enabling all-message mode and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
243
243
  '正在为现有飞书应用申请群消息权限二维码,请稍候。': 'Requesting a group-message permission QR code for the existing Feishu app…',
244
244
  '群消息权限没有开通完成': 'Group-message permission was not granted',
245
- '扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with only the card-button callback. It does not create a new app. This bot reconnects briefly after confirmation; other bots are not affected.',
246
- '核对现有应用名称,并确认只新增卡片回调': 'Review the existing app name and confirm that only the card callback is added',
247
- '保持本页打开,等待卡片按钮修复完成': 'Keep this page open until card-button repair finishes',
248
- '取消修复': 'Cancel repair',
249
- '已确认,正在完成卡片按钮修复': 'Confirmed. Finishing card-button repair',
250
- '正在准备修复二维码': 'Preparing the repair QR code',
251
- '配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The update was submitted. Verifying the card callback and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
245
+ '扫码会更新现有飞书应用,最多增量补充卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with up to three missing items: the card-button callback, im:message:readonly for reading images or files in user messages (shown by Feishu as “Read direct and group messages”), and im:resource for uploading images or files sent by the bot. It does not create a new app. The confirmation page shows only missing items; this bot reconnects briefly afterward, while other bots are unaffected.',
246
+ '核对现有应用名称,并确认只新增当前缺少的上述配置': 'Review the existing app name and confirm that only the missing items described above are added',
247
+ '保持本页打开,等待权限与回调补全完成': 'Keep this page open until permissions and the callback are complete',
248
+ '取消补全': 'Cancel setup',
249
+ '已确认,正在完成权限与回调配置': 'Confirmed. Completing permissions and callback setup',
250
+ '正在准备权限补全二维码': 'Preparing the permission-completion QR code',
251
+ '配置已提交,正在保存权限、验证卡片回调并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The update was submitted. Saving permissions, verifying the card callback, and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
252
252
  '正在为现有飞书应用申请一次性更新二维码,请稍候。': 'Requesting a one-time update QR code for the existing Feishu app…',
253
- '卡片按钮没有修复完成': 'Card-button repair did not finish',
254
- '修复卡片按钮': 'Repair card buttons',
253
+ '权限与回调没有补全完成': 'Permissions and callback setup did not finish',
254
+ '补全权限': 'Complete permissions',
255
+ '补全范围': 'Completion scope',
256
+ '最多增量添加卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;确认页只显示当前缺少项,不会创建新应用。': 'Adds up to the missing card callback card.action.trigger, im:message:readonly for reading images or files in messages (shown by Feishu as “Read direct and group messages”), and im:resource for uploading images or files sent by the bot. The confirmation page shows only missing items; no new app is created.',
255
257
  '等待扫码…': 'Waiting for scan…',
256
- '飞书服务返回了不匹配的卡片修复二维码': 'Feishu returned a repair QR code for a different bot',
258
+ '飞书服务返回了不匹配的权限补全二维码': 'Feishu returned a permission-completion QR code for a different bot',
257
259
  '飞书服务返回了不匹配的群消息权限二维码': 'Feishu returned a group-message permission QR code for a different bot',
258
260
  '飞书服务返回的修复信息缺少 botId': 'Feishu repair status is missing the bot ID',
259
261
  '飞书服务返回的应用更新信息缺少 botId': 'Feishu app-update status is missing the bot ID',
@@ -261,16 +263,16 @@ const EN = Object.freeze({
261
263
  '此机器人': 'this bot',
262
264
  '用于为${botName}开通群消息权限的一次性授权二维码': 'One-time QR code for granting group-message permission to ${botName}',
263
265
  '正在为「${botName}」开通群消息权限': 'Granting group-message permission to “${botName}”',
264
- '${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。': 'Repair QR code generated for ${botName ?? "bot"}. Scan it with Feishu.',
266
+ '${botName ?? "机器人"}的权限补全二维码已生成,请使用飞书扫码。': 'Permission-completion QR code generated for ${botName ?? "bot"}. Scan it with Feishu.',
265
267
  '${botName ?? "机器人"}的群消息权限二维码已生成,请使用飞书确认。': 'Group-message permission QR code generated for ${botName ?? "bot"}. Confirm it with Feishu.',
266
268
  '${targetBotName}已开通群消息权限,并启用“响应所有群消息”。': '${targetBotName} now has group-message permission and “Respond to all group messages” is enabled.',
267
269
  '${targetBot.bot.name}已开通群消息权限,并启用“响应所有群消息”。': '${targetBot.bot.name} now has group-message permission and “Respond to all group messages” is enabled.',
268
270
  '${targetBot.bot.name}已连接,可以在飞书中开始聊天。': '${targetBot.bot.name} is connected and ready to chat in Feishu.',
269
- '已取消卡片按钮修复。': 'Card-button repair was cancelled.',
271
+ '已取消补全权限与回调。': 'Completing permissions and the callback was cancelled.',
270
272
  '已取消群消息权限授权。': 'Group-message permission authorization was cancelled.',
271
- '卡片按钮已更新,但暂时无法确认机器人连接状态': 'The card callback was updated, but the bot connection could not be confirmed yet',
273
+ '权限与回调已更新,但暂时无法确认机器人连接状态': 'Permissions and the callback were updated, but the bot connection could not be confirmed yet',
272
274
  '群消息权限已更新,但暂时无法确认机器人连接状态': 'The group-message permission was updated, but the bot connection could not be confirmed yet',
273
- '飞书卡片按钮修复失败': 'Could not repair the Feishu card buttons',
275
+ '飞书权限与回调补全失败': 'Could not complete Feishu permissions and callback setup',
274
276
  '飞书群消息权限开通失败': 'Could not grant the Feishu group-message permission',
275
277
  '请先完成当前飞书授权操作,再开通群消息权限。': 'Finish the current Feishu authorization before granting group-message permission.',
276
278
  '已确认,正在连接新机器人': 'Confirmed. Connecting the new bot',
@@ -598,22 +600,22 @@ function translateDynamic(text) {
598
600
  if (match) return `Remove ${match[1]} from DeepSeek Harness`;
599
601
  match = /^(.+)的飞书授权流程$/.exec(text);
600
602
  if (match) return `Feishu authorization flow for ${match[1]}`;
601
- match = /^用于修复(.+)卡片按钮的一次性授权二维码$/.exec(text);
602
- if (match) return `One-time QR code for repairing card buttons for ${match[1]}`;
603
+ match = /^用于为(.+)补全权限与回调的一次性授权二维码$/.exec(text);
604
+ if (match) return `One-time QR code for completing permissions and the callback for ${match[1]}`;
603
605
  match = /^用于为(.+)开通群消息权限的一次性授权二维码$/.exec(text);
604
606
  if (match) return `One-time QR code for granting group-message permission to ${match[1]}`;
605
- match = /^正在修复「(.+)」$/.exec(text);
606
- if (match) return `Repairing “${match[1]}”`;
607
+ match = /^正在为「(.+)」补全权限与回调$/.exec(text);
608
+ if (match) return `Completing permissions and the callback for “${match[1]}”`;
607
609
  match = /^正在为「(.+)」开通群消息权限$/.exec(text);
608
610
  if (match) return `Granting group-message permission to “${match[1]}”`;
609
- match = /^修复(.+)的卡片按钮$/.exec(text);
610
- if (match) return `Repair card buttons for ${match[1]}`;
611
- match = /^(.+)的修复二维码已生成,请使用飞书扫码。$/.exec(text);
612
- if (match) return `Repair QR code generated for ${match[1]}. Scan it with Feishu.`;
611
+ match = /^为(.+)补全权限与回调$/.exec(text);
612
+ if (match) return `Complete permissions and the callback for ${match[1]}`;
613
+ match = /^(.+)的权限补全二维码已生成,请使用飞书扫码。$/.exec(text);
614
+ if (match) return `Permission-completion QR code generated for ${match[1]}. Scan it with Feishu.`;
613
615
  match = /^(.+)的群消息权限二维码已生成,请使用飞书确认。$/.exec(text);
614
616
  if (match) return `Group-message permission QR code generated for ${match[1]}. Confirm it with Feishu.`;
615
- match = /^(.+)的卡片按钮已修复。$/.exec(text);
616
- if (match) return `Card buttons repaired for ${match[1]}.`;
617
+ match = /^(.+)的权限与回调已补全。$/.exec(text);
618
+ if (match) return `Permissions and the callback completed for ${match[1]}.`;
617
619
  match = /^(.+)已开通群消息权限,并启用“响应所有群消息”。$/.exec(text);
618
620
  if (match) return `${match[1]} now has group-message permission and “Respond to all group messages” is enabled.`;
619
621
  match = /^(检查连接|重试连接)(.+)$/.exec(text);
@@ -24,6 +24,12 @@ import {
24
24
  } from '../shared/preset-command.mjs';
25
25
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
26
26
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
27
+ import {
28
+ BatchInputManager,
29
+ batchInputBusyMessage,
30
+ batchInputGroupUnsupportedMessage,
31
+ isBatchInputCommand,
32
+ } from '../shared/batch-input.mjs';
27
33
  import {
28
34
  hasInboundImages,
29
35
  imagePromptUserMessage,
@@ -67,7 +73,11 @@ const HELP_TEXT_LINES = [
67
73
  '/preset --default 跟随 Host 默认',
68
74
  '/stop 停止当前任务',
69
75
  '/steer 补充指令 纠偏当前任务',
76
+ '/batch 开始批量输入(仅私聊,最多 10 条文字)',
77
+ '/send 提交当前批次',
78
+ '/cancel 取消当前批次',
70
79
  '/status 检查连接状态',
80
+ '/version 查看插件版本',
71
81
  '/help 显示本帮助',
72
82
  ];
73
83
 
@@ -344,6 +354,7 @@ export class DingtalkHarnessBridge {
344
354
  #commandTasks = new Set();
345
355
  #acceptedMessageIds = new Set();
346
356
  #approvals;
357
+ #batchInputs = new BatchInputManager();
347
358
 
348
359
  constructor({
349
360
  api,
@@ -416,12 +427,52 @@ export class DingtalkHarnessBridge {
416
427
  clientSecret: this.#clientSecret,
417
428
  });
418
429
  const commandText = nonEmptyString(promptMessage.content) ?? '';
430
+ const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
431
+ const direct = String(message.conversationType) !== '2';
432
+ const batchCommand = String(message?.msgtype).toLowerCase() === 'text'
433
+ && isBatchInputCommand(commandText);
434
+ const batchStatus = this.#batchInputs.status(key);
435
+ if (batchCommand && !direct && sessionWebhook && addressed) {
436
+ return this.#finishBatchResult(
437
+ messageId,
438
+ sessionWebhook,
439
+ { message: batchInputGroupUnsupportedMessage() },
440
+ );
441
+ }
442
+ if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
443
+ const exactBatchStart = /^\/batch$/iu.test(commandText);
444
+ const result = exactBatchStart
445
+ && batchStatus.phase === 'idle'
446
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
447
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
448
+ : this.#batchInputs.handle(key, commandText, {
449
+ plainText: Boolean(commandText)
450
+ && String(message?.msgtype).toLowerCase() === 'text'
451
+ && !hasInboundFiles(promptMessage)
452
+ && !hasInboundImages(promptMessage),
453
+ });
454
+ if (result.handled) {
455
+ if (result.kind === 'submit') {
456
+ return this.#enqueueMessage(
457
+ {
458
+ ...message,
459
+ msgtype: 'text',
460
+ text: { content: result.prompt },
461
+ },
462
+ messageId,
463
+ sender,
464
+ key,
465
+ { batchSubmission: result },
466
+ );
467
+ }
468
+ return this.#finishBatchResult(messageId, sessionWebhook, result);
469
+ }
470
+ }
419
471
  const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
420
472
  ? runControlCommand
421
473
  : (isModelCommand(commandText)
422
474
  ? runModelCommand
423
475
  : (isPresetCommand(commandText) ? runPresetCommand : null));
424
- const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
425
476
  if (commandRunner && sessionWebhook && addressed) {
426
477
  let task;
427
478
  task = this.#processFastCommand(
@@ -521,6 +572,7 @@ export class DingtalkHarnessBridge {
521
572
  #enqueueMessage(message, messageId, sender, key, {
522
573
  releaseMessageId = true,
523
574
  alreadyRecorded = false,
575
+ batchSubmission = null,
524
576
  } = {}) {
525
577
  let hasSafeReplyRoute = false;
526
578
  try {
@@ -543,6 +595,7 @@ export class DingtalkHarnessBridge {
543
595
  .then(() => this.#process(message, messageId, sender, key, {
544
596
  alreadyRecorded,
545
597
  preparedMessage,
598
+ batchSubmission,
546
599
  }))
547
600
  .finally(() => {
548
601
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
@@ -595,9 +648,32 @@ export class DingtalkHarnessBridge {
595
648
  this.#status.lastError = null;
596
649
  }
597
650
 
651
+ #finishBatchResult(messageId, sessionWebhook, result) {
652
+ let task;
653
+ task = Promise.resolve().then(async () => {
654
+ if (this.#state.hasSeen(messageId)) return;
655
+ await this.#state.markSeen(messageId);
656
+ increment(this.#status, 'messagesReceived');
657
+ this.#status.lastMessageAt = new Date().toISOString();
658
+ if (result.message) await this.#send(sessionWebhook, result.message);
659
+ this.#status.lastError = null;
660
+ }).catch(async (error) => {
661
+ if (this.#signal?.aborted) return;
662
+ this.#status.lastError = t('钉钉命令处理失败。');
663
+ this.#logger.error?.('[dsh-dingtalk] failed to process a batch input message', safeErrorDiagnostic(error));
664
+ await this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
665
+ }).finally(() => {
666
+ this.#acceptedMessageIds.delete(messageId);
667
+ this.#commandTasks.delete(task);
668
+ });
669
+ this.#commandTasks.add(task);
670
+ return task;
671
+ }
672
+
598
673
  async #process(message, messageId, sender, key, {
599
674
  alreadyRecorded = false,
600
675
  preparedMessage,
676
+ batchSubmission = null,
601
677
  } = {}) {
602
678
  this.#signal?.throwIfAborted();
603
679
  if (!alreadyRecorded) {
@@ -633,6 +709,7 @@ export class DingtalkHarnessBridge {
633
709
  const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
634
710
  let cardStream = null;
635
711
  let cardStarted = false;
712
+ let batchSettled = batchSubmission === null;
636
713
  try {
637
714
  if (!text && !hasImages && !hasFiles) {
638
715
  await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
@@ -717,6 +794,10 @@ export class DingtalkHarnessBridge {
717
794
  files: promptMessage.files,
718
795
  },
719
796
  });
797
+ if (batchSubmission) {
798
+ this.#batchInputs.complete(key, batchSubmission.token);
799
+ batchSettled = true;
800
+ }
720
801
  const answerText = typeof answer === 'string' && answer.trim()
721
802
  ? answer
722
803
  : artifacts.length > 0 ? t('结果文件已生成。') : answer;
@@ -753,6 +834,15 @@ export class DingtalkHarnessBridge {
753
834
  this.#status.lastError = null;
754
835
  return delivery.receipt;
755
836
  } catch (error) {
837
+ let batchFailureMessage = null;
838
+ if (!batchSettled && batchSubmission) {
839
+ if (error?.code === 'turn-stopped') {
840
+ this.#batchInputs.complete(key, batchSubmission.token);
841
+ } else {
842
+ batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
843
+ }
844
+ batchSettled = true;
845
+ }
756
846
  if (error?.code === 'turn-stopped') {
757
847
  if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
758
848
  return;
@@ -767,8 +857,11 @@ export class DingtalkHarnessBridge {
767
857
  const errorText = inboundFileUserMessage(error)
768
858
  ?? dingtalkImageErrorUserMessage(error)
769
859
  ?? t(CARD_ERROR_TEXT);
770
- const streamed = cardStarted && await cardStream.finish(errorText);
771
- if (!streamed) await this.#send(sessionWebhook, errorText);
860
+ const visibleError = batchFailureMessage
861
+ ? `${errorText}\n\n${batchFailureMessage}`
862
+ : errorText;
863
+ const streamed = cardStarted && await cardStream.finish(visibleError);
864
+ if (!streamed) await this.#send(sessionWebhook, visibleError);
772
865
  } catch {
773
866
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
774
867
  }
@@ -217,6 +217,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
217
217
  kind: direct ? 'direct' : 'group',
218
218
  conversationId: String(message.channel_id),
219
219
  content: stripBotMention(message.content ?? '', botId),
220
+ plainText: (!Array.isArray(message.attachments) || message.attachments.length === 0)
221
+ && (!Array.isArray(message.sticker_items) || message.sticker_items.length === 0)
222
+ && !message.poll,
220
223
  images: Array.isArray(message.attachments)
221
224
  ? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
222
225
  : [],