@xmanrui/dsh-im 0.4.0 → 0.5.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 (64) hide show
  1. package/README.md +30 -0
  2. package/lib/client.js +709 -285
  3. package/lib/index.js +112 -110
  4. package/package.json +1 -1
  5. package/plugin-src/client/channels/dingtalk/api.js +2 -0
  6. package/plugin-src/client/channels/dingtalk/index.js +67 -14
  7. package/plugin-src/client/channels/feishu/api.js +2 -0
  8. package/plugin-src/client/channels/feishu/index.js +70 -22
  9. package/plugin-src/client/channels/qq/api.js +2 -0
  10. package/plugin-src/client/channels/qq/index.js +45 -8
  11. package/plugin-src/client/channels/shared/token-api.js +2 -0
  12. package/plugin-src/client/channels/shared/token-channel.js +34 -8
  13. package/plugin-src/client/channels/wecom/api.js +2 -0
  14. package/plugin-src/client/channels/wecom/index.js +45 -8
  15. package/plugin-src/client/channels/weixin/api.js +2 -0
  16. package/plugin-src/client/channels/weixin/index.js +68 -8
  17. package/plugin-src/client/channels/whatsapp/api.js +2 -0
  18. package/plugin-src/client/channels/whatsapp/index.js +27 -5
  19. package/plugin-src/client/i18n.js +13 -0
  20. package/plugin-src/client/styles.js +13 -0
  21. package/plugin-src/client/workspace-editor.js +96 -0
  22. package/plugin-src/client/workspace-snapshot-fence.js +28 -0
  23. package/plugin-src/host/channels/dingtalk/production.mjs +34 -11
  24. package/plugin-src/host/channels/dingtalk/rpc.mjs +17 -2
  25. package/plugin-src/host/channels/feishu/production.mjs +42 -5
  26. package/plugin-src/host/channels/feishu/rpc.mjs +17 -2
  27. package/plugin-src/host/channels/qq/production.mjs +48 -22
  28. package/plugin-src/host/channels/qq/rpc.mjs +16 -2
  29. package/plugin-src/host/channels/shared/production.mjs +48 -22
  30. package/plugin-src/host/channels/shared/rpc.mjs +15 -0
  31. package/plugin-src/host/channels/shared/workspace-rpc.mjs +25 -0
  32. package/plugin-src/host/channels/slack/production.mjs +48 -23
  33. package/plugin-src/host/channels/slack/rpc.mjs +16 -0
  34. package/plugin-src/host/channels/wecom/production.mjs +49 -23
  35. package/plugin-src/host/channels/wecom/rpc.mjs +16 -2
  36. package/plugin-src/host/channels/weixin/production.mjs +31 -11
  37. package/plugin-src/host/channels/weixin/rpc.mjs +21 -2
  38. package/plugin-src/host/channels/whatsapp/production.mjs +49 -23
  39. package/plugin-src/host/channels/whatsapp/rpc.mjs +16 -2
  40. package/src/channels/dingtalk/dingtalk-bridge.mjs +22 -11
  41. package/src/channels/dingtalk/dingtalk-controller.mjs +6 -1
  42. package/src/channels/dingtalk/harness-client.mjs +4 -3
  43. package/src/channels/dingtalk/state-store.mjs +5 -0
  44. package/src/channels/discord/discord-api.mjs +1 -1
  45. package/src/channels/feishu/bridge.mjs +36 -16
  46. package/src/channels/feishu/harness-client.mjs +6 -5
  47. package/src/channels/feishu/state-store.mjs +5 -0
  48. package/src/channels/qq/qq-bridge.mjs +25 -15
  49. package/src/channels/qq/qq-controller.mjs +8 -1
  50. package/src/channels/qq/state-store.mjs +5 -0
  51. package/src/channels/shared/bot-workspace-store.mjs +584 -0
  52. package/src/channels/shared/conversation-state-store.mjs +5 -0
  53. package/src/channels/shared/text-harness-bridge.mjs +23 -13
  54. package/src/channels/shared/workspace-command.mjs +26 -0
  55. package/src/channels/shared/workspace-session.mjs +44 -0
  56. package/src/channels/wecom/state-store.mjs +5 -0
  57. package/src/channels/wecom/wecom-bridge.mjs +22 -13
  58. package/src/channels/wecom/wecom-controller.mjs +8 -1
  59. package/src/channels/weixin/harness-client.mjs +6 -5
  60. package/src/channels/weixin/state-store.mjs +5 -0
  61. package/src/channels/weixin/weixin-api.mjs +1 -1
  62. package/src/channels/weixin/weixin-bridge.mjs +16 -6
  63. package/src/channels/weixin/weixin-controller.mjs +6 -1
  64. package/src/channels/whatsapp/whatsapp-controller.mjs +8 -1
