@zhin.js/core 1.5.10 → 1.5.12

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 (55) hide show
  1. package/README.md +2 -2
  2. package/lib/adapter.d.ts +3 -0
  3. package/lib/built/ai-trigger.d.ts +0 -2
  4. package/lib/built/ai-trigger.js +0 -1
  5. package/lib/built/inbound-pipeline.js +0 -1
  6. package/lib/built/inbound-runner.js +0 -2
  7. package/lib/built/login-assist.d.ts +22 -4
  8. package/lib/built/login-assist.js +71 -7
  9. package/lib/built/message-enrich.d.ts +0 -1
  10. package/lib/built/message-enrich.js +0 -1
  11. package/lib/built/rich-segments/markdown-to-text.js +2 -2
  12. package/lib/built/schema-endpoint-manager.js +6 -6
  13. package/lib/built/segment-contract/types.d.ts +4 -45
  14. package/lib/built/segment-contract/types.js +0 -4
  15. package/lib/built/user-interaction.d.ts +30 -0
  16. package/lib/built/user-interaction.js +248 -0
  17. package/lib/feature/handler.d.ts +1 -1
  18. package/lib/feature/handler.js +1 -1
  19. package/lib/im-scene.js +0 -2
  20. package/lib/index.d.ts +4 -3
  21. package/lib/index.js +3 -3
  22. package/lib/message.d.ts +0 -5
  23. package/lib/message.js +0 -13
  24. package/lib/plugin-runtime/im/im-runtime.d.ts +28 -8
  25. package/lib/plugin-runtime/im/im-runtime.js +459 -136
  26. package/lib/plugin-runtime/im/index.d.ts +2 -0
  27. package/lib/plugin-runtime/im/index.js +2 -0
  28. package/lib/plugin-runtime/im/login-assist-host.d.ts +6 -0
  29. package/lib/plugin-runtime/im/login-assist-host.js +6 -0
  30. package/lib/plugin-runtime/im/message-dispatcher.d.ts +2 -2
  31. package/lib/plugin-runtime/im/message-dispatcher.js +2 -2
  32. package/lib/plugin-runtime/im/outbound-segments.d.ts +4 -0
  33. package/lib/plugin-runtime/im/outbound-segments.js +26 -0
  34. package/lib/plugin-runtime/im/side-event-gateway.d.ts +13 -0
  35. package/lib/plugin-runtime/im/side-event-gateway.js +2 -0
  36. package/lib/plugin.d.ts +1 -0
  37. package/lib/{prompt.d.ts → schema-interaction.d.ts} +6 -6
  38. package/lib/{prompt.js → schema-interaction.js} +12 -12
  39. package/lib/side-event/base.d.ts +7 -0
  40. package/lib/side-event/dispatch.d.ts +15 -0
  41. package/lib/side-event/dispatch.js +140 -0
  42. package/lib/side-event/index.d.ts +1 -0
  43. package/lib/side-event/index.js +1 -0
  44. package/lib/side-event/normalize.d.ts +2 -0
  45. package/lib/side-event/normalize.js +4 -0
  46. package/lib/side-event/types.d.ts +4 -2
  47. package/lib/side-event/types.js +1 -1
  48. package/lib/system-event.d.ts +15 -0
  49. package/lib/system-event.js +7 -0
  50. package/lib/types.d.ts +0 -15
  51. package/package.json +11 -10
  52. package/lib/built/prepend-quote-context.d.ts +0 -34
  53. package/lib/built/prepend-quote-context.js +0 -130
  54. package/lib/message-quote.d.ts +0 -10
  55. package/lib/message-quote.js +0 -90
@@ -2,38 +2,96 @@ import { createToken, generationAdmissionBinder, htmlRendererToken, } from '@zhi
2
2
  import { createPermissionHost, permissionHostToken } from '@zhin.js/permission';
3
3
  import { MessageBus, messageBusToken } from './message-bus.js';
4
4
  import { adapterFeatureId, isAdapterIndex, endpointControlOf, resolveEndpointManagement, } from '@zhin.js/adapter';
5
- import { isDeliveryReceipt, } from '@zhin.js/im-contract';
5
+ import { MemoryConversationEventStore, conversationRefKey, messageRefKey, } from '@zhin.js/im-contract';
6
+ import { segmentsToPlainText } from '../../built/segment-contract/text.js';
6
7
  import { isMiddlewareIndex, middlewareFeatureId } from '@zhin.js/middleware';
7
8
  import { isHandlerIndex, handlerFeatureId } from '../../feature/handler.js';
8
9
  import { formatCompact, getLogger, truncatePreview } from '@zhin.js/logger';
9
10
  import { Message, createOutboundEnvelope, } from './contracts.js';
11
+ import { sideEventGatewayToken, } from './side-event-gateway.js';
12
+ import { loginAssistToken } from './login-assist-host.js';
13
+ import { LoginAssist } from '../../built/login-assist.js';
14
+ import { sideEventSendChannel } from '../../side-event/base.js';
10
15
  import { defaultCommandPrefixResolver, MessageDispatcher } from './message-dispatcher.js';
11
16
  import { OutboundRenderer } from './outbound-renderer.js';
