@xmanrui/dsh-im 4.3.0 → 4.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 (81) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +976 -634
  4. package/lib/index.js +241 -238
  5. package/package.json +1 -1
  6. package/plugin-src/client/access-policy-settings.js +351 -0
  7. package/plugin-src/client/channel-card-meta.js +2 -1
  8. package/plugin-src/client/channels/dingtalk/api.js +5 -0
  9. package/plugin-src/client/channels/dingtalk/index.js +1 -0
  10. package/plugin-src/client/channels/feishu/api.js +5 -0
  11. package/plugin-src/client/channels/feishu/index.js +1 -0
  12. package/plugin-src/client/channels/qq/api.js +5 -0
  13. package/plugin-src/client/channels/qq/index.js +1 -0
  14. package/plugin-src/client/channels/shared/token-api.js +5 -0
  15. package/plugin-src/client/channels/shared/token-channel.js +1 -0
  16. package/plugin-src/client/channels/telegram/api.js +2 -17
  17. package/plugin-src/client/channels/telegram/index.js +0 -112
  18. package/plugin-src/client/channels/telegram/styles.js +0 -28
  19. package/plugin-src/client/channels/wecom/api.js +5 -0
  20. package/plugin-src/client/channels/wecom/index.js +1 -0
  21. package/plugin-src/client/channels/weixin/api.js +5 -0
  22. package/plugin-src/client/channels/weixin/index.js +1 -0
  23. package/plugin-src/client/channels/whatsapp/api.js +4 -10
  24. package/plugin-src/client/channels/whatsapp/index.js +1 -125
  25. package/plugin-src/client/channels/whatsapp/styles.js +0 -25
  26. package/plugin-src/client/context-enhancement.js +19 -5
  27. package/plugin-src/client/delivery-settings.js +61 -9
  28. package/plugin-src/client/i18n.js +61 -2
  29. package/plugin-src/client/index.js +1 -0
  30. package/plugin-src/client/styles.js +50 -4
  31. package/plugin-src/host/channels/dingtalk/production.mjs +12 -1
  32. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  33. package/plugin-src/host/channels/feishu/production.mjs +39 -1
  34. package/plugin-src/host/channels/feishu/rpc.mjs +19 -2
  35. package/plugin-src/host/channels/qq/production.mjs +12 -1
  36. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  37. package/plugin-src/host/channels/shared/access-policy-production.mjs +137 -0
  38. package/plugin-src/host/channels/shared/access-policy-rpc.mjs +17 -0
  39. package/plugin-src/host/channels/shared/production.mjs +15 -1
  40. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  41. package/plugin-src/host/channels/slack/production.mjs +12 -1
  42. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  43. package/plugin-src/host/channels/telegram/rpc.mjs +2 -49
  44. package/plugin-src/host/channels/wecom/production.mjs +12 -1
  45. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  46. package/plugin-src/host/channels/weixin/production.mjs +12 -1
  47. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  48. package/plugin-src/host/channels/whatsapp/production.mjs +16 -2
  49. package/plugin-src/host/channels/whatsapp/rpc.mjs +10 -15
  50. package/plugin-src/host/modern-harness-api.mjs +3 -0
  51. package/src/channels/dingtalk/dingtalk-bridge.mjs +70 -12
  52. package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -0
  53. package/src/channels/discord/discord-runtime.mjs +29 -1
  54. package/src/channels/feishu/bridge.mjs +207 -82
  55. package/src/channels/feishu/feishu-cards.mjs +2 -2
  56. package/src/channels/feishu/feishu-channel.mjs +36 -6
  57. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  58. package/src/channels/qq/qq-bridge.mjs +67 -20
  59. package/src/channels/qq/qq-runtime.mjs +20 -2
  60. package/src/channels/shared/access-policy.mjs +210 -0
  61. package/src/channels/shared/bot-workspace-store.mjs +181 -33
  62. package/src/channels/shared/command-permission.mjs +32 -0
  63. package/src/channels/shared/compact-command.mjs +5 -2
  64. package/src/channels/shared/context-enhancement.mjs +9 -4
  65. package/src/channels/shared/harness-client.mjs +6 -0
  66. package/src/channels/shared/i18n-en/feishu.mjs +3 -3
  67. package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
  68. package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
  69. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  70. package/src/channels/shared/inbound-access.mjs +43 -0
  71. package/src/channels/shared/session-title.mjs +74 -0
  72. package/src/channels/shared/text-harness-bridge.mjs +54 -6
  73. package/src/channels/shared/workspace-command.mjs +26 -5
  74. package/src/channels/shared/workspace-session.mjs +28 -2
  75. package/src/channels/slack/slack-runtime.mjs +4 -0
  76. package/src/channels/telegram/telegram-runtime.mjs +6 -13
  77. package/src/channels/wecom/wecom-bridge.mjs +57 -6
  78. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  79. package/src/channels/weixin/weixin-bridge.mjs +67 -19
  80. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  81. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -20
