evolcore 0.0.3 → 0.0.5

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 (148) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +45 -10
  3. package/bin/ec-safe-output.js +89 -24
  4. package/dist/agents/baseagent.js +46 -0
  5. package/dist/agents/claude-runner.js +1 -31
  6. package/dist/agents/codex-app-server-client.js +68 -0
  7. package/dist/agents/codex-runner.js +157 -5
  8. package/dist/agents/runner-types.js +2 -2
  9. package/dist/aun/aid/agentmd.js +79 -0
  10. package/dist/aun/aid/encryption-seed-policy.js +29 -0
  11. package/dist/aun/aid/index.js +1 -1
  12. package/dist/aun/aid/store.js +2 -5
  13. package/dist/channels/aun.js +220 -112
  14. package/dist/channels/daemon.js +18 -4
  15. package/dist/channels/dingtalk.js +52 -137
  16. package/dist/channels/feishu.js +56 -4
  17. package/dist/channels/qqbot.js +23 -1
  18. package/dist/channels/wechat.js +303 -155
  19. package/dist/channels/wecom.js +383 -7
  20. package/dist/cli/aun-commands.js +11 -11
  21. package/dist/cli/daemon-commands.js +46 -15
  22. package/dist/cli/handoff-command.js +2 -1
  23. package/dist/cli/init-channel.js +185 -67
  24. package/dist/cli/init.js +49 -27
  25. package/dist/cli/trigger-command.js +92 -11
  26. package/dist/config/access-policy-domain.js +18 -0
  27. package/dist/config/access-policy.js +110 -0
  28. package/dist/config/builtin-role-templates.js +27 -14
  29. package/dist/config/builtin-roles.js +25 -6
  30. package/dist/config/config-field-policy.js +17 -5
  31. package/dist/config/config-manager.js +198 -22
  32. package/dist/config/config-operation-service.js +35 -38
  33. package/dist/config/contact-bind-code.js +274 -0
  34. package/dist/config/contact-book-store.js +173 -5
  35. package/dist/config/contact-book.js +32 -0
  36. package/dist/config/contact-operation-service.js +9 -3
  37. package/dist/config/contact-request-service.js +331 -0
  38. package/dist/config/peer-role-resolver.js +3 -1
  39. package/dist/config/schema-registry.js +49 -24
  40. package/dist/config-store.js +14 -2
  41. package/dist/core/auth/agent-delegation.js +105 -11
  42. package/dist/core/auth/authorization-audit.js +6 -0
  43. package/dist/core/auth/operation-authorizer.js +8 -0
  44. package/dist/core/auth/operation-catalog.js +51 -3
  45. package/dist/core/auth/trigger-authorization.js +15 -0
  46. package/dist/core/bootstrap-service.js +2 -9
  47. package/dist/core/channel-loader.js +6 -2
  48. package/dist/core/command/command-handler.js +10 -13
  49. package/dist/core/command/connect-menu.js +229 -26
  50. package/dist/core/command/evol-menu-version-gate.js +38 -0
  51. package/dist/core/command/group-menu.js +421 -0
  52. package/dist/core/command/menu-handler.js +269 -85
  53. package/dist/core/command/menu-protocol.js +8 -2
  54. package/dist/core/command/menu-token-store.js +102 -0
  55. package/dist/core/command/role-menu.js +16 -0
  56. package/dist/core/command/slash-gate.js +1 -1
  57. package/dist/core/command/slash-handler.js +158 -31
  58. package/dist/core/daemon-file-cache.js +28 -0
  59. package/dist/core/event-catalog.js +29 -0
  60. package/dist/core/evolagent-registry.js +4 -39
  61. package/dist/core/handoff/runtime.js +162 -37
  62. package/dist/core/handoff/store.js +46 -2
  63. package/dist/core/handoff/types.js +1 -0
  64. package/dist/core/inference/text-inference.js +16 -74
  65. package/dist/core/message/file-markers.js +7 -0
  66. package/dist/core/message/im-renderer.js +18 -14
  67. package/dist/core/message/inbound-admission.js +134 -0
  68. package/dist/core/message/message-bridge.js +616 -74
  69. package/dist/core/message/message-log.js +1 -0
  70. package/dist/core/message/message-queue.js +185 -10
  71. package/dist/core/message/peer-mode.js +7 -8
  72. package/dist/core/message/response-engine.js +381 -79
  73. package/dist/core/permission/approval-gateway.js +17 -10
  74. package/dist/core/permission/ec-command-parser.js +101 -46
  75. package/dist/core/permission/tool-policy.js +69 -24
  76. package/dist/core/protected-paths.js +36 -11
  77. package/dist/core/session/session-fs-store.js +19 -4
  78. package/dist/core/session/session-manager.js +232 -4
  79. package/dist/core/session/session-mapper.js +2 -0
  80. package/dist/core/session/session-renew.js +125 -69
  81. package/dist/core/session/session-turn-coordinator.js +5 -1
  82. package/dist/eck/kit-renderer.js +12 -1
  83. package/dist/eck/message-renderer.js +79 -1
  84. package/dist/index.js +224 -89
  85. package/dist/ipc.js +81 -4
  86. package/dist/paths.js +3 -0
  87. package/dist/response-system/config-resolver.js +29 -0
  88. package/dist/response-system/coordinator.js +8 -32
  89. package/dist/response-system/engines/v1/proactive-flow.js +1 -1
  90. package/dist/response-system/index.js +1 -0
  91. package/dist/response-system/modes/single-session/index.js +7 -4
  92. package/dist/trigger/parser.js +55 -15
  93. package/dist/trigger/patch.js +4 -1
  94. package/dist/trigger/scheduler.js +441 -39
  95. package/dist/utils/cross-platform.js +8 -2
  96. package/dist/utils/error-dict.json +7 -0
  97. package/dist/utils/evolcore-version.js +21 -0
  98. package/dist/utils/logger.js +2 -2
  99. package/dist/utils/process-introspect.js +19 -3
  100. package/dist/utils/stable-semver.js +21 -0
  101. package/dist/utils/stats.js +33 -9
  102. package/kits/docs/channels/aun.md +4 -13
  103. package/kits/docs/evolcore/INDEX.md +1 -1
  104. package/kits/docs/evolcore/config.md +47 -2
  105. package/kits/docs/evolcore/contact.md +8 -3
  106. package/kits/docs/evolcore/group-rules.md +46 -4
  107. package/kits/docs/evolcore/group.md +4 -4
  108. package/kits/docs/evolcore/msg.md +5 -5
  109. package/kits/docs/evolcore/trigger.md +25 -8
  110. package/kits/docs/path-registry.md +36 -17
  111. package/kits/eck_message_manifest.json +12 -1
  112. package/kits/migrations/migrate-contact-book-v2.mjs +7 -0
  113. package/kits/rules/01-overview.md +17 -7
  114. package/kits/rules/02-navigation.md +38 -18
  115. package/kits/rules/03-identity.md +28 -24
  116. package/kits/rules/04-relation.md +44 -28
  117. package/kits/rules/05-venue.md +31 -15
  118. package/kits/rules/06-channel.md +11 -7
  119. package/kits/schemas/_meta.json +18 -7
  120. package/kits/schemas/agent-config.schema.7.json +303 -0
  121. package/kits/schemas/agent-config.schema.8.json +304 -0
  122. package/kits/schemas/agent-config.schema.9.json +364 -0
  123. package/kits/schemas/contact-book.schema.3.json +67 -0
  124. package/kits/schemas/daemon.schema.1.json +3 -2
  125. package/kits/schemas/daemon.schema.2.json +101 -0
  126. package/kits/schemas/daemon.schema.3.json +123 -0
  127. package/kits/schemas/defaults.schema.2.json +85 -0
  128. package/kits/schemas/defaults.schema.3.json +73 -0
  129. package/kits/schemas/relation-config.schema.6.json +46 -0
  130. package/kits/schemas/relation-config.schema.7.json +59 -0
  131. package/kits/schemas/single-session.schema.2.json +57 -0
  132. package/kits/templates/message-fragments/handoff-context-to-target.md +9 -0
  133. package/kits/templates/message-fragments/handoff-request-to-target.md +14 -8
  134. package/kits/templates/message-fragments/handoff-response-to-origin.md +5 -6
  135. package/kits/templates/roles/admin.json +46 -0
  136. package/kits/templates/roles/member.json +4 -0
  137. package/kits/templates/roles/visitor.json +4 -0
  138. package/kits/templates/system-fragments/channel.md +12 -2
  139. package/kits/templates/system-fragments/identity.md +3 -1
  140. package/kits/templates/system-fragments/relation.md +1 -1
  141. package/kits/templates/system-fragments/session.md +6 -2
  142. package/package.json +4 -2
  143. package/MIGRATION-0.5.0.md +0 -378
  144. package/ROLE_ACCESS_CONTROL.md +0 -174
  145. package/dist/channels/contact-bind-code.js +0 -134
  146. package/dist/channels/wecom-card.js +0 -101
  147. package/dist/channels/wecom-onboarding.js +0 -82
  148. package/dist/channels/wecom-state.js +0 -191