12
- import { applyOutboundInteractivePolicy, normalizeOutboundPayload, resolveOutboundInteractivePolicy, resolveOutboundMediaPolicy, } from './outbound-segments.js';
17
+ import { applyOutboundInteractivePolicy, applyOutboundMarkdownPolicy, normalizeOutboundPayload, resolveOutboundInteractivePolicy, resolveOutboundMarkdownPolicy, resolveOutboundMediaPolicy, } from './outbound-segments.js';
13
18
  import { assertCanonicalSegments } from '../../built/segment-contract/assert.js';
14
19
  import { keyboardFallbackStore } from '../../built/interactive-segments/fallback-store.js';
20
+ import { assertUserInteractionRequest, parseUserInteractionAnswer, projectUserInteraction, renderUserInteraction, } from '../../built/user-interaction.js';
15
21
  import { findRuntimeInteractiveHandler, resolveRuntimeInteractivePayload, runtimeInteractiveConversationKey, } from './interactive.js';
16
22
  const logger = getLogger('im');
17
23
  export const messageGatewayToken = createToken('zhin.im.message-gateway');
18
24
  export const ingressRouteToken = createToken('zhin.im.ingress-route');
19
25
  export const messagePreviewLimit = 200;
26
+ class UserInteractionTimeoutError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'UserInteractionTimeoutError';
30
+ }
31
+ }
32
+ class UserInteractionSupersededError extends Error {
33
+ constructor() {
34
+ super('User interaction superseded');
35
+ this.name = 'UserInteractionSupersededError';
36
+ }
37
+ }
38
+ class UserInteractionDeliveryError extends Error {
39
+ constructor(receipt) {
40
+ super(`User interaction delivery failed: ${receipt.failure?.code ?? receipt.status}`);
41
+ this.name = 'UserInteractionDeliveryError';
42
+ }
43
+ }
20
44
  export class ImRuntime {
21
45
  #dispatcher;
22
46
  #renderer;
23
47
  #messageListeners = new Set();
24
48
  #interactiveHandlers = [];
25
- #promptClaims = new Map();
49
+ #interactionClaims = new Map();
26
50
  #snapshots;
27
51
  #inboundClaim;
28
52
  #enrichSender;
53
+ conversationEvents;
29
54
  constructor(options = {}) {
30
55
  this.#dispatcher = new MessageDispatcher(options.commandPrefix === undefined
31
56
  ? defaultCommandPrefixResolver
32
57
  : () => options.commandPrefix ?? '');
33
58
  this.#renderer = options.renderer ?? new OutboundRenderer();
59
+ this.conversationEvents = options.conversationEvents ?? new MemoryConversationEventStore();
34
60
  this.#inboundClaim = options.inboundClaim;
35
61
  this.#enrichSender = options.enrichSender;
36
62
  }