@@ -1,4 +1,8 @@
1
1
  import { t } from './i18n.mjs';
2
+ import {
3
+ COMMAND_PERMISSION_DENIED_MESSAGE,
4
+ evaluateInboundAccess,
5
+ } from './inbound-access.mjs';
2
6
  import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
3
7
  import { runWorkspaceCommand } from './workspace-command.mjs';
4
8
  import { runCompactCommand } from './compact-command.mjs';
@@ -129,6 +133,7 @@ export class TextHarnessBridge {
129
133
  #harness;
130
134
  #state;
131
135
  #contextEnhancement;
136
+ #accessPolicy;
132
137
  #status;
133
138
  #logger;
134
139
  #replyTimeoutMs;
@@ -149,6 +154,7 @@ export class TextHarnessBridge {
149
154
  harness,
150
155
  state,
151
156
  contextEnhancement,
157
+ accessPolicy,
152
158
  status = createTextBridgeStatus(),
153
159
  logger = console,
154
160
  replyTimeoutMs = 600_000,
@@ -162,6 +168,7 @@ export class TextHarnessBridge {
162
168
  this.#harness = harness;
163
169
  this.#state = state;
164
170
  this.#contextEnhancement = contextEnhancement;
171
+ this.#accessPolicy = accessPolicy;
165
172
  this.#status = status;
166
173
  this.#logger = logger;
167
174
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -176,7 +183,7 @@ export class TextHarnessBridge {
176
183
  return structuredClone(this.#status);
177
184
  }
178
185
 
179
- accept(message, { contextSnapshot } = {}) {
186
+ accept(message, { contextSnapshot, accessDecision } = {}) {
180
187
  if (this.#signal?.aborted) return Promise.resolve();
181
188
  const conversationId = cleanText(message?.conversationId);
182
189
  const kind = message?.kind === 'group' ? 'group' : 'direct';
@@ -187,6 +194,40 @@ export class TextHarnessBridge {
187
194
  || this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
188
195
  return Promise.resolve();
189
196
  }
197
+ // Preserve the channel trigger boundary. Access policy never turns an
198
+ // unaddressed group message into a denial reply.
199
+ if (kind === 'group' && normalized.addressed !== true) {
200
+ this.#status.messagesRejected += 1;
201
+ this.#status.lastRejectedAt = new Date().toISOString();
202
+ this.#acceptedMessageIds.set(messageId, null);
203
+ return this.#finishLocalMessage(normalized, messageId, null);
204
+ }
205
+ if (this.#accessPolicy || accessDecision) {
206
+ const hasImages = hasInboundImages(normalized);
207
+ const hasFiles = hasInboundFiles(normalized);
208
+ const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
209
+ conversationType: kind,
210
+ senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
211
+ text: normalized.content,
212
+ hasImages,
213
+ hasFiles,
214
+ });
215
+ if (!decision.allowed) {
216
+ this.#status.messagesRejected += 1;
217
+ this.#status.lastRejectedAt = new Date().toISOString();
218
+ // Mark policy denials so a webhook replay cannot repeat local work or
219
+ // a command-permission notice.
220
+ this.#acceptedMessageIds.set(messageId, null);
221
+ return this.#finishLocalMessage(
222
+ normalized,
223
+ messageId,
224
+ decision.reason === 'command-not-allowed'
225
+ ? t(COMMAND_PERMISSION_DENIED_MESSAGE)
226
+ : null,
227
+ { recordReceived: decision.reason === 'command-not-allowed' },
228
+ );
229
+ }
230
+ }
190
231
  this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
191
232
  ? captureContextEnhancement(this.#contextEnhancement, message?.kind)
192
233
  : contextSnapshot);
@@ -350,13 +391,15 @@ export class TextHarnessBridge {
350
391
  return this.#enqueueMessage(normalized, messageId, senderId, key);
351
392
  }
352
393
 
353
- #finishLocalMessage(message, messageId, reply) {
394
+ #finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
354
395
  let task;
355
396
  task = (async () => {
356
397
  if (this.#state.hasSeen(messageId)) return;
357
398
  await this.#state.markSeen(messageId);
358
- this.#status.messagesReceived += 1;
359
- this.#status.lastMessageAt = new Date().toISOString();
399
+ if (recordReceived) {
400
+ this.#status.messagesReceived += 1;
401
+ this.#status.lastMessageAt = new Date().toISOString();
402
+ }
360
403
  if (reply) await this.#bot.sendText(message.replyTarget, reply);
361
404
  this.#status.lastError = null;
362
405
  })().catch(async (error) => {
@@ -545,7 +588,7 @@ export class TextHarnessBridge {
545
588
  t('/new 开启一个全新会话'),
546
589
  t('/compact 压缩当前会话的较早上下文'),
547
590
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
548
- t('/workspace 工作区绝对路径 切换工作区'),
591
+ t('/workspace 工作区序号或绝对路径 切换工作区'),
549
592
  t('/workspacelist 列出工作区绝对路径'),
550
593
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
551
594
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -630,12 +673,16 @@ export class TextHarnessBridge {
630
673
  ? await promptContentForMessage(message, { signal: this.#signal })
631
674
  : undefined;
632
675
  const snapshot = this.#acceptedMessageIds.get(messageId);
676
+ let contextEnhanced = false;
633
677
  if (snapshot) {
634
- content = enhanceContextContent(content ?? text, snapshot, () => ({
678
+ const originalContent = content ?? text;
679
+ content = enhanceContextContent(originalContent, snapshot, () => ({
635
680
  channel: this.#descriptor.key,
636
681
  senderId,
637
682
  senderName: message.contextSource?.()?.senderName,
683
+ conversationTitle: message.contextSource?.()?.conversationTitle,
638
684
  }));
685
+ contextEnhanced = content !== originalContent;
639
686
  }
640
687
  const { answer, artifacts = [] } = await askInWorkspaceSession({
641
688
  harness: this.#harness,
@@ -643,6 +690,7 @@ export class TextHarnessBridge {
643
690
  key: conversationKey,
644
691
  text,
645
692
  content,
693
+ contextEnhanced,
646
694
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
647
695
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
648
696
  askOptions: {
@@ -142,7 +142,7 @@ async function runWorkspaceListCommand(match, harness) {
142
142
  `${index + 1}. ${workspace}${workspace === current ? t('(当前)') : ''}`
143
143
  )),
144
144
  '',
145
- t('切换用法:/workspace 工作区绝对路径'),
145
+ t('切换用法:/workspace 工作区序号或绝对路径'),
146
146
  t('查看会话:/sessionlist 工作区序号或绝对路径'),
147
147
  ];
148
148
  const message = lines.join('\n');
@@ -353,7 +353,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
353
353
  }
354
354
 
355
355
  export async function runWorkspaceCommand(text, harness, conversationKey) {
356
- if (typeof text !== 'string') return null;
356
+ if (!isWorkspaceCommand(text)) return null;
357
357
  const command = text.trim();
358
358
  if (SESSION_BIND_PREFIX.test(command)) {
359
359
  return runSessionBindCommand(command, harness, conversationKey);
@@ -366,18 +366,30 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
366
366
  if (!match) return null;
367
367
  const workspace = match[1]?.trim();
368
368
  if (!workspace) {
369
- return commandResult(t('用法:/workspace 工作区绝对路径'));
369
+ return commandResult(t('用法:/workspace 工作区序号或绝对路径'));
370
370
  }
371
371
  if (typeof harness?.switchWorkspace !== 'function') {
372
372
  return commandResult(t('当前机器人暂不支持切换工作区。'));
373
373
  }
374
374
  try {
375
- const current = await harness.switchWorkspace(workspace);
375
+ let selected = workspace;
376
+ if (/^\d+$/u.test(workspace)) {
377
+ if (typeof harness?.listWorkspaces !== 'function') {
378
+ return commandResult(t('当前机器人暂不支持按序号选择工作区。'));
379
+ }
380
+ const { paths } = await workspacePathSnapshot(harness);
381
+ const position = Number(workspace);
382
+ if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
383
+ return commandResult(t('工作区序号不存在,请先执行 /workspacelist。'));
384
+ }
385
+ selected = paths[position - 1];
386
+ }
387
+ const current = await harness.switchWorkspace(selected);
376
388
  return commandResult(t('工作区已切换为:{workspace}', { workspace: current }));
377
389
  } catch (error) {
378
390
  if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
379
391
  return commandResult(t(`{message}
380
- 用法:/workspace 工作区绝对路径`, { message: error.message }));
392
+ 用法:/workspace 工作区序号或绝对路径`, { message: error.message }));
381
393
  }
382
394
  if (error?.code === 'workspace-bot-not-found') {
383
395
  return commandResult(t('机器人正在移除或已重新接入,无法切换原会话的工作区。'));
@@ -385,3 +397,12 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
385
397
  throw error;
386
398
  }
387
399
  }
400
+
401
+ export function isWorkspaceCommand(text) {
402
+ if (typeof text !== 'string') return false;
403
+ const command = text.trim();
404
+ return SESSION_BIND_PREFIX.test(command)
405
+ || SESSION_LIST_COMMAND.test(command)
406
+ || WORKSPACE_LIST_COMMAND.test(command)
407
+ || WORKSPACE_COMMAND.test(command);
408
+ }
@@ -1,4 +1,5 @@
1
1
  import { withSessionBindingLock } from './session-binding-lock.mjs';
2
+ import { initialSessionTitle } from './session-title.mjs';
2
3
 
3
4
  export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
4
5
 
@@ -6,7 +7,7 @@ function workspaceSession(harness, sessionId) {
6
7
  if (typeof harness.workspaceSession === 'function') {
7
8
  return harness.workspaceSession(sessionId);
8
9
  }
9
- return Object.freeze({
10
+ const session = {
10
11
  sessionId,
11
12
  sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
12
13
  models: (...args) => harness.getSessionModels(sessionId, ...args),
@@ -16,7 +17,11 @@ function workspaceSession(harness, sessionId) {
16
17
  stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
17
18
  steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
18
19
  ask: (...args) => harness.ask(sessionId, ...args),
19
- });
20
+ };
21
+ if (typeof harness.renameSession === 'function') {
22
+ session.renameTitle = (...args) => harness.renameSession(sessionId, ...args);
23
+ }
24
+ return Object.freeze(session);
20
25
  }
21
26
 
22
27
  async function sessionExists(session, options) {
@@ -42,10 +47,21 @@ export async function askInWorkspaceSession({
42
47
  key,
43
48
  text,
44
49
  content,
50
+ contextEnhanced = false,
45
51
  createOptions,
46
52
  existsOptions,
47
53
  askOptions,
48
54
  }) {
55
+ const initialTitle = contextEnhanced
56
+ ? initialSessionTitle({
57
+ text,
58
+ content,
59
+ files: typeof askOptions === 'object' ? askOptions?.files : undefined,
60
+ })
61
+ : null;
62
+ const renameSignal = createOptions?.signal
63
+ ?? (typeof askOptions === 'object' ? askOptions?.signal : undefined);
64
+ const renameOptions = renameSignal ? { signal: renameSignal } : undefined;
49
65
  while (true) {
50
66
  try {
51
67
  const binding = await withSessionBindingLock(state, key, async () => {
@@ -55,6 +71,16 @@ export async function askInWorkspaceSession({
55
71
  sessionId = await createSession(harness, createOptions);
56
72
  if (await state.setSession(key, sessionId) === false) return null;
57
73
  session = workspaceSession(harness, sessionId);
74
+ if (initialTitle && typeof session.renameTitle === 'function') {
75
+ try {
76
+ await session.renameTitle(initialTitle, renameOptions);
77
+ } catch (error) {
78
+ if (error?.code === WORKSPACE_SESSION_STALE || renameOptions?.signal?.aborted) {
79
+ throw error;
80
+ }
81
+ console.warn('[dsh-im] unable to set the initial Session title:', error?.message ?? error);
82
+ }
83
+ }
58
84
  }
59
85
  return { sessionId, session };
60
86
  });
@@ -347,6 +347,7 @@ export class SlackRuntime {
347
347
  #harness;
348
348
  #state;
349
349
  #contextEnhancement;
350
+ #accessPolicy;
350
351
  #logger;
351
352
  #replyTimeoutMs;
352
353
  #connectTimeoutMs;
@@ -371,6 +372,7 @@ export class SlackRuntime {
371
372
  harness,
372
373
  state,
373
374
  contextEnhancement,
375
+ accessPolicy,
374
376
  logger = console,
375
377
  replyTimeoutMs = 600_000,
376
378
  connectTimeoutMs = 20_000,
@@ -387,6 +389,7 @@ export class SlackRuntime {
387
389
  this.#harness = harness;
388
390
  this.#state = state;
389
391
  this.#contextEnhancement = contextEnhancement;
392
+ this.#accessPolicy = accessPolicy;
390
393
  this.#logger = logger;
391
394
  this.#replyTimeoutMs = replyTimeoutMs;
392
395
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -464,6 +467,7 @@ export class SlackRuntime {
464
467
  harness: this.#harness,
465
468
  state: this.#state,
466
469
  contextEnhancement: this.#contextEnhancement,
470
+ accessPolicy: this.#accessPolicy,
467
471
  status: this.#status,
468
472
  logger: this.#logger,
469
473
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -14,7 +14,6 @@ import {
14
14
  } from './telegram-rich-message.mjs';
15
15
  import {
16
16
  TELEGRAM_ACCESS_MODES,
17
- normalizeTelegramAccessPolicy,
18
17
  } from './config-store.mjs';
19
18
 
20
19
  export const TELEGRAM_COMMAND_MENU = Object.freeze([
@@ -167,6 +166,7 @@ export function normalizeTelegramUpdate(update, {
167
166
  senderName: [message.from?.first_name, message.from?.last_name]
168
167
  .filter((value) => typeof value === 'string' && value.trim())
169
168
  .map((value) => value.trim()).join(' ') || message.from?.username,
169
+ conversationTitle: direct ? undefined : message.chat?.title,
170
170
  }),
171
171
  senderIsBot: message.from?.is_bot === true,
172
172
  kind: direct ? 'direct' : 'group',
@@ -669,12 +669,11 @@ export class TelegramRuntime {
669
669
  #harness;
670
670
  #state;
671
671
  #contextEnhancement;
672
+ #accessPolicy;
672
673
  #logger;
673
674
  #replyTimeoutMs;
674
675
  #createApi;
675
676
  #createHttpTransport;
676
- #accessMode;
677
- #allowedPrivateUserIds;
678
677
  #status = createTelegramRuntimeStatus();
679
678
  #httpTransport = null;
680
679
  #api = null;
@@ -689,6 +688,7 @@ export class TelegramRuntime {
689
688
  harness,
690
689
  state,
691
690
  contextEnhancement,
691
+ accessPolicy,
692
692
  logger = console,
693
693
  replyTimeoutMs = 600_000,
694
694
  createApi = (options) => new TelegramApi(options),
@@ -702,13 +702,11 @@ export class TelegramRuntime {
702
702
  this.#harness = harness;
703
703
  this.#state = state;
704
704
  this.#contextEnhancement = contextEnhancement;
705
+ this.#accessPolicy = accessPolicy;
705
706
  this.#logger = logger;
706
707
  this.#replyTimeoutMs = replyTimeoutMs;
707
708
  this.#createApi = createApi;
708
709
  this.#createHttpTransport = createHttpTransport;
709
- const accessPolicy = normalizeTelegramAccessPolicy(config);
710
- this.#accessMode = accessPolicy.accessMode;
711
- this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
712
710
  }
713
711
 
714
712
  get status() {
@@ -805,6 +803,7 @@ export class TelegramRuntime {
805
803
  harness: this.#harness,
806
804
  state: this.#state,
807
805
  contextEnhancement: this.#contextEnhancement,
806
+ accessPolicy: this.#accessPolicy,
808
807
  status: this.#status,
809
808
  logger: this.#logger,
810
809
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -865,10 +864,7 @@ export class TelegramRuntime {
865
864
  loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
866
865
  loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
867
866
  });
868
- if (message && telegramInboundAllowed(message, {
869
- accessMode: this.#accessMode,
870
- allowedPrivateUserIds: this.#allowedPrivateUserIds,
871
- })) {
867
+ if (message) {
872
868
  void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
873
869
  if (signal.aborted) return;
874
870
  this.#logger.error?.(
@@ -876,9 +872,6 @@ export class TelegramRuntime {
876
872
  error,
877
873
  );
878
874
  });
879
- } else if (message) {
880
- this.#status.messagesRejected += 1;
881
- this.#status.lastRejectedAt = new Date().toISOString();
882
875
  }
883
876
  cursor = update.update_id + 1;
884
877
  await this.#state.setCursor(cursor);
@@ -51,6 +51,10 @@ import {
51
51
  messageFailureText,
52
52
  setLastMessageFailure,
53
53
  } from '../shared/message-failure.mjs';
54
+ import {
55
+ COMMAND_PERMISSION_DENIED_MESSAGE,
56
+ evaluateInboundAccess,
57
+ } from '../shared/inbound-access.mjs';
54
58
  import { t } from '../shared/i18n.mjs';
55
59
 
56
60
  const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
@@ -64,7 +68,7 @@ function helpText() {
64
68
  t('/new 开启一个全新会话'),
65
69
  t('/compact 压缩当前会话的较早上下文'),
66
70
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
67
- t('/workspace 工作区绝对路径 切换工作区'),
71
+ t('/workspace 工作区序号或绝对路径 切换工作区'),
68
72
  t('/workspacelist 列出工作区绝对路径'),
69
73
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
70
74
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -490,6 +494,7 @@ export class WecomHarnessBridge {
490
494
  #harness;
491
495
  #state;
492
496
  #contextEnhancement;
497
+ #accessPolicy;
493
498
  #status;
494
499
  #logger;
495
500
  #replyTimeoutMs;
@@ -512,6 +517,7 @@ export class WecomHarnessBridge {
512
517
  harness,
513
518
  state,
514
519
  contextEnhancement,
520
+ accessPolicy,
515
521
  status = createWecomBridgeStatus(),
516
522
  logger = console,
517
523
  replyTimeoutMs = 600_000,
@@ -530,6 +536,7 @@ export class WecomHarnessBridge {
530
536
  this.#harness = harness;
531
537
  this.#state = state;
532
538
  this.#contextEnhancement = contextEnhancement;
539
+ this.#accessPolicy = accessPolicy;
533
540
  this.#status = status;
534
541
  this.#logger = logger;
535
542
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -557,16 +564,28 @@ export class WecomHarnessBridge {
557
564
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
558
565
 
559
566
  const key = conversationKey(frame);
567
+ const pending = this.#pendingInteractions.get(key);
568
+ const commandMessage = wecomInboundMessage(frame, this.#client);
569
+ const commandText = nonEmptyString(commandMessage.content) ?? '';
570
+ const conversationType = body.chattype === 'single' ? 'direct' : 'group';
571
+ const access = evaluateInboundAccess(this.#accessPolicy, {
572
+ conversationType,
573
+ senderIds: senderId,
574
+ text: commandText,
575
+ hasImages: hasInboundImages(commandMessage),
576
+ hasFiles: hasInboundFiles(commandMessage),
577
+ });
578
+ if (!access.allowed) {
579
+ this.#acceptedMessageIds.set(messageId, null);
580
+ return this.#finishAccessDecision(frame, messageId, chatId, access);
581
+ }
560
582
  this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
561
583
  this.#contextEnhancement,
562
- body.chattype === 'single' ? 'direct' : 'group',
584
+ conversationType,
563
585
  ));
564
586
  if (body.chattype === 'single') {
565
587
  rememberConnectionTestTarget(this.#state, { chatId });
566
588
  }
567
- const pending = this.#pendingInteractions.get(key);
568
- const commandMessage = wecomInboundMessage(frame, this.#client);
569
- const commandText = nonEmptyString(commandMessage.content) ?? '';
570
589
  const batchCommand = isBatchInputCommand(commandText);
571
590
  const batchStatus = this.#batchInputs.status(key);
572
591
  if (batchCommand && body.chattype === 'group') {
@@ -760,6 +779,34 @@ export class WecomHarnessBridge {
760
779
  return task;
761
780
  }
762
781
 
782
+ #finishAccessDecision(frame, messageId, chatId, access) {
783
+ let task;
784
+ task = Promise.resolve().then(async () => {
785
+ if (this.#state.hasSeen(messageId)) return;
786
+ await this.#state.markSeen(messageId);
787
+ if (access.reason === 'command-not-allowed') {
788
+ this.#status.messagesReceived += 1;
789
+ this.#status.lastMessageAt = new Date().toISOString();
790
+ await this.#sendImmediate(frame, chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE));
791
+ this.#status.messagesReplied += 1;
792
+ this.#status.lastReplyAt = new Date().toISOString();
793
+ } else {
794
+ this.#status.messagesRejected += 1;
795
+ this.#status.lastRejectedAt = new Date().toISOString();
796
+ }
797
+ this.#status.lastError = null;
798
+ }).catch((error) => {
799
+ if (this.#signal?.aborted) return;
800
+ this.#status.lastError = error?.message ?? String(error);
801
+ this.#logger.error?.('[dsh-im:wecom] failed to apply inbound access policy', error);
802
+ }).finally(() => {
803
+ this.#acceptedMessageIds.delete(messageId);
804
+ this.#commandTasks.delete(task);
805
+ });
806
+ this.#commandTasks.add(task);
807
+ return task;
808
+ }
809
+
763
810
  async waitForIdle() {
764
811
  await Promise.allSettled([
765
812
  ...this.#queues.values(),
@@ -960,11 +1007,14 @@ export class WecomHarnessBridge {
960
1007
  ? await promptContentForMessage(message, { signal: this.#signal })
961
1008
  : undefined;
962
1009
  const snapshot = this.#acceptedMessageIds.get(messageId);
1010
+ let contextEnhanced = false;
963
1011
  if (snapshot) {
964
- content = enhanceContextContent(content ?? text, snapshot, () => ({
1012
+ const originalContent = content ?? text;
1013
+ content = enhanceContextContent(originalContent, snapshot, () => ({
965
1014
  channel: 'wecom',
966
1015
  senderId,
967
1016
  }));
1017
+ contextEnhanced = content !== originalContent;
968
1018
  }
969
1019
  await this.#state.markSeen(messageId);
970
1020
  promptRecorded = true;
@@ -974,6 +1024,7 @@ export class WecomHarnessBridge {
974
1024
  key,
975
1025
  text,
976
1026
  content,
1027
+ contextEnhanced,
977
1028
  createOptions: { signal: this.#signal },
978
1029
  existsOptions: { signal: this.#signal },
979
1030
  askOptions: {
@@ -29,6 +29,7 @@ export class WecomRuntime {
29
29
  #harness;
30
30
  #state;
31
31
  #contextEnhancement;
32
+ #accessPolicy;
32
33
  #logger;
33
34
  #replyTimeoutMs;
34
35
  #connectTimeoutMs;
@@ -47,6 +48,7 @@ export class WecomRuntime {
47
48
  harness,
48
49
  state,
49
50
  contextEnhancement,
51
+ accessPolicy,
50
52
  logger = console,
51
53
  replyTimeoutMs = 600_000,
52
54
  connectTimeoutMs = 20_000,
@@ -61,6 +63,7 @@ export class WecomRuntime {
61
63
  this.#harness = harness;
62
64
  this.#state = state;
63
65
  this.#contextEnhancement = contextEnhancement;
66
+ this.#accessPolicy = accessPolicy;
64
67
  this.#logger = logger;
65
68
  this.#replyTimeoutMs = replyTimeoutMs;
66
69
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -111,6 +114,7 @@ export class WecomRuntime {
111
114
  harness: this.#harness,
112
115
  state: this.#state,
113
116
  contextEnhancement: this.#contextEnhancement,
117
+ accessPolicy: this.#accessPolicy,
114
118
  status: this.#status,
115
119
  logger: this.#logger,
116
120
  replyTimeoutMs: this.#replyTimeoutMs,