@@ -0,0 +1,96 @@
1
+ import * as React from 'react';
2
+
3
+ import { h } from './i18n.js';
4
+
5
+ function looksLikeAbsolutePath(value) {
6
+ return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
7
+ }
8
+
9
+ export function WorkspaceEditor({ workspace, disabled = false, onSave }) {
10
+ const [editing, setEditing] = React.useState(false);
11
+ const [draft, setDraft] = React.useState(workspace ?? '');
12
+ const [saving, setSaving] = React.useState(false);
13
+ const [error, setError] = React.useState(null);
14
+ const inputRef = React.useRef(null);
15
+ const editButtonRef = React.useRef(null);
16
+ const restoreFocus = React.useRef(false);
17
+
18
+ React.useEffect(() => {
19
+ if (!editing) setDraft(workspace ?? '');
20
+ }, [workspace, editing]);
21
+
22
+ React.useEffect(() => {
23
+ if (editing) inputRef.current?.focus();
24
+ else if (restoreFocus.current) {
25
+ restoreFocus.current = false;
26
+ editButtonRef.current?.focus();
27
+ }
28
+ }, [editing]);
29
+
30
+ const cancel = () => {
31
+ setDraft(workspace ?? '');
32
+ setError(null);
33
+ restoreFocus.current = true;
34
+ setEditing(false);
35
+ };
36
+
37
+ const submit = async (event) => {
38
+ event.preventDefault();
39
+ const value = draft.trim();
40
+ if (!value || saving || disabled) return;
41
+ if (!looksLikeAbsolutePath(value)) {
42
+ setError('工作区必须是绝对路径。');
43
+ return;
44
+ }
45
+ setSaving(true);
46
+ setError(null);
47
+ try {
48
+ await onSave?.(value);
49
+ restoreFocus.current = true;
50
+ setEditing(false);
51
+ } catch (cause) {
52
+ setError(cause?.message ?? '工作区修改失败,请重试。');
53
+ } finally {
54
+ setSaving(false);
55
+ }
56
+ };
57
+
58
+ return h('div', { className: 'dim-workspace' },
59
+ h('div', { className: 'dim-workspaceHeader' },
60
+ h('span', null, '当前工作区'),
61
+ editing ? null : h('button', {
62
+ type: 'button',
63
+ ref: editButtonRef,
64
+ className: 'dim-workspaceEdit',
65
+ onClick: () => { setEditing(true); setError(null); },
66
+ disabled,
67
+ }, '修改')),
68
+ editing
69
+ ? h('form', { className: 'dim-workspaceForm', onSubmit: submit },
70
+ h('input', {
71
+ ref: inputRef,
72
+ value: draft,
73
+ onChange: (event) => setDraft(event.target.value),
74
+ placeholder: '/绝对路径/到/工作区',
75
+ 'aria-label': '工作区绝对路径',
76
+ autoCapitalize: 'none',
77
+ autoCorrect: 'off',
78
+ spellCheck: false,
79
+ maxLength: 4_096,
80
+ required: true,
81
+ disabled: saving || disabled,
82
+ }),
83
+ h('div', { className: 'dim-workspaceActions' },
84
+ h('button', {
85
+ type: 'submit', disabled: saving || disabled || !draft.trim(),
86
+ }, saving ? '保存中…' : '保存'),
87
+ h('button', { type: 'button', onClick: cancel, disabled: saving || disabled }, '取消')),
88
+ error ? h('p', { className: 'dim-workspaceError', role: 'alert' }, error) : null)
89
+ : workspace
90
+ ? React.createElement('code', {
91
+ className: 'dim-workspacePath',
92
+ title: workspace,
93
+ }, workspace)
94
+ : h('code', { className: 'dim-workspacePath' }, '未设置'),
95
+ );
96
+ }
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+
3
+ /** Keep every full-snapshot response ordered behind the latest client mutation. */
4
+ export function useWorkspaceSnapshotFence() {
5
+ const state = React.useRef({ version: 0, pendingMutations: 0 });
6
+ return React.useMemo(() => Object.freeze({
7
+ beginStatus() {
8
+ return state.current.pendingMutations === 0 ? state.current.version : null;
9
+ },
10
+ canCommitStatus(version) {
11
+ return version !== null
12
+ && state.current.pendingMutations === 0
13
+ && state.current.version === version;
14
+ },
15
+ beginMutation() {
16
+ state.current.pendingMutations += 1;
17
+ state.current.version += 1;
18
+ return state.current.version;
19
+ },
20
+ canCommitMutation(version) {
21
+ return state.current.version === version;
22
+ },
23
+ endMutation() {
24
+ state.current.pendingMutations = Math.max(0, state.current.pendingMutations - 1);
25
+ return state.current.pendingMutations === 0;
26
+ },
27
+ }), []);
28
+ }
@@ -8,6 +8,12 @@ import { DingtalkController } from '../../../../src/channels/dingtalk/dingtalk-c
8
8
  import { DingtalkRuntime } from '../../../../src/channels/dingtalk/dingtalk-runtime.mjs';