@@ -2,40 +2,63 @@ import { createHash, randomBytes } from 'crypto';
2
2
  import { logger } from '../../utils/logger.js';
3
3
  import { StreamDebouncer } from './stream-debouncer.js';
4
4
  import { appendMessageLog, appendMessageLogStrict, buildInboundEntry, isTransientProtocolMessage } from './message-log.js';
5
- import { buildEnvelope } from './message-utils.js';
5
+ import { buildEnvelope, sendInteractionPayload } from './message-utils.js';
6
6
  import { chatDirPath } from '../session/session-fs-store.js';
7
- import { tryParseChannelKey } from '../channel-loader.js';
7
+ import { formatChannelKey, tryParseChannelKey } from '../channel-loader.js';
8
8
  import { agentDir, resolvePaths } from '../../paths.js';
9
- import { resolvePeerRoleDetail } from '../../config/peer-role-resolver.js';
10
- import { isBlockedContact, resolveContactView } from '../../config/contact-book.js';
9
+ import { listStaticAgentAdmins, listStaticAgentOwners, resolvePeerRoleDetail, } from '../../config/peer-role-resolver.js';
10
+ import { resolveContactView, resolvePeerDisplayLabel, resolvePrincipal } from '../../config/contact-book.js';
11
+ import { findContactRequestNote, expirePendingContactRequests, reviewContactRequest, submitContactRequest, } from '../../config/contact-request-service.js';
11
12
  import { isValidAid } from '../../aun/aid/validation.js';
12
13
  import { PeerIdentityCache } from '../relation/peer-identity.js';
13
- import { handlePendingDingtalkContactBindMessage } from '../../channels/dingtalk.js';
14
- import { handlePendingWecomContactBindMessage } from '../../channels/wecom.js';
14
+ import { handlePendingDingtalkContactBindMessage, registerPendingDingtalkContactBind } from '../../channels/dingtalk.js';
15
+ import { handlePendingFeishuContactBindMessage, registerPendingFeishuContactBind } from '../../channels/feishu.js';
16
+ import { handlePendingQQBotContactBindMessage, registerPendingQQBotContactBind } from '../../channels/qqbot.js';
17
+ import { handlePendingWecomContactBindMessage, registerPendingWecomContactBind } from '../../channels/wecom.js';
18
+ import { handlePendingWechatContactBindMessage, registerPendingWechatContactBind } from '../../channels/wechat.js';
15
19
  import { authorizeAccess, buildAuthSubject } from '../auth/auth-gateway.js';
16
20
  import { MenuDiagnosticLimiter, MenuRequestDeduper, hasValidMenuId, menuFailure, menuPayloadFingerprint, menuSuccess, normalizeMenuError, parseMenuControl, validateMenuRequest, } from '../command/menu-protocol.js';
21
+ import { evaluateEvolMenuVersionGate, evolMenuResponseTransportMetadata, isAunMenuTokenRequired } from '../command/evol-menu-version-gate.js';
22
+ import { MenuTokenStore } from '../command/menu-token-store.js';
17
23
  import { SessionRenewService } from '../session/session-renew.js';
18
24
  import { createRootCausation, deriveCausation, normalizeCausation } from '../causation/context.js';
19
25
  import { recordCausationSpan } from '../causation/audit.js';
26
+ import { renderActionAsText } from '../interaction-router.js';
27
+ import { planApprovalRoute } from '../permission/approval-gateway.js';
28
+ import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './inbound-admission.js';
20
29
  const CONTACT_REJECTION_NOTICE = '[rejection] Not accepting messages from you.';
21
30
  const REJECTABLE_PEER_TYPES = new Set(['ai', 'bot', 'agent', 'service']);
22
31
  const REJECTION_MAX = 3;
23
32
  const REJECTION_WINDOW_MS = 60 * 60 * 1000;
24
33
  const rejectionState = new Map();
