@xmanrui/dsh-im 4.28.0 → 4.29.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 (33) hide show
  1. package/README.en.md +9 -7
  2. package/README.md +9 -7
  3. package/lib/client.js +370 -115
  4. package/lib/index.js +298 -298
  5. package/package.json +5 -1
  6. package/plugin-src/client/channels/feishu/api.js +3 -0
  7. package/plugin-src/client/channels/feishu/voice-editor.js +115 -0
  8. package/plugin-src/client/channels/feishu/voice-settings.js +71 -0
  9. package/plugin-src/client/delivery-settings.js +8 -0
  10. package/plugin-src/client/i18n.js +16 -0
  11. package/plugin-src/client/styles.js +11 -0
  12. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  13. package/plugin-src/host/channels/feishu/production.mjs +12 -1
  14. package/plugin-src/host/channels/feishu/rpc.mjs +21 -0
  15. package/plugin-src/host/channels/qq/production.mjs +1 -1
  16. package/plugin-src/host/channels/shared/production.mjs +1 -1
  17. package/plugin-src/host/channels/slack/production.mjs +1 -1
  18. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  19. package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
  20. package/plugin-src/host/channels/weixin/production.mjs +22 -5
  21. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  22. package/src/channels/feishu/bridge.mjs +165 -12
  23. package/src/channels/feishu/feishu-cards.mjs +4 -2
  24. package/src/channels/feishu/feishu-runtime.mjs +16 -0
  25. package/src/channels/feishu/multi-bot-controller.mjs +22 -0
  26. package/src/channels/feishu/plugin-config-store.mjs +2 -0
  27. package/src/channels/feishu/voice-config.mjs +34 -0
  28. package/src/channels/feishu/voice.mjs +134 -0
  29. package/src/channels/shared/bot-workspace-store.mjs +30 -10
  30. package/src/channels/shared/default-workspace.mjs +6 -3
  31. package/src/channels/shared/harness-approval.mjs +24 -17
  32. package/src/channels/shared/harness-session-binding.mjs +4 -0
  33. package/src/channels/weixin/weixin-api.mjs +3 -2
@@ -1,7 +1,7 @@
1
1
  import { t } from './i18n.mjs';
2
2
  import { atConnectionStage, connectionStageError, extractConnectionEvidence } from './connection-error.mjs';
3
3
  import { validateBotAlias, withBotAlias } from './bot-alias.mjs';