63
+ /** Process composition replaces the bootstrap memory store after required DB activation. */
64
+ replaceConversationEventStore(store) {
65
+ this.conversationEvents = store;
66
+ }
67
+ async resolveConversationReference(lease, reference, context) {
68
+ if (!this.#snapshots?.owns(lease) || !lease.active) {
69
+ return Object.freeze({ status: 'expired', code: 'generation_lease_expired' });
70
+ }
71
+ if (reference.kind === 'message') {
72
+ const local = await this.conversationEvents.getMessage(reference.message);
73
+ if (local)
74
+ return Object.freeze({ status: 'resolved', reference, value: local });
75
+ }
76
+ context.signal.throwIfAborted();
77
+ const conversation = reference.kind === 'message' ? reference.message.conversation : reference.conversation;
78
+ return requireAdapters(lease.value).resolveContent(conversation.endpoint.id, reference, context);
79
+ }
80
+ async readConversationContext(conversation, consumer, throughSequence, limit = 50, excludeMessageId) {
81
+ if (!Number.isSafeInteger(throughSequence) || throughSequence < 0) {
82
+ throw new TypeError('Conversation context throughSequence must be a non-negative integer');
83
+ }
84
+ const cursor = await this.conversationEvents.getCursor(consumer, conversation);
85
+ const events = await this.conversationEvents.listBetween(conversation, cursor, throughSequence, limit);
86
+ const blocks = aggregateConversationContext(events, excludeMessageId);
87
+ return Object.freeze({
88
+ blocks: Object.freeze(blocks),
89
+ cursor: Math.max(cursor, throughSequence),
90
+ });
91
+ }
92
+ async commitConversationContext(conversation, consumer, cursor) {
93
+ await this.conversationEvents.commitCursor(consumer, conversation, cursor);
94
+ }
37
95
  attach(snapshots) {
38
96
  if (this.#snapshots && this.#snapshots !== snapshots) {
39
97
  throw new Error('ImRuntime is already attached to another Root');
@@ -42,11 +100,36 @@ export class ImRuntime {
42
100
  }
43
101
  permissionHost = createPermissionHost();
44
102
  messageBus = new MessageBus();
103
+ loginAssist = new LoginAssist();
45
104
  install(resources) {
46
105
  resources.provide(messageGatewayToken, this);
106
+ resources.provide(sideEventGatewayToken, this.#sideEventGateway);
107
+ resources.provide(loginAssistToken, this.loginAssist);
47
108
  resources.provide(permissionHostToken, this.permissionHost);
48
109
  resources.provide(messageBusToken, this.messageBus);
49
110
  }
111
+ #sideEventGateway = (() => {
112
+ const self = this;
113
+ const gateway = {
114
+ receiveNotice: (notice) => self.receiveNotice(notice),
115
+ receiveRequest: (request) => self.receiveRequest(request),
116
+ receiveSystem: (event) => self.receiveSystem(event),
117
+ [generationAdmissionBinder](gate) {
118
+ return Object.freeze({
119
+ receiveNotice: async (notice) => {
120
+ await gate.enter(() => self.receiveNotice(notice));
121
+ },
122
+ receiveRequest: async (request) => {
123
+ await gate.enter(() => self.receiveRequest(request));
124
+ },
125
+ receiveSystem: async (event) => {
126
+ await gate.enter(() => self.receiveSystem(event));
127
+ },
128
+ });
129
+ },
130
+ };
131
+ return gateway;
132
+ })();
50
133
  [generationAdmissionBinder](gate) {
51
134
  const gateway = {
52
135
  receive: async (input) => gate.enter(() => this.#receive(input, gate))
@@ -74,18 +157,17 @@ export class ImRuntime {
74
157
  };
75
158
  }
76
159
  // ==========================================================================
77
- // Prompt claim — 命令对话式交互
160
+ // User interaction claim — 命令/Agent 对话式交互
78
161
  // ==========================================================================
79
- #promptConversationKey(message, subjectId = message.sender?.id ?? '') {
80
- const conv = message.conversation;
81
- return `${conv.endpoint.adapter}:${conv.endpoint.id}:${conv.kind}:${conv.id}:${subjectId}`;
162
+ #interactionConversationKey(message, subjectId = message.sender?.id ?? '') {
163
+ return `${conversationRefKey(message.conversation)}:${subjectId}`;
82
164
  }
83
- #resolvePromptClaim(message) {
84
- const key = this.#promptConversationKey(message);
85
- const claim = this.#promptClaims.get(key);
165
+ #resolveInteractionClaim(message) {
166
+ const key = this.#interactionConversationKey(message);
167
+ const claim = this.#interactionClaims.get(key);
86
168
  if (!claim)
87
169
  return false;
88
- claim.resolve(message.content);
170
+ claim.resolve(resolveRuntimeInteractivePayload(message) ?? message.content);
89
171
  return true;
90
172
  }
91
173
  #claimNextMessage(message, timeout, timeoutText, signal, subjectId) {
@@ -94,146 +176,126 @@ export class ImRuntime {
94
176
  reject(abortError(signal, timeoutText));
95
177
  return;
96
178
  }
97
- const key = this.#promptConversationKey(message, subjectId);
98
- const existing = this.#promptClaims.get(key);
179
+ const key = this.#interactionConversationKey(message, subjectId);
180
+ const existing = this.#interactionClaims.get(key);
99
181
  if (existing) {
100
182
  clearTimeout(existing.timer);
101
- existing.reject(new Error('Prompt superseded'));
183
+ existing.reject(new UserInteractionSupersededError());
102
184
  }
103
185
  const timer = setTimeout(() => {
104
- settle(undefined, new Error(timeoutText));
186
+ settle(undefined, new UserInteractionTimeoutError(timeoutText));
105
187
  }, timeout);
106
188
  const onAbort = () => settle(undefined, abortError(signal, timeoutText));
107
189
  signal?.addEventListener('abort', onAbort, { once: true });
108
190
  const settle = (value, error) => {
109
- if (this.#promptClaims.get(key) !== claim)
191
+ if (this.#interactionClaims.get(key) !== claim)
110
192
  return;
111
- this.#promptClaims.delete(key);
193
+ this.#interactionClaims.delete(key);
112
194
  clearTimeout(timer);
113
195
  signal?.removeEventListener('abort', onAbort);
114
196
  if (value !== undefined)
115
197
  resolve(value);
116
198
  else
117
- reject(error ?? new Error(timeoutText));
199
+ reject(error ?? new UserInteractionTimeoutError(timeoutText));
118
200
  };
119
201
  const claim = {
120
202
  resolve: (raw) => settle(raw),
121
203
  reject: (error) => settle(undefined, error),
122
204
  timer,
123
205
  };
124
- this.#promptClaims.set(key, claim);
206
+ this.#interactionClaims.set(key, claim);
125
207
  });
126
208
  }
127
- #buildCommandPrompt(message, subjectId) {
209
+ #buildUserInteraction(message, subjectId) {
128
210
  const DEFAULT_TIMEOUT = 3 * 60 * 1000;
129
211
  const DEFAULT_TIMEOUT_TEXT = '输入超时';
130
212
  const claim = (timeout, timeoutText, signal) => this.#claimNextMessage(message, timeout, timeoutText, signal, subjectId);
