@awiki/dsh-plugin 0.3.10 → 0.3.11-rc.2

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 (126) hide show
  1. package/README.md +14 -5
  2. package/README.zh.md +11 -4
  3. package/lib/client.js +2067 -272
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +536 -167
  6. package/lib/model-proxy-contract.js +8 -8
  7. package/lib/provider.js +32 -2
  8. package/lib/{sdk-adapter-CYcJH7sM.mjs → sdk-adapter-DpqT2en9.mjs} +17 -7
  9. package/lib/typert.host.js +181 -117
  10. package/lib/typert.remote-client.d.ts +3 -1
  11. package/lib/typert.remote-client.d.ts.map +1 -1
  12. package/lib/typert.remote-client.js +181 -117
  13. package/lib/types/base64.d.ts +4 -0
  14. package/lib/types/base64.d.ts.map +1 -0
  15. package/lib/types/base64.js +28 -0
  16. package/lib/types/base64.js.map +1 -0
  17. package/lib/types/client/AwikiIdentityAccess.d.ts +6 -4
  18. package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -1
  19. package/lib/types/client/AwikiIdentityAccess.js +147 -58
  20. package/lib/types/client/AwikiIdentityAccess.js.map +1 -1
  21. package/lib/types/client/AwikiMail.d.ts +1 -1
  22. package/lib/types/client/AwikiMail.d.ts.map +1 -1
  23. package/lib/types/client/AwikiMail.js +139 -6
  24. package/lib/types/client/AwikiMail.js.map +1 -1
  25. package/lib/types/client/AwikiOverlay.js +2 -2
  26. package/lib/types/client/AwikiOverlay.js.map +1 -1
  27. package/lib/types/client/AwikiRecoveryForm.d.ts +1 -0
  28. package/lib/types/client/AwikiRecoveryForm.d.ts.map +1 -1
  29. package/lib/types/client/AwikiRecoveryForm.js +3 -2
  30. package/lib/types/client/AwikiRecoveryForm.js.map +1 -1
  31. package/lib/types/client/AwikiSettingsSection.d.ts +1 -1
  32. package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
  33. package/lib/types/client/awiki-client-bridge.d.ts +1 -1
  34. package/lib/types/client/awiki-client-bridge.d.ts.map +1 -1
  35. package/lib/types/client/awiki-client-bridge.js +1 -1
  36. package/lib/types/client/awiki-client-bridge.js.map +1 -1
  37. package/lib/types/client/controller.d.ts +15 -1
  38. package/lib/types/client/controller.d.ts.map +1 -1
  39. package/lib/types/client/controller.js +303 -54
  40. package/lib/types/client/controller.js.map +1 -1
  41. package/lib/types/client/index.d.ts +1 -1
  42. package/lib/types/client/index.d.ts.map +1 -1
  43. package/lib/types/client/index.js +4 -1
  44. package/lib/types/client/index.js.map +1 -1
  45. package/lib/types/client/mail-attachment.d.ts +40 -0
  46. package/lib/types/client/mail-attachment.d.ts.map +1 -0
  47. package/lib/types/client/mail-attachment.js +187 -0
  48. package/lib/types/client/mail-attachment.js.map +1 -0
  49. package/lib/types/client/otp-cooldown.d.ts +1 -0
  50. package/lib/types/client/otp-cooldown.d.ts.map +1 -1
  51. package/lib/types/client/otp-cooldown.js +12 -1
  52. package/lib/types/client/otp-cooldown.js.map +1 -1
  53. package/lib/types/client/settings-controller.d.ts +4 -2
  54. package/lib/types/client/settings-controller.d.ts.map +1 -1
  55. package/lib/types/client/settings-controller.js +24 -8
  56. package/lib/types/client/settings-controller.js.map +1 -1
  57. package/lib/types/client/slots.d.ts +6 -1
  58. package/lib/types/client/slots.d.ts.map +1 -1
  59. package/lib/types/client/store.d.ts +1 -1
  60. package/lib/types/client/store.d.ts.map +1 -1
  61. package/lib/types/client/store.js +1 -1
  62. package/lib/types/client/store.js.map +1 -1
  63. package/lib/types/external-http-auth.d.ts +3 -1
  64. package/lib/types/external-http-auth.d.ts.map +1 -1
  65. package/lib/types/external-http-auth.js +2 -2
  66. package/lib/types/external-http-auth.js.map +1 -1
  67. package/lib/types/identity-handle.d.ts +5 -0
  68. package/lib/types/identity-handle.d.ts.map +1 -0
  69. package/lib/types/identity-handle.js +33 -0
  70. package/lib/types/identity-handle.js.map +1 -0
  71. package/lib/types/index.d.ts +11 -2
  72. package/lib/types/index.d.ts.map +1 -1
  73. package/lib/types/index.js +101 -20
  74. package/lib/types/index.js.map +1 -1
  75. package/lib/types/listener.d.ts.map +1 -1
  76. package/lib/types/listener.js +2 -1
  77. package/lib/types/listener.js.map +1 -1
  78. package/lib/types/mail-attachment-client.d.ts +14 -0
  79. package/lib/types/mail-attachment-client.d.ts.map +1 -0
  80. package/lib/types/mail-attachment-client.js +173 -0
  81. package/lib/types/mail-attachment-client.js.map +1 -0
  82. package/lib/types/mail.d.ts +28 -2
  83. package/lib/types/mail.d.ts.map +1 -1
  84. package/lib/types/mail.js +79 -2
  85. package/lib/types/mail.js.map +1 -1
  86. package/lib/types/model-proxy-contract.d.ts +8 -8
  87. package/lib/types/model-proxy-contract.d.ts.map +1 -1
  88. package/lib/types/model-proxy-contract.js +8 -8
  89. package/lib/types/model-proxy-contract.js.map +1 -1
  90. package/lib/types/package-version.generated.d.ts +2 -2
  91. package/lib/types/package-version.generated.d.ts.map +1 -1
  92. package/lib/types/package-version.generated.js +2 -2
  93. package/lib/types/package-version.generated.js.map +1 -1
  94. package/lib/types/provider.d.ts +1 -1
  95. package/lib/types/provider.d.ts.map +1 -1
  96. package/lib/types/provider.js +12 -1
  97. package/lib/types/provider.js.map +1 -1
  98. package/lib/types/registration-policy.d.ts +5 -0
  99. package/lib/types/registration-policy.d.ts.map +1 -0
  100. package/lib/types/registration-policy.js +13 -0
  101. package/lib/types/registration-policy.js.map +1 -0
  102. package/lib/types/sdk-adapter.d.ts +2 -1
  103. package/lib/types/sdk-adapter.d.ts.map +1 -1
  104. package/lib/types/sdk-adapter.js +13 -4
  105. package/lib/types/sdk-adapter.js.map +1 -1
  106. package/lib/types/settings-rpc-contract.d.ts +14 -14
  107. package/lib/types/settings-rpc-contract.d.ts.map +1 -1
  108. package/lib/types/settings-rpc-contract.js +14 -14
  109. package/lib/types/settings-rpc-contract.js.map +1 -1
  110. package/lib/types/settings-rpc.d.ts.map +1 -1
  111. package/lib/types/settings-rpc.js +2 -2
  112. package/lib/types/settings-rpc.js.map +1 -1
  113. package/lib/types/settings-transport.d.ts +7 -0
  114. package/lib/types/settings-transport.d.ts.map +1 -0
  115. package/lib/types/settings-transport.js +52 -0
  116. package/lib/types/settings-transport.js.map +1 -0
  117. package/lib/types/tools.d.ts +1 -1
  118. package/lib/types/tools.d.ts.map +1 -1
  119. package/lib/types/tools.js +20 -4
  120. package/lib/types/tools.js.map +1 -1
  121. package/lib/types/types.d.ts +39 -2
  122. package/lib/types/types.d.ts.map +1 -1
  123. package/lib/types/types.js.map +1 -1
  124. package/lib/types/update-policy.d.ts +2 -2
  125. package/lib/types/update-policy.d.ts.map +1 -1
  126. package/package.json +84 -75