9
9
  import { HarnessClient } from '../../../../src/channels/dingtalk/harness-client.mjs';
10
10
  import { DingtalkStateStore } from '../../../../src/channels/dingtalk/state-store.mjs';
11
+ import {
12
+ BotWorkspaceStore,
13
+ createBotWorkspaceScope,
14
+ createWorkspaceAwareController,
15
+ observeBotWorkspaceRemovals,
16
+ } from '../../../../src/channels/shared/bot-workspace-store.mjs';
11
17
  import { createConnectionSupervisor } from './connection-supervisor.mjs';
12
18
 
13
19
  function harnessOrigin(webServer, configured) {
@@ -26,6 +32,7 @@ function pluginPaths(config) {
26
32
  root,
27
33
  config: resolve(config.configPath ?? join(root, 'config.json')),
28
34
  bots: resolve(config.botsDir ?? join(root, 'bots')),
35
+ workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
29
36
  };
30
37
  }
31
38
 
@@ -45,6 +52,19 @@ export async function createProductionController(ctx, config = {}, internals = {
45
52
  : (ctx.logger ?? console);
46
53
  const paths = pluginPaths(config);
47
54
  const configStore = await new ConfigStore(paths.config).load();
55
+ const defaultWorkspace = resolve(config.workspace ?? process.cwd());
56
+ const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
57
+ const workspaces = internals.workspaces
58
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
59
+ const canListConfiguredBots = typeof configStore.list === 'function';
60
+ const configuredBots = canListConfiguredBots ? configStore.list() : [];
61
+ if (canListConfiguredBots) {
62
+ await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
63
+ }
64
+ await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
65
+ const observedConfigStore = typeof configStore.remove === 'function'
66
+ ? observeBotWorkspaceRemovals(configStore, { workspaces })
67
+ : configStore;
48
68
  const deviceAuth = internals.deviceAuth ?? new DeviceAuth({
49
69
  baseUrl: config.registrationBaseUrl,
50
70
  });
@@ -61,23 +81,25 @@ export async function createProductionController(ctx, config = {}, internals = {
61
81
  };
62
82
  const harness = new Harness({
63
83
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
64
- workspace: resolve(config.workspace ?? process.cwd()),
84
+ workspace: defaultWorkspace,
65
85
  agentPreset: config.agentPreset ?? 'standard',
66
86
  autostart: false,
67
87
  dshBin: config.dshBin ?? 'dsh',
68
88
  });
69
- const controller = new Controller({
89
+ const coreController = new Controller({
70
90
  deviceAuth,
71
91
  credentials: ctx.credentials,
72
- configStore,
92
+ configStore: observedConfigStore,
73
93
  logger,
74
94
  createRuntime: async ({ botId, config: botConfig, clientSecret }) => {
75
95
  const state = await stateFor(botId);
96
+ await workspaces.ensure(botId);
97
+ const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
76
98
  return new Runtime({
77
99
  config: botConfig,
78
100
  clientSecret,
79
- harness,
80
- state,
101
+ harness: workspaceScope.harness,
102
+ state: workspaceScope.state,
81
103
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
82
104
  maxMessageChars: config.maxMessageChars ?? 4_000,
83
105
  connectTimeoutMs: config.connectTimeoutMs ?? 15_000,
@@ -94,15 +116,16 @@ export async function createProductionController(ctx, config = {}, internals = {
94
116
  stateStores.delete(botId);
95
117
  if (state && typeof state.remove === 'function') {
96
118
  await state.remove();
97
- return;
98
- }
99
- try {
100
- await unlink(statePath(botId));
101
- } catch (error) {
102
- if (error?.code !== 'ENOENT') throw error;
119
+ } else {
120
+ try {
121
+ await unlink(statePath(botId));
122
+ } catch (error) {
123
+ if (error?.code !== 'ENOENT') throw error;
124
+ }
103
125
  }
104
126
  },
105
127
  });
128
+ const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
106
129
  const supervisor = createSupervisor({
107
130
  controller,
108
131
  harness,
@@ -1,5 +1,6 @@
1
1
  import QRCode from 'qrcode';
2
2
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
+ import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
3
4
 
4
5
  export const DINGTALK_RPC_CHANNEL = '/dingtalk';
5
6
  export const DINGTALK_ENDPOINTS = Object.freeze({
@@ -10,6 +11,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
10
11
  bindCredentials: 'bot.bind-credentials',
11
12
  reconnectBot: 'bot.reconnect',
12
13
  deleteBot: 'bot.delete',
14
+ setWorkspace: SET_WORKSPACE_ENDPOINT,
13
15
  approveSender: 'bot.sender.approve',
14
16
  revokeSender: 'bot.sender.revoke',
15
17
  });
@@ -76,6 +78,10 @@ function payloadFailure(endpoint, payload) {
76
78
  ? null
77
79
  : 'bot.delete requires a botId and confirm=true.';
78
80
  }
81
+ if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {
82
+ return validWorkspacePayload(payload)
83
+ ? null : '请输入工作区绝对路径。';
84
+ }
79
85
  if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
80
86
  return exactKeys(payload, ['botId', 'requestId', 'confirm'])
81
87
  && validId(payload.botId)
@@ -207,6 +213,12 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
207
213
  value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
208
214
  } else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
209
215
  value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
216
+ } else if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {
217
+ if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
218
+ value = await publicStatus(
219
+ await controller.updateWorkspace(payload.botId, payload.workspace),
220
+ cachedEncode,
221
+ );
210
222
  } else if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
211
223
  value = await publicStatus(
212
224
  await controller.approveSender(payload.botId, payload.requestId),
@@ -219,8 +231,11 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
219
231
  );
220
232
  }
221
233
  return signal?.aborted ? cancelled() : { ok: true, value };
222
- } catch {
223
- return signal?.aborted ? cancelled() : internalFailure();
234
+ } catch (error) {
235
+ const workspaceError = publicWorkspaceError(error);
236
+ return signal?.aborted ? cancelled() : workspaceError
237
+ ? { ok: false, error: workspaceError }
238
+ : internalFailure();
224
239
  }
225
240
  };
226
241
  }
@@ -12,6 +12,12 @@ import {
12
12
  } from '../../../../src/channels/feishu/plugin-config-store.mjs';
13
13
  import { MultiBotDshFeishuController } from '../../../../src/channels/feishu/multi-bot-controller.mjs';
14
14
  import { StateStore } from '../../../../src/channels/feishu/state-store.mjs';
15
+ import {
16
+ BotWorkspaceStore,
17
+ createBotWorkspaceScope,
18
+ createWorkspaceAwareController,
19
+ observeBotWorkspaceRemovals,
20
+ } from '../../../../src/channels/shared/bot-workspace-store.mjs';
15
21
 
16
22
  function harnessOrigin(webServer, configured) {
17
23
  if (configured !== undefined) return new URL(configured);
@@ -34,6 +40,7 @@ function pluginPaths(config) {
34
40
  config: resolve(config.configPath ?? join(root, 'config.json')),
35
41
  legacyState: resolve(config.statePath ?? join(root, 'state.json')),
36
42
  bots: resolve(config.botsDir ?? join(root, 'bots')),
43
+ workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
37
44
  };
38
45
  }
39
46
 
@@ -58,6 +65,24 @@ export async function createProductionController(ctx, config = {}, internals = {
58
65
  : (ctx.logger ?? console);
59
66
  const paths = pluginPaths(config);
60
67
  const configStore = await new ConfigStore(paths.config).load();
68
+ const defaultWorkspace = resolve(config.workspace ?? process.cwd());
69
+ const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
70
+ const workspaces = internals.workspaces
71
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
72
+ const canListConfiguredBots = typeof configStore.list === 'function';
73
+ const listConfiguredBots = () => canListConfiguredBots ? configStore.list() : [];
74
+ const configuredBots = listConfiguredBots();
75
+ if (canListConfiguredBots) {
76
+ await workspaces.reconcile(configuredBots.map((bot) => bot.id));
77
+ }
78
+ await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.id)));
79
+ const observedConfigStore = typeof configStore.removeBot === 'function'
80
+ ? observeBotWorkspaceRemovals(configStore, {
81
+ workspaces,
82
+ method: 'removeBot',
83
+ botIdFromRemoved: (removed) => removed.id,
84
+ })
85
+ : configStore;
61
86
  // State is lazy per bot. A corrupt legacy file can therefore fail only the
62
87
  // migrated bot and cannot prevent healthy v2 bots from starting.
63
88
  const stateStores = new Map();
@@ -75,9 +100,14 @@ export async function createProductionController(ctx, config = {}, internals = {
75
100
  }
76
101
  return state;
77
102
  };
103
+ const stateForBotId = async (botId) => {
104
+ const botConfig = listConfiguredBots().find((bot) => bot.id === botId);
105
+ if (!botConfig) throw new Error('Unknown Feishu bot');
106
+ return stateFor(botConfig);
107
+ };
78
108
  const harness = new Harness({
79
109
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
80
- workspace: resolve(config.workspace ?? process.cwd()),
110
+ workspace: defaultWorkspace,
81
111
  agentPreset: config.agentPreset ?? 'standard',
82
112
  // This plugin is already hosted by a running DSH process. Starting a
83
113
  // second DSH would create a competing server and lifecycle.
@@ -85,21 +115,24 @@ export async function createProductionController(ctx, config = {}, internals = {
85
115
  dshBin: config.dshBin ?? 'dsh',
86
116
  });
87
117
 
88
- const controller = new Controller({
118
+ const coreController = new Controller({
89
119
  registerApp: (options) => lark.registerApp(options),
90
120
  verifyApp,
91
121
  credentials: ctx.credentials,
92
- configStore,
122
+ configStore: observedConfigStore,
93
123
  createRuntime: async ({ botId, config: botConfig, appSecret }) => {
94
124
  const state = await stateFor(botConfig);
125
+ const id = botId ?? botConfig.id ?? botConfig.appId;
126
+ await workspaces.ensure(id);
127
+ const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
95
128
  return new Runtime({
96
129
  lark,
97
130
  appId: botConfig.appId,
98
131
  appSecret,
99
132
  domain: botConfig.domain,
100
133
  ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
101
- harness,
102
- state,
134
+ harness: workspaceScope.harness,
135
+ state: workspaceScope.state,
103
136
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
104
137
  logger: {
105
138
  error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
@@ -118,6 +151,10 @@ export async function createProductionController(ctx, config = {}, internals = {
118
151
  }
119
152
  },
120
153
  });
154
+ const controller = createWorkspaceAwareController(coreController, {
155
+ workspaces,
156
+ stateFor: stateForBotId,
157
+ });
121
158
 
122
159
  const supervisor = createSupervisor({
123
160
  controller,
@@ -1,5 +1,6 @@
1
1
  import QRCode from 'qrcode';
2
2
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
+ import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
3
4
  import {
4
5
  FEISHU_ENDPOINTS,
5
6
  FEISHU_RPC_CHANNEL,
@@ -174,6 +175,7 @@ function publicBotEntry(entry) {
174
175
  bot: publicBot(source.bot),
175
176
  health: publicHealth(source, connected),
176
177
  };
178
+ if (typeof source.workspace === 'string' && source.workspace) result.workspace = source.workspace;
177
179
  const error = publicError(source.error);
178
180
  if (error) result.error = error;
179
181
  return result;
@@ -268,6 +270,10 @@ function validPayload(endpoint, payload) {
268
270
  ? null
269
271
  : 'Deleting a bot requires a valid botId and confirm=true.';
270
272
  }
273
+ if (endpoint === FEISHU_ENDPOINTS.setWorkspace) {
274
+ return validWorkspacePayload(payload)
275
+ ? null : '请输入工作区绝对路径。';
276
+ }
271
277
  return 'Unknown Feishu endpoint.';
272
278
  }
273
279
 
@@ -429,14 +435,23 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
429
435
  } else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
430
436
  if (typeof controller.disconnectBot !== 'function') throw new Error('Multi-bot disconnect is unavailable');
431
437
  value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
438
+ } else if (endpoint === FEISHU_ENDPOINTS.setWorkspace) {
439
+ if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
440
+ value = await toPublicFeishuStatus(
441
+ await controller.updateWorkspace(payload.botId, payload.workspace),
442
+ { encodeQr: cachedEncodeQr },
443
+ );
432
444
  } else {
433
445
  if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
434
446
  value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
435
447
  }
436
448
  if (signal?.aborted) return cancelled();
437
449
  return { ok: true, value };
438
- } catch {
439
- return signal?.aborted ? cancelled() : internalFailure();
450
+ } catch (error) {
451
+ const workspaceError = publicWorkspaceError(error);
452
+ return signal?.aborted ? cancelled() : workspaceError
453
+ ? { ok: false, error: { ...workspaceError, details: {} } }
454
+ : internalFailure();
440
455
  }
441
456
  };
442
457
  }
@@ -8,6 +8,12 @@ import { QqController } from '../../../../src/channels/qq/qq-controller.mjs';
8
8
  import { QqRuntime } from '../../../../src/channels/qq/qq-runtime.mjs';
9
9
  import { QqQrAuth } from '../../../../src/channels/qq/qr-auth.mjs';
10
10
  import { QqStateStore } from '../../../../src/channels/qq/state-store.mjs';
11
+ import {
12
+ BotWorkspaceStore,
13
+ createBotWorkspaceScope,
14
+ createWorkspaceAwareController,
15
+ observeBotWorkspaceRemovals,
16
+ } from '../../../../src/channels/shared/bot-workspace-store.mjs';
11
17
  import { createConnectionSupervisor } from './connection-supervisor.mjs';
12
18
 
13
19
  function harnessOrigin(webServer, configured) {
@@ -25,6 +31,7 @@ function pluginPaths(config) {
25
31
  return {
26
32
  config: resolve(config.configPath ?? join(root, 'config.json')),
27
33
  bots: resolve(config.botsDir ?? join(root, 'bots')),
34
+ workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
28
35
  };
29
36
  }
30
37
 
@@ -42,6 +49,16 @@ export async function createProductionController(ctx, config = {}, internals = {
42
49
  const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:qq') : (ctx.logger ?? console);
43
50
  const paths = pluginPaths(config);
44
51
  const configStore = await new ConfigStore(paths.config).load();
52
+ const defaultWorkspace = resolve(config.workspace ?? process.cwd());
53
+ const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
54
+ const workspaces = internals.workspaces
55
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
56
+ const configuredBots = configStore.list();
57
+ await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
58
+ await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
59
+ const observedConfigStore = typeof configStore.remove === 'function'
60
+ ? observeBotWorkspaceRemovals(configStore, { workspaces })
61
+ : configStore;
45
62
  const qrAuth = internals.qrAuth ?? new QrAuth({ source: config.qrSource ?? 'deepseek-harness' });
46
63
  const stateStores = new Map();
47
64
  const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
@@ -55,41 +72,50 @@ export async function createProductionController(ctx, config = {}, internals = {
55
72
  };
56
73
  const harness = new Harness({
57
74
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
58
- workspace: resolve(config.workspace ?? process.cwd()),
75
+ workspace: defaultWorkspace,
59
76
  agentPreset: config.agentPreset ?? 'standard',
60
77
  autostart: false,
61
78
  dshBin: config.dshBin ?? 'dsh',
62
79
  });
63
- const controller = new Controller({
80
+ const coreController = new Controller({
64
81
  qrAuth,
65
82
  credentials: ctx.credentials,
66
- configStore,
83
+ configStore: observedConfigStore,
67
84
  logger,
68
- createRuntime: async ({ botId, config: botConfig, appSecret }) => new Runtime({
69
- config: botConfig,
70
- appSecret,
71
- harness,
72
- state: await stateFor(botId),
73
- replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
74
- connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
75
- logger: {
76
- error: (...args) => logger.error?.(`[${botId}]`, ...args),
77
- warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
78
- info: (...args) => logger.info?.(`[${botId}]`, ...args),
79
- debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
80
- },
81
- }),
85
+ createRuntime: async ({ botId, config: botConfig, appSecret }) => {
86
+ const state = await stateFor(botId);
87
+ await workspaces.ensure(botId);
88
+ const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
89
+ return new Runtime({
90
+ config: botConfig,
91
+ appSecret,
92
+ harness: workspaceScope.harness,
93
+ state: workspaceScope.state,
94
+ replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
95
+ connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
96
+ logger: {
97
+ error: (...args) => logger.error?.(`[${botId}]`, ...args),
98
+ warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
99
+ info: (...args) => logger.info?.(`[${botId}]`, ...args),
100
+ debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
101
+ },
102
+ });
103
+ },
82
104
  deleteState: async ({ botId }) => {
83
105
  const state = stateStores.get(botId);
84
106
  stateStores.delete(botId);
85
- if (state && typeof state.remove === 'function') return state.remove();
86
- try {
87
- await unlink(statePath(botId));
88
- } catch (error) {
89
- if (error?.code !== 'ENOENT') throw error;
107
+ if (state && typeof state.remove === 'function') {
108
+ await state.remove();
109
+ } else {
110
+ try {
111
+ await unlink(statePath(botId));
112
+ } catch (error) {
113
+ if (error?.code !== 'ENOENT') throw error;
114
+ }
90
115
  }
91
116
  },
92
117
  });
118
+ const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
93
119
  const supervisor = createSupervisor({
94
120
  controller,
95
121
  harness,
@@ -1,5 +1,6 @@
1
1
  import QRCode from 'qrcode';
2
2
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
+ import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
3
4
 
4
5
  export const QQ_RPC_CHANNEL = '/qq';
5
6
  export const QQ_ENDPOINTS = Object.freeze({
@@ -10,6 +11,7 @@ export const QQ_ENDPOINTS = Object.freeze({
10
11
  bindCredentials: 'bot.bind-credentials',
11
12
  reconnectBot: 'bot.reconnect',
12
13
  deleteBot: 'bot.delete',
14
+ setWorkspace: SET_WORKSPACE_ENDPOINT,
13
15
  });
14
16
  export const QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS));
15
17
 
@@ -57,6 +59,10 @@ function payloadFailure(endpoint, payload) {
57
59
  return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
58
60
  ? null : 'bot.delete requires a botId and confirm=true.';
59
61
  }
62
+ if (endpoint === QQ_ENDPOINTS.setWorkspace) {
63
+ return validWorkspacePayload(payload)
64
+ ? null : '请输入工作区绝对路径。';
65
+ }
60
66
  return 'Unknown QQ endpoint.';
61
67
  }
62
68
 
@@ -120,16 +126,24 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
120
126
  value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
121
127
  } else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
122
128
  value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
129
+ } else if (endpoint === QQ_ENDPOINTS.setWorkspace) {
130
+ if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
131
+ value = await publicStatus(
132
+ await controller.updateWorkspace(payload.botId, payload.workspace),
133
+ cachedEncode,
134
+ );
123
135
  } else {
124
136
  value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
125
137
  }
126
138
  return signal?.aborted
127
139
  ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
128
140
  : { ok: true, value };
129
- } catch {
141
+ } catch (error) {
142
+ const workspaceError = publicWorkspaceError(error);
130
143
  return signal?.aborted
131
144
  ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
132
- : { ok: false, error: { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
145
+ : { ok: false, error: workspaceError
146
+ ?? { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
133
147
  }
134
148
  };
135
149
  }