131
- const reply = (content) => message.$reply(content);
132
- return {
133
- async text(tips, options) {
134
- await reply(tips);
135
- try {
136
- return await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
137
- }
138
- catch (e) {
139
- if (options?.default !== undefined)
140
- return options.default;
141
- await reply(e.message);
142
- throw e;
143
- }
144
- },
145
- async number(tips, options) {
146
- await reply(tips);
147
- try {
148
- const raw = await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
149
- return +raw;
150
- }
151
- catch (e) {
152
- if (options?.default !== undefined)
153
- return options.default;
154
- await reply(e.message);
155
- throw e;
156
- }
157
- },
158
- async confirm(tips, options) {
159
- const condition = options?.condition ?? 'yes';
160
- await reply(`${tips}\n输入"${condition}"以确认`);
161
- try {
162
- const raw = await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
163
- return raw === condition;
164
- }
165
- catch (e) {
166
- if (options?.default !== undefined)
167
- return options.default;
168
- await reply(e.message);
169
- throw e;
170
- }
171
- },
172
- async list(tips, options) {
173
- const separator = options?.separator ?? ',';
174
- await reply(`${tips}\n值之间使用"${separator}"分隔`);
213
+ const reply = async (view) => {
214
+ const receipt = await message.$reply(renderUserInteraction(view));
215
+ if (receipt.status !== 'sent')
216
+ throw new UserInteractionDeliveryError(receipt);
217
+ };
218
+ const ask = async (request, progress) => {
219
+ assertUserInteractionRequest(request);
220
+ const timeout = request.timeout ?? DEFAULT_TIMEOUT;
221
+ const timeoutText = request.timeoutText ?? DEFAULT_TIMEOUT_TEXT;
222
+ const deadline = Date.now() + timeout;
223
+ const view = projectUserInteraction(request, progress);
224
+ await reply(view);
225
+ while (true) {
175
226
  try {
176
- const raw = await claim(options?.timeout ?? DEFAULT_TIMEOUT, options?.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options?.signal);
177
- const type = options?.type ?? 'text';
178
- return raw.split(separator).map((v) => {
179
- if (type === 'number')
180
- return +v;
181
- if (type === 'boolean')
182
- return v === 'true';
183
- return v;
227
+ const raw = await claim(Math.max(1, deadline - Date.now()), timeoutText, request.signal);
228
+ const result = parseUserInteractionAnswer(request, raw);
229
+ if (result.ok)
230
+ return result.value;
231
+ await reply({
232
+ ...view,
233
+ tip: [request.invalidText ?? result.message, view.tip].filter(Boolean).join('\n'),
184
234
  });
185
235
  }
186
- catch (e) {
187
- if (options?.default !== undefined)
188
- return options.default;
189
- await reply(e.message);
190
- throw e;
191
- }
192
- },
193
- async pick(tips, options) {
194
- const items = options.options.map((o, i) => `${i + 1}.${o.label}`);
195
- const separator = options.separator ?? ',';
196
- if (options.multiple)
197
- items.push(`多选请用"${separator}"分隔`);
198
- await reply(`${tips}\n${items.join('\n')}`);
199
- try {
200
- const raw = await claim(options.timeout ?? DEFAULT_TIMEOUT, options.timeoutText ?? DEFAULT_TIMEOUT_TEXT, options.signal);
201
- if (!options.multiple) {
202
- return options.options.find((_, i) => i + 1 === +raw)?.value;
236
+ catch (error) {
237
+ if (error instanceof UserInteractionTimeoutError && 'default' in request && request.default !== undefined) {
238
+ return request.default;
203
239
  }
204
- const indices = raw.split(separator).map(Number);
205
- return options.options
206
- .filter((_, i) => indices.includes(i + 1))
207
- .map((o) => o.value);
208
- }
209
- catch (e) {
210
- if (options.default !== undefined)
211
- return options.default;
212
- await reply(e.message);
213
- throw e;
240
+ if (error instanceof UserInteractionTimeoutError) {
241
+ await reply({ title: '交互已结束', description: error.message });
242
+ }
243
+ throw error;
214
244
  }
215
- },
245
+ }
246
+ };
247
+ const sequence = async (definition) => {
248
+ if (!definition.title.trim())
249
+ throw new TypeError('User interaction sequence title must not be empty');
250
+ const ids = new Set();
251
+ for (const step of definition.steps) {
252
+ if (!step.id.trim())
253
+ throw new TypeError('User interaction sequence step id must not be empty');
254
+ if (ids.has(step.id))
255
+ throw new TypeError(`Duplicate user interaction sequence step id: ${step.id}`);
256
+ ids.add(step.id);
257
+ }
258
+ const result = {};
259
+ for (let index = 0; index < definition.steps.length; index += 1) {
260
+ const step = definition.steps[index];
261
+ const request = {
262
+ ...step,
263
+ timeout: step.timeout ?? definition.timeout,
264
+ timeoutText: step.timeoutText ?? definition.timeoutText,
265
+ invalidText: step.invalidText ?? definition.invalidText,
266
+ signal: step.signal ?? definition.signal,
267
+ };
268
+ result[step.id] = await ask(request, {
269
+ title: definition.title,
270
+ description: definition.description,
271
+ tip: definition.tip,
272
+ index: index + 1,
273
+ total: definition.steps.length,
274
+ });
275
+ }
276
+ return Object.freeze(result);
216
277
  };
278
+ return Object.freeze({ ask, sequence });
217
279
  }
218
280
  /**
219
- * Bound Prompt for this conversation.
281
+ * User interaction bound to this conversation.
220
282
  * `bind.subjectId` waits for that user (e.g. master) instead of the message sender.
221
283
  */
222
- createPrompt(message, bind) {
284
+ createInteraction(message, bind) {
223
285
  const subjectId = bind?.subjectId?.trim();
224
286
  if (bind && !subjectId)
225
287
  return undefined;
226
288
  if (typeof message.$reply !== 'function' || !message.conversation)
227
289
  return undefined;
228
- return this.#buildCommandPrompt(message, subjectId);
290
+ return this.#buildUserInteraction(message, subjectId);
229
291
  }
230
- #createPromptForSource(source) {
292
+ #createInteractionForSource(source) {
231
293
  if (!source || typeof source !== 'object')
232
294
  return undefined;
233
295
  const msg = source;
234
296
  if (typeof msg.$reply !== 'function' || !msg.conversation)
235
297
  return undefined;
236
- return this.#buildCommandPrompt(msg);
298
+ return this.#buildUserInteraction(msg);
237
299
  }
238
300
  /**
239
301
  * 订阅消息事件(入站 dispatch 完成后 / 出站发送成功后回调)。
@@ -293,24 +355,51 @@ export class ImRuntime {
293
355
  },
294
356
  }, lease.value);
295
357
  }, enrichedSender, Object.freeze({ ...input.metadata }), input.segments ? Object.freeze([...input.segments]) : undefined, input.message, input.endpointId, input.mentioned, input.replyTo);
296
- await runHandlers(lease.value, 'message.receive', message);
358
+ let conversationSequence;
359
+ if (input.message?.id) {
360
+ const appended = await this.conversationEvents.append(Object.freeze({
361
+ eventId: `message:${messageRefKey(input.message)}`,
362
+ conversation,
363
+ timestamp: Date.now(),
364
+ type: 'message.created',
365
+ message: Object.freeze({
366
+ ref: input.message,
367
+ ...(enrichedSender ? { actor: Object.freeze({
368
+ id: enrichedSender.id,
369
+ ...(enrichedSender.name ? { displayName: enrichedSender.name } : {}),
370
+ }) } : {}),
371
+ segments: Object.freeze(input.segments?.length
372
+ ? [...input.segments]
373
+ : [{ type: 'text', data: { text: input.content } }]),
374
+ timestamp: Date.now(),
375
+ ...(input.replyTo ? { replyTo: Object.freeze({ conversation, id: input.replyTo.id }) } : {}),
376
+ }),
377
+ }));
378
+ conversationSequence = appended.sequence;
379
+ }
297
380
  let result = Object.freeze({ matched: false });
298
381
  const claimed = await this.#inboundClaim?.(message) === true;
299
382
  if (claimed) {
300
383
  result = Object.freeze({ matched: true, command: 'interaction', owner: requester });
301
384
  }
302
- else if (this.#resolvePromptClaim(message)) {
303
- result = Object.freeze({ matched: true, command: 'prompt', owner: requester });
385
+ else if (this.#resolveInteractionClaim(message)) {
386
+ result = Object.freeze({ matched: true, command: 'interaction', owner: requester });
304
387
  }
305
388
  else {
306
- const promptFactory = (source) => this.#createPromptForSource(source);
389
+ const interactionFactory = (source) => this.#createInteractionForSource(source);
307
390
  await runMiddleware(lease.value, message, async () => {
308
- result = await this.#dispatchInteractive(message, requester, admission)
309
- ?? await this.#dispatcher.dispatch(message, lease.value, promptFactory);
310
391
  const ingressRoute = resolveIngressRoute(lease.value);
392
+ const preRouted = await ingressRoute?.preRoute?.(message, lease, requester, conversationSequence) === true;
393
+ if (preRouted) {
394
+ result = Object.freeze({ matched: true, command: 'pre-route', owner: requester });
395
+ return;
396
+ }
397
+ await this.#runHandlers(lease.value, 'message.receive', [message]);
398
+ result = await this.#dispatchInteractive(message, requester, admission)
399
+ ?? await this.#dispatcher.dispatch(message, lease.value, interactionFactory);
311
400
  if (!result.matched && ingressRoute) {
312
401
  logger.debug(formatCompact({ op: 'unmatched', conv: formatConversationLog(conversation) }));
313
- const handled = await ingressRoute.route(message, lease, requester);
402
+ const handled = await ingressRoute.route(message, lease, requester, conversationSequence);
314
403
  if (handled) {
315
404
  result = Object.freeze({ matched: true, command: 'ai', owner: requester });
316
405
  }
@@ -348,12 +437,108 @@ export class ImRuntime {
348
437
  lease.release();
349
438
  }
350
439
  }
440
+ async receiveNotice(notice) {
441
+ const event = conversationEventFromNotice(notice);
442
+ if (event)
443
+ await this.conversationEvents.append(event);
444
+ await this.#receiveSideEvent('notice.receive', notice);
445
+ }
446
+ async receiveRequest(request) {
447
+ await this.#withRequestActionScope(request, async (scoped) => {
448
+ await this.#receiveSideEvent('request.receive', scoped);
449
+ });
450
+ }
451
+ async #withRequestActionScope(request, dispatch) {
452
+ let active = true;
453
+ const actions = new Set();
454
+ const run = (action) => {
455
+ if (!active)
456
+ throw new Error('Request action port expired with its generation operation');
457
+ const operation = Promise.resolve().then(action);
458
+ actions.add(operation);
459
+ void operation.then(() => actions.delete(operation), () => actions.delete(operation));
460
+ return operation;
461
+ };
462
+ const scoped = Object.assign(Object.create(Object.getPrototypeOf(request)), request, {
463
+ $approve: async (remark) => run(() => request.$approve(remark)),
464
+ $reject: async (reason) => run(() => request.$reject(reason)),
465
+ });
466
+ try {
467
+ await dispatch(scoped);
468
+ }
469
+ finally {
470
+ active = false;
471
+ await Promise.allSettled([...actions]);
472
+ }
473
+ }
474
+ async receiveSystem(event) {
475
+ await this.#receiveSideEvent('system.receive', event);
476
+ }
477
+ async #receiveSideEvent(event, payload) {
478
+ const lease = this.#acquire();
479
+ try {
480
+ await this.#runHandlers(lease.value, event, [payload]);
481
+ }
482
+ finally {
483
+ lease.release();
484
+ }
485
+ }
486
+ /**
487
+ * User interaction bound to a side-event scene (private/group channel derived from
488
+ * `$scene_type` / `$scene_id`). Returns undefined when outbound is unavailable.
489
+ */
490
+ #createInteractionForSideEvent(payload, snapshot) {
491
+ const adapter = String(payload.$adapter);
492
+ const endpointKey = String(payload.$endpoint);
493
+ if (!adapter || !endpointKey)
494
+ return undefined;
495
+ const channel = sideEventSendChannel(payload);
496
+ const conversation = Object.freeze({
497
+ endpoint: Object.freeze({ adapter, id: endpointKey }),
498
+ kind: channel.type,
499
+ id: channel.id || endpointKey,
500
+ });
501
+ const requester = snapshot.root;
502
+ const source = Object.freeze({
503
+ conversation,
504
+ ...(payload.$actor?.id
505
+ ? { sender: Object.freeze({ id: payload.$actor.id }) }
506
+ : {}),
507
+ $reply: (content) => this.#sendWithSnapshot({
508
+ conversation,
509
+ requester,
510
+ content,
511
+ }, snapshot),
512
+ });
513
+ return this.#buildUserInteraction(source);
514
+ }
515
+ async #runHandlers(snapshot, event, args) {
516
+ const index = handlers(snapshot);
517
+ if (!index)
518
+ return;
519
+ const options = {
520
+ resolveInteraction: (name, interactionArgs) => {
521
+ const payload = interactionArgs[0];
522
+ if (name === 'message.receive' && payload instanceof Message) {
523
+ return this.createInteraction(payload);
524
+ }
525
+ if (name === 'notice.receive'
526
+ || name === 'request.receive'
527
+ || name === 'system.receive') {
528
+ return this.#createInteractionForSideEvent(payload, snapshot);
529
+ }
530
+ return undefined;
531
+ },
532
+ };
533
+ await index.dispatch(event, args, options);
534
+ }
351
535
  /** Console `endpoint.list` — empty until Adapter Feature projection is ready. */