@@ -1,6 +1,33 @@
1
1
  import { AwikiDraftStore } from "./drafts.js";
2
+ import { SHORT_HANDLE_INVITE_MESSAGE } from "../registration-policy.js";
2
3
  import { IndexedDbAwikiBrowserImageCache, } from "./image-cache.js";
3
4
  import { clearMailBrowserCache } from "./mail-list-cache.js";
5
+ const GENERIC_BUSINESS_FAILURE_MESSAGE = 'AWiki 暂时无法完成此操作,请稍后重试。';
6
+ const GENERIC_CARRIER_FAILURE_MESSAGE = '暂时无法连接 AWiki Host,请稍后重试。';
7
+ const GENERIC_THROW_FAILURE_MESSAGE = 'AWiki 调用暂时失败,请稍后重试。';
8
+ /** Project only a stable business code into Browser copy; provider messages remain Host-only. */
9
+ function genericFailureMessage(failure) {
10
+ switch (failure.code) {
11
+ case 'network':
12
+ return '无法连接 AWiki 服务,请检查网络后重试。';
13
+ case 'rate-limited':
14
+ return 'AWiki 请求过于频繁,请稍后重试。';
15
+ case 'not-registered':
16
+ return '请先注册 AWiki 身份后重试。';
17
+ case 'signed-out':
18
+ return '当前 AWiki 身份已退出,请重新进入后重试。';
19
+ case 'identity-recovery-required':
20
+ return '当前设备的 AWiki 身份凭证已失效,请重新恢复身份。';
21
+ case 'device-rejoin-required':
22
+ return '当前设备已被撤销,请重新申请加入。';
23
+ default:
24
+ return GENERIC_BUSINESS_FAILURE_MESSAGE;
25
+ }
26
+ }
27
+ /** Carrier diagnostics are not a Browser contract, including for unknown carrier codes. */
28
+ function genericCarrierFailureMessage(_failure) {
29
+ return GENERIC_CARRIER_FAILURE_MESSAGE;
30
+ }
4
31
  /** Turn a registration rejection into an actionable message without exposing remote response text. */
