@xmanrui/dsh-im 0.14.0 → 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",
@@ -11,6 +11,7 @@ export const FEISHU_RPC_CHANNEL = "/feishu";
11
11
  export const FEISHU_ENDPOINTS = Object.freeze({
12
12
  status: "connection.status",
13
13
  beginProvisioning: "provision.begin",
14
+ beginCallbackRepair: "bot.callback-repair.begin",
14
15
  pollProvisioning: "provision.poll",
15
16
  cancelProvisioning: "provision.cancel",
16
17
  bindCredentials: "bot.bind-credentials",
@@ -23,6 +24,11 @@ export const FEISHU_ENDPOINTS = Object.freeze({
23
24
  disconnect: "connection.disconnect",
24
25
  });
25
26
 
27
+ export const FEISHU_REGISTRATION_OPERATIONS = Object.freeze({
28
+ PROVISION: "provision",
29
+ CALLBACK_REPAIR: "callback_repair",
30
+ });
31
+
26
32
  const CONNECTION_STATES = new Set([
27
33
  "disconnected",
28
34
  "offline",
@@ -67,6 +73,12 @@ function clamp(value, min, max, fallback) {
67
73
  : fallback;
68
74
  }
69
75
 
76
+ function normalizeRegistrationOperation(value) {
77
+ return value === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR
78
+ ? FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR
79
+ : FEISHU_REGISTRATION_OPERATIONS.PROVISION;
80
+ }
81
+
70
82
  export function unwrapRpcResult(result) {
71
83
  if (!isRecord(result) || typeof result.ok !== "boolean") {
72
84
  throw new Error("飞书服务返回了无法识别的响应");
@@ -88,16 +100,25 @@ export function normalizeProvisioning(value, now = Date.now()) {
88
100
  ?? optionalString(source.provisioningId);
89
101
  const verificationUrl = optionalString(source.verificationUrl);
90
102
  const qrCodeDataUrl = optionalString(source.qrCodeDataUrl);
91
- if (!attemptId || (!verificationUrl && !qrCodeDataUrl)) {
103
+ const submitted = source.submitted === true;
104
+ if (!attemptId || (!verificationUrl && !qrCodeDataUrl && !submitted)) {
92
105
  throw new Error("飞书服务返回的二维码信息不完整");
93
106
  }
94
107
 
95
108
  const explicitExpiry = optionalTimestamp(source.expiresAt);
96
109
  const expireIn = clamp(source.expireIn, 1, 60 * 60, 5 * 60);
110
+ const operation = normalizeRegistrationOperation(source.operation);
111
+ const botId = optionalString(source.botId);
112
+ if (operation === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR && !botId) {
113
+ throw new Error("飞书服务返回的修复信息缺少 botId");
114
+ }
97
115
  return {
98
116
  attemptId,
117
+ operation,
118
+ botId,
99
119
  verificationUrl,
100
120
  qrCodeDataUrl,
121
+ submitted,
101
122
  expiresAt: explicitExpiry ?? now + expireIn * 1000,
102
123
  pollIntervalMs: clamp(source.pollIntervalMs, 800, 10_000, 1_800),
103
124
  };
@@ -296,6 +317,7 @@ export function normalizePollResult(value) {
296
317
 
297
318
  const normalized = {
298
319
  status,
320
+ operation: normalizeRegistrationOperation(value.operation),
299
321
  botId: optionalString(value.botId),
300
322
  message: optionalString(value.error?.message) ?? optionalString(value.message),
301
323
  connection: undefined,
@@ -5,6 +5,7 @@ import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../.
5
5
  import { h } from "../../i18n.js";
6
6
  import {
7
7
  FEISHU_ENDPOINTS,
8
+ FEISHU_REGISTRATION_OPERATIONS,
8
9
  FEISHU_RPC_CHANNEL,
9
10
  formatRemaining,
10
11
  normalizeBotsSnapshot,
@@ -21,6 +22,12 @@ import { installFeishuStyles } from "./styles.js";
21
22
  export const name = "feishu-settings";
22
23
  export const inject = ["slots", "connection"];
23
24
 
25
+ const CALLBACK_REPAIR_OPERATION = FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
26
+
27
+ function isCallbackRepair(value) {
28
+ return value?.operation === CALLBACK_REPAIR_OPERATION;
29
+ }
30
+
24
31
  function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
25
32
  return h("svg", {
26
33
  width: size,
@@ -197,7 +204,18 @@ function safeVerificationHref(value) {
197
204
  if (!value) return undefined;
198
205
  try {
199
206
  const url = new URL(value);
200
- return url.protocol === "https:" ? url.toString() : undefined;
207
+ return url.protocol === "https:"
208
+ && [
209
+ "accounts.feishu.cn",
210
+ "accounts.larksuite.com",
211
+ "open.feishu.cn",
212
+ "open.larksuite.com",
213
+ ].includes(url.hostname)
214
+ && !url.port
215
+ && !url.username
216
+ && !url.password
217
+ ? url.toString()
218
+ : undefined;
201
219
  } catch {
202
220
  return undefined;
203
221
  }
@@ -217,6 +235,8 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
217
235
  const remaining = Math.max(0, provision.expiresAt - now);
218
236
  const expired = provision.expired === true || remaining === 0;
219
237
  const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
238
+ const repairing = isCallbackRepair(provision);
239
+ const botName = provision.botName ?? "此机器人";
220
240
 
221
241
  React.useEffect(() => setImageFailed(false), [qrSource]);
222
242
 
@@ -225,9 +245,11 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
225
245
  h("div", { className: "bxf-qrColumn dim-qrColumn" },
226
246
  h("div", { className: "bxf-qrFrame dim-qrFrame" },
227
247
  qrSource && !imageFailed
228
- ? h("img", {
248
+ ? h("img", {
229
249
  src: qrSource,
230
- alt: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
250
+ alt: repairing
251
+ ? `用于修复${botName}卡片按钮的一次性授权二维码`
252
+ : "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
231
253
  onError: () => setImageFailed(true),
232
254
  })
233
255
  : h("div", { className: "bxf-qrFallback dim-qrFallback" },
@@ -251,13 +273,21 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
251
273
  h("div", { className: "bxf-qrCopy dim-qrCopy" },
252
274
  h("div", { className: "bxf-stateLabel dim-stateLabel" },
253
275
  h("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
254
- h("span", null, "正在添加新机器人")),
255
- h("h3", null, expired ? "刷新二维码后继续" : "使用飞书扫码创建机器人"),
256
- h("p", null, "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
276
+ h("span", null, repairing ? `正在修复「${botName}」` : "正在添加新机器人")),
277
+ h("h3", null, expired
278
+ ? "刷新二维码后继续"
279
+ : repairing ? "使用飞书扫码修复卡片按钮" : "使用飞书扫码创建机器人"),
280
+ h("p", null, repairing
281
+ ? "扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。"
282
+ : "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
257
283
  h("ol", { className: "bxf-steps dim-steps" },
258
284
  h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
259
- h("li", null, "核对应用名称与权限范围,并确认创建"),
260
- h("li", null, "保持本页打开,等待新机器人的长连接就绪")),
285
+ h("li", null, repairing
286
+ ? "核对现有应用名称,并确认只新增卡片回调"
287
+ : "核对应用名称与权限范围,并确认创建"),
288
+ h("li", null, repairing
289
+ ? "保持本页打开,等待卡片按钮修复完成"
290
+ : "保持本页打开,等待新机器人的长连接就绪")),
261
291
  h("div", { className: "bxf-actions dim-viewActions" },
262
292
  expired
263
293
  ? h(Button, {
@@ -272,35 +302,43 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
272
302
  !expired
273
303
  ? h(Button, { onClick: onRefresh, disabled: busy }, "换一个二维码")
274
304
  : null,
275
- h(Button, { onClick: onCancel, disabled: busy }, "取消添加")),
305
+ h(Button, { onClick: onCancel, disabled: busy }, repairing ? "取消修复" : "取消添加")),
276
306
  ),
277
307
  ),
278
308
  );
279
309
  }
280
310
 
281
- function ProvisionProgress({ phase, onCancel, busy }) {
311
+ function ProvisionProgress({ phase, provision, onCancel, busy }) {
282
312
  const connecting = phase === "connecting";
313
+ const repairing = isCallbackRepair(provision);
283
314
  return h("div", {
284
315
  className: "bxf-card bxf-provisionCard dim-surfaceCard dim-loadingView",
285
316
  "aria-busy": "true",
286
317
  },
287
318
  h("div", { className: "dim-spinner", "aria-hidden": "true" }),
288
- h("h3", null, connecting ? "已确认,正在连接新机器人" : "正在准备授权二维码"),
319
+ h("h3", null, connecting
320
+ ? repairing ? "已确认,正在完成卡片按钮修复" : "已确认,正在连接新机器人"
321
+ : repairing ? "正在准备修复二维码" : "正在准备授权二维码"),
289
322
  h("p", null, connecting
290
- ? "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
291
- : "正在向飞书申请一次性授权二维码,请稍候。"),
292
- connecting
323
+ ? repairing
324
+ ? "配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。"
325
+ : "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
326
+ : repairing
327
+ ? "正在为现有飞书应用申请一次性更新二维码,请稍候。"
328
+ : "正在向飞书申请一次性授权二维码,请稍候。"),
329
+ connecting && onCancel
293
330
  ? h("div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
294
- h(Button, { onClick: onCancel, disabled: busy }, "取消添加"))
331
+ h(Button, { onClick: onCancel, disabled: busy }, repairing ? "取消修复" : "取消添加"))
295
332
  : null,
296
333
  );
297
334
  }
298
335
 
299
- function ProvisionError({ error, onRetry, onCancel, busy }) {
336
+ function ProvisionError({ error, provision, onRetry, onCancel, busy }) {
337
+ const repairing = isCallbackRepair(provision);
300
338
  return h("div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" },
301
339
  h("div", { className: "bxf-inlineError dim-inlineError", role: "alert" },
302
340
  h("div", null,
303
- h("h3", null, "新机器人没有添加完成"),
341
+ h("h3", null, repairing ? "卡片按钮没有修复完成" : "新机器人没有添加完成"),
304
342
  h("p", null, error.message),
305
343
  error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
306
344
  h("div", { className: "bxf-actions dim-viewActions" },
@@ -373,10 +411,12 @@ function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
373
411
  export function BotCard({
374
412
  connection,
375
413
  busy,
414
+ repairDisabled,
376
415
  actionError,
377
416
  testNotice,
378
417
  removing,
379
418
  onReconnect,
419
+ onRepairCallback,
380
420
  onWorkspaceSave,
381
421
  onRequestRemove,
382
422
  onConfirmRemove,
@@ -441,6 +481,13 @@ export function BotCard({
441
481
  disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
442
482
  "aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
443
483
  }, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
484
+ h(Button, {
485
+ className: "bxf-repairButton dim-cardAction",
486
+ onClick: onRepairCallback,
487
+ disabled: Boolean(busy) || repairDisabled,
488
+ "aria-busy": busy === "callback-repair" ? "true" : undefined,
489
+ "aria-label": `修复${bot.name}的卡片按钮`,
490
+ }, busy === "callback-repair" ? "等待扫码…" : "修复卡片按钮"),
444
491
  h(Button, {
445
492
  className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
446
493
  disabled: Boolean(busy), ref: removeButtonRef,
@@ -467,11 +514,15 @@ function BotList(props) {
467
514
  props.bots.map((bot) => h("li", { key: bot.botId },
468
515
  h(BotCard, {
469
516
  connection: bot,
470
- busy: props.busyByBot[bot.botId],
517
+ busy: props.busyByBot[bot.botId]
518
+ ?? (isCallbackRepair(props.provisioning)
519
+ && props.provisioning.botId === bot.botId ? "callback-repair" : undefined),
520
+ repairDisabled: Boolean(props.provisioning),
471
521
  actionError: props.errorsByBot[bot.botId],
472
522
  testNotice: props.testNoticesByBot[bot.botId],
473
523
  removing: props.removeTargetId === bot.botId,
474
524
  onReconnect: () => props.onReconnect(bot),
525
+ onRepairCallback: () => props.onRepairCallback(bot),
475
526
  onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
476
527
  onRequestRemove: () => props.onRequestRemove(bot),
477
528
  onConfirmRemove: () => props.onConfirmRemove(bot),
@@ -507,11 +558,12 @@ export function mergeFeishuSnapshotState(
507
558
  if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
508
559
  let provisioning = current.provisioning;
509
560
  if (!provisioning && restoreProvisioning && snapshot.provisioning) {
561
+ const submitted = snapshot.provisioning.submitted === true;
510
562
  provisioning = {
511
- phase: snapshot.state === "connecting" ? "connecting" : "qr",
563
+ phase: submitted || snapshot.state === "connecting" ? "connecting" : "qr",
512
564
  ...snapshot.provisioning,
513
565
  durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
514
- expired: snapshot.provisioning.expiresAt <= now,
566
+ expired: !submitted && snapshot.provisioning.expiresAt <= now,
515
567
  };
516
568
  }
517
569
  return {
@@ -640,7 +692,15 @@ export function FeishuSettingsTab({ rpcCall }) {
640
692
  setFocusBotId(null);
641
693
  }, [focusBotId, model.bots]);
642
694
 
643
- const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
695
+ const startProvisioning = React.useCallback(async ({
696
+ replace = false,
697
+ operation = FEISHU_REGISTRATION_OPERATIONS.PROVISION,
698
+ bot,
699
+ } = {}) => {
700
+ const repairing = operation === CALLBACK_REPAIR_OPERATION;
701
+ const botId = repairing ? bot?.botId ?? model.provisioning?.botId : undefined;
702
+ const botName = repairing ? bot?.bot?.name ?? model.provisioning?.botName : undefined;
703
+ if (repairing && !botId) return;
644
704
  setCredentialOpen(false);
645
705
  setCredentialError(null);
646
706
  setProvisionBusy(true);
@@ -649,16 +709,33 @@ export function FeishuSettingsTab({ rpcCall }) {
649
709
  setModel((current) => ({
650
710
  ...current,
651
711
  phase: current.phase === "loading" ? "ready" : current.phase,
652
- provisioning: { phase: "creating" },
712
+ provisioning: {
713
+ phase: "creating",
714
+ operation,
715
+ ...(botId ? { botId } : {}),
716
+ ...(botName ? { botName } : {}),
717
+ },
653
718
  }));
654
719
  try {
655
720
  if (replace && previousAttemptId) {
656
- await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
721
+ // A Host restart intentionally drops its in-memory registration map.
722
+ // Replacing a stale browser attempt must still be able to start a new
723
+ // authoritative attempt; both controller start paths already
724
+ // supersede/deduplicate a still-live registration safely.
725
+ try {
726
+ await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
727
+ } catch {
728
+ // Continue with begin. It is the source of truth for the new attempt.
729
+ }
657
730
  }
658
731
  const provision = normalizeProvisioning(await invoke(
659
- FEISHU_ENDPOINTS.beginProvisioning,
660
- { locale: "zh-CN" },
732
+ repairing ? FEISHU_ENDPOINTS.beginCallbackRepair : FEISHU_ENDPOINTS.beginProvisioning,
733
+ repairing ? { botId } : { locale: "zh-CN" },
661
734
  ));
735
+ if (repairing
736
+ && (provision.operation !== CALLBACK_REPAIR_OPERATION || provision.botId !== botId)) {
737
+ throw new Error("飞书服务返回了不匹配的卡片修复二维码");
738
+ }
662
739
  const timestamp = Date.now();
663
740
  setNow(timestamp);
664
741
  setModel((current) => ({
@@ -666,20 +743,36 @@ export function FeishuSettingsTab({ rpcCall }) {
666
743
  provisioning: {
667
744
  phase: "qr",
668
745
  ...provision,
746
+ ...(botName ? { botName } : {}),
669
747
  durationMs: Math.max(1, provision.expiresAt - timestamp),
670
748
  expired: false,
671
749
  },
672
750
  }));
673
- announce("授权二维码已生成,请使用飞书扫码。");
751
+ announce(repairing
752
+ ? `${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。`
753
+ : "授权二维码已生成,请使用飞书扫码。");
674
754
  } catch (error) {
675
755
  setModel((current) => ({
676
756
  ...current,
677
- provisioning: { phase: "error", error: presentError(error) },
757
+ provisioning: {
758
+ phase: "error",
759
+ operation,
760
+ ...(botId ? { botId } : {}),
761
+ ...(botName ? { botName } : {}),
762
+ ...(replace && previousAttemptId ? { attemptId: previousAttemptId } : {}),
763
+ error: presentError(error),
764
+ },
678
765
  }));
679
766
  } finally {
680
767
  setProvisionBusy(false);
681
768
  }
682
- }, [announce, invoke, model.provisioning?.attemptId]);
769
+ }, [
770
+ announce,
771
+ invoke,
772
+ model.provisioning?.attemptId,
773
+ model.provisioning?.botId,
774
+ model.provisioning?.botName,
775
+ ]);
683
776
 
684
777
  const bindCredentials = React.useCallback(async ({ identity, secret }) => {
685
778
  const snapshotVersion = workspaceFence.beginMutation();
@@ -705,23 +798,71 @@ export function FeishuSettingsTab({ rpcCall }) {
705
798
  }, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
706
799
 
707
800
  const cancelProvisioning = React.useCallback(async () => {
708
- const attemptId = model.provisioning?.attemptId;
801
+ const activeProvision = model.provisioning;
802
+ const attemptId = activeProvision?.attemptId;
803
+ const repairing = isCallbackRepair(activeProvision);
804
+ const targetBot = repairing
805
+ ? model.bots.find((bot) => bot.botId === activeProvision?.botId)
806
+ : undefined;
709
807
  setProvisionBusy(true);
710
808
  try {
711
- if (attemptId) await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId });
809
+ const result = attemptId
810
+ ? normalizePollResult(await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId }))
811
+ : null;
812
+ if (repairing && result) {
813
+ if (result.operation !== CALLBACK_REPAIR_OPERATION
814
+ || result.botId !== activeProvision.botId) {
815
+ throw new Error("飞书服务返回了不匹配的注册进度");
816
+ }
817
+ if (result.status === "connecting") {
818
+ setModel((current) => current.provisioning?.attemptId === attemptId
819
+ ? {
820
+ ...current,
821
+ provisioning: {
822
+ ...current.provisioning,
823
+ ...(result.provisioning ?? {}),
824
+ phase: "connecting",
825
+ submitted: true,
826
+ expired: false,
827
+ },
828
+ }
829
+ : current);
830
+ announce("配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。");
831
+ return;
832
+ }
833
+ if (result.status === "connected") {
834
+ const targetBotName = targetBot?.bot.name ?? activeProvision.botName ?? "机器人";
835
+ setModel((current) => ({ ...current, provisioning: null }));
836
+ announce(`${targetBotName}的卡片按钮已修复。`);
837
+ if (activeProvision.botId) setFocusBotId(activeProvision.botId);
838
+ await loadStatus({ silent: true, restoreProvisioning: false });
839
+ return;
840
+ }
841
+ }
712
842
  setModel((current) => ({ ...current, provisioning: null }));
713
- announce("已取消添加机器人。");
843
+ announce(repairing ? "已取消卡片按钮修复。" : "已取消添加机器人。");
714
844
  await loadStatus({ silent: true, restoreProvisioning: false });
715
- scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
845
+ scheduleAnimationFrame(() => {
846
+ if (repairing && activeProvision.botId) {
847
+ cardRefs.current.get(activeProvision.botId)?.focus();
848
+ } else {
849
+ addButtonRef.current?.focus();
850
+ }
851
+ }, "focus");
716
852
  } catch (error) {
717
853
  setModel((current) => ({
718
854
  ...current,
719
- provisioning: { phase: "error", attemptId, error: presentError(error) },
855
+ provisioning: {
856
+ ...activeProvision,
857
+ phase: "error",
858
+ attemptId,
859
+ error: presentError(error),
860
+ },
720
861
  }));
721
862
  } finally {
722
863
  setProvisionBusy(false);
723
864
  }
724
- }, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]);
865
+ }, [announce, invoke, loadStatus, model.bots, model.provisioning, scheduleAnimationFrame]);
725
866
 
726
867
  const countdownAttemptId = model.provisioning?.attemptId;
727
868
  const countdownPhase = model.provisioning?.phase;
@@ -758,27 +899,36 @@ export function FeishuSettingsTab({ rpcCall }) {
758
899
  { attemptId: provision.attemptId },
759
900
  controller.signal,
760
901
  ));
902
+ if (result.operation !== provision.operation
903
+ || (isCallbackRepair(provision) && result.botId !== provision.botId)) {
904
+ throw new Error("飞书服务返回了不匹配的注册进度");
905
+ }
761
906
  if (result.status === "connected") {
762
907
  const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
763
- const newBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
908
+ const targetBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
764
909
  if (!snapshot) {
765
- throw new Error("机器人已经创建,但暂时无法确认连接状态");
910
+ throw new Error(isCallbackRepair(provision)
911
+ ? "卡片按钮已更新,但暂时无法确认机器人连接状态"
912
+ : "机器人已经创建,但暂时无法确认连接状态");
766
913
  }
767
- if (!newBot?.connected) {
914
+ if (!targetBot?.connected) {
768
915
  setModel((current) => current.provisioning?.attemptId === provision.attemptId
769
916
  ? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
770
917
  : current);
771
918
  return;
772
919
  }
773
920
  setModel((current) => ({ ...current, provisioning: null }));
774
- announce(newBot
775
- ? `${newBot.bot.name}已连接,可以在飞书中开始聊天。`
776
- : "新飞书机器人已连接,可以开始聊天。");
921
+ announce(isCallbackRepair(provision)
922
+ ? `${targetBot.bot.name}的卡片按钮已修复。`
923
+ : targetBot
924
+ ? `${targetBot.bot.name}已连接,可以在飞书中开始聊天。`
925
+ : "新飞书机器人已连接,可以开始聊天。");
777
926
  if (result.botId) setFocusBotId(result.botId);
778
927
  return;
779
928
  }
780
929
  if (result.status === "failed") {
781
- const error = new Error(result.message ?? "飞书应用创建失败");
930
+ const error = new Error(result.message
931
+ ?? (isCallbackRepair(provision) ? "飞书卡片按钮修复失败" : "飞书应用创建失败"));
782
932
  error.code = "FEISHU_PROVISION_FAILED";
783
933
  throw error;
784
934
  }
@@ -806,6 +956,7 @@ export function FeishuSettingsTab({ rpcCall }) {
806
956
  ? {
807
957
  ...current,
808
958
  provisioning: {
959
+ ...current.provisioning,
809
960
  phase: "error",
810
961
  attemptId: provision.attemptId,
811
962
  error: presentError(error),
@@ -838,6 +989,21 @@ export function FeishuSettingsTab({ rpcCall }) {
838
989
  });
839
990
  }, []);
840
991
 
992
+ const repairCallback = React.useCallback((connection) => {
993
+ if (model.provisioning) return;
994
+ setRemoveTargetId(null);
995
+ setBotError(connection.botId, null);
996
+ setTestNoticesByBot((current) => {
997
+ const next = { ...current };
998
+ delete next[connection.botId];
999
+ return next;
1000
+ });
1001
+ void startProvisioning({
1002
+ operation: CALLBACK_REPAIR_OPERATION,
1003
+ bot: connection,
1004
+ });
1005
+ }, [model.provisioning, setBotError, startProvisioning]);
1006
+
841
1007
  const reconnectOneBot = React.useCallback(async (connection) => {
842
1008
  const { botId, bot } = connection;
843
1009
  const snapshotVersion = workspaceFence.beginMutation();
@@ -938,27 +1104,48 @@ export function FeishuSettingsTab({ rpcCall }) {
938
1104
  }, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
939
1105
 
940
1106
  const provision = model.provisioning;
1107
+ const provisionBot = provision?.botId
1108
+ ? model.bots.find((bot) => bot.botId === provision.botId)
1109
+ ?? { botId: provision.botId, bot: { name: provision.botName ?? "此机器人" } }
1110
+ : undefined;
1111
+ const restartProvisioning = ({ replace = false } = {}) => startProvisioning({
1112
+ replace,
1113
+ operation: provision?.operation ?? FEISHU_REGISTRATION_OPERATIONS.PROVISION,
1114
+ bot: provisionBot,
1115
+ });
941
1116
  let provisionContent = null;
942
1117
  if (provision?.phase === "creating") {
943
- provisionContent = h(ProvisionProgress, { phase: "creating", busy: provisionBusy });
1118
+ provisionContent = h(ProvisionProgress, {
1119
+ phase: "creating", provision, busy: provisionBusy,
1120
+ });
944
1121
  } else if (provision?.phase === "qr") {
945
1122
  provisionContent = h(QrPane, {
946
1123
  provision, now,
947
- onRefresh: () => void startProvisioning({ replace: true }),
1124
+ onRefresh: () => void restartProvisioning({ replace: true }),
948
1125
  onCancel: () => void cancelProvisioning(),
949
1126
  busy: provisionBusy || model.phase !== "ready",
950
1127
  });
951
1128
  } else if (provision?.phase === "connecting") {
952
1129
  provisionContent = h(ProvisionProgress, {
953
1130
  phase: "connecting",
954
- onCancel: () => void cancelProvisioning(),
1131
+ provision,
1132
+ onCancel: isCallbackRepair(provision) ? undefined : () => void cancelProvisioning(),
955
1133
  busy: provisionBusy,
956
1134
  });
957
1135
  } else if (provision?.phase === "error") {
958
1136
  provisionContent = h(ProvisionError, {
959
1137
  error: provision.error,
960
- onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
961
- onCancel: () => void cancelProvisioning(),
1138
+ provision,
1139
+ onRetry: () => void restartProvisioning({ replace: Boolean(provision.attemptId) }),
1140
+ onCancel: () => {
1141
+ const targetBotId = provision.botId;
1142
+ setModel((current) => ({ ...current, provisioning: null }));
1143
+ void loadStatus({ silent: true, restoreProvisioning: false });
1144
+ scheduleAnimationFrame(() => {
1145
+ if (targetBotId) cardRefs.current.get(targetBotId)?.focus();
1146
+ else addButtonRef.current?.focus();
1147
+ }, "focus");
1148
+ },
962
1149
  busy: provisionBusy,
963
1150
  });
964
1151
  }
@@ -1026,7 +1213,9 @@ export function FeishuSettingsTab({ rpcCall }) {
1026
1213
  errorsByBot,
1027
1214
  testNoticesByBot,
1028
1215
  removeTargetId,
1216
+ provisioning: provision,
1029
1217
  onReconnect: (bot) => void reconnectOneBot(bot),
1218
+ onRepairCallback: repairCallback,
1030
1219
  onWorkspaceSave: saveWorkspace,
1031
1220
  onRequestRemove: requestRemove,
1032
1221
  onConfirmRemove: (bot) => void confirmRemove(bot),
@@ -412,6 +412,8 @@ const CSS = String.raw`
412
412
  .bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
413
413
  .bxf-botActions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
414
414
  .bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
415
+ .bxf-botActions .bxf-repairButton { color: var(--bxf-accent); border-color: color-mix(in srgb, var(--bxf-accent) 35%, var(--dsw-alias-border-l2, #dee0e3)); }
416
+ .bxf-botActions .bxf-repairButton:hover:not(:disabled) { background: color-mix(in srgb, var(--bxf-accent) 7%, transparent); }
415
417
 
416
418
  .bxf-confirm {
417
419
  border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
@@ -9,6 +9,7 @@ const EN = Object.freeze({
9
9
  'IM 渠道': 'IM channels',
10
10
  '让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach',
11
11
  'AI Office': 'AI Office',
12
+ '(实验功能)': '(Experimental)',
12
13
  'AI Office 设置': 'AI Office settings',
13
14
  'AI Office 设置页缺少 RPC 连接': 'AI Office settings are missing an RPC connection',
14
15
  '正在读取 AI Office Connector…': 'Loading AI Office Connector…',
@@ -196,6 +197,27 @@ const EN = Object.freeze({
196
197
  '保持本页打开,等待新机器人的长连接就绪': 'Keep this page open until the bot connection is ready',
197
198
  '在飞书中打开': 'Open in Feishu',
198
199
  '取消添加': 'Cancel',
200
+ '使用飞书扫码修复卡片按钮': 'Scan with Feishu to repair card buttons',
201
+ '扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。': '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.',
202
+ '核对现有应用名称,并确认只新增卡片回调': 'Review the existing app name and confirm that only the card callback is added',
203
+ '保持本页打开,等待卡片按钮修复完成': 'Keep this page open until card-button repair finishes',
204
+ '取消修复': 'Cancel repair',
205
+ '已确认,正在完成卡片按钮修复': 'Confirmed. Finishing card-button repair',
206
+ '正在准备修复二维码': 'Preparing the repair QR code',
207
+ '配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The update was submitted. Verifying the card callback and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
208
+ '正在为现有飞书应用申请一次性更新二维码,请稍候。': 'Requesting a one-time update QR code for the existing Feishu app…',
209
+ '卡片按钮没有修复完成': 'Card-button repair did not finish',
210
+ '修复卡片按钮': 'Repair card buttons',
211
+ '等待扫码…': 'Waiting for scan…',
212
+ '飞书服务返回了不匹配的卡片修复二维码': 'Feishu returned a repair QR code for a different bot',
213
+ '飞书服务返回的修复信息缺少 botId': 'Feishu repair status is missing the bot ID',
214
+ '飞书服务返回了不匹配的注册进度': 'Feishu returned registration progress for a different operation',
215
+ '此机器人': 'this bot',
216
+ '${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。': 'Repair QR code generated for ${botName ?? "bot"}. Scan it with Feishu.',
217
+ '${targetBot.bot.name}已连接,可以在飞书中开始聊天。': '${targetBot.bot.name} is connected and ready to chat in Feishu.',
218
+ '已取消卡片按钮修复。': 'Card-button repair was cancelled.',
219
+ '卡片按钮已更新,但暂时无法确认机器人连接状态': 'The card callback was updated, but the bot connection could not be confirmed yet',
220
+ '飞书卡片按钮修复失败': 'Could not repair the Feishu card buttons',
199
221
  '已确认,正在连接新机器人': 'Confirmed. Connecting the new bot',
200
222
  '正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。': 'Saving credentials and checking the new bot connection. Existing bots will not be interrupted.',
201
223
  '正在向飞书申请一次性授权二维码,请稍候。': 'Requesting a one-time authorization QR code from Feishu…',
@@ -230,6 +252,13 @@ const EN = Object.freeze({
230
252
  '这是微信附加的安全确认步骤。配对码只用于本次扫码轮询,不会写入配置或日志。': 'This is an additional WeChat confirmation step. The pairing code is used only for this connection and is never stored.',
231
253
  '正在保存凭据并验证 Harness 与微信长轮询。': 'Saving credentials and verifying the WeChat connection.',
232
254
  '微信已确认,正在启动消息连接': 'Confirmed in WeChat. Starting the message connection',
255
+ '微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Check the DSH credential store.',
256
+ '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。': 'WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.',
257
+ '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。': 'WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.',
258
+ '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。': 'WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.',
259
+ '微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。': 'WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.',
260
+ '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。': 'WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.',
261
+ '微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.',
233
262
  '微信已绑定,可以开始向已绑定的机器人发消息。': 'WeChat is connected and ready for messages.',
234
263
  '这个微信账号已经绑定并保持在线。': 'This WeChat account is connected and online.',
235
264
  '微信账号及本机凭据已移除。': 'The WeChat account and local credentials were removed.',
@@ -473,6 +502,16 @@ function translateDynamic(text) {
473
502
  if (match) return `Remove “${match[1]}” from DeepSeek Harness?`;
474
503
  match = /^从 DeepSeek Harness 移除(.+)$/.exec(text);
475
504
  if (match) return `Remove ${match[1]} from DeepSeek Harness`;
505
+ match = /^用于修复(.+)卡片按钮的一次性授权二维码$/.exec(text);
506
+ if (match) return `One-time QR code for repairing card buttons for ${match[1]}`;
507
+ match = /^正在修复「(.+)」$/.exec(text);
508
+ if (match) return `Repairing “${match[1]}”`;
509
+ match = /^修复(.+)的卡片按钮$/.exec(text);
510
+ if (match) return `Repair card buttons for ${match[1]}`;
511
+ match = /^(.+)的修复二维码已生成,请使用飞书扫码。$/.exec(text);
512
+ if (match) return `Repair QR code generated for ${match[1]}. Scan it with Feishu.`;
513
+ match = /^(.+)的卡片按钮已修复。$/.exec(text);
514
+ if (match) return `Card buttons repaired for ${match[1]}.`;
476
515
  match = /^(检查连接|重试连接)(.+)$/.exec(text);
477
516
  if (match) return `${localizeText(match[1])} ${match[2]}`;
478
517
  match = /^移除(.+)$/.exec(text);