@xmanrui/dsh-im 0.4.0 → 0.6.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 +36 -0
  2. package/lib/client.js +709 -285
  3. package/lib/index.js +114 -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 +25 -11
  41. package/src/channels/dingtalk/dingtalk-controller.mjs +6 -1
  42. package/src/channels/dingtalk/harness-client.mjs +17 -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 +39 -16
  46. package/src/channels/feishu/harness-client.mjs +19 -5
  47. package/src/channels/feishu/state-store.mjs +5 -0
  48. package/src/channels/qq/qq-bridge.mjs +28 -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 +618 -0
  52. package/src/channels/shared/conversation-state-store.mjs +5 -0
  53. package/src/channels/shared/text-harness-bridge.mjs +26 -13
  54. package/src/channels/shared/workspace-command.mjs +115 -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 +25 -13
  58. package/src/channels/wecom/wecom-controller.mjs +8 -1
  59. package/src/channels/weixin/harness-client.mjs +19 -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 +19 -6
  63. package/src/channels/weixin/weixin-controller.mjs +6 -1
  64. package/src/channels/whatsapp/whatsapp-controller.mjs +8 -1
@@ -0,0 +1,115 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+
4
+ const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
5
+ const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
6
+ const MAX_WORKSPACE_PATH_LENGTH = 4_096;
7
+ const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
8
+
9
+ function commandResult(message, messages = [message]) {
10
+ return { handled: true, message, messages };
11
+ }
12
+
13
+ function normalizedWorkspacePath(value) {
14
+ if (typeof value !== 'string' || value.length > MAX_WORKSPACE_PATH_LENGTH
15
+ || !isAbsolute(value) || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return null;
16
+ return resolve(value);
17
+ }
18
+
19
+ async function existingWorkspacePaths(values) {
20
+ const unique = [...new Set(values.map(normalizedWorkspacePath).filter(Boolean))];
21
+ const checked = await Promise.all(unique.map(async (workspace) => {
22
+ try {
23
+ return (await stat(workspace)).isDirectory() ? workspace : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }));
28
+ return checked.filter(Boolean);
29
+ }
30
+
31
+ export function splitWorkspaceCommandMessage(message) {
32
+ const messages = [];
33
+ let offset = 0;
34
+ while (offset < message.length) {
35
+ let end = Math.min(offset + MAX_COMMAND_MESSAGE_LENGTH, message.length);
36
+ if (end < message.length) {
37
+ const lineBreak = message.lastIndexOf('\n', end - 1);
38
+ if (lineBreak >= offset) {
39
+ end = lineBreak + 1;
40
+ } else {
41
+ const trailing = message.charCodeAt(end - 1);
42
+ const leading = message.charCodeAt(end);
43
+ if (trailing >= 0xd800 && trailing <= 0xdbff
44
+ && leading >= 0xdc00 && leading <= 0xdfff) end -= 1;
45
+ }
46
+ }
47
+ messages.push(message.slice(offset, end));
48
+ offset = end;
49
+ }
50
+ return messages;
51
+ }
52
+
53
+ async function runWorkspaceListCommand(match, harness) {
54
+ if (match[1]?.trim()) return commandResult('用法:/workspacelist');
55
+ if (typeof harness?.listWorkspaces !== 'function') {
56
+ return commandResult('当前机器人暂不支持列出工作区。');
57
+ }
58
+ try {
59
+ const listed = await harness.listWorkspaces();
60
+ const current = typeof harness.currentWorkspace === 'function'
61
+ ? normalizedWorkspacePath(harness.currentWorkspace())
62
+ : null;
63
+ const paths = await existingWorkspacePaths([
64
+ ...(current ? [current] : []),
65
+ ...(Array.isArray(listed) ? listed : []),
66
+ ]);
67
+ harness.assertWorkspaceScope?.();
68
+ if (paths.length === 0) {
69
+ return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。');
70
+ }
71
+ const lines = [
72
+ `当前 Harness Host 上存在的工作区(${paths.length}):`,
73
+ ...paths.map((workspace, index) => (
74
+ `${index + 1}. ${workspace}${workspace === current ? '(当前)' : ''}`
75
+ )),
76
+ '',
77
+ '切换用法:/workspace 工作区绝对路径',
78
+ ];
79
+ const message = lines.join('\n');
80
+ return commandResult(message, splitWorkspaceCommandMessage(message));
81
+ } catch (error) {
82
+ if (error?.code === 'workspace-bot-not-found') {
83
+ return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区。');
84
+ }
85
+ return commandResult('暂时无法获取工作区列表,请稍后重试。');
86
+ }
87
+ }
88
+
89
+ export async function runWorkspaceCommand(text, harness) {
90
+ if (typeof text !== 'string') return null;
91
+ const command = text.trim();
92
+ const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
93
+ if (listMatch) return runWorkspaceListCommand(listMatch, harness);
94
+ const match = WORKSPACE_COMMAND.exec(command);
95
+ if (!match) return null;
96
+ const workspace = match[1]?.trim();
97
+ if (!workspace) {
98
+ return commandResult('用法:/workspace 工作区绝对路径');
99
+ }
100
+ if (typeof harness?.switchWorkspace !== 'function') {
101
+ return commandResult('当前机器人暂不支持切换工作区。');
102
+ }
103
+ try {
104
+ const current = await harness.switchWorkspace(workspace);
105
+ return commandResult(`工作区已切换为:${current}`);
106
+ } catch (error) {
107
+ if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
108
+ return commandResult(`${error.message}\n用法:/workspace 工作区绝对路径`);
109
+ }
110
+ if (error?.code === 'workspace-bot-not-found') {
111
+ return commandResult('机器人正在移除或已重新接入,无法切换原会话的工作区。');
112
+ }
113
+ throw error;
114
+ }
115
+ }
@@ -0,0 +1,44 @@
1
+ export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
2
+
3
+ async function sessionExists(harness, sessionId, options) {
4
+ return options === undefined
5
+ ? harness.sessionExists(sessionId)
6
+ : harness.sessionExists(sessionId, options);
7
+ }
8
+
9
+ async function createSession(harness, options) {
10
+ return options === undefined
11
+ ? harness.createSession()
12
+ : harness.createSession(options);
13
+ }
14
+
15
+ /**
16
+ * Resolve, persist, and ask through a session that belongs to the bot's
17
+ * current workspace. A concurrent workspace switch invalidates the scoped
18
+ * session and retries before any prompt is sent to the stale session.
19
+ */
20
+ export async function askInWorkspaceSession({
21
+ harness,
22
+ state,
23
+ key,
24
+ text,
25
+ createOptions,
26
+ existsOptions,
27
+ askOptions,
28
+ }) {
29
+ while (true) {
30
+ let sessionId = state.sessionFor(key);
31
+ if (!sessionId || !(await sessionExists(harness, sessionId, existsOptions))) {
32
+ sessionId = await createSession(harness, createOptions);
33
+ if (await state.setSession(key, sessionId) === false) continue;
34
+ }
35
+ try {
36
+ return {
37
+ sessionId,
38
+ answer: await harness.ask(sessionId, text, askOptions),
39
+ };
40
+ } catch (error) {
41
+ if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
42
+ }
43
+ }
44
+ }
@@ -54,6 +54,11 @@ export class WecomStateStore {
54
54
  await this.#persist();
55
55
  }
56
56
 
57
+ async clearSessions() {
58
+ this.#state.sessions = {};
59
+ await this.#persist();
60
+ }
61
+
57
62
  hasSeen(messageId) {
58
63
  return this.#state.seenMessageIds.includes(messageId);
59
64
  }
@@ -1,10 +1,14 @@
1
1
  import { generateReqId } from '@wecom/aibot-node-sdk';
2
+ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
3
+ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
2
4
 
3
5
  const HELP_TEXT = [
4
6
  '企业微信机器人已连接 DeepSeek Harness。',
5
7
  '',
6
8
  '直接发送文字即可继续当前会话。',
7
9
  '/new 开启一个全新会话',
10
+ '/workspace 工作区绝对路径 切换工作区',
11
+ '/workspacelist 列出工作区绝对路径',
8
12
  '/status 检查连接状态',
9
13
  '/help 显示本帮助',
10
14
  ].join('\n');
@@ -182,11 +186,13 @@ export class WecomHarnessBridge {
182
186
  await this.#state.markSeen(messageId);
183
187
  return;
184
188
  }
185
-
186
- let sessionId = this.#state.sessionFor(key);
187
- if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
188
- sessionId = await this.#harness.createSession();
189
- await this.#state.setSession(key, sessionId);
189
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
190
+ if (workspaceCommand) {
191
+ for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
192
+ await this.#sendImmediate(frame, chatId, reply);
193
+ }
194
+ await this.#state.markSeen(messageId);
195
+ return;
190
196
  }