5
32
  function registrationFailureMessage(failure) {
6
33
  switch (failure.code) {
@@ -14,6 +41,8 @@ function registrationFailureMessage(failure) {
14
41
  return '验证码状态已失效,请重新获取验证码后再注册。';
15
42
  case 'handle-unavailable':
16
43
  return '该 Handle 刚刚已被注册。请返回身份入口,再按恢复流程重新获取验证码。';
44
+ case 'short-handle-invite-required':
45
+ return SHORT_HANDLE_INVITE_MESSAGE;
17
46
  case 'conflict':
18
47
  return '注册冲突:服务端可能已收到上次注册请求,或该手机号 / Handle 已绑定其他身份。请保留当前页面并再次提交;若仍失败,请勿清除本机身份数据,联系管理员并提供失败时间。';
19
48
  case 'rate-limited':
@@ -27,12 +56,14 @@ function registrationFailureMessage(failure) {
27
56
  case 'remote':
28
57
  return 'AWiki 服务暂时无法完成注册,请稍后重试;若持续失败,请联系管理员并提供失败时间。';
29
58
  default:
30
- return `${failure.code}:${failure.message}`;
59
+ return 'AWiki 服务暂时无法完成注册,请稍后重试;若持续失败,请联系管理员并提供失败时间。';
31
60
  }
32
61
  }
33
62
  /** Turn a verification-code request failure into a safe next action. */
34
63
  function registrationOtpFailureMessage(failure) {
35
64
  switch (failure.code) {
65
+ case 'short-handle-invite-required':
66
+ return SHORT_HANDLE_INVITE_MESSAGE;
36
67
  case 'rate-limited':
37
68
  return '验证码发送过于频繁,请等待限流解除后再重新获取。';
38
69
  case 'invalid-request':
@@ -62,7 +93,7 @@ function recoveryPreparationFailureMessage(failure) {
62
93
  case 'remote':
63
94
  return 'AWiki 服务暂时无法验证恢复信息,请稍后重试。';
64
95
  default:
65
- return `${failure.code}:${failure.message}`;
96
+ return 'AWiki 服务暂时无法验证恢复信息,请稍后重试。';
66
97
  }
67
98
  }
68
99
  function recoveryContinuationFailureMessage(failure, phase) {
@@ -84,7 +115,7 @@ function recoveryContinuationFailureMessage(failure, phase) {
84
115
  case 'forbidden':
85
116
  return 'AWiki 服务暂时未能完成恢复。可以重新检查结果,或返回入口稍后继续。';
86
117
  default:
87
- return `${failure.code}:${failure.message}`;
118
+ return '身份恢复暂未完成。请保留当前恢复操作并重新检查状态,不要重新获取验证码或创建新身份。';
88
119
  }
89
120
  }
90
121
  function identityAccessInspectionFailureMessage(failure) {
@@ -96,9 +127,10 @@ function identityAccessInspectionFailureMessage(failure) {
96
127
  case 'remote':
97
128
  return 'AWiki 服务暂时无法确认该 Handle 的状态,请稍后重试。';
98
129
  default:
99
- return `${failure.code}:${failure.message}`;
130
+ return 'AWiki 服务暂时无法确认该 Handle 的状态,请稍后重试。';
100
131
  }
101
132
  }
133
+ const IDENTITY_ACCESS_FAILURE_MESSAGE = '暂时无法读取本机身份状态,请稍后重新检查。';
102
134
  const INITIAL_VIEW = Object.freeze({
103
135
  status: 'cold',
104
136
  sessionStatus: 'unregistered',
@@ -126,6 +158,7 @@ const INITIAL_VIEW = Object.freeze({
126
158
  accessError: null,
127
159
  recoveryOperationId: null,
128
160
  recoveryProgress: null,
161
+ recoveryOtpRetryAt: null,
129
162
  });
130
163
  /** Turn a closed Host summary failure into one actionable Chinese message. */
131
164
  function summaryFailureMessage(failure) {
@@ -135,7 +168,7 @@ function summaryFailureMessage(failure) {
135
168
  case 'summary-cancelled': return 'AI 总结已取消,请重新生成。';
136
169
  case 'summary-invalid-output': return '模型没有返回有效的结构化摘要,请重新生成。';
137
170
  case 'summary-failed': return '暂时无法生成 AI 总结,请检查模型连接后重试。';
138
- default: return `${failure.code}:${failure.message}`;
171
+ default: return '暂时无法生成 AI 总结,请检查模型连接后重试。';
139
172
  }
140
173
  }
141
174
  /** Turn closed mail failures into safe, actionable browser messages. */
@@ -163,7 +196,7 @@ function groupCreateFailureMessage(failure) {
163
196
  case 'network': return '无法连接 AWiki 服务,请检查网络后重试。';
164
197
  case 'conflict': return '群聊创建状态发生冲突,请刷新群聊列表后确认是否已经创建。';
165
198
  case 'remote': return 'AWiki 服务暂时无法创建群聊,请稍后重试。';
166
- default: return `${failure.code}:${failure.message}`;
199
+ default: return 'AWiki 服务暂时无法创建群聊,请稍后重试。';
167
200
  }
168
201
  }
169
202
  /** Keep group authorization failures actionable without exposing raw service text. */
@@ -182,7 +215,7 @@ function groupReadFailureMessage(failure) {
182
215
  case 'remote':
183
216
  return 'AWiki 暂时无法读取这个群聊,请稍后重试。';
184
217
  default:
185
- return `${failure.code}:${failure.message}`;
218
+ return 'AWiki 暂时无法读取这个群聊,请稍后重试。';
186
219
  }
187
220
  }
188
221
  function conversationPreferenceFailureMessage(_failure) {
@@ -217,21 +250,18 @@ async function callGroupRead(operation) {
217
250
  }
218
251
  }
219
252
  /** Flatten the carrier and business result once for every controller caller. */
220
- async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`, carrierFailureMessage = message => `连接 AWiki Host 失败:${message}`) {
253
+ async function call(operation, failureMessage = genericFailureMessage, carrierFailureMessage = genericCarrierFailureMessage) {
221
254
  try {
222
255
  const carried = await operation();
223
256
  if (!carried.ok)
224
- return { ok: false, error: carrierFailureMessage(carried.error.message) };
257
+ return { ok: false, error: carrierFailureMessage(carried.error) };
225
258
  if (!carried.value.ok) {
226
259
  return { ok: false, error: failureMessage(carried.value.error) };
227
260
  }
228
261
  return { ok: true, value: carried.value.value };
229
262
  }
230
- catch (error) {
231
- return {
232
- ok: false,
233
- error: error instanceof Error ? `AWiki 调用失败:${error.message}` : 'AWiki 调用失败',
234
- };
263
+ catch {
264
+ return { ok: false, error: GENERIC_THROW_FAILURE_MESSAGE };
235
265
  }
236
266
  }
237
267
  /** Flatten the carrier and the isolated Guest Integration business result. */
@@ -239,9 +269,22 @@ async function callIntegration(operation) {
239
269
  try {
240
270
  const carried = await operation();
241
271
  if (!carried.ok)
242
- return { ok: false, error: `连接 AWiki Host 失败:${carried.error.message}` };
243
- if (!carried.value.ok)
244
- return { ok: false, error: carried.value.error.message };
272
+ return { ok: false, error: genericCarrierFailureMessage(carried.error) };
273
+ if (!carried.value.ok) {
274
+ const failure = carried.value.error;
275
+ const error = failure.code === 'not-found'
276
+ ? '尚未创建 Integration。'
277
+ : failure.code === 'conflict'
278
+ ? 'Integration 已在其他位置更新,请重新加载后再试。'
279
+ : failure.code === 'invalid-request'
280
+ ? 'Integration 信息不完整或格式不正确。'
281
+ : failure.code === 'unauthorized' || failure.code === 'forbidden'
282
+ ? '当前 AWiki 身份无权管理此 Integration。'
283
+ : failure.code === 'rate-limited'
284
+ ? 'Integration 操作过于频繁,请稍后重试。'
285
+ : '临时消息服务暂时不可用,请稍后重试。';
286
+ return { ok: false, error, failureCode: failure.code };
287
+ }
245
288
  return { ok: true, value: carried.value.value };
246
289
  }
247
290
  catch {
@@ -249,31 +292,28 @@ async function callIntegration(operation) {
249
292
  }
250
293
  }
251
294
  /** Preserve only the stable business code for controller-level state transitions. */
252
- async function callWithFailureCode(operation) {
295
+ async function callWithFailureCode(operation, failureMessage = genericFailureMessage) {
253
296
  try {
254
297
  const carried = await operation();
255
298
  if (!carried.ok)
256
- return { ok: false, error: `连接 AWiki Host 失败:${carried.error.message}` };
299
+ return { ok: false, error: genericCarrierFailureMessage(carried.error) };
257
300
  if (!carried.value.ok) {
258
301
  return {
259
302
  ok: false,
260
- error: `${carried.value.error.code}:${carried.value.error.message}`,
303
+ error: failureMessage(carried.value.error),
261
304
  failureCode: carried.value.error.code,
262
305
  };
263
306
  }
264
307
  return { ok: true, value: carried.value.value };
265
308
  }
266
- catch (error) {
267
- return {
268
- ok: false,
269
- error: error instanceof Error ? `AWiki 调用失败:${error.message}` : 'AWiki 调用失败',
270
- };
309
+ catch {
310
+ return { ok: false, error: GENERIC_THROW_FAILURE_MESSAGE };
271
311
  }
272
312
  }
273
- function recoveryCarrierFailureMessage(message) {
274
- return message.includes('business result failed boundary validation')
313
+ function recoveryCarrierFailureMessage(failure) {
314
+ return failure.code === 'invalid-response'
275
315
  ? '恢复信息已验证,但暂时无法读取恢复状态。请稍后重试。'
276
- : `连接 AWiki Host 失败:${message}`;
316
+ : '暂时无法连接 AWiki Host,无法完成身份恢复。请稍后重试。';
277
317
  }
278
318
  /** Append unique values while retaining existing references. */
279
319
  function appendUnique(current, incoming, id) {
@@ -373,11 +413,15 @@ const GROUP_HISTORY_RETRY_DELAYS_MS = [250, 750, 1_500, 2_500];
373
413
  /** Runtime-only decoded-byte budget that prevents repeat Host calls while browsing. */
374
414
  const BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES = 32 * 1024 * 1024;
375
415
  const RECOVERY_OPERATION_STORAGE_KEY = 'awiki.handle-recovery.operation.v1';
416
+ const RECOVERY_OTP_RETRY_STORAGE_KEY = 'awiki.handle-recovery.retry-at.v1';
376
417
  function recoveryOperationStorageKey(tenantId) {
377
418
  return tenantId === undefined || tenantId === ''
378
419
  ? RECOVERY_OPERATION_STORAGE_KEY
379
420
  : `${RECOVERY_OPERATION_STORAGE_KEY}.${encodeURIComponent(tenantId)}`;
380
421
  }
422
+ function recoveryTenantKey(tenantId) {
423
+ return tenantId === undefined || tenantId === '' ? 'default' : tenantId;
424
+ }
381
425
  function storeRecoveryOperation(operationId, tenantId) {
382
426
  try {
383
427
  const key = recoveryOperationStorageKey(tenantId);
@@ -390,6 +434,49 @@ function storeRecoveryOperation(operationId, tenantId) {
390
434
  // Recovery remains durable in Core even when browser storage is unavailable.
391
435
  }
392
436
  }
437
+ function recoveryOtpRetryStorageKey(operationId, tenantId) {
438
+ return `${RECOVERY_OTP_RETRY_STORAGE_KEY}.${encodeURIComponent(recoveryTenantKey(tenantId))}.${encodeURIComponent(operationId)}`;
439
+ }
440
+ function recoveryOtpRetryAt(result) {
441
+ const advertised = Date.parse(result.retryAt);
442
+ const deadline = Number.isFinite(advertised)
443
+ ? advertised
444
+ : Date.now() + Math.max(0, result.retryAfterSeconds) * 1_000;
445
+ return new Date(deadline).toISOString();
446
+ }
447
+ function storedRecoveryOtpRetryAt(operationId, tenantId) {
448
+ if (operationId === null)
449
+ return null;
450
+ try {
451
+ const key = recoveryOtpRetryStorageKey(operationId, tenantId);
452
+ const value = globalThis.localStorage?.getItem(key);
453
+ if (value === null || value === undefined)
454
+ return null;
455
+ const deadline = Date.parse(value);
456
+ if (!Number.isFinite(deadline) || deadline <= Date.now()) {
457
+ globalThis.localStorage?.removeItem(key);
458
+ return null;
459
+ }
460
+ return new Date(deadline).toISOString();
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ function storeRecoveryOtpRetryAt(operationId, retryAt, tenantId) {
467
+ try {
468
+ globalThis.localStorage?.setItem(recoveryOtpRetryStorageKey(operationId, tenantId), retryAt);
469
+ }
470
+ catch { }
471
+ }
472
+ function clearRecoveryOtpRetryAt(operationId, tenantId) {
473
+ if (operationId === null)
474
+ return;
475
+ try {
476
+ globalThis.localStorage?.removeItem(recoveryOtpRetryStorageKey(operationId, tenantId));
477
+ }
478
+ catch { }
479
+ }
393
480
  /** Wait between group-history readiness probes without retaining controller state. */
394
481
  function delay(milliseconds) {
395
482
  return new Promise(resolve => setTimeout(resolve, milliseconds));
@@ -439,6 +526,8 @@ export class AwikiController {
439
526
  drafts = new AwikiDraftStore();
440
527
  view = INITIAL_VIEW;
441
528
  listeners = new Set();
529
+ /** Runtime-only selected operations survive tenant round trips without reopening stale browser hints after reload. */
530
+ recoveryOperationsByTenant = new Map();
442
531
  config = null;
443
532
  conversationsCursor;
444
533
  historyCursor;
@@ -521,6 +610,9 @@ export class AwikiController {
521
610
  && previous.sessionStatus === session.value.status && previous.identity?.did === identity?.did;
522
611
  this.drafts.setScope(config.value.tenantId ?? 'default', identity?.did ?? session.value.status);
523
612
  this.activatePresentationCache(identity);
613
+ const selectedRecovery = previousTenant === config.value.tenantId
614
+ ? previous.recoveryOperationId
615
+ : this.recoveryOperationsByTenant.get(recoveryTenantKey(config.value.tenantId)) ?? null;
524
616
  this.publish({
525
617
  ...(sameOwner ? this.view : INITIAL_VIEW),
526
618
  status: 'ready',
@@ -529,7 +621,8 @@ export class AwikiController {
529
621
  error: null,
530
622
  attachmentMaxBytes: config.value.attachmentMaxBytes,
531
623
  handleRecoveryPhoneEnabled: config.value.handleRecoveryPhoneEnabled,
532
- recoveryOperationId: previousTenant === config.value.tenantId ? previous.recoveryOperationId : null,
624
+ recoveryOperationId: selectedRecovery,
625
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, config.value.tenantId),
533
626
  });
534
627
  await this.refreshIdentityAccess();
535
628
  if (!this.current(generation))
@@ -684,7 +777,10 @@ export class AwikiController {
684
777
  * @returns challenge retry metadata or one display-safe failure.
685
778
  */
686
779
  async sendRegistrationOtp(request) {
687
- return this.withPending('发送验证码', () => call(() => this.remote.sendRegistrationOtp(request), registrationOtpFailureMessage));
780
+ const result = await this.withPending('发送验证码', () => callWithFailureCode(() => this.remote.sendRegistrationOtp(request), registrationOtpFailureMessage));
781
+ return !result.ok && result.failureCode !== 'short-handle-invite-required'
782
+ ? { ok: false, error: result.error }
783
+ : result;
688
784
  }
689
785
  /** Discover Core-owned work without selecting an account or activating a session. */
690
786
  refreshIdentityAccess() {
@@ -705,28 +801,36 @@ export class AwikiController {
705
801
  if (!this.recoveryCurrent(generation, revision))
706
802
  return this.recoveryLeft();
707
803
  if (!result.ok) {
708
- this.publish({ ...this.view, accessLoading: false, accessError: result.error });
709
- return result;
804
+ this.publish({ ...this.view, accessLoading: false, accessError: IDENTITY_ACCESS_FAILURE_MESSAGE });
805
+ return { ok: false, error: IDENTITY_ACCESS_FAILURE_MESSAGE };
710
806
  }
711
807
  const candidates = result.value.recoveries;
712
808
  const selected = this.view.recoveryOperationId;
713
- this.publish({ ...this.view, identityAccess: result.value });
809
+ this.publish({ ...this.view, identityAccess: result.value, recoveryOperationId: selected,
810
+ recoveryProgress: selected === this.view.recoveryOperationId ? this.view.recoveryProgress : null,
811
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, this.config?.tenantId) });
714
812
  if (selected !== null) {
715
813
  const status = await callWithFailureCode(() => this.remote.getRecoveryStatus({ operationId: selected }));
716
814
  if (!this.recoveryCurrent(generation, revision))
717
815
  return this.recoveryLeft();
718
816
  if (!status.ok) {
719
817
  if (status.failureCode === 'not-found' && candidates.length === 0) {
720
- storeRecoveryOperation(null, this.config?.tenantId);
721
- this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null });
818
+ clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
819
+ this.rememberRecoveryOperation(null, this.config?.tenantId);
820
+ this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, recoveryOtpRetryAt: null });
722
821
  }
723
822
  else {
724
- this.publish({ ...this.view, accessLoading: false, accessError: status.error });
725
- return { ok: false, error: status.error };
823
+ this.publish({ ...this.view, accessLoading: false, accessError: IDENTITY_ACCESS_FAILURE_MESSAGE });
824
+ return { ok: false, error: IDENTITY_ACCESS_FAILURE_MESSAGE };
726
825
  }
727
826
  }
728
827
  else {
729
- this.publish({ ...this.view, recoveryProgress: status.value });
828
+ const complete = status.value.phase === 'applied';
829
+ if (complete)
830
+ clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
831
+ this.publish({ ...this.view, recoveryProgress: status.value,
832
+ recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, this.config?.tenantId),
833
+ identityAccess: result.value });
730
834
  }
731
835
  }
732
836
  this.publish({ ...this.view, accessLoading: false, accessError: null });
@@ -740,7 +844,9 @@ export class AwikiController {
740
844
  this.leaveRecovery();
741
845
  else
742
846
  this.recoveryRevision++;
743
- this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: null });
847
+ this.rememberRecoveryOperation(operationId, this.config?.tenantId);
848
+ this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: null,
849
+ recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, this.config?.tenantId) });
744
850
  const result = await this.refreshRecoveryStatus();
745
851
  return result.ok ? { ok: true, value: undefined } : result;
746
852
  }
@@ -752,6 +858,16 @@ export class AwikiController {
752
858
  recoveryLeft() {
753
859
  return { ok: false, error: '已离开此恢复流程;进度保留在本机。' };
754
860
  }
861
+ rememberRecoveryOperation(operationId, tenantId) {
862
+ const key = recoveryTenantKey(tenantId);
863
+ if (operationId === null) {
864
+ this.recoveryOperationsByTenant.delete(key);
865
+ storeRecoveryOperation(null, tenantId);
866
+ }
867
+ else {
868
+ this.recoveryOperationsByTenant.set(key, operationId);
869
+ }
870
+ }
755
871
  /** Navigation only. Never cancel or delete the Core-owned operation. */
756
872
  leaveRecovery() {
757
873
  this.recoveryRevision++;
@@ -761,7 +877,7 @@ export class AwikiController {
761
877
  this.pendingRequest = undefined;
762
878
  this.pendingRecovery = false;
763
879
  this.drafts.clearFlow();
764
- storeRecoveryOperation(null, this.config?.tenantId);
880
+ this.rememberRecoveryOperation(null, this.config?.tenantId);
765
881
  this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, accessError: null,
766
882
  accessLoading: false, pending: wasPending ? null : this.view.pending, error: null });
767
883
  }
@@ -792,10 +908,16 @@ export class AwikiController {
792
908
  */
793
909
  async registerIdentity(request) {
794
910
  const generation = this.generation;
795
- const result = await this.withPending('注册身份', () => call(() => this.remote.registerIdentity(request), registrationFailureMessage));
911
+ const classified = await this.withPending('注册身份', () => callWithFailureCode(() => this.remote.registerIdentity(request), registrationFailureMessage));
912
+ const result = !classified.ok && classified.failureCode !== 'short-handle-invite-required'
913
+ ? { ok: false, error: classified.error }
914
+ : classified;
796
915
  if (!result.ok) {
797
916
  if (this.current(generation))
798
917
  await this.refreshIdentityAccess();
918
+ if (this.current(generation) && result.failureCode === 'short-handle-invite-required') {
919
+ this.publish({ ...this.view, error: null });
920
+ }
799
921
  return result;
800
922
  }
801
923
  if (!this.current(generation))
@@ -839,6 +961,68 @@ export class AwikiController {
839
961
  return result.ok ? { ok: true, value: undefined } : result;
840
962
  });
841
963
  }
964
+ /** Cancel one exact Join choice and send Recovery OTP only while its original browser scope remains current. */
965
+ async beginRecoveryFromDeviceJoin(request) {
966
+ const generation = this.generation;
967
+ const tenantId = this.config?.tenantId;
968
+ const flowScope = this.drafts.getScope('recovery:retryDeadline');
969
+ const flowEpoch = this.drafts.epoch(flowScope);
970
+ const changed = () => ({
971
+ ok: false,
972
+ error: '恢复能力或身份环境已改变,请重新检查后再选择。',
973
+ });
974
+ const scopeCurrent = () => this.current(generation)
975
+ && this.config?.tenantId === tenantId
976
+ && this.drafts.getScope('recovery:retryDeadline') === flowScope
977
+ && this.drafts.epoch(flowScope) === flowEpoch;
978
+ if (!scopeCurrent())
979
+ return changed();
980
+ const latest = await call(() => this.remote.getConfig());
981
+ if (!scopeCurrent())
982
+ return changed();
983
+ if (!latest.ok)
984
+ return { ok: false, error: '暂时无法确认服务器的恢复能力,请稍后重试。' };
985
+ if (latest.value.tenantId !== tenantId)
986
+ return changed();
987
+ this.config = latest.value;
988
+ this.publish({ ...this.view, handleRecoveryPhoneEnabled: latest.value.handleRecoveryPhoneEnabled });
989
+ if (latest.value.tenantOnline === false) {
990
+ return { ok: false, error: '暂时无法确认服务器的恢复能力,请稍后重试。' };
991
+ }
992
+ if (!latest.value.handleRecoveryPhoneEnabled) {
993
+ return { ok: false, error: '当前服务器未启用手机号恢复,请加入新设备或联系管理员。' };
994
+ }
995
+ const current = () => scopeCurrent() && this.config?.handleRecoveryPhoneEnabled === true;
996
+ const result = await this.withPending('开始恢复身份', async () => {
997
+ const discarded = await call(() => this.remote.cancelDeviceJoin());
998
+ if (!discarded.ok)
999
+ return discarded;
1000
+ if (!current())
1001
+ return changed();
1002
+ this.publish({ ...this.view, identityAccess: { choice: null, joining: false, recoveries: [] } });
1003
+ if (request.phone === '')
1004
+ return { ok: true, value: null };
1005
+ return call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage);
1006
+ }, { publishFailure: false, recovery: true });
1007
+ if (!result.ok) {
1008
+ if (current())
1009
+ await this.continueRecoveryForHandle(request.fullHandle);
1010
+ return result;
1011
+ }
1012
+ if (result.value === null)
1013
+ return result;
1014
+ this.recordRecoveryOtp(result.value, tenantId);
1015
+ const sameFlow = this.config?.tenantId === tenantId
1016
+ && this.drafts.getScope('recovery:retryDeadline') === flowScope
1017
+ && this.drafts.epoch(flowScope) === flowEpoch;
1018
+ if (!sameFlow)
1019
+ return result;
1020
+ const retryAt = recoveryOtpRetryAt(result.value);
1021
+ this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null,
1022
+ recoveryOtpRetryAt: retryAt });
1023
+ await this.refreshRecoveryStatus();
1024
+ return result;
1025
+ }
842
1026
  retireDeviceIdentityForRejoin() {
843
1027
  return this.withPending('准备重新加入设备', async () => {
844
1028
  const result = await call(() => this.remote.retireDeviceIdentityForRejoin());
@@ -912,17 +1096,29 @@ export class AwikiController {
912
1096
  async sendRecoveryOtp(request) {
913
1097
  const generation = this.generation;
914
1098
  const revision = this.recoveryRevision;
1099
+ const tenantId = this.config?.tenantId;
1100
+ const flowScope = this.drafts.getScope('recovery:retryDeadline');
915
1101
  const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage), { publishFailure: false, recovery: true });
916
- if (!this.recoveryCurrent(generation, revision))
917
- return this.recoveryLeft();
918
1102
  if (!result.ok) {
1103
+ if (!this.recoveryCurrent(generation, revision))
1104
+ return this.recoveryLeft();
919
1105
  await this.continueRecoveryForHandle(request.fullHandle);
920
1106
  return result;
921
1107
  }
922
- this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null });
1108
+ this.recordRecoveryOtp(result.value, tenantId);
1109
+ if (!this.recoveryCurrent(generation, revision)
1110
+ || this.config?.tenantId !== tenantId
1111
+ || this.drafts.getScope('recovery:retryDeadline') !== flowScope)
1112
+ return result;
1113
+ this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null,
1114
+ recoveryOtpRetryAt: recoveryOtpRetryAt(result.value) });
923
1115
  await this.refreshRecoveryStatus();
924
1116
  return this.recoveryCurrent(generation, revision) ? result : this.recoveryLeft();
925
1117
  }
1118
+ recordRecoveryOtp(result, tenantId) {
1119
+ this.rememberRecoveryOperation(result.operationId, tenantId);
1120
+ storeRecoveryOtpRetryAt(result.operationId, recoveryOtpRetryAt(result), tenantId);
1121
+ }
926
1122
  /** Publish results only into the exact selected flow; Core work survives navigation. */
927
1123
  async runRecovery(label, operation, enterWhenApplied = false) {
928
1124
  const generation = this.generation;
@@ -935,14 +1131,16 @@ export class AwikiController {
935
1131
  return this.recoveryLeft();
936
1132
  if (!result.ok) {
937
1133
  // Reconcile authority after an ambiguous failure, never infer whether Commit occurred.
938
- const status = await call(() => this.remote.getRecoveryStatus({ operationId }));
1134
+ const status = await call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage);
939
1135
  if (!this.recoveryCurrent(generation, revision))
940
1136
  return this.recoveryLeft();
941
1137
  this.publish({ ...this.view, recoveryProgress: status.ok ? status.value : this.view.recoveryProgress,
942
- accessError: status.ok ? null : status.error });
1138
+ accessError: status.ok ? null : IDENTITY_ACCESS_FAILURE_MESSAGE });
943
1139
  return result;
944
1140
  }
945
1141
  this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1142
+ if (result.value.phase === 'applied')
1143
+ clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
946
1144
  if (enterWhenApplied && result.value.phase === 'applied')
947
1145
  return this.enterRecoveredSession();
948
1146
  return result;
@@ -960,7 +1158,25 @@ export class AwikiController {
960
1158
  }
961
1159
  /** Read-only even for applied operations. Entering the identity is a separate action. */
962
1160
  async refreshRecoveryStatus() {
963
- return this.runRecovery('刷新恢复状态', operationId => call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage));
1161
+ const generation = this.generation;
1162
+ const revision = this.recoveryRevision;
1163
+ const operationId = this.view.recoveryOperationId;
1164
+ if (operationId === null)
1165
+ return { ok: false, error: '没有可查询的身份恢复操作' };
1166
+ const result = await this.withPending('刷新恢复状态', async () => {
1167
+ const status = await call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage);
1168
+ return status.ok ? status : { ok: false, error: IDENTITY_ACCESS_FAILURE_MESSAGE };
1169
+ }, { publishFailure: false, recovery: true });
1170
+ if (!this.recoveryCurrent(generation, revision))
1171
+ return this.recoveryLeft();
1172
+ if (!result.ok) {
1173
+ this.publish({ ...this.view, accessError: IDENTITY_ACCESS_FAILURE_MESSAGE });
1174
+ return result;
1175
+ }
1176
+ this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
1177
+ if (result.value.phase === 'applied')
1178
+ clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1179
+ return result;
964
1180
  }
965
1181
  async resumeRecovery() {
966
1182
  const progress = this.view.recoveryProgress;
@@ -980,6 +1196,7 @@ export class AwikiController {
980
1196
  return this.recoveryLeft();
981
1197
  if (!result.ok)
982
1198
  return result;
1199
+ clearRecoveryOtpRetryAt(progress.operationId, this.config?.tenantId);
983
1200
  this.leaveRecovery();
984
1201
  const opened = await this.open();
985
1202
  return opened.ok ? result : opened;
@@ -999,10 +1216,23 @@ export class AwikiController {
999
1216
  return this.recoveryLeft();
1000
1217
  if (!result.ok)
1001
1218
  return result;
1219
+ clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
1002
1220
  this.leaveRecovery();
1003
1221
  void this.refreshIdentityAccess();
1004
1222
  return { ok: true, value: undefined };
1005
1223
  }
1224
+ /** Return cached browser-safe runtime policy, or load it once on demand. */
1225
+ async getConfig() {
1226
+ if (this.config !== null)
1227
+ return { ok: true, value: this.config };
1228
+ const generation = this.generation;
1229
+ const result = await call(() => this.remote.getConfig(), mailFailureMessage);
1230
+ if (!this.current(generation))
1231
+ return { ok: false, error: 'AWiki 会话已切换' };
1232
+ if (result.ok)
1233
+ this.config = result.value;
1234
+ return result;
1235
+ }
1006
1236
  /** Read the active deployment identity's public mailbox state. */
1007
1237
  getMailAccount() {
1008
1238
  return call(() => this.remote.getMailAccount(), mailFailureMessage);
@@ -1023,6 +1253,16 @@ export class AwikiController {
1023
1253
  sendMail(request) {
1024
1254
  return call(() => this.remote.sendMail(request), mailFailureMessage);
1025
1255
  }
1256
+ /** Download one explicitly selected mail attachment without retaining its bytes. */
1257
+ async downloadMailAttachment(request) {
1258
+ if (this.disposed)
1259
+ return { ok: false, error: 'AWiki 插件已卸载' };
1260
+ const generation = this.generation;
1261
+ const result = await call(() => this.remote.downloadMailAttachment(request), mailFailureMessage);
1262
+ if (!this.current(generation))
1263
+ return { ok: false, error: 'AWiki 已关闭' };
1264
+ return result;
1265
+ }
1026
1266
  /**
1027
1267
  * Load another page of the conversation roster.
1028
1268
  * @returns successful pagination or one display-safe failure.
@@ -1132,10 +1372,15 @@ export class AwikiController {
1132
1372
  return this.selectConversation(existing.id);
1133
1373
  }
1134
1374
  const generation = this.generation;
1135
- const resolved = await this.withPending('查找用户', () => call(() => this.remote.resolvePeer({ peer })));
1136
- if (!resolved.ok) {
1137
- return resolved.error.startsWith('not-found') ? this.fail('该 Handle 不存在') : resolved;
1138
- }
1375
+ const resolved = await this.withPending('查找用户', async () => {
1376
+ const result = await callWithFailureCode(() => this.remote.resolvePeer({ peer }));
1377
+ if (!result.ok) {
1378
+ return { ok: false, error: result.failureCode === 'not-found' ? '该 Handle 不存在' : result.error };
1379
+ }
1380
+ return result;
1381
+ });
1382
+ if (!resolved.ok)
1383
+ return resolved;
1139
1384
  if (!this.current(generation))
1140
1385
  return { ok: true, value: undefined };
1141
1386
  if (resolved.value.did === identity.did)
@@ -1811,18 +2056,22 @@ export class AwikiController {
1811
2056
  }
1812
2057
  catch { }
1813
2058
  }
1814
- storeRecoveryOperation(null, tenantId);
2059
+ clearRecoveryOtpRetryAt(this.view.recoveryOperationId, tenantId);
2060
+ this.rememberRecoveryOperation(null, tenantId);
1815
2061
  if (!this.current(generation))
1816
2062
  return result;
1817
2063
  this.drafts.clearScope();
1818
2064
  this.close();
1819
- this.config = null;
2065
+ // Preserve tenantId for the confirm-time getConfig scope check without reopening.
1820
2066
  this.conversationsCursor = undefined;
1821
2067
  this.historyCursor = undefined;
1822
2068
  this.unreadAtOpen.clear();
1823
2069
  this.summaryBaselines.clear();
1824
2070
  this.clearPresentationCache();
1825
- this.publish({ ...INITIAL_VIEW, status: 'ready', accessLoading: false, identityAccess: { choice: null, joining: false, recoveries: [] } });
2071
+ this.publish({ ...INITIAL_VIEW, status: 'ready', accessLoading: false,
2072
+ attachmentMaxBytes: this.config?.attachmentMaxBytes ?? INITIAL_VIEW.attachmentMaxBytes,
2073
+ handleRecoveryPhoneEnabled: this.config?.handleRecoveryPhoneEnabled ?? false,
2074
+ identityAccess: { choice: null, joining: false, recoveries: [] } });
1826
2075
  return result;
1827
2076
  }
1828
2077
  /** Read the Integration without coupling Guest Gateway health to the main AWiki view. */