352
536
  listEndpoints() {
353
537
  try {
354
538
  const lease = this.#acquire();
355
539
  try {
356
540
  return requireAdapters(lease.value).describe().map((row) => Object.freeze({
541
+ id: String(row.id),
357
542
  name: row.name,
358
543
  // adapter 列显示平台类型(owner 包名去 scope/adapter- 前缀),不是 slot localName
359
544
  adapter: adapterTypeName(lease.value.tree.get(row.owner)?.packageName) ?? row.name,
@@ -450,7 +635,7 @@ export class ImRuntime {
450
635
  requester,
451
636
  content,
452
637
  }, lease.value);
453
- return { messageId: result.message?.id ?? result.legacyMessageId ?? '' };
638
+ return { messageId: result.message?.id ?? '' };
454
639
  }
455
640
  finally {
456
641
  lease.release();
@@ -545,19 +730,33 @@ export class ImRuntime {
545
730
  conversation: request.conversation,
546
731
  payload,
547
732
  });
548
- receipt = receiptFromEndpointResult(result);
733
+ receipt = receiptFromEndpointResult(result, request.conversation);
549
734
  }
550
735
  catch (error) {
551
736
  receipt = receiptFromEndpointError(error);
552
737
  }
553
738
  if (receipt?.status === 'sent') {
739
+ if (receipt.message?.id) {
740
+ const segments = conversationSegmentsFromContent(request.content);
741
+ await this.conversationEvents.append(Object.freeze({
742
+ eventId: `message:${messageRefKey(receipt.message)}`,
743
+ conversation: request.conversation,
744
+ timestamp: Date.now(),
745
+ type: 'message.created',
746
+ message: Object.freeze({
747
+ ref: receipt.message,
748
+ segments,
749
+ timestamp: Date.now(),
750
+ }),
751
+ }));
752
+ }
554
753
  this.#emitMessage({
555
754
  direction: 'outbound',
556
755
  conversation: request.conversation,
557
756
  requester: request.requester,
558
757
  contentPreview: previewText(payload),
559
- ...(receipt.message?.id || receipt.legacyMessageId
560
- ? { messageId: receipt.message?.id ?? receipt.legacyMessageId }
758
+ ...(receipt.message?.id
759
+ ? { messageId: receipt.message.id }
561
760
  : {}),
562
761
  timestamp: Date.now(),
563
762
  });
@@ -608,6 +807,133 @@ export class ImRuntime {
608
807
  : undefined;
609
808
  }
610
809
  }
810
+ function conversationEventFromNotice(notice) {
811
+ const sceneType = notice.$scene_type;
812
+ const kind = sceneType === 'group' ? 'group'
813
+ : sceneType === 'channel' ? 'channel'
814
+ : sceneType === 'friend' || sceneType === 'private' ? 'private'
815
+ : undefined;
816
+ if (!kind || !notice.$scene_id || !notice.$id)
817
+ return undefined;
818
+ const conversation = Object.freeze({
819
+ endpoint: Object.freeze({ adapter: String(notice.$adapter), id: String(notice.$endpoint) }),
820
+ kind,
821
+ id: String(notice.$scene_id),
822
+ });
823
+ const actor = notice.$actor?.id
824
+ ? Object.freeze({ id: String(notice.$actor.id), ...(notice.$actor.name ? { displayName: notice.$actor.name } : {}) })
825
+ : undefined;
826
+ const target = notice.$target?.id
827
+ ? Object.freeze({ id: String(notice.$target.id), ...(notice.$target.name ? { displayName: notice.$target.name } : {}) })
828
+ : undefined;
829
+ const base = Object.freeze({
830
+ eventId: `notice:${conversationRefKey(conversation)}:${String(notice.$id)}`,
831
+ conversation,
832
+ timestamp: notice.$timestamp,
833
+ });
834
+ switch (notice.$sub_type) {
835
+ case 'member_increase':
836
+ case 'increase':
837
+ return target ? Object.freeze({ ...base, type: 'member.joined', member: target, ...(actor ? { actor } : {}) }) : undefined;
838
+ case 'member_decrease':
839
+ return target ? Object.freeze({ ...base, type: 'member.left', member: target, ...(actor ? { actor } : {}), reason: actor ? 'removed' : 'left' }) : undefined;
840
+ case 'ban':
841
+ if (!target)
842
+ return undefined;
843
+ return notice.$duration_seconds === 0
844
+ ? Object.freeze({ ...base, type: 'member.unmuted', member: target, ...(actor ? { actor } : {}) })
845
+ : Object.freeze({ ...base, type: 'member.muted', member: target, ...(actor ? { actor } : {}), durationSeconds: notice.$duration_seconds ?? 0 });
846
+ case 'admin_change':
847
+ return target && notice.$role && typeof notice.$enabled === 'boolean'
848
+ ? Object.freeze({ ...base, type: 'member.role_changed', member: target, ...(actor ? { actor } : {}), role: notice.$role, enabled: notice.$enabled })
849
+ : undefined;
850
+ case 'recall':
851
+ return notice.$message_id
852
+ ? Object.freeze({ ...base, type: 'message.recalled', message: Object.freeze({ conversation, id: notice.$message_id }), ...(actor ? { actor } : {}) })
853
+ : undefined;
854
+ case 'emoji_reaction':
855
+ return notice.$message_id && notice.$reaction && notice.$operation
856
+ ? Object.freeze({ ...base, type: 'message.reaction_changed', message: Object.freeze({ conversation, id: notice.$message_id }), ...(actor ? { actor } : {}), reaction: notice.$reaction, operation: notice.$operation })
857
+ : undefined;
858
+ case 'poke':
859
+ return Object.freeze({ ...base, type: 'conversation.poked', ...(actor ? { actor } : {}), ...(target ? { target } : {}) });
860
+ default:
861
+ return undefined;
862
+ }
863
+ }
864
+ function describeConversationEvent(event) {
865
+ const actor = 'actor' in event && event.actor ? `${event.actor.displayName ?? event.actor.id} (${event.actor.id})` : undefined;
866
+ switch (event.type) {
867
+ case 'message.recalled': return `${actor ?? 'Someone'} recalled message ${event.message.id}.`;
868
+ case 'message.reaction_changed': return `${actor ?? 'Someone'} ${event.operation} reaction ${event.reaction} on message ${event.message.id}.`;
869
+ case 'conversation.poked': return `${actor ?? 'Someone'} poked ${event.target ? `${event.target.displayName ?? event.target.id} (${event.target.id})` : 'someone'}.`;
870
+ case 'member.joined': return `${event.member.displayName ?? event.member.id} (${event.member.id}) joined the conversation.`;
871
+ case 'member.left': return `${event.member.displayName ?? event.member.id} (${event.member.id}) left the conversation (${event.reason ?? 'left'}).`;
872
+ case 'member.muted': return `${event.member.displayName ?? event.member.id} (${event.member.id}) was muted for ${event.durationSeconds} seconds${actor ? ` by ${actor}` : ''}.`;
873
+ case 'member.unmuted': return `${event.member.displayName ?? event.member.id} (${event.member.id}) was unmuted${actor ? ` by ${actor}` : ''}.`;
874
+ case 'member.role_changed': return `${event.member.displayName ?? event.member.id} (${event.member.id}) role ${event.role} was ${event.enabled ? 'enabled' : 'disabled'}${actor ? ` by ${actor}` : ''}.`;
875
+ case 'message.created': return '';
876
+ }
877
+ }
878
+ function describeConversationMessage(message) {
879
+ const actor = message.actor
880
+ ? `${message.actor.displayName ?? message.actor.id} (${message.actor.id})`
881
+ : undefined;
882
+ if (!actor)
883
+ return '';
884
+ const text = segmentsToPlainText(message.segments).trim();
885
+ const attachmentTypes = [...new Set(message.segments
886
+ .filter((segment) => segment.type !== 'text' && segment.type !== 'mention' && segment.type !== 'at')
887
+ .map((segment) => segment.type))];
888
+ const attachmentNote = attachmentTypes.length > 0
889
+ ? ` [message also contains ${attachmentTypes.join(', ')} data; inspect its reference before relying on that content]`
890
+ : '';
891
+ return `${actor}: ${text || '(no plain-text content)'}${attachmentNote}`;
892
+ }
893
+ function aggregateConversationContext(events, excludeMessageId) {
894
+ const ordinary = [];
895
+ const noisy = new Map();
896
+ for (const { sequence, event } of events) {
897
+ if (event.type === 'message.created') {
898
+ if (event.message.ref.id === excludeMessageId)
899
+ continue;
900
+ const text = describeConversationMessage(event.message);
901
+ if (!text)
902
+ continue;
903
+ ordinary.push(Object.freeze({
904
+ kind: 'conversation_event',
905
+ sequence,
906
+ eventType: event.type,
907
+ text,
908
+ }));
909
+ continue;
910
+ }
911
+ if (event.type === 'message.reaction_changed' || event.type === 'conversation.poked') {
912
+ const key = event.type === 'message.reaction_changed'
913
+ ? `${event.type}:${event.message.id}:${event.actor?.id ?? ''}:${event.reaction}:${event.operation}`
914
+ : `${event.type}:${event.actor?.id ?? ''}:${event.target?.id ?? ''}`;
915
+ const previous = noisy.get(key);
916
+ noisy.set(key, { sequence, event, count: (previous?.count ?? 0) + 1 });
917
+ continue;
918
+ }
919
+ ordinary.push(Object.freeze({
920
+ kind: 'conversation_event',
921
+ sequence,
922
+ eventType: event.type,
923
+ text: describeConversationEvent(event),
924
+ }));
925
+ }
926
+ for (const { sequence, event, count } of noisy.values()) {
927
+ ordinary.push(Object.freeze({
928
+ kind: 'conversation_event',
929
+ sequence,
930
+ eventType: event.type,
931
+ text: `${describeConversationEvent(event)}${count > 1 ? ` (${count} similar events.)` : ''}`,
932
+ }));
933
+ }
934
+ ordinary.sort((left, right) => left.sequence - right.sequence);
935
+ return Object.freeze(ordinary);
936
+ }
611
937
  function resolveIngressRoute(snapshot) {
612
938
  const candidate = snapshot.resources.get(snapshot.root)?.get(ingressRouteToken.id);
613
939
  return candidate
@@ -641,9 +967,10 @@ function isDirectHtmlConsumer(snapshot, adapter) {
641
967
  async function prepareOutboundPayload(rendered, conversation, snapshot, finalizeInteractive = false) {
642
968
  const adapter = conversation.endpoint.id;
643
969
  const directHtml = isDirectHtmlConsumer(snapshot, adapter);
970
+ const markdownResolved = applyOutboundMarkdownPolicy(rendered, resolveOutboundMarkdownPolicy(adapter, snapshot));
644
971
  let payload = directHtml
645
- ? rendered
646
- : await normalizeOutboundPayload(rendered, resolveHtmlRenderer(snapshot), {
972
+ ? markdownResolved
973
+ : await normalizeOutboundPayload(markdownResolved, resolveHtmlRenderer(snapshot), {
647
974
  mediaPolicy: resolveOutboundMediaPolicy(adapter, snapshot),
648
975
  });
649
976
  if (finalizeInteractive) {
@@ -653,23 +980,12 @@ async function prepareOutboundPayload(rendered, conversation, snapshot, finalize
653
980
  assertCanonicalSegments(payload);
654
981
  return payload;
655
982
  }
656
- function receiptFromEndpointResult(result) {
657
- if (isDeliveryReceipt(result))
658
- return result;
659
- const legacyMessageId = legacyMessageIdOf(result);
983
+ function receiptFromEndpointResult(messageId, conversation) {
660
984
  return Object.freeze({
661
985
  status: 'sent',
662
- ...(legacyMessageId ? { legacyMessageId } : {}),
986
+ message: Object.freeze({ conversation, id: messageId }),
663
987
  });
664
988
  }
665
- function legacyMessageIdOf(result) {
666
- if (typeof result === 'string' || typeof result === 'number')
667
- return String(result);
668
- if (!result || typeof result !== 'object')
669
- return undefined;
670
- const id = result.id;
671
- return typeof id === 'string' || typeof id === 'number' ? String(id) : undefined;
672
- }
673
989
  function receiptFromEndpointError(error) {
674
990
  const message = error instanceof Error ? error.message : String(error);
675
991
  if (/Unknown Adapter Endpoint|does not support outbound/u.test(message)) {
@@ -725,11 +1041,6 @@ function handlers(snapshot) {
725
1041
  const projection = snapshot.projections.get(handlerFeatureId);
726
1042
  return isHandlerIndex(projection) ? projection : undefined;
727
1043
  }
728
- async function runHandlers(snapshot, event, ...args) {
729
- const index = handlers(snapshot);
730
- if (index)
731
- await index.dispatch(event, ...args);
732
- }
733
1044
  function normalizeConsoleContent(content) {
734
1045
  if (typeof content === 'string')
735
1046
  return content;
@@ -782,6 +1093,18 @@ function flattenContent(content) {
782
1093
  }
783
1094
  return String(content);
784
1095
  }
1096
+ function conversationSegmentsFromContent(content) {
1097
+ if (Array.isArray(content)) {
1098
+ try {
1099
+ assertCanonicalSegments(content);
1100
+ return Object.freeze([...content]);
1101
+ }
1102
+ catch {
1103
+ // Fall through to a truthful text projection of non-canonical output.
1104
+ }
1105
+ }
1106
+ return Object.freeze([{ type: 'text', data: Object.freeze({ text: flattenContent(content) }) }]);
1107
+ }
785
1108
  function abortError(signal, fallback) {
786
1109
  if (signal?.reason instanceof Error)
787
1110
  return signal.reason;