@xmanrui/dsh-im 0.9.0 → 0.11.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 (69) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/lib/client.js +378 -27
  4. package/lib/index.js +134 -131
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +19 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +67 -10
  8. package/plugin-src/client/channels/feishu/index.js +35 -5
  9. package/plugin-src/client/channels/qq/api.js +21 -0
  10. package/plugin-src/client/channels/qq/index.js +39 -1
  11. package/plugin-src/client/channels/shared/token-channel.js +29 -3
  12. package/plugin-src/client/channels/wecom/api.js +11 -0
  13. package/plugin-src/client/channels/wecom/index.js +71 -1
  14. package/plugin-src/client/channels/weixin/api.js +11 -0
  15. package/plugin-src/client/channels/weixin/index.js +40 -4
  16. package/plugin-src/client/channels/whatsapp/index.js +41 -2
  17. package/plugin-src/client/i18n.js +13 -0
  18. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
  20. package/plugin-src/host/channels/feishu/production.mjs +1 -1
  21. package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
  22. package/plugin-src/host/channels/qq/production.mjs +1 -1
  23. package/plugin-src/host/channels/qq/rpc.mjs +35 -2
  24. package/plugin-src/host/channels/shared/production.mjs +1 -1
  25. package/plugin-src/host/channels/shared/rpc.mjs +25 -1
  26. package/plugin-src/host/channels/slack/production.mjs +1 -1
  27. package/plugin-src/host/channels/slack/rpc.mjs +28 -2
  28. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  29. package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
  30. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  31. package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
  32. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
  34. package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
  36. package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
  37. package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
  38. package/src/channels/discord/discord-api.mjs +1 -1
  39. package/src/channels/discord/discord-runtime.mjs +54 -1
  40. package/src/channels/feishu/bridge.mjs +45 -18
  41. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  42. package/src/channels/feishu/message-utils.mjs +227 -6
  43. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  44. package/src/channels/feishu/plugin-controller.mjs +1 -0
  45. package/src/channels/qq/qq-bridge.mjs +107 -20
  46. package/src/channels/qq/qq-controller.mjs +18 -0
  47. package/src/channels/qq/qq-runtime.mjs +26 -0
  48. package/src/channels/shared/bot-workspace-store.mjs +2 -0
  49. package/src/channels/shared/connection-test.mjs +54 -0
  50. package/src/channels/shared/harness-client.mjs +14 -8
  51. package/src/channels/shared/image-prompt.mjs +268 -0
  52. package/src/channels/shared/text-harness-bridge.mjs +62 -16
  53. package/src/channels/shared/token-bot-controller.mjs +21 -0
  54. package/src/channels/shared/workspace-session.mjs +2 -1
  55. package/src/channels/slack/manifest.mjs +1 -0
  56. package/src/channels/slack/slack-api.mjs +95 -0
  57. package/src/channels/slack/slack-controller.mjs +19 -0
  58. package/src/channels/slack/slack-runtime.mjs +34 -3
  59. package/src/channels/telegram/telegram-api.mjs +37 -0
  60. package/src/channels/telegram/telegram-runtime.mjs +81 -9
  61. package/src/channels/wecom/wecom-bridge.mjs +160 -23
  62. package/src/channels/wecom/wecom-controller.mjs +18 -0
  63. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  64. package/src/channels/weixin/weixin-api.mjs +98 -2
  65. package/src/channels/weixin/weixin-bridge.mjs +52 -19
  66. package/src/channels/weixin/weixin-controller.mjs +18 -0
  67. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  68. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  69. package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "通过扫码、App Manifest或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -85,6 +85,16 @@ function normalizeError(value, fallbackCode, fallbackMessage) {
85
85
  };
86
86
  }
87
87
 