4
- import { defaultImWorkspace, sameWorkspacePath } from './default-workspace.mjs';
4
+ import { defaultImWorkspace, ensureImWorkspaceDirectory, sameWorkspacePath } from './default-workspace.mjs';
5
5
  import {
6
6
  mkdir,
7
7
  readFile,
@@ -367,13 +367,14 @@ async function writeStoredDocument(path, document) {
367
367
  await rename(temporary, path);
368
368
  }
369
369
 
370
- export async function validateWorkspacePath(value) {
370
+ export async function validateWorkspacePath(value, { ungroupedWorkspace } = {}) {
371
371
  if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
372
372
  const error = new Error('工作区必须是绝对路径。');
373
373
  error.code = 'workspace-not-absolute';
374
374
  throw error;
375
375
  }
376
376
  const workspace = resolve(value.trim());
377
+ await ensureImWorkspaceDirectory(workspace, ungroupedWorkspace);
377
378
  let info;
378
379
  try {
379
380
  info = await stat(workspace);
@@ -393,6 +394,7 @@ export async function validateWorkspacePath(value) {
393
394
  export class BotWorkspaceStore {
394
395
  #path;
395
396
  #defaultWorkspace;
397
+ #ungroupedWorkspace;
396
398
  #version = 1;
397
399
  #workspaces = {};
398
400
  #agentPresets = {};
@@ -414,10 +416,11 @@ export class BotWorkspaceStore {
414
416
  #writeQueue = Promise.resolve();
415
417
  #botQueues = new Map();
416
418
 
417
- constructor(path, { defaultWorkspace = defaultImWorkspace() } = {}) {
419
+ constructor(path, { defaultWorkspace = defaultImWorkspace(), ungroupedWorkspace } = {}) {
418
420
  if (typeof path !== 'string' || !path) throw new TypeError('workspace store path is required');
419
421
  this.#path = path;
420
422
  this.#defaultWorkspace = resolve(defaultWorkspace);
423
+ this.#ungroupedWorkspace = ungroupedWorkspace;
421
424
  }
422
425
 
423
426
  async load() {
@@ -691,11 +694,20 @@ export class BotWorkspaceStore {
691
694
  const createsBot = !this.#workspaces[id];
692
695
  const initializesAccessPolicy = initialAccessPolicy !== undefined
693
696
  && !Object.hasOwn(this.#accessPolicies, id);
697
+ const accessPolicy = initializesAccessPolicy
698
+ ? validateAccessPolicy(initialAccessPolicy)
699
+ : undefined;
700
+ const agentPreset = createsBot ? validateAgentPresetId(defaultAgentPreset) : null;
701
+ // Resolve saved bot/conversation choices before preparing the shared IM
702
+ // directory. Merely loading a channel must not create an unused fallback.
703
+ const selectedWorkspaces = new Set([
704
+ this.#workspaces[id] ?? initialWorkspace,
705
+ ...Object.values(this.#conversationWorkspaces[id] ?? {}),
706
+ ]);
707
+ for (const selected of selectedWorkspaces) {
708
+ await ensureImWorkspaceDirectory(selected, this.#ungroupedWorkspace);
709
+ }
694
710
  if (createsBot || initializesAccessPolicy) {
695
- const accessPolicy = initializesAccessPolicy
696
- ? validateAccessPolicy(initialAccessPolicy)
697
- : undefined;
698
- const agentPreset = createsBot ? validateAgentPresetId(defaultAgentPreset) : null;
699
711
  const hadAgentPreset = Object.hasOwn(this.#agentPresets, id);
700
712
  const previousAgentPreset = this.#agentPresets[id];
701
713
  const nextAccessPolicies = initializesAccessPolicy
@@ -744,7 +756,7 @@ export class BotWorkspaceStore {
744
756
  error.code = 'workspace-bot-not-found';
745
757
  throw error;
746
758
  }
747
- const workspace = await validateWorkspacePath(value);
759
+ const workspace = await validateWorkspacePath(value, { ungroupedWorkspace: this.#ungroupedWorkspace });
748
760
  return this.#enqueue(id, async () => {
749
761
  if (!this.has(id)
750
762
  || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
@@ -965,7 +977,9 @@ export class BotWorkspaceStore {
965
977
  }
966
978
  // Validate before queueing so an invalid path fails without disturbing the
967
979
  // conversation's current workspace or its session.
968
- const workspace = value === null ? null : await validateWorkspacePath(value);
980
+ const workspace = value === null ? null : await validateWorkspacePath(value, {
981
+ ungroupedWorkspace: this.#ungroupedWorkspace,
982
+ });
969
983
  return this.#enqueue(id, async () => {
970
984
  const assertCurrentSwitch = () => {
971
985
  if (!this.has(id)
@@ -981,6 +995,10 @@ export class BotWorkspaceStore {
981
995
  }
982
996
  };
983
997
  assertCurrentSwitch();
998
+ if (workspace === null) {
999
+ await ensureImWorkspaceDirectory(this.workspaceFor(id), this.#ungroupedWorkspace);
1000
+ assertCurrentSwitch();
1001
+ }
984
1002
  const previousOverrides = this.#conversationWorkspaces[id];
985
1003
  const previous = previousOverrides?.[key];
986
1004
  const hadOverride = Boolean(previousOverrides)
@@ -1065,7 +1083,9 @@ export class BotWorkspaceStore {
1065
1083
  );
1066
1084
  }
1067
1085
  };
1068
- const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value));
1086
+ const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value, {
1087
+ ungroupedWorkspace: this.#ungroupedWorkspace,
1088
+ }));
1069
1089
  return this.#enqueue(id, async () => {
1070
1090
  if (!this.has(id)
1071
1091
  || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
@@ -21,8 +21,11 @@ export async function sameWorkspacePath(left, right) {
21
21
  export async function prepareBotWorkspace(config = {}) {
22
22
  const ungroupedWorkspace = defaultImWorkspace(config);
23
23
  const defaultWorkspace = resolve(config.workspace ?? ungroupedWorkspace);
24
- if (await sameWorkspacePath(defaultWorkspace, ungroupedWorkspace)) {
25
- await mkdir(defaultWorkspace, { recursive: true });
26
- }
27
24
  return { defaultWorkspace, ungroupedWorkspace };
28
25
  }
26
+
27
+ export async function ensureImWorkspaceDirectory(workspace, ungroupedWorkspace) {
28
+ if (await sameWorkspacePath(workspace, ungroupedWorkspace)) {
29
+ await mkdir(workspace, { recursive: true });
30
+ }
31
+ }
@@ -272,6 +272,7 @@ export class HarnessApprovalQueue {
272
272
  requiresMention: context.requiresMention === true,
273
273
  send,
274
274
  render,
275
+ onResolved: typeof context.onResolved === 'function' ? context.onResolved : null,
275
276
  text,
276
277
  presented: false,
277
278
  presentationTask: null,
@@ -328,8 +329,7 @@ export class HarnessApprovalQueue {
328
329
  delivered = await presentationTask.then(() => true, () => false);
329
330
  }
330
331
  if (shouldNotify && delivered) {
331
- pending.resolutionNotified = true;
332
- await send(approvalOutcomeText(resolution.outcome)).catch(() => undefined);
332
+ await this.#notifyResolved(pending, resolution.outcome, send);
333
333
  }
334
334
  });
335
335
  return true;
@@ -347,16 +347,14 @@ export class HarnessApprovalQueue {
347
347
  { signal: AbortSignal.timeout(5_000) },
348
348
  );
349
349
  pending.closedOutcome = 'rejected';
350
- if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
351
- pending.resolutionNotified = true;
352
- await pending.send(approvalOutcomeText('rejected')).catch(() => undefined);
350
+ if (pending.presented || pending.deliveryCompleted) {
351
+ await this.#notifyResolved(pending, 'rejected');
353
352
  }
354
353
  } catch (error) {
355
354
  if (error?.code === 'interaction-not-pending') {
356
355
  pending.closedOutcome = 'resolved';
357
- if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
358
- pending.resolutionNotified = true;
359
- await pending.send(t(APPROVAL_RESOLVED_TEXT)).catch(() => undefined);
356
+ if (pending.presented || pending.deliveryCompleted) {
357
+ await this.#notifyResolved(pending, 'resolved');
360
358
  }
361
359
  } else {
362
360
  this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error);
@@ -383,9 +381,8 @@ export class HarnessApprovalQueue {
383
381
  pending.deliveryCompleted = true;
384
382
  if (!pending.inactive) {
385
383
  pending.presented = true;
386
- } else if (pending.closedOutcome && !pending.resolutionNotified) {
387
- pending.resolutionNotified = true;
388
- await pending.send(approvalOutcomeText(pending.closedOutcome)).catch(() => undefined);
384
+ } else if (pending.closedOutcome) {
385
+ await this.#notifyResolved(pending, pending.closedOutcome);
389
386
  }
390
387
  } finally {
391
388
  if (pending.presentationTask === task) pending.presentationTask = null;
@@ -401,9 +398,7 @@ export class HarnessApprovalQueue {
401
398
  const send = pending.send;
402
399
  const next = this.#remove(pending);
403
400
  await this.#transition(next, async () => {
404
- if (!pending.resolutionNotified) {
405
- await send(t(APPROVAL_RESOLVED_TEXT)).catch(() => undefined);
406
- }
401
+ await this.#notifyResolved(pending, 'resolved', send);
407
402
  });
408
403
  return;
409
404
  }
@@ -417,12 +412,24 @@ export class HarnessApprovalQueue {
417
412
  const send = pending.send;
418
413
  const next = this.#remove(pending);
419
414
  await this.#transition(next, async () => {
420
- if (!pending.resolutionNotified) {
421
- await send(approvalOutcomeText(outcome)).catch(() => undefined);
422
- }
415
+ await this.#notifyResolved(pending, outcome, send);
423
416
  });
424
417
  }
425
418
 
419
+ async #notifyResolved(pending, outcome, send = pending.send) {
420
+ if (pending.resolutionNotified) return;
421
+ // Claim notification before awaiting the channel, so a concurrent close or
422
+ // resolved event cannot replace the confirmed result or notify twice.
423
+ pending.resolutionNotified = true;
424
+ const text = approvalOutcomeText(outcome);
425
+ try {
426
+ await pending.onResolved?.(text);
427
+ } catch (error) {
428
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to update a resolved approval:`, error);
429
+ }
430
+ await send(text).catch(() => undefined);
431
+ }
432
+
426
433
  async #transition(next, work) {
427
434
  let release;
428
435
  const barrier = new Promise((resolve) => { release = resolve; });
@@ -1,3 +1,4 @@
1
+ import { mkdir } from 'node:fs/promises';
1
2
  import { isAbsolute } from 'node:path';
2
3
 
3
4
  const MAX_SESSION_ID_LENGTH = 256;
@@ -99,6 +100,9 @@ export async function adoptRegisteredWorkspaceSession(client, value, options = {
99
100
  }
100
101
  workspace = { path: summary.cwd };
101
102
  }
103
+ if (await client.isUngroupedWorkspace?.(workspace.path)) {
104
+ await mkdir(workspace.path, { recursive: true });
105
+ }
102
106
  const adopted = await client.rpc('session.create', {
103
107
  ...(workspace.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: workspace.path }),
104
108
  sessionId,
@@ -504,8 +504,9 @@ function validateLoginResponse(value) {
504
504
  return value;
505
505
  }
506
506
 
507
- export function createWeixinApi({ fetchImpl = fetch } = {}) {
507
+ export function createWeixinApi({ fetchImpl = fetch, uploadFetchImpl = fetchImpl } = {}) {
508
508
  if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
509
+ if (typeof uploadFetchImpl !== 'function') throw new TypeError('uploadFetchImpl must be a function');
509
510
 
510
511
  async function sendArtifact({
511
512
  baseUrl,
@@ -566,7 +567,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
566
567
  const ciphertextSize = aesEcbPaddedSize(file.bytes.byteLength);
567
568
  let downloadParam;
568
569
  try {
569
- downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, file.bytes, aesKey, { signal });
570
+ downloadParam = await uploadWeixinCdn(uploadFetchImpl, uploadUrl, file.bytes, aesKey, { signal });
570
571
  } catch (error) {
571
572
  if (signal?.aborted) throw abortError(signal);
572
573
  const status = Number(error?.status);