34
+ const SELF_SERVICE_CONTACT_BIND_TYPES = ['feishu', 'dingtalk', 'wecom', 'qqbot', 'wechat'];
35
+ const SELF_SERVICE_CONTACT_BIND_DISPLAY = {
36
+ feishu: '飞书',
37
+ dingtalk: '钉钉',
38
+ wecom: '企微',
39
+ qqbot: 'QQ',
40
+ wechat: '微信',
41
+ };
25
42
  export function shouldBlockInboundContact(input) {
26
- if (String(input.channelType || '').trim().toLowerCase() !== 'aun' || !input.selfAid) {
43
+ if (!input.selfAid) {
27
44
  return { blocked: false, status: 'unsupported-channel' };
28
45
  }
29
46
  if (input.chatType !== 'private')
30
47
  return { blocked: false, status: 'unsupported-chat' };
31
- const primaryId = String(input.actorId || '').trim();
32
- if (!isValidAid(primaryId))
48
+ const channelType = String(input.channelType || '').trim().toLowerCase();
49
+ if (channelType !== 'aun' && !input.channelKey) {
50
+ return { blocked: false, status: 'unsupported-channel' };
51
+ }
52
+ const actorId = String(input.actorId || '').trim();
53
+ const resolution = resolvePrincipal(input.selfAid, channelType, actorId, input.channelKey);
54
+ const primaryId = resolution.principalId;
55
+ if (!primaryId)
33
56
  return { blocked: false, status: 'invalid-aid' };
34
- if (input.chatType === 'private' && resolveContactView(input.selfAid, primaryId).isOwner) {
57
+ if (resolveContactView(input.selfAid, primaryId).isOwner) {
35
58
  return { blocked: false, status: 'owner-exempt', primaryId };
36
59
  }
37
60
  return {
38
- blocked: isBlockedContact(input.selfAid, primaryId),
61
+ blocked: resolveContactView(input.selfAid, primaryId).blocked,
39
62
  status: 'resolved',
40
63
  primaryId,
41
64
  };
@@ -56,6 +79,7 @@ export function shouldSendContactRejection(peerType, selfAid, primaryId, now = D
56
79
  }
57
80
  export function clearContactRejectionRateLimits() {
58
81
  rejectionState.clear();
82
+ clearAdmissionReplyBudgets();
59
83
  }
60
84
  /**
61
85
  * MessageBridge — Channel 与 Core 之间的消息桥梁
@@ -78,8 +102,12 @@ export class MessageBridge {
78
102
  bootstrapService;
79
103
  menuDeduper = new MenuRequestDeduper();
80
104
  menuDiagnostics = new MenuDiagnosticLimiter();
105
+ menuTokenStore = new MenuTokenStore();
81
106
  handoffRuntime;
82
107
  sessionRenewService;
108
+ aidStatsCollector;
109
+ contactBindRuntimeChecker;
110
+ interactionRouter;
83
111
  constructor(defaultProjectPath, sessionManager, processor, messageQueue, cmdHandler, eventBus, defaultDebounce, processOwnersProvider) {
84
112
  this.defaultProjectPath = defaultProjectPath;
85
113
  this.sessionManager = sessionManager;
@@ -89,7 +117,7 @@ export class MessageBridge {
89
117
  this.eventBus = eventBus;
90
118
  this.processOwnersProvider = processOwnersProvider;
91
119
  this.defaultDebounce = defaultDebounce ?? 0;
92
- this.sessionRenewService = new SessionRenewService(sessionManager, processor);
120
+ this.sessionRenewService = new SessionRenewService(sessionManager, processor, { eventBus });
93
121
  }
94
122
  /** Inject EvolAgentRegistry so owner lookups/writes route to agent.json for agent-owned channels. */
95
123
  setAgentRegistry(registry) {
@@ -98,9 +126,388 @@ export class MessageBridge {
98
126
  setBootstrapService(service) {
99
127
  this.bootstrapService = service;
100
128
  }
129
+ /** 非 AUN 渠道的 per-AID 消息统计由本桥梁记账(AUN 在 channel 层自行记账) */
130
+ setAidStatsCollector(collector) {
131
+ this.aidStatsCollector = collector;
132
+ }
101
133
  setHandoffRuntime(runtime) {
102
134
  this.handoffRuntime = runtime;
103
135
  }
136
+ setContactBindRuntimeChecker(checker) {
137
+ this.contactBindRuntimeChecker = checker;
138
+ }
139
+ setInteractionRouter(router) {
140
+ this.interactionRouter = router;
141
+ }
142
+ /**
143
+ * AUN supplies an authenticated sender AID, while the IM-side code proves
144
+ * possession of the external account. This command only creates that
145
+ * identity mapping; it never changes a role assignment.
146
+ */
147
+ handleAunSelfServiceContactBind(input) {
148
+ const match = /^\s*\/bind(?:\s+(.+?))?\s*$/i.exec(input.content);
149
+ if (!match)
150
+ return { handled: false };
151
+ if (input.chatType !== 'private') {
152
+ return {
153
+ handled: true,
154
+ status: 'private-only',
155
+ reply: '联系人绑定只能在 AUN 私聊中发起。用法:/bind <渠道> [实例名]。',
156
+ };
157
+ }
158
+ const selfAid = String(input.selfAid || '').trim();
159
+ const actorId = String(input.actorId || '').trim();
160
+ if (!isValidAid(selfAid) || !isValidAid(actorId)) {
161
+ return {
162
+ handled: true,
163
+ status: 'invalid-aid',
164
+ reply: '无法验证当前 AUN 身份,未创建联系人绑定码。请通过已认证的 AUN 私聊重新发送请求。',
165
+ };
166
+ }
167
+ const args = String(match[1] || '').trim().split(/\s+/).filter(Boolean);
168
+ if (args.length < 1 || args.length > 2) {
169
+ return { handled: true, status: 'format', reply: this.aunSelfServiceBindUsage() };
170
+ }
171
+ const channelType = args[0].toLowerCase();
172
+ if (!SELF_SERVICE_CONTACT_BIND_TYPES.includes(channelType)) {
173
+ return { handled: true, status: 'unsupported-channel', reply: this.aunSelfServiceBindUsage() };
174
+ }
175
+ const agent = input.owningAgent ?? this.agentRegistry?.get(selfAid);
176
+ if (!agent || agent.aid !== selfAid) {
177
+ return {
178
+ handled: true,
179
+ status: 'agent-unavailable',
180
+ reply: '目标 Agent 当前不可用,未创建联系人绑定码。',
181
+ };
182
+ }
183
+ const instances = (agent.config.channels ?? []).filter((instance) => (String(instance?.type || '').trim().toLowerCase() === channelType
184
+ && instance.enabled !== false
185
+ && typeof instance.name === 'string'
186
+ && instance.name.trim().length > 0));
187
+ const requestedInstance = args[1];
188
+ let instance;
189
+ if (requestedInstance) {
190
+ instance = instances.find((candidate) => candidate.name === requestedInstance);
191
+ if (!instance) {
192
+ return {
193
+ handled: true,
194
+ status: 'unknown-instance',
195
+ reply: `未找到已启用的 ${SELF_SERVICE_CONTACT_BIND_DISPLAY[channelType]} 实例 “${requestedInstance}”。${this.aunSelfServiceBindInstances(channelType, instances)}`,
196
+ };
197
+ }
198
+ }
199
+ else if (instances.length === 1) {
200
+ instance = instances[0];
201
+ }
202
+ else if (instances.length === 0) {
203
+ return {
204
+ handled: true,
205
+ status: 'not-configured',
206
+ reply: `目标 Agent 未启用 ${SELF_SERVICE_CONTACT_BIND_DISPLAY[channelType]} 机器人,无法创建联系人绑定码。`,
207
+ };
208
+ }
209
+ else {
210
+ return {
211
+ handled: true,
212
+ status: 'instance-required',
213
+ reply: `该 Agent 有多个 ${SELF_SERVICE_CONTACT_BIND_DISPLAY[channelType]} 实例。${this.aunSelfServiceBindInstances(channelType, instances)}`,
214
+ };
215
+ }
216
+ const channelName = formatChannelKey({ type: channelType, selfAID: selfAid, name: instance.name });
217
+ const runtimeAvailable = this.contactBindRuntimeChecker
218
+ ? this.contactBindRuntimeChecker(channelName)
219
+ : !!this.processor.getChannelInfo?.(channelName);
220
+ if (!runtimeAvailable) {
221
+ return {
222
+ handled: true,
223
+ status: 'runtime-unavailable',
224
+ reply: `${SELF_SERVICE_CONTACT_BIND_DISPLAY[channelType]}实例当前未连接,未创建联系人绑定码。请等待机器人上线后重试。`,
225
+ };
226
+ }
227
+ const result = this.registerSelfServiceContactBind({
228
+ channelType,
229
+ selfAid,
230
+ channelName,
231
+ primaryId: actorId,
232
+ });
233
+ if (!result.ok) {
234
+ logger.warn(`[MessageBridge] Failed to register AUN self-service contact bind: self=${selfAid} actor=${actorId} channel=${channelName} error=${result.error}`);
235
+ return {
236
+ handled: true,
237
+ status: 'register-failed',
238
+ reply: `未能创建${SELF_SERVICE_CONTACT_BIND_DISPLAY[channelType]}身份绑定码:${result.error}。请稍后重试。`,
239
+ };
240
+ }
241
+ return {
242
+ handled: true,
243
+ status: result.replaced ? 'replaced' : 'issued',
244
+ reply: `请在 ${SELF_SERVICE_CONTACT_BIND_DISPLAY[channelType]} 机器人私聊中发送 /bind ${result.code} 完成联系人绑定。绑定 10 分钟内有效,不会修改 Owner、Admin 或关系角色配置。`,
245
+ };
246
+ }
247
+ registerSelfServiceContactBind(input) {
248
+ switch (input.channelType) {
249
+ case 'feishu': return registerPendingFeishuContactBind(input);
250
+ case 'dingtalk': return registerPendingDingtalkContactBind(input);
251
+ case 'wecom': return registerPendingWecomContactBind(input);
252
+ case 'qqbot': return registerPendingQQBotContactBind(input);
253
+ case 'wechat': return registerPendingWechatContactBind(input);
254
+ }
255
+ }
256
+ aunSelfServiceBindUsage() {
257
+ return `用法:/bind <渠道> [实例名]。支持渠道:${SELF_SERVICE_CONTACT_BIND_TYPES.join('、')}。`;
258
+ }
259
+ aunSelfServiceBindInstances(channelType, instances) {
260
+ const names = instances.map(instance => String(instance.name || '').trim()).filter(Boolean);
261
+ return names.length > 0
262
+ ? `可用实例:${names.join('、')}。请发送 /bind ${channelType} <实例名>。`
263
+ : '';
264
+ }
265
+ async handleReservedContactControl(input) {
266
+ const { context, msg, content, owningAgent, sendReply } = input;
267
+ const bindContext = {
268
+ selfAid: context.selfAid,
269
+ channelName: context.channelKey,
270
+ channelType: context.channelType,
271
+ chatType: context.chatType,
272
+ actorId: context.actorId,
273
+ content,
274
+ };
275
+ const contactBind = context.channelType === 'dingtalk'
276
+ ? handlePendingDingtalkContactBindMessage(bindContext)
277
+ : context.channelType === 'feishu'
278
+ ? handlePendingFeishuContactBindMessage(bindContext)
279
+ : context.channelType === 'wecom'
280
+ ? handlePendingWecomContactBindMessage(bindContext)
281
+ : context.channelType === 'qqbot'
282
+ ? handlePendingQQBotContactBindMessage(bindContext)
283
+ : context.channelType === 'wechat'
284
+ ? handlePendingWechatContactBindMessage(bindContext)
285
+ : { handled: false };
286
+ if (contactBind.handled) {
287
+ logger.info(`[MessageBridge] ${context.channelType} contact bind handled before admission: channel=${context.channelKey} actor=${context.actorId || '<none>'} status=${contactBind.status}`);
288
+ if (contactBind.reply) {
289
+ await this.sendAdmissionControlReply(context, msg, sendReply, contactBind.reply);
290
+ }
291
+ return true;
292
+ }
293
+ if (context.channelType === 'aun') {
294
+ const aunSelfServiceBind = this.handleAunSelfServiceContactBind({
295
+ selfAid: context.selfAid,
296
+ actorId: context.actorId,
297
+ chatType: context.chatType,
298
+ content,
299
+ owningAgent,
300
+ });
301
+ if (aunSelfServiceBind.handled) {
302
+ logger.info(`[MessageBridge] AUN self-service contact bind handled before admission: channel=${context.channelKey} actor=${context.actorId || '<none>'} status=${aunSelfServiceBind.status}`);
303
+ if (aunSelfServiceBind.reply) {
304
+ await this.sendAdmissionControlReply(context, msg, sendReply, aunSelfServiceBind.reply);
305
+ }
306
+ return true;
307
+ }
308
+ }
309
+ const requestMatch = /^\s*\/request(?:\s+([\s\S]*?))?\s*$/i.exec(content);
310
+ if (!requestMatch)
311
+ return false;
312
+ if (context.channelType !== 'aun') {
313
+ await this.sendAdmissionControlReply(context, msg, sendReply, '联系人申请仅支持 AUN 私聊。');
314
+ return true;
315
+ }
316
+ if (context.chatType !== 'private') {
317
+ await this.sendAdmissionControlReply(context, msg, sendReply, '联系人申请仅支持 AUN 私聊。');
318
+ return true;
319
+ }
320
+ if (!context.primaryId) {
321
+ await this.sendAdmissionControlReply(context, msg, sendReply, '无法验证当前 AUN 身份,未提交联系人申请。');
322
+ return true;
323
+ }
324
+ let result;
325
+ try {
326
+ result = await submitContactRequest({
327
+ selfAid: context.selfAid,
328
+ applicantAid: context.primaryId,
329
+ note: requestMatch[1],
330
+ sourceChannelKey: context.channelKey,
331
+ });
332
+ }
333
+ catch (error) {
334
+ logger.warn(`[MessageBridge] Contact request submission failed: self=${context.selfAid} applicant=${context.primaryId} error=${error instanceof Error ? error.message : String(error)}`);
335
+ await this.sendAdmissionControlReply(context, msg, sendReply, '当前无法受理联系人申请,请稍后再试。');
336
+ return true;
337
+ }
338
+ if ((result.code === 'submitted' || result.code === 'resubmitted') && result.requestId) {
339
+ void this.deliverContactReviewCard(context.selfAid, context.primaryId, result, owningAgent)
340
+ .catch(error => logger.warn(`[MessageBridge] Contact review delivery failed: self=${context.selfAid} applicant=${context.primaryId} error=${error instanceof Error ? error.message : String(error)}`));
341
+ }
342
+ await this.sendAdmissionControlReply(context, msg, sendReply, this.contactRequestReply(result));
343
+ return true;
344
+ }
345
+ async sendAdmissionControlReply(context, msg, sendReply, text) {
346
+ if (!text || !consumeAdmissionReplyBudget(context))
347
+ return false;
348
+ await sendReply(msg.channelId, text, msg.replyContext);
349
+ return true;
350
+ }
351
+ admissionDeniedNotice(context, channelType) {
352
+ if (context.contactStatus === 'pending') {
353
+ return '联系人申请审核中;可再次发送 /request 更新说明。';
354
+ }
355
+ if (context.contactSource === 'contact' && context.contactStatus === 'active') {
356
+ return '当前不接受任何消息。';
357
+ }
358
+ if (channelType === 'aun') {
359
+ return '当前不接受陌生人消息。可发送 /request <申请说明> 申请成为联系人。';
360
+ }
361
+ return '当前不接受陌生人消息。联系人申请仅支持 AUN 私聊。';
362
+ }
363
+ contactRequestReply(result) {
364
+ switch (result.code) {
365
+ case 'submitted': return '联系人申请已提交,等待管理员审核。';
366
+ case 'resubmitted': return '联系人申请已更新,等待管理员审核。';
367
+ case 'already-contact': return '你已经是联系人。';
368
+ case 'owner': return '你已经是 Owner。';
369
+ case 'blocked': return CONTACT_REJECTION_NOTICE;
370
+ case 'rate-limited': return '联系人申请过于频繁,请稍后再试。';
371
+ case 'capacity': return '当前申请较多,请稍后再试。';
372
+ case 'no-owner': return '当前无法受理联系人申请。';
373
+ case 'invalid-applicant': return '无法验证当前 AUN 身份,未提交联系人申请。';
374
+ case 'invalid-note': return '申请说明不能超过 500 个字符。';
375
+ }
376
+ }
377
+ async deliverContactReviewCard(selfAid, applicantAid, submission, owningAgent) {
378
+ if (!this.interactionRouter || !submission.requestId || !submission.expiresAt || !submission.submittedAt) {
379
+ return false;
380
+ }
381
+ const ownerAdapterKey = formatChannelKey({ type: 'aun', selfAID: selfAid, name: 'main' });
382
+ const ownerAdapter = this.processor.getChannelInfo?.(ownerAdapterKey)?.adapter;
383
+ const resolveApproverCandidates = () => ({
384
+ owners: listStaticAgentOwners(selfAid, { fresh: true }),
385
+ admins: listStaticAgentAdmins(selfAid, { fresh: true }),
386
+ });
387
+ const { owners, admins } = resolveApproverCandidates();
388
+ const route = planApprovalRoute({
389
+ id: submission.requestId,
390
+ sessionId: `contact-request:${selfAid}:${submission.requestId}`,
391
+ toolName: 'contact.request.review',
392
+ toolInput: { applicantAid, requestId: submission.requestId },
393
+ summary: `Review contact request from ${applicantAid}`,
394
+ grantable: true,
395
+ approverPolicy: 'agent_manager',
396
+ createdAt: Date.parse(submission.submittedAt),
397
+ }, {
398
+ userId: applicantAid,
399
+ chatType: 'private',
400
+ selfAid,
401
+ approvalRouting: {
402
+ approverPolicy: 'agent_manager',
403
+ owners,
404
+ admins,
405
+ resolveApproverCandidates,
406
+ ownerAdapter,
407
+ forceHandoff: true,
408
+ selfAid,
409
+ originSessionId: `contact-request:${selfAid}:${submission.requestId}`,
410
+ originChannel: 'aun',
411
+ originChannelId: applicantAid,
412
+ originPeerId: applicantAid,
413
+ originPrincipalId: applicantAid,
414
+ },
415
+ });
416
+ if (route.kind !== 'handoff') {
417
+ logger.warn(`[MessageBridge] Contact review route unavailable: self=${selfAid} request=${submission.requestId} reason=${route.kind === 'unavailable' ? route.reason : 'local-route-not-allowed'}`);
418
+ return false;
419
+ }
420
+ let note = '申请说明不可用';
421
+ try {
422
+ note = findContactRequestNote(selfAid, submission.requestId) || '(未填写)';
423
+ }
424
+ catch { }
425
+ const expiresAtMs = Date.parse(submission.expiresAt);
426
+ const sessionId = `contact-request:${selfAid}:${submission.requestId}`;
427
+ const interaction = {
428
+ type: 'interaction',
429
+ id: submission.requestId,
430
+ channelId: route.approverOperatorId,
431
+ sessionId,
432
+ initiatorId: route.approverOperatorId,
433
+ expiresAt: expiresAtMs,
434
+ kind: {
435
+ kind: 'action',
436
+ title: '联系人申请审核',
437
+ bodyFormat: 'plain',
438
+ body: `申请人:${applicantAid}\n申请说明:${note}\n提交时间:${submission.submittedAt}\n截止时间:${submission.expiresAt}\n\n此卡片只处理当前这一次联系人申请。申请人重新提交、已被他人处理或审批超时,均不会修改当前联系人状态。卡片因重启或投递失效时,可在联系人列表处理仍未到期的 pending;已过期申请需由申请人重新发送 /request。`,
439
+ buttons: [
440
+ { key: 'approve', label: '通过', style: 'primary' },
441
+ { key: 'reject', label: '拒绝', style: 'default' },
442
+ { key: 'block', label: '拉黑', style: 'danger', confirm: { title: '拉黑申请人', body: `确定拉黑 ${applicantAid}?` } },
443
+ ],
444
+ },
445
+ };
446
+ this.interactionRouter.register(submission.requestId, sessionId, async (action, _values, operatorId) => {
447
+ if (action !== 'approve' && action !== 'reject' && action !== 'block')
448
+ return false;
449
+ const reviewed = await reviewContactRequest({
450
+ selfAid,
451
+ primaryId: applicantAid,
452
+ requestId: submission.requestId,
453
+ expectedContactRevision: submission.contactRevision,
454
+ approverId: route.approverOperatorId,
455
+ actorId: String(operatorId || ''),
456
+ decision: action,
457
+ });
458
+ try {
459
+ await this.sendContactReviewOutcome(route.adapter, route.approverOperatorId, applicantAid, reviewed.code, owningAgent?.name);
460
+ }
461
+ catch (error) {
462
+ logger.warn(`[MessageBridge] Contact review outcome notification failed: request=${submission.requestId} error=${error instanceof Error ? error.message : String(error)}`);
463
+ }
464
+ if (reviewed.code === 'approved' || reviewed.code === 'rejected' || reviewed.code === 'blocked') {
465
+ try {
466
+ await this.notifyContactApplicant(route.adapter, applicantAid, reviewed.code, owningAgent?.name);
467
+ }
468
+ catch (error) {
469
+ logger.warn(`[MessageBridge] Contact applicant notification failed: request=${submission.requestId} error=${error instanceof Error ? error.message : String(error)}`);
470
+ }
471
+ return true;
472
+ }
473
+ return false;
474
+ }, {
475
+ timeoutMs: Math.max(1, expiresAtMs - Date.now()),
476
+ initiatorId: route.approverOperatorId,
477
+ initiatorChannelKey: route.approverChannelKey,
478
+ onTimeout: () => { void expirePendingContactRequests(selfAid); },
479
+ onCancel: reason => route.adapter.invalidateInteraction?.(submission.requestId, reason),
480
+ });
481
+ const envelope = buildEnvelope({
482
+ taskId: `contact-request-${submission.requestId}`,
483
+ sessionId,
484
+ channel: route.adapter.channelName,
485
+ channelId: route.approverOperatorId,
486
+ agentName: owningAgent?.name ?? selfAid,
487
+ chatmode: 'interactive',
488
+ replyContext: { metadata: { source: 'handoff', chatmode: 'interactive' } },
489
+ });
490
+ const sent = await sendInteractionPayload(route.adapter, envelope, interaction, renderActionAsText(interaction), envelope.replyContext);
491
+ if (!sent)
492
+ await this.interactionRouter.cancel(submission.requestId, 'delivery_failed');
493
+ return !!sent;
494
+ }
495
+ async sendContactReviewOutcome(adapter, ownerAid, applicantAid, code, agentName) {
496
+ const text = code === 'approved' ? `已通过 ${applicantAid} 的联系人申请。`
497
+ : code === 'rejected' ? `已拒绝 ${applicantAid} 的联系人申请。`
498
+ : code === 'blocked' ? `已拉黑 ${applicantAid}。`
499
+ : code === 'request-expired' ? '审核已过期。'
500
+ : code === 'request-stale' ? '申请已更新或已被处理。'
501
+ : code === 'revision-conflict' ? '联系人列表已变化,请从联系人列表重新审核。'
502
+ : '当前无审核权限。';
503
+ await adapter.send(buildEnvelope({ channel: adapter.channelName, channelId: ownerAid, agentName: agentName ?? '<unknown>' }), { kind: 'system.notice', text, subtype: 'contact-review' });
504
+ }
505
+ async notifyContactApplicant(adapter, applicantAid, code, agentName) {
506
+ const text = code === 'approved' ? '你的联系人申请已通过。'
507
+ : code === 'rejected' ? '你的联系人申请未通过。'
508
+ : '你的联系人申请未通过。';
509
+ await adapter.send(buildEnvelope({ channel: adapter.channelName, channelId: applicantAid, agentName: agentName ?? '<unknown>' }), { kind: 'system.notice', text, subtype: 'contact-request-result' });
510
+ }
104
511
  getDebouncer(channelName, channelType) {
105
512
  let d = this.debouncers.get(channelName);
106
513
  if (!d) {
@@ -163,23 +570,58 @@ export class MessageBridge {
163
570
  const conversationId = chatType === 'group' ? (msg.groupId || msg.channelId) : msg.peerId;
164
571
  const selfAid = msg.selfAID || owningAgent?.aid || parsedChannelKey?.selfAID;
165
572
  const resolvedChannelType = msg.channelType || parsedChannelKey?.type || effectiveChannelType;
166
- const blockDecision = shouldBlockInboundContact({
573
+ const trustedHandoffEcho = msg.source === 'handoff'
574
+ && resolvedChannelType === 'aun'
575
+ && !!selfAid
576
+ && actorId === selfAid;
577
+ const admissionPreflight = evaluateInboundAdmissionPreflight({
167
578
  selfAid,
579
+ channelKey,
168
580
  channelType: resolvedChannelType,
169
581
  chatType,
170
582
  actorId,
171
- conversationId,
583
+ trustedHandoffEcho,
172
584
  });
173
- if (blockDecision.blocked && selfAid && blockDecision.primaryId) {
174
- let peerType = msg.peerType;
175
- if (chatType === 'private' && !peerType) {
176
- peerType = PeerIdentityCache.get('aun', blockDecision.primaryId, agentDir(selfAid))?.type;
585
+ if (!admissionPreflight.allow) {
586
+ const requestCommand = /^\s*\/request(?:\s|$)/i.test(content);
587
+ if (admissionPreflight.reason === 'blocked' && admissionPreflight.context.primaryId) {
588
+ const primaryId = admissionPreflight.context.primaryId;
589
+ let peerType = msg.peerType;
590
+ if (chatType === 'private' && !peerType) {
591
+ peerType = PeerIdentityCache.get('aun', primaryId, agentDir(selfAid))?.type;
592
+ }
593
+ if (chatType === 'private'
594
+ && REJECTABLE_PEER_TYPES.has(String(peerType || '').trim().toLowerCase())) {
595
+ await this.sendAdmissionControlReply(admissionPreflight.context, msg, sendReply, CONTACT_REJECTION_NOTICE);
596
+ }
597
+ logger.info(`[MessageBridge] Blocked canonical inbound before business routing: self=${selfAid} peer=${primaryId} type=${peerType ?? 'unknown'} channel=${channelKey}`);
598
+ }
599
+ else if (admissionPreflight.reason === 'restricted_mode_without_owner' && requestCommand) {
600
+ await this.sendAdmissionControlReply(admissionPreflight.context, msg, sendReply, '当前无法受理联系人申请。');
177
601
  }
178
- if (chatType === 'private'
179
- && shouldSendContactRejection(peerType, selfAid, blockDecision.primaryId)) {
180
- await sendReply(msg.channelId, CONTACT_REJECTION_NOTICE, msg.replyContext);
602
+ else {
603
+ logger.warn(`[MessageBridge] Admission preflight denied: self=${selfAid ?? '<none>'} actor=${actorId ?? '<none>'} channel=${channelKey} reason=${admissionPreflight.reason}`);
181
604
  }
182
- logger.info(`[MessageBridge] Blocked AUN inbound before business routing: self=${selfAid} peer=${blockDecision.primaryId} type=${peerType ?? 'unknown'} chatType=${chatType}`);
605
+ return;
606
+ }
607
+ if (!trustedHandoffEcho && await this.handleReservedContactControl({
608
+ context: admissionPreflight.context,
609
+ msg,
610
+ content,
611
+ owningAgent,
612
+ sendReply,
613
+ })) {
614
+ return;
615
+ }
616
+ const admissionDecision = evaluateOrdinaryInboundAdmission(admissionPreflight.context);
617
+ if (!admissionDecision.allow) {
618
+ if (chatType === 'private') {
619
+ const notice = this.admissionDeniedNotice(admissionDecision.context, resolvedChannelType);
620
+ if (notice) {
621
+ await this.sendAdmissionControlReply(admissionDecision.context, msg, sendReply, notice);
622
+ }
623
+ }
624
+ logger.info(`[MessageBridge] Inbound admission denied before business routing: self=${selfAid} actor=${actorId ?? '<none>'} channel=${channelKey} reason=${admissionDecision.reason}`);
183
625
  return;
184
626
  }
185
627
  const menuControl = resolvedChannelType === 'aun'
@@ -191,39 +633,46 @@ export class MessageBridge {
191
633
  this.logMenuDiagnostic('missing-id', channelName, msg, menuControl);
192
634
  return;
193
635
  }
194
- const validationError = validateMenuRequest(menuControl.request);
195
- if (validationError) {
636
+ const versionError = evaluateEvolMenuVersionGate({
637
+ encrypted: msg.encrypted,
638
+ protectedHeaders: msg.protectedHeaders,
639
+ });
640
+ if (versionError) {
196
641
  const header = { id: menuControl.id, ...(menuControl.name?.trim() ? { name: menuControl.name } : {}) };
197
- await this.sendMenuResponse(adapter, channelName, msg, menuFailure(header, validationError));
642
+ await this.sendMenuResponse(adapter, channelName, msg, menuFailure(header, versionError));
198
643
  return;
199
644
  }
200
- }
201
- if (!menuControl.isMenu) {
202
- const bindContext = {
203
- selfAid,
204
- channelName: channelKey,
205
- channelType: resolvedChannelType,
206
- chatType,
207
- actorId,
208
- content,
209
- };
210
- const contactBind = resolvedChannelType === 'dingtalk'
211
- ? handlePendingDingtalkContactBindMessage(bindContext)
212
- : resolvedChannelType === 'wecom'
213
- ? handlePendingWecomContactBindMessage(bindContext)
214
- : { handled: false };
215
- if (contactBind.handled) {
216
- logger.info(`[MessageBridge] ${resolvedChannelType} contact bind handled: channel=${channelKey} actor=${actorId ?? '<none>'} status=${contactBind.status}`);
217
- if (contactBind.reply) {
218
- await sendReply(msg.channelId, contactBind.reply, msg.replyContext);
645
+ const header = { id: menuControl.id, ...(menuControl.name?.trim() ? { name: menuControl.name } : {}) };
646
+ const isTokenRequest = menuControl.type === 'menu.token.request';
647
+ const menuTokenRequired = isAunMenuTokenRequired();
648
+ if ((menuTokenRequired || isTokenRequest) && !msg.encrypted) {
649
+ await this.sendMenuResponse(adapter, channelName, msg, menuFailure(header, {
650
+ code: 'MENU_TOKEN_ENCRYPTION_REQUIRED',
651
+ message: 'Menu 请求必须使用 E2EE',
652
+ }), true);
653
+ return;
654
+ }
655
+ if (!isTokenRequest) {
656
+ const tokenOwnerAid = this.menuTokenOwnerAid(channelName, msg);
657
+ const tokenSupplied = Object.prototype.hasOwnProperty.call(menuControl.request, 'menu_token');
658
+ const tokenValid = (menuTokenRequired || tokenSupplied)
659
+ && !!tokenOwnerAid
660
+ && this.menuTokenStore.use(tokenOwnerAid, msg.peerId, menuControl.request.menu_token);
661
+ if (menuTokenRequired && !tokenValid) {
662
+ this.logMenuDiagnostic('token-required', channelName, msg, menuControl, 'MENU_TOKEN_REQUIRED');
663
+ await this.sendMenuResponse(adapter, channelName, msg, menuFailure(header, {
664
+ code: 'MENU_TOKEN_REQUIRED',
665
+ message: 'Menu token 无效或已过期,请发送 menu.token.request 重新申请',
666
+ }));
667
+ return;
219
668
  }
669
+ }
670
+ const validationError = validateMenuRequest(menuControl.request);
671
+ if (validationError) {
672
+ await this.sendMenuResponse(adapter, channelName, msg, menuFailure(header, validationError));
220
673
  return;
221
674
  }
222
675
  }
223
- const trustedHandoffEcho = msg.source === 'handoff'
224
- && resolvedChannelType === 'aun'
225
- && !!selfAid
226
- && actorId === selfAid;
227
676
  const roleDetail = trustedHandoffEcho
228
677
  ? {
229
678
  effectiveRole: 'owner',
@@ -284,6 +733,18 @@ export class MessageBridge {
284
733
  logger.debug(`[MessageBridge] Transient protocol message ignored: channel=${channelName} type=${msg.payloadType || msg.msgType || '<unknown>'}`);
285
734
  return;
286
735
  }
736
+ // 0.5 Per-AID 消息统计(预览行/tooltip 数据源)。
737
+ // AUN 在 channel 层自行记账(早于本桥梁,且含协议细节),这里只补非 AUN 渠道,避免重复计数。
738
+ if (resolvedChannelType !== 'aun' && selfAid) {
739
+ this.aidStatsCollector?.recordInbound(selfAid, conversationId || msg.channelId, Buffer.byteLength(content, 'utf-8'), content, false, undefined, undefined, 'send', {
740
+ channelType: resolvedChannelType,
741
+ // 联系人簿的 displayName 是本端主动命名,优先于渠道昵称;都没有才退回原生 ID
742
+ peerLabel: resolvePeerDisplayLabel(selfAid, resolvedChannelType, actorId ?? '', channelKey)
743
+ || msg.peerName || actorId || msg.channelId,
744
+ // 飞书私聊的 channelId 是 oc_*、peerId 是 ou_*;缓存二者关联供所有出站类型复用。
745
+ ...(chatType === 'private' ? { routeId: msg.channelId } : {}),
746
+ });
747
+ }
287
748
  // 1. owner 绑定(按实例名绑定)
288
749
  if (adapter && actorId && roleDetail.effectiveRole === 'owner') {
289
750
  await this.bootstrapService?.tryStartBootstrap({
@@ -382,6 +843,12 @@ export class MessageBridge {
382
843
  const replyToMessageId = typeof msg.replyContext?.metadata?.refMessageId === 'string'
383
844
  ? msg.replyContext.metadata.refMessageId
384
845
  : msg.replyContext?.replyToMessageId ?? null;
846
+ const restoredCausation = (msg.channelType || effectiveChannelType) === 'aun'
847
+ ? normalizeCausation(msg.causation)
848
+ : undefined;
849
+ const inboundCausation = restoredCausation
850
+ ? deriveCausation(restoredCausation)
851
+ : createRootCausation();
385
852
  const renewResult = await this.sessionRenewService.resolve({
386
853
  session,
387
854
  channelName,
@@ -395,6 +862,7 @@ export class MessageBridge {
395
862
  content,
396
863
  replyToMessageId,
397
864
  isNewSession: !hadMainSession,
865
+ causation: inboundCausation,
398
866
  });
399
867
  session = renewResult.session;
400
868
  // 4. 群聊发送者标注由消息渲染层(message-renderer)逐条承担,不再在此硬编码前缀,
@@ -422,19 +890,15 @@ export class MessageBridge {
422
890
  topicName: this.extractTopicName(msg),
423
891
  replyContext: msg.replyContext,
424
892
  source: msg.source,
893
+ boundSessionId: session.id,
894
+ resolvedIdentity: identity,
425
895
  dispatchMode: msg.dispatchMode,
426
- causation: (() => {
427
- const restored = (msg.channelType || effectiveChannelType) === 'aun'
428
- ? normalizeCausation(msg.causation)
429
- : undefined;
430
- const inbound = restored ? deriveCausation(restored) : createRootCausation();
431
- recordCausationSpan(inbound, 'message.inbound', {
432
- status: 'completed',
433
- refs: { messageId: msg.messageId, sessionId: session.id },
434
- });
435
- return inbound;
436
- })(),
896
+ causation: inboundCausation,
437
897
  };
898
+ recordCausationSpan(inboundCausation, 'message.inbound', {
899
+ status: 'completed',
900
+ refs: { messageId: msg.messageId, sessionId: session.id },
901
+ });
438
902
  const inboundEntry = (() => {
439
903
  const chatDir = this.sessionManager.getChatDir(session);
440
904
  const inboundEncrypt = msg.replyContext?.metadata?.encrypted != null ? !!(msg.replyContext.metadata.encrypted) : undefined;
@@ -564,6 +1028,7 @@ export class MessageBridge {
564
1028
  effort: '/effort',
565
1029
  chatmode: '/chatmode',
566
1030
  mentionmode: '/mentionmode',
1031
+ group: '/group',
567
1032
  permission: '/perm',
568
1033
  activity: '/activity',
569
1034
  observable: '/observable',
@@ -574,6 +1039,7 @@ export class MessageBridge {
574
1039
  file: '/file',
575
1040
  capability: '/capability',
576
1041
  role: '/role',
1042
+ connect: '/connect',
577
1043
  };
578
1044
  extractTopicName(msg) {
579
1045
  const raw = msg.topicName
@@ -612,18 +1078,40 @@ export class MessageBridge {
612
1078
  if (!hasValidMenuId(parsed))
613
1079
  return;
614
1080
  const header = { id: parsed.id, ...(parsed.name?.trim() ? { name: parsed.name } : {}) };
615
- const access = authorizeAccess(authSubject);
616
- if (!access.allow) {
617
- this.logMenuDiagnostic('access-denied', channel, msg, parsed, access.code);
618
- await this.sendMenuResponse(adapter, channel, msg, menuFailure(header, {
619
- code: 'ROLE_ACCESS_DENIED',
620
- message: access.reason,
621
- }));
622
- return;
1081
+ const isTokenRequest = parsed.type === 'menu.token.request';
1082
+ if (isTokenRequest) {
1083
+ if (msg.chatType !== 'private') {
1084
+ await this.sendMenuResponse(adapter, channel, msg, menuFailure(header, {
1085
+ code: 'MENU_TOKEN_REJECTED',
1086
+ message: '授权申请未通过',
1087
+ }));
1088
+ return;
1089
+ }
1090
+ if (!this.menuTokenOwnerAid(channel, msg) || !msg.peerId) {
1091
+ await this.sendMenuResponse(adapter, channel, msg, menuFailure(header, {
1092
+ code: 'MENU_TOKEN_REJECTED',
1093
+ message: '授权申请未通过',
1094
+ }));
1095
+ return;
1096
+ }
1097
+ }
1098
+ if (!isTokenRequest) {
1099
+ const access = authorizeAccess(authSubject);
1100
+ if (!access.allow) {
1101
+ this.logMenuDiagnostic('access-denied', channel, msg, parsed, access.code);
1102
+ await this.sendMenuResponse(adapter, channel, msg, menuFailure(header, {
1103
+ code: 'ROLE_ACCESS_DENIED',
1104
+ message: access.reason,
1105
+ }));
1106
+ return;
1107
+ }
623
1108
  }
624
1109
  const scope = msg.chatType === 'group' ? (msg.groupId || msg.channelId) : msg.peerId;
625
1110
  const dedupKey = [msg.selfAID || channel, channelType, msg.chatType || 'private', scope, msg.peerId, msg.threadId || '', parsed.id].join('\u001f');
626
- const deduped = await this.menuDeduper.execute(dedupKey, menuPayloadFingerprint(parsed.raw), () => this.dispatchMenuRequest(parsed.request, header, channel, msg, authSubject));
1111
+ let afterResponse;
1112
+ const deduped = await this.menuDeduper.execute(dedupKey, menuPayloadFingerprint(parsed.raw), () => isTokenRequest
1113
+ ? this.issueMenuTokenResponse(header, channel, msg)
1114
+ : this.dispatchMenuRequest(parsed.request, header, channel, msg, authSubject, effect => { afterResponse = effect; }));
627
1115
  if ('conflict' in deduped) {
628
1116
  this.logMenuDiagnostic('request-conflict', channel, msg, parsed, 'CONFLICT');
629
1117
  await this.sendMenuResponse(adapter, channel, msg, menuFailure(header, {
@@ -634,9 +1122,18 @@ export class MessageBridge {
634
1122
  }
635
1123
  if (deduped.replayed)
636
1124
  this.logMenuDiagnostic('request-replay', channel, msg, parsed);
637
- await this.sendMenuResponse(adapter, channel, msg, deduped.value);
1125
+ await this.sendMenuResponse(adapter, channel, msg, deduped.value, isTokenRequest);
1126
+ const deferredEffect = afterResponse;
1127
+ if (!deduped.replayed && deferredEffect) {
1128
+ try {
1129
+ await deferredEffect();
1130
+ }
1131
+ catch (error) {
1132
+ logger.warn(`[MenuControl] deferred effect failed: request=${parsed.id} error=${error instanceof Error ? error.message : String(error)}`);
1133
+ }
1134
+ }
638
1135
  }
639
- async dispatchMenuRequest(request, header, channel, msg, authSubject) {
1136
+ async dispatchMenuRequest(request, header, channel, msg, authSubject, registerAfterResponse) {
640
1137
  try {
641
1138
  const identity = authSubject.identity;
642
1139
  const name = header.name ?? '';
@@ -644,7 +1141,7 @@ export class MessageBridge {
644
1141
  const args = request.args;
645
1142
  switch (request.type) {
646
1143
  case 'menu.list':
647
- return menuSuccess(header, this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private', msg.isControlChannel ? 'control' : 'agent'));
1144
+ return menuSuccess(header, this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private', msg.isControlChannel ? 'control' : 'agent', authSubject));
648
1145
  case 'menu.query': {
649
1146
  const result = await this.cmdHandler.execMenuQuery(this.resolveCmd(name, cmd), channel, msg.channelId, msg.peerId, args, msg.chatType, msg.isControlChannel ?? false, identity, authSubject);
650
1147
  if ('error' in result)
@@ -676,7 +1173,9 @@ export class MessageBridge {
676
1173
  }
677
1174
  actionArgs = { ...(args ?? {}), targetThreadId: msg.threadId };
678
1175
  }
679
- const result = await this.cmdHandler.execMenuAction(this.resolveCmd(name, cmd), request.action, actionArgs, channel, msg.channelId, msg.peerId, identity, msg.chatType, header.id, msg.isControlChannel ?? false, authSubject);
1176
+ const resolvedCmd = this.resolveCmd(name, cmd);
1177
+ const isFileFetch = resolvedCmd.trim().split(' ')[0] === '/file' && request.action === 'fetch';
1178
+ const result = await this.cmdHandler.execMenuAction(resolvedCmd, request.action, actionArgs, channel, msg.channelId, msg.peerId, identity, msg.chatType, header.id, msg.isControlChannel ?? false, authSubject, 'menu', msg.replyContext, isFileFetch ? registerAfterResponse : undefined);
680
1179
  if ('error' in result)
681
1180
  throw result;
682
1181
  return menuSuccess(header, result.data);
@@ -692,7 +1191,7 @@ export class MessageBridge {
692
1191
  return menuFailure(header, normalizeMenuError(error));
693
1192
  }
694
1193
  }
695
- async sendMenuResponse(adapter, channel, msg, response) {
1194
+ async sendMenuResponse(adapter, channel, msg, response, forceEncryption = false) {
696
1195
  if (!adapter?.send) {
697
1196
  this.logMenuDiagnostic('transport-unavailable', channel, msg, {
698
1197
  isMenu: true,
@@ -705,15 +1204,47 @@ export class MessageBridge {
705
1204
  return;
706
1205
  }
707
1206
  const agentName = this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>';
1207
+ const replyContext = {
1208
+ ...(msg.replyContext ?? {}),
1209
+ metadata: {
1210
+ ...(msg.replyContext?.metadata ?? {}),
1211
+ ...evolMenuResponseTransportMetadata(forceEncryption ? true : msg.encrypted),
1212
+ },
1213
+ };
708
1214
  const envelope = buildEnvelope({
709
1215
  taskId: `menu-${randomBytes(4).toString('hex')}`,
710
1216
  channel,
711
1217
  channelId: msg.channelId,
712
1218
  agentName,
713
- replyContext: msg.replyContext,
1219
+ replyContext,
714
1220
  });
715
1221
  await adapter.send(envelope, { kind: 'custom', channelType: channel, payload: response });
716
1222
  }
1223
+ menuTokenOwnerAid(channel, msg) {
1224
+ return msg.selfAID || this.agentRegistry?.resolveByChannel(channel)?.aid;
1225
+ }
1226
+ async issueMenuTokenResponse(header, channel, msg) {
1227
+ const tokenOwnerAid = this.menuTokenOwnerAid(channel, msg);
1228
+ if (!tokenOwnerAid || !msg.peerId) {
1229
+ return menuFailure(header, { code: 'MENU_TOKEN_REJECTED', message: '授权申请未通过' });
1230
+ }
1231
+ const headers = msg.protectedHeaders ?? {};
1232
+ const issued = this.menuTokenStore.issue(tokenOwnerAid, msg.peerId, {
1233
+ clientName: this.menuTokenHeaderValue(headers.client_name ?? headers.app_name),
1234
+ clientVersion: this.menuTokenHeaderValue(headers.client_version ?? headers.app_version),
1235
+ evolVersion: this.menuTokenHeaderValue(headers.evol_version),
1236
+ });
1237
+ if (!issued) {
1238
+ return menuFailure(header, { code: 'MENU_TOKEN_REJECTED', message: '授权申请未通过' });
1239
+ }
1240
+ return menuSuccess(header, { menu_token: issued.menuToken, idle_timeout: issued.idleTimeout });
1241
+ }
1242
+ menuTokenHeaderValue(value) {
1243
+ if (typeof value !== 'string')
1244
+ return undefined;
1245
+ const normalized = value.trim();
1246
+ return normalized.length > 0 && normalized.length <= 256 ? normalized : undefined;
1247
+ }
717
1248
  logMenuInbound(channel, msg, parsed) {
718
1249
  logger.channelIn({
719
1250
  channel,
@@ -816,8 +1347,19 @@ export class MessageBridge {
816
1347
  }
817
1348
  // 出站走 adapter.send 统一入口
818
1349
  const adapter = this.processor.getChannelInfo?.(channel)?.adapter;
1350
+ let responseSessionId;
1351
+ try {
1352
+ const responseSession = threadId
1353
+ ? await this.sessionManager.getThreadSession(channel, channelId, threadId)
1354
+ : await this.sessionManager.getActiveSession(channel, channelId);
1355
+ responseSessionId = responseSession?.id;
1356
+ }
1357
+ catch (error) {
1358
+ logger.debug(`[MessageBridge] Unable to bind command response session: ${error instanceof Error ? error.message : String(error)}`);
1359
+ }
819
1360
  const envelope = buildEnvelope({
820
1361
  taskId: `cmd-${randomBytes(5).toString('hex')}`,
1362
+ sessionId: responseSessionId,
821
1363
  channel,
822
1364
  channelId,
823
1365
  agentName: this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>',