88
+ function normalizeTestMessage(value) {
89
+ if (!isRecord(value)) return null;
90
+ if (value.sent === true) return { sent: true };
91
+ if (value.sent !== false) return null;
92
+ const code = value.code === 'test-target-unavailable'
93
+ ? 'test-target-unavailable'
94
+ : 'test-message-failed';
95
+ return { sent: false, code };
96
+ }
97
+
88
98
  export function unwrapRpcResult(result) {
89
99
  if (!isRecord(result) || typeof result.ok !== 'boolean') {
90
100
  throw new Error('钉钉服务返回了无法识别的响应');
@@ -189,9 +199,18 @@ export function normalizeSnapshot(value) {
189
199
  connected: bots.filter((bot) => bot.connected).length,
190
200
  },
191
201
  provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
202
+ testMessage: normalizeTestMessage(source.testMessage),
192
203
  };
193
204
  }
194
205
 
206
+ export function connectionTestFeedback(result) {
207
+ if (result?.sent === true) return '钉钉连接检查完成,测试消息已发送。';
208
+ if (result?.code === 'test-target-unavailable') {
209
+ return '连接检查完成。机器人尚未收到可用于测试的私聊消息。';
210
+ }
211
+ return result ? '钉钉连接检查完成,但测试消息发送失败。' : null;
212
+ }
213
+
195
214
  export function presentError(error) {
196
215
  return {
197
216
  code: safeErrorCode(error?.code, 'DINGTALK_ERROR'),
@@ -7,6 +7,7 @@ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
7
7
  import {
8
8
  DINGTALK_ENDPOINTS,
9
9
  DINGTALK_RPC_CHANNEL,
10
+ connectionTestFeedback,
10
11
  formatRemaining,
11
12
  normalizeProvisioning,
12
13
  normalizeSnapshot,
@@ -208,6 +209,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
208
209
  export function AccountCard({
209
210
  account,
210
211
  busy,
212
+ feedback,
211
213
  removing,
212
214
  onReconnect,
213
215
  onWorkspaceSave,
@@ -241,6 +243,10 @@ export function AccountCard({
241
243
  }),
242
244
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
243
245
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
246
+ feedback ? h('div', {
247
+ className: 'ddt-summary dim-cardSummary',
248
+ role: 'status',
249
+ }, feedback) : null,
244
250
  h('div', { className: 'ddt-actions dim-cardActions' },
245
251
  h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
246
252
  busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
@@ -262,6 +268,7 @@ function AccountList(props) {
262
268
  h(AccountCard, {
263
269
  account,
264
270
  busy: props.busyByBot[account.botId],
271
+ feedback: props.feedbackByBot[account.botId]?.message,
265
272
  removing: props.removeTarget === account.botId,
266
273
  onReconnect: () => props.onReconnect(account),
267
274
  onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
@@ -280,6 +287,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
280
287
  const [provision, setProvision] = React.useState(null);
281
288
  const [busy, setBusy] = React.useState(false);
282
289
  const [busyByBot, setBusyByBot] = React.useState({});
290
+ const [feedbackByBot, setFeedbackByBot] = React.useState({});
283
291
  const [removeTarget, setRemoveTarget] = React.useState(null);
284
292
  const [credentialOpen, setCredentialOpen] = React.useState(false);
285
293
  const [credentialError, setCredentialError] = React.useState(null);
@@ -325,6 +333,22 @@ export function DingtalkSettingsTab({ rpcCall }) {
325
333
  }
326
334
  }, []);
327
335
 
336
+ const discardStaleFeedback = React.useCallback((snapshot) => {
337
+ const botsById = new Map(snapshot.bots.map((bot) => [bot.botId, bot]));
338
+ setFeedbackByBot((current) => {
339
+ let changed = false;
340
+ const next = { ...current };
341
+ for (const [botId, feedback] of Object.entries(next)) {
342
+ const bot = botsById.get(botId);
343
+ if (!bot || (feedback.clearWhenDisconnected && (!bot.connected || bot.error))) {
344
+ delete next[botId];
345
+ changed = true;
346
+ }
347
+ }
348
+ return changed ? next : current;
349
+ });
350
+ }, []);
351
+
328
352
  const focusAddButton = React.useCallback(() => {
329
353
  if (!mountedRef.current) return;
330
354
  if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current);
@@ -366,6 +390,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
366
390
  revision: snapshot.revision,
367
391
  error: null,
368
392
  });
393
+ discardStaleFeedback(snapshot);
369
394
  if (restoreProvisioning && snapshot.provisioning) {
370
395
  setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
371
396
  ? {
@@ -386,7 +411,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
386
411
  }));
387
412
  return undefined;
388
413
  }
389
- }, [invoke, workspaceFence]);
414
+ }, [discardStaleFeedback, invoke, workspaceFence]);
390
415
 