191
197
 
192
198
  streamId = this.#generateReqId('stream');
@@ -197,14 +203,20 @@ export class WecomHarnessBridge {
197
203
  this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
198
204
  }
199
205
 
200
- const answer = await this.#harness.ask(sessionId, text, {
201
- timeoutMs: this.#replyTimeoutMs,
202
- onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
203
- ? async (update) => {
204
- const progress = splitUtf8(progressText(update))[0];
205
- if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
206
- }
207
- : undefined,
206
+ const { answer } = await askInWorkspaceSession({
207
+ harness: this.#harness,
208
+ state: this.#state,
209
+ key,
210
+ text,
211
+ askOptions: {
212
+ timeoutMs: this.#replyTimeoutMs,
213
+ onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
214
+ ? async (update) => {
215
+ const progress = splitUtf8(progressText(update))[0];
216
+ if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
217
+ }
218
+ : undefined,
219
+ },
208
220
  });
209
221
 
210
222
  const chunks = splitUtf8(answer || '任务已完成,但没有生成可显示的文本。');
@@ -400,7 +400,14 @@ export class WecomController {
400
400
  if (record.controller.signal.aborted) {
401
401
  await this.#stopRuntime(identity.botId);
402
402
  if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
403
- else await this.#configStore.remove(identity.botId).catch(() => undefined);
403
+ else {
404
+ const removed = await this.#configStore.remove(identity.botId).catch(() => null);
405
+ if (removed) {
406
+ await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
407
+ this.#logger.warn?.('[dsh-im:wecom] cancelled bot state cleanup failed:', cleanupError);
408
+ });
409
+ }
410
+ }
404
411
  await this.#restoreCredential(identity.secretRef, previousSecret);