391
416
  React.useEffect(() => {
392
417
  const controller = new AbortController();
@@ -480,6 +505,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
480
505
  revision: snapshot.revision,
481
506
  error: null,
482
507
  });
508
+ discardStaleFeedback(snapshot);
483
509
  }
484
510
  setCredentialOpen(false);
485
511
  announce('钉钉机器人凭据已绑定。');
@@ -490,7 +516,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
490
516
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
491
517
  if (mountedRef.current) setBusy(false);
492
518
  }
493
- }, [announce, invoke, loadStatus, workspaceFence]);
519
+ }, [announce, discardStaleFeedback, invoke, loadStatus, workspaceFence]);
494
520
 
495
521
  const cancelProvisioning = React.useCallback(async () => {
496
522
  if (!mountedRef.current) return;
@@ -595,6 +621,13 @@ export function DingtalkSettingsTab({ rpcCall }) {
595
621
  if (!mountedRef.current) return undefined;
596
622
  const snapshotVersion = workspaceFence.beginMutation();
597
623
  setBotBusy(account.botId, operation);
624
+ if (operation === 'reconnect') {
625
+ setFeedbackByBot((current) => {
626
+ const next = { ...current };
627
+ delete next[account.botId];
628
+ return next;
629
+ });
630
+ }
598
631
  try {
599
632
  const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
600
633
  if (!mountedRef.current) return undefined;
@@ -606,28 +639,50 @@ export function DingtalkSettingsTab({ rpcCall }) {
606
639
  revision: snapshot.revision,
607
640
  error: null,
608
641
  });
642
+ discardStaleFeedback(snapshot);
643
+ }
644
+ const successMessage = typeof success === 'function' ? success(snapshot) : success;
645
+ if (operation === 'reconnect') {
646
+ setFeedbackByBot((current) => ({
647
+ ...current,
648
+ [account.botId]: {
649
+ message: successMessage,
650
+ clearWhenDisconnected: snapshot.testMessage?.sent === true,
651
+ },
652
+ }));
609
653
  }
610
- announce(typeof success === 'function' ? success(snapshot) : success);
654
+ announce(successMessage);
611
655
  return snapshot;
612
656
  } catch (error) {
613
657
  if (!mountedRef.current) return undefined;
614
- announce(`操作失败:${presentError(error).message}`);
658
+ const failureMessage = operation === 'reconnect'
659
+ ? '连接检查失败,请稍后重试。'
660
+ : `操作失败:${presentError(error).message}`;
661
+ if (operation === 'reconnect') {
662
+ setFeedbackByBot((current) => ({
663
+ ...current,
664
+ [account.botId]: { message: failureMessage, clearWhenDisconnected: false },
665
+ }));
666
+ }
667
+ announce(failureMessage);
615
668
  return undefined;
616
669
  } finally {
617
670
  const shouldRefresh = workspaceFence.endMutation();
618
671
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true, restoreProvisioning: false });
619
672
  if (mountedRef.current) setBotBusy(account.botId, null);
620
673
  }
621
- }, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
674
+ }, [announce, discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
622
675
 
623
676
  const reconnect = React.useCallback((account) => runBotAction({
624
677
  account,
625
678
  operation: 'reconnect',
626
679
  endpoint: DINGTALK_ENDPOINTS.reconnectBot,
627
- payload: { botId: account.botId },
628
- success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected
629
- ? '钉钉连接检查完成。'
630
- : '钉钉仍未连接,插件会继续自动重试。',
680
+ payload: { botId: account.botId, sendTest: true },
681
+ success: (snapshot) => {
682
+ const refreshed = snapshot?.bots.find((bot) => bot.botId === account.botId);
683
+ if (!refreshed?.connected) return '钉钉仍未连接,插件会继续自动重试。';
684
+ return connectionTestFeedback(snapshot.testMessage) ?? '钉钉连接检查完成。';
685
+ },
631
686
  }), [runBotAction]);
632
687
 
633
688
  const saveWorkspace = React.useCallback(async (account, workspace) => {
@@ -646,13 +701,14 @@ export function DingtalkSettingsTab({ rpcCall }) {
646
701
  revision: snapshot.revision,
647
702
  error: null,
648
703
  });
704
+ discardStaleFeedback(snapshot);
649
705
  }
650
706
  } finally {
651
707
  const shouldRefresh = workspaceFence.endMutation();
652
708
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
653
709
  if (mountedRef.current) setBotBusy(account.botId, null);
654
710
  }
655
- }, [invoke, loadStatus, setBotBusy, workspaceFence]);
711
+ }, [discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
656
712
 
657
713
  const remove = React.useCallback(async (account) => {
658
714
  const snapshot = await runBotAction({
@@ -738,6 +794,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
738
794
  ? h(AccountList, {
739
795
  bots: model.bots,
740
796
  busyByBot,
797
+ feedbackByBot,
741
798
  removeTarget,
742
799
  onReconnect: (account) => void reconnect(account),
743
800
  onWorkspaceSave: saveWorkspace,
@@ -330,6 +330,16 @@ function formatCheckedTime(timestamp) {
330
330
  }
331
331
  }
332
332
 
333
+ function connectionTestNotice(value) {
334
+ if (value?.testMessage?.sent === true) {
335
+ return '测试消息已发送,请到飞书会话中确认。';
336
+ }
337
+ if (value?.testMessage?.code === 'test-target-unavailable') {
338
+ return '连接检查完成。机器人尚未收到可用于测试的私聊消息。';
339
+ }
340
+ return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
341
+ }
342
+
333
343
  function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
334
344
  const cancelRef = React.useRef(null);
335
345
  const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -364,6 +374,7 @@ export function BotCard({
364
374
  connection,
365
375
  busy,
366
376
  actionError,
377
+ testNotice,
367
378
  removing,
368
379
  onReconnect,
369
380
  onWorkspaceSave,
@@ -420,6 +431,10 @@ export function BotCard({
420
431
  h("div", { className: "bxf-connectedFooter dim-cardFooter" },
421
432
  summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
422
433
  summary) : null,
434
+ testNotice ? h("div", {
435
+ className: "bxf-healthSummary dim-cardSummary",
436
+ role: "status",
437
+ }, testNotice) : null,
423
438
  h("div", { className: "bxf-actions bxf-botActions dim-cardActions" },
424
439
  h(Button, {
425
440
  className: "dim-cardAction", onClick: onReconnect,
@@ -454,6 +469,7 @@ function BotList(props) {
454
469
  connection: bot,
455
470
  busy: props.busyByBot[bot.botId],
456
471
  actionError: props.errorsByBot[bot.botId],
472
+ testNotice: props.testNoticesByBot[bot.botId],
457
473
  removing: props.removeTargetId === bot.botId,
458
474
  onReconnect: () => props.onReconnect(bot),
459
475
  onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
@@ -527,6 +543,7 @@ export function FeishuSettingsTab({ rpcCall }) {
527
543
  const [credentialError, setCredentialError] = React.useState(null);
528
544
  const [busyByBot, setBusyByBot] = React.useState({});
529
545
  const [errorsByBot, setErrorsByBot] = React.useState({});
546
+ const [testNoticesByBot, setTestNoticesByBot] = React.useState({});
530
547
  const [removeTargetId, setRemoveTargetId] = React.useState(null);
531
548
  const [announcement, setAnnouncement] = React.useState("");
532
549
  const [now, setNow] = React.useState(() => Date.now());
@@ -826,8 +843,14 @@ export function FeishuSettingsTab({ rpcCall }) {
826
843
  const snapshotVersion = workspaceFence.beginMutation();
827
844
  setBotBusy(botId, "reconnect");
828
845
  setBotError(botId, null);
846
+ setTestNoticesByBot((current) => {
847
+ const next = { ...current };
848
+ delete next[botId];
849
+ return next;
850
+ });
829
851
  try {
830
- const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
852
+ const value = await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId, sendTest: true });
853
+ const snapshot = normalizeBotsSnapshot(value);
831
854
  if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
832
855
  mergeSnapshot(snapshot);
833
856
  }
@@ -839,12 +862,18 @@ export function FeishuSettingsTab({ rpcCall }) {
839
862
  error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
840
863
  throw error;
841
864
  }
842
- announce(connection.connected
865
+ const testNotice = connectionTestNotice(value);
866
+ if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
867
+ setTestNoticesByBot((current) => ({ ...current, [botId]: testNotice }));
868
+ }
869
+ announce(testNotice ?? (connection.connected
843
870
  ? `${bot.name}连接检查完成。`
844
- : `${bot.name}已重新连接。`);
871
+ : `${bot.name}已重新连接。`));
845
872
  } catch (error) {
846
- setBotError(botId, error);
847
- announce(`${bot.name}操作失败,请查看机器人状态。`);
873
+ const failure = new Error("连接检查失败,请稍后重试。");
874
+ failure.code = error?.code;
875
+ setBotError(botId, failure);
876
+ announce(failure.message);
848
877
  } finally {
849
878
  const shouldRefresh = workspaceFence.endMutation();
850
879
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -995,6 +1024,7 @@ export function FeishuSettingsTab({ rpcCall }) {
995
1024
  bots: model.bots,
996
1025
  busyByBot,
997
1026
  errorsByBot,
1027
+ testNoticesByBot,
998
1028
  removeTargetId,
999
1029
  onReconnect: (bot) => void reconnectOneBot(bot),
1000
1030
  onWorkspaceSave: saveWorkspace,
@@ -13,6 +13,7 @@ export const QQ_ENDPOINTS = Object.freeze({
13
13
 
14
14
  const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
15
15
  const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
16
+ const TEST_MESSAGE_CODES = new Set(['test-target-unavailable', 'test-message-failed']);
16
17
  const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
17
18
 
18
19
  function isRecord(value) {
@@ -96,18 +97,38 @@ function normalizeBot(value) {
96
97
  };
97
98
  }
98
99
 
100
+ function normalizeTestMessage(value) {
101
+ if (!isRecord(value) || typeof value.sent !== 'boolean') return undefined;
102
+ if (value.sent) return { sent: true };
103
+ const code = text(value.code, 'test-message-failed', 80);
104
+ return {
105
+ sent: false,
106
+ code: TEST_MESSAGE_CODES.has(code) ? code : 'test-message-failed',
107
+ };
108
+ }
109
+
99
110
  export function normalizeSnapshot(value) {
100
111
  const source = isRecord(value?.snapshot) ? value.snapshot : value;
101
112
  if (!isRecord(source) || !Array.isArray(source.bots)) throw new Error('QQ 服务没有返回有效的机器人列表');
102
113
  const bots = source.bots.map(normalizeBot).filter(Boolean);
114
+ const testMessage = normalizeTestMessage(source.testMessage);
103
115
  return {
104
116
  revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
105
117
  bots,
106
118
  totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
107
119
  provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
120
+ ...(testMessage ? { testMessage } : {}),
108
121
  };
109
122
  }
110
123
 
124
+ export function connectionTestFeedback(result) {
125
+ if (result?.sent === true) return '测试消息已发送,请到对应机器人会话中确认。';
126
+ if (result?.code === 'test-target-unavailable') {
127
+ return '连接检查完成。机器人尚未收到可用于测试的私聊消息。';
128
+ }
129
+ return result ? '连接检查完成,但测试消息发送失败。' : null;
130
+ }
131
+
111
132
  export function presentError(error) {
112
133
  return {
113
134
  code: text(error?.code, 'QQ_ERROR', 80),
@@ -9,6 +9,7 @@ import { installDingtalkStyles } from '../dingtalk/styles.js';
9
9
  import {
10
10
  QQ_ENDPOINTS,
11
11
  QQ_RPC_CHANNEL,
12
+ connectionTestFeedback,
12
13
  formatRemaining,
13
14
  normalizeProvisioning,
14
15
  normalizeSnapshot,
@@ -151,6 +152,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
151
152
  export function AccountCard({
152
153
  account,
153
154
  busy,
155
+ feedback,
154
156
  removing,
155
157
  onReconnect,
156
158
  onWorkspaceSave,
@@ -180,6 +182,11 @@ export function AccountCard({
180
182
  }),
181
183
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
182
184
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
185
+ feedback ? h('div', {
186
+ className: 'ddt-summary dim-cardSummary',
187
+ role: 'status',
188
+ 'aria-live': 'polite',
189
+ }, feedback) : null,
183
190
  h('div', { className: 'ddt-actions dim-cardActions' },
184
191
  h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
185
192
  h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
@@ -193,6 +200,7 @@ export function QqSettingsTab({ rpcCall }) {
193
200
  const [provision, setProvision] = React.useState(null);
194
201
  const [busy, setBusy] = React.useState(false);
195
202
  const [busyByBot, setBusyByBot] = React.useState({});
203
+ const [feedbackByBot, setFeedbackByBot] = React.useState({});
196
204
  const [removeTarget, setRemoveTarget] = React.useState(null);
197
205
  const [credentialOpen, setCredentialOpen] = React.useState(false);
198
206
  const [credentialError, setCredentialError] = React.useState(null);
@@ -361,6 +369,35 @@ export function QqSettingsTab({ rpcCall }) {
361
369
  }
362
370
  }, [invoke, loadStatus, workspaceFence]);
363
371
 
372
+ const reconnect = React.useCallback(async (account) => {
373
+ setFeedbackByBot((current) => {
374
+ const next = { ...current };
375
+ delete next[account.botId];
376
+ return next;
377
+ });
378
+ try {
379
+ const snapshot = await botAction(
380
+ account,
381
+ 'reconnect',
382
+ QQ_ENDPOINTS.reconnectBot,
383
+ { botId: account.botId, sendTest: true },
384
+ );
385
+ if (mounted.current) {
386
+ setFeedbackByBot((current) => ({
387
+ ...current,
388
+ [account.botId]: connectionTestFeedback(snapshot?.testMessage),
389
+ }));
390
+ }
391
+ } catch {
392
+ if (mounted.current) {
393
+ setFeedbackByBot((current) => ({
394
+ ...current,
395
+ [account.botId]: '连接检查失败,请稍后重试。',
396
+ }));
397
+ }
398
+ }
399
+ }, [botAction]);
400
+
364
401
  let provisionView = null;
365
402
  if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请 QQ 二维码…');
366
403
  else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
@@ -378,8 +415,9 @@ export function QqSettingsTab({ rpcCall }) {
378
415
  h('li', { key: account.botId }, h(AccountCard, {
379
416
  account,
380
417
  busy: busyByBot[account.botId],
418
+ feedback: feedbackByBot[account.botId],
381
419
  removing: removeTarget === account.botId,
382
- onReconnect: () => void botAction(account, 'reconnect', QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
420
+ onReconnect: () => void reconnect(account),
383
421
  onWorkspaceSave: (workspace) => botAction(
384
422
  account,
385
423
  'workspace',
@@ -30,6 +30,14 @@ function checkedTime(value) {
30
30
  }
31
31
  }
32
32
 
33
+ function connectionTestNotice(value) {
34
+ if (value?.testMessage?.sent === true) return '测试消息已发送,请到对应机器人会话中确认。';
35
+ if (value?.testMessage?.code === 'test-target-unavailable') {
36
+ return '连接检查完成。机器人尚未收到可用于测试的私聊消息。';
37
+ }
38
+ return value?.testMessage ? '连接检查完成,但测试消息发送失败。' : null;
39
+ }
40
+
33
41
  export function createTokenChannelSettings(definition) {
34
42
  const {
35
43
  channel,
@@ -53,7 +61,7 @@ export function createTokenChannelSettings(definition) {
53
61
  emptyActionLabel = '填写 Bot Token',
54
62
  } = definition;
55
63
 
56
- function AccountCard({ account, busy, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
64
+ function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
57
65
  const state = busy === 'reconnect' ? 'connecting' : account.state;
58
66
  const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
59
67
  const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
@@ -82,6 +90,7 @@ export function createTokenChannelSettings(definition) {
82
90
  }),
83
91
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
84
92
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
93
+ testNotice ? h('div', { className: 'ddt-summary dim-cardSummary', role: 'status' }, testNotice) : null,
85
94
  h('div', { className: 'ddt-actions dim-cardActions' },
86
95
  h(Button, {
87
96
  className: 'dim-cardAction',
@@ -111,6 +120,7 @@ export function createTokenChannelSettings(definition) {
111
120
  const [credentialError, setCredentialError] = React.useState(null);
112
121
  const [busy, setBusy] = React.useState(false);
113
122
  const [busyByBot, setBusyByBot] = React.useState({});
123
+ const [testNoticeByBot, setTestNoticeByBot] = React.useState({});
114
124
  const [removeTarget, setRemoveTarget] = React.useState(null);
115
125
  const mounted = React.useRef(true);
116
126
  const workspaceFence = useWorkspaceSnapshotFence();
@@ -198,10 +208,25 @@ export function createTokenChannelSettings(definition) {
198
208
  const snapshotVersion = workspaceFence.beginMutation();
199
209
  setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
200
210
  try {
201
- const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload));
211
+ const value = await invoke(endpoint, payload);
212
+ const snapshot = api.normalizeSnapshot(value);
202
213
  if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
203
214
  setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
204
215
  }
216
+ if (mounted.current && operation === 'reconnect') {
217
+ setTestNoticeByBot((current) => ({
218
+ ...current,
219
+ [account.botId]: connectionTestNotice(value),
220
+ }));
221
+ }
222
+ } catch (error) {
223
+ if (operation !== 'reconnect') throw error;
224
+ if (mounted.current) {
225
+ setTestNoticeByBot((current) => ({
226
+ ...current,
227
+ [account.botId]: '连接检查失败,请稍后重试。',
228
+ }));
229
+ }
205
230
  } finally {
206
231
  const shouldRefresh = workspaceFence.endMutation();
207
232
  if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
@@ -221,12 +246,13 @@ export function createTokenChannelSettings(definition) {
221
246
  h('li', { key: account.botId }, h(AccountCard, {
222
247
  account,
223
248
  busy: busyByBot[account.botId],
249
+ testNotice: testNoticeByBot[account.botId],
224
250
  removing: removeTarget === account.botId,
225
251
  onReconnect: () => void botAction(
226
252
  account,
227
253
  'reconnect',
228
254
  endpoints.reconnectBot,
229
- { botId: account.botId },
255
+ { botId: account.botId, sendTest: true },
230
256
  ),
231
257
  onWorkspaceSave: (workspace) => botAction(
232
258
  account,
@@ -34,6 +34,16 @@ function timestamp(value) {
34
34
  return Number.isNaN(parsed) ? undefined : parsed;
35
35
  }
36
36
 
37
+ function normalizeTestMessage(value) {
38
+ if (!isRecord(value)) return null;
39
+ if (value.sent === true) return { sent: true };
40
+ if (value.sent !== false) return null;
41
+ const code = value.code === 'test-target-unavailable'
42
+ ? 'test-target-unavailable'
43
+ : 'test-message-failed';
44
+ return { sent: false, code };
45
+ }
46
+
37
47
  export function unwrapRpcResult(result) {
38
48
  if (!isRecord(result) || typeof result.ok !== 'boolean') throw new Error('企业微信服务返回了无法识别的响应');
39
49
  if (!result.ok) {
@@ -105,6 +115,7 @@ export function normalizeSnapshot(value) {
105
115
  bots,
106
116
  totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
107
117
  provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
118
+ testMessage: normalizeTestMessage(source.testMessage),
108
119
  };
109
120
  }
110
121