405
412
  throw error;
406
413
  }
@@ -1,8 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { isAbsolute } from 'node:path';
3
4
 
4
5
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
6
 
7
+ function workspacePaths(value) {
8
+ if (!Array.isArray(value?.items)) return [];
9
+ return value.items.flatMap((item) => (
10
+ typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
11
+ ));
12
+ }
13
+
6
14
  function assistantMessageText(event) {
7
15
  return (event?.data?.message?.content ?? [])
8
16
  .filter((part) => part.type === 'text' && typeof part.text === 'string')
@@ -187,17 +195,23 @@ export class HarnessClient {
187
195
  throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
188
196
  }
189
197
 
190
- async workspaceId() {
198
+ async listWorkspaces(options = {}) {
199
+ await this.ensureRunning();
200
+ return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
201
+ }
202
+
203
+ async workspaceId(options = {}) {
204
+ const workspace = options.workspace ?? this.#workspace;
191
205
  const { items } = await this.rpc('workspace.list', {});
192
- const existing = items.find((item) => item.path === this.#workspace);
206
+ const existing = items.find((item) => item.path === workspace);
193
207
  if (existing) return existing.workspaceId;
194
- const created = await this.rpc('workspace.create', { path: this.#workspace });
208
+ const created = await this.rpc('workspace.create', { path: workspace });
195
209
  return created.workspace.workspaceId;
196
210
  }
197
211
 
198
- async createSession() {
212
+ async createSession(options = {}) {
199
213
  await this.ensureRunning();
200
- const workspaceId = await this.workspaceId();
214
+ const workspaceId = await this.workspaceId(options);
201
215
  const created = await this.rpc('session.create', {
202
216
  workspaceId,
203
217
  agentPreset: this.#agentPreset,
@@ -62,6 +62,11 @@ export class WeixinStateStore {
62
62
  await this.#persist();
63
63
  }
64
64
 
65
+ async clearSessions() {
66
+ this.#state.sessions = {};
67
+ await this.#persist();
68
+ }
69
+
65
70
  hasSeen(messageId) {
66
71
  return this.#state.seenMessageIds.includes(messageId);
67
72
  }
@@ -92,7 +92,7 @@ function authenticatedHeaders(token) {
92
92
  function baseInfo() {
93
93
  return {
94
94
  channel_version: WEIXIN_PROTOCOL_VERSION,
95
- bot_agent: 'DeepSeekHarness/0.4.0',
95
+ bot_agent: 'DeepSeekHarness/0.6.0',
96
96
  };
97
97
  }
98
98
 
@@ -3,12 +3,16 @@ import {
3
3
  splitWeixinText,
4
4
  weixinMessageId,
5
5
  } from './weixin-api.mjs';
6
+ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
7
+ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
6
8
 
7
9
  const HELP_TEXT = [
8
10
  '微信已连接 DeepSeek Harness。',
9
11
  '',
10
12
  '直接发送文字或带文字识别结果的语音即可继续当前会话。',
11
13
  '/new 开启一个全新会话',
14
+ '/workspace 工作区绝对路径 切换工作区',
15
+ '/workspacelist 列出工作区绝对路径',
12
16
  '/status 检查连接状态',
13
17
  '/help 显示本帮助',
14
18
  ].join('\n');
@@ -133,14 +137,23 @@ export class WeixinHarnessBridge {
133
137
  await this.#state.markSeen(messageId);
134
138
  return;
135
139
  }
140
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
141
+ if (workspaceCommand) {
142
+ for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
143
+ await this.#send(sender, reply, contextToken, runId);
144
+ }
145
+ await this.#state.markSeen(messageId);
146
+ return;
147
+ }
136
148
 
137
149
  const key = conversationKey(sender);
138
- let sessionId = this.#state.sessionFor(key);
139
- if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
140
- sessionId = await this.#harness.createSession();
141
- await this.#state.setSession(key, sessionId);
142
- }
143
- const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
150
+ const { answer } = await askInWorkspaceSession({
151
+ harness: this.#harness,
152
+ state: this.#state,
153
+ key,
154
+ text,
155
+ askOptions: { timeoutMs: this.#replyTimeoutMs },
156
+ });
144
157
  await this.#send(sender, answer, contextToken, runId);
145
158
  await this.#state.markSeen(messageId);
146
159
  this.#status.messagesReplied += 1;
@@ -468,7 +468,12 @@ export class WeixinController {
468
468
  await this.#stopRuntime(identity.botId);
469
469
  if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
470
470
  else if (this.#configStore.get(identity.botId)) {
471
- await this.#configStore.remove(identity.botId).catch(() => undefined);
471
+ const removed = await this.#configStore.remove(identity.botId).catch(() => null);
472
+ if (removed) {
473
+ await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
474
+ this.#logger.warn?.('[dsh-weixin] failed to clean up cancelled bot state:', cleanupError);
475
+ });
476
+ }
472
477
  }
473
478
  await this.#restoreCredential(identity.tokenRef, previousToken);
474
479
  if (previousConfig && previousToken?.value) {
@@ -311,7 +311,14 @@ export class WhatsappController {
311
311
  if (record.controller.signal.aborted || this.#closed || error?.name === 'AbortError') {
312
312
  await this.#stopRuntime(botId);
313
313
  if (previous) await this.#configStore.save(previous).catch(() => undefined);
314
- else await this.#configStore.remove(botId).catch(() => undefined);
314
+ else {
315
+ const removed = await this.#configStore.remove(botId).catch(() => null);
316
+ if (removed) {
317
+ await this.#deleteState({ botId, config }).catch((cleanupError) => {
318
+ this.#logger.warn?.('[dsh-im:whatsapp] cancelled bot state cleanup failed:', cleanupError);
319
+ });
320
+ }
321
+ }
315
322
  await this.#deleteAuth(record.authDirectory).catch(() => undefined);
316
323
  if (previous) await this.#startRuntime(previous).catch(() => undefined);
317
324
  record.state = 'cancelled';