@zhin.js/core 1.4.2 → 1.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.
@@ -10,6 +10,8 @@ export const mediaRefSchema = Schema.object({
10
10
  kind: mediaKindSchema.required(),
11
11
  value: Schema.string().required(),
12
12
  mime_type: Schema.string(),
13
+ file_name: Schema.string(),
14
+ size: Schema.number(),
13
15
  });
14
16
  export const textSegmentSchema = Schema.object({
15
17
  type: Schema.const('text'),
@@ -32,6 +34,31 @@ export const imageSegmentSchema = Schema.object({
32
34
  }).required(),
33
35
  platform: platformSchema,
34
36
  });
37
+ export const audioSegmentSchema = Schema.object({
38
+ type: Schema.const('audio'),
39
+ data: Schema.object({
40
+ media: mediaRefSchema.required(),
41
+ duration: Schema.number(),
42
+ }).required(),
43
+ platform: platformSchema,
44
+ });
45
+ export const videoSegmentSchema = Schema.object({
46
+ type: Schema.const('video'),
47
+ data: Schema.object({
48
+ media: mediaRefSchema.required(),
49
+ duration: Schema.number(),
50
+ alt: Schema.string(),
51
+ }).required(),
52
+ platform: platformSchema,
53
+ });
54
+ export const fileSegmentSchema = Schema.object({
55
+ type: Schema.const('file'),
56
+ data: Schema.object({
57
+ media: mediaRefSchema.required(),
58
+ name: Schema.string(),
59
+ }).required(),
60
+ platform: platformSchema,
61
+ });
35
62
  export const replySegmentSchema = Schema.object({
36
63
  type: Schema.const('reply'),
37
64
  data: Schema.object({ message_id: Schema.string().required() }).required(),
@@ -87,6 +114,28 @@ export const canonicalSegmentSchema = Schema.discriminatedUnion('type', {
87
114
  }).required(),
88
115
  platform: platformSchema,
89
116
  },
117
+ audio: {
118
+ data: Schema.object({
119
+ media: mediaRefSchema.required(),
120
+ duration: Schema.number(),
121
+ }).required(),
122
+ platform: platformSchema,
123
+ },
124
+ video: {
125
+ data: Schema.object({
126
+ media: mediaRefSchema.required(),
127
+ duration: Schema.number(),
128
+ alt: Schema.string(),
129
+ }).required(),
130
+ platform: platformSchema,
131
+ },
132
+ file: {
133
+ data: Schema.object({
134
+ media: mediaRefSchema.required(),
135
+ name: Schema.string(),
136
+ }).required(),
137
+ platform: platformSchema,
138
+ },
90
139
  reply: {
91
140
  data: Schema.object({ message_id: Schema.string().required() }).required(),
92
141
  platform: platformSchema,
@@ -1,4 +1,5 @@
1
1
  import type { CapabilityId, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import type { ConversationRef, DeliveryReceipt, MessageRef } from '@zhin.js/im-contract';
2
3
  import type { MediaRef, Segment } from '../../built/segment-contract/types.js';
3
4
  export type { MediaRef, Segment };
4
5
  declare const componentCallBrand: "zhin.component-call/1";
@@ -12,13 +13,23 @@ export interface RawContent<TPayload = unknown> {
12
13
  readonly $content: typeof rawContentBrand;
13
14
  readonly payload: TPayload;
14
15
  }
15
- export type SendContent = string | ComponentCall | RawContent | readonly SendContent[];
16
+ /**
17
+ * 出站内容:纯文本 / canonical Segment(一等公民,媒体与富文本的统一表达)/
18
+ * ComponentCall / RawContent,可任意嵌套数组。
19
+ */
20
+ export type SendContent = string | Segment | ComponentCall | RawContent | readonly SendContent[];
16
21
  export declare function component<TProps>(name: string, props: TProps): ComponentCall<TProps>;
17
22
  export declare function raw<TPayload>(payload: TPayload): RawContent<TPayload>;
18
23
  export declare function isComponentCall(value: SendContent): value is ComponentCall;
19
24
  export declare function isRawContent(value: SendContent): value is RawContent;
25
+ /** canonical Segment 一等公民判定(与 ComponentCall/RawContent 的 $content brand 互斥)。 */
26
+ export declare function isSegmentContent(value: unknown): value is Segment;
20
27
  export interface IncomingMessage {
21
28
  readonly adapter: CapabilityId;
29
+ /** Structured identity supplied by migrated adapters; target remains the bridge. */
30
+ readonly conversation?: ConversationRef;
31
+ /** Structured native message identity supplied by migrated adapters. */
32
+ readonly message?: MessageRef;
22
33
  readonly target: string;
23
34
  /**
24
35
  * 纯文本视图:与 `segments` 同源(adapter 从同一份入站载荷派生二者)。
@@ -38,6 +49,8 @@ export interface IncomingMessage {
38
49
  }
39
50
  export interface SendRequest {
40
51
  readonly adapter: CapabilityId;
52
+ /** Structured destination supplied to Adapter endpoints in parallel with target. */
53
+ readonly conversation?: ConversationRef;
41
54
  readonly target: string;
42
55
  readonly requester: PluginId;
43
56
  readonly content: SendContent;
@@ -51,6 +64,7 @@ export interface ChannelParent {
51
64
  }
52
65
  export interface OutboundEnvelope {
53
66
  readonly adapter: CapabilityId;
67
+ readonly conversation?: ConversationRef;
54
68
  readonly target: string;
55
69
  readonly requester: PluginId;
56
70
  readonly generation: number;
@@ -60,6 +74,10 @@ export interface OutboundEnvelope {
60
74
  }
61
75
  export interface MessageGateway {
62
76
  receive(input: IncomingMessage): Promise<MessageDispatchResult>;
77
+ /**
78
+ * Compatibility surface for existing Adapter-facing gateway consumers.
79
+ * Runtime callers that need an outcome use DeliveryMessageGateway instead.
80
+ */
63
81
  send(request: SendRequest): Promise<unknown>;
64
82
  /**
65
83
  * 注册 interactive action 回跳 handler(prefix 最长匹配;返回注销函数)。
@@ -74,6 +92,10 @@ export interface MessageGateway {
74
92
  */
75
93
  setUnmatchedHandler(handler: (message: Message, snapshot: RuntimeSnapshot, requester: PluginId) => Promise<boolean>): void;
76
94
  }
95
+ /** Structured outbound gateway exposed by the Plugin Runtime. */
96
+ export interface DeliveryMessageGateway extends MessageGateway {
97
+ send(request: SendRequest): Promise<DeliveryReceipt>;
98
+ }
77
99
  export interface MessageDispatchResult {
78
100
  readonly matched: boolean;
79
101
  readonly command?: string;
@@ -93,13 +115,21 @@ export declare class Message {
93
115
  * Command dispatcher 优先使用此字段,以支持 mention、image 等结构化参数。
94
116
  */
95
117
  readonly segments?: readonly Segment[] | undefined;
118
+ /** Structured inbound conversation when supplied by a migrated adapter. */
119
+ readonly conversation?: ConversationRef | undefined;
120
+ /** Structured inbound message identity when supplied by a migrated adapter. */
121
+ readonly message?: MessageRef | undefined;
96
122
  constructor(adapter: CapabilityId, target: string, content: string, generation: number, reply: (content: SendContent, requester?: PluginId) => Promise<unknown>, id?: string | undefined, sender?: string | undefined, metadata?: Readonly<Record<string, unknown>>,
97
123
  /**
98
124
  * 结构化段视图(与 `content` 纯文本视图同源,见 IncomingMessage.segments)。
99
125
  * Command dispatcher 优先使用此字段,以支持 mention、image 等结构化参数。
100
126
  */
101
- segments?: readonly Segment[] | undefined);
102
- readonly $reply: (content: SendContent) => Promise<unknown>;
103
- readonly $replyFrom: (requester: PluginId, content: SendContent) => Promise<unknown>;
127
+ segments?: readonly Segment[] | undefined,
128
+ /** Structured inbound conversation when supplied by a migrated adapter. */
129
+ conversation?: ConversationRef | undefined,
130
+ /** Structured inbound message identity when supplied by a migrated adapter. */
131
+ message?: MessageRef | undefined);
132
+ readonly $reply: (content: SendContent) => Promise<DeliveryReceipt>;
133
+ readonly $replyFrom: (requester: PluginId, content: SendContent) => Promise<DeliveryReceipt>;
104
134
  }
105
135
  export declare function createOutboundEnvelope(request: Omit<OutboundEnvelope, 'payload' | 'replace'>, initialPayload: unknown): OutboundEnvelope;
@@ -22,6 +22,16 @@ export function isRawContent(value) {
22
22
  && '$content' in value
23
23
  && value.$content === rawContentBrand;
24
24
  }
25
+ /** canonical Segment 一等公民判定(与 ComponentCall/RawContent 的 $content brand 互斥)。 */
26
+ export function isSegmentContent(value) {
27
+ return !Array.isArray(value)
28
+ && typeof value === 'object'
29
+ && value !== null
30
+ && !('$content' in value)
31
+ && typeof value.type === 'string'
32
+ && typeof value.data === 'object'
33
+ && value.data !== null;
34
+ }
25
35
  export class Message {
26
36
  adapter;
27
37
  target;
@@ -31,12 +41,22 @@ export class Message {
31
41
  sender;
32
42
  metadata;
33
43
  segments;
34
- constructor(adapter, target, content, generation, reply, id, sender, metadata = Object.freeze({}),
44
+ conversation;
45
+ message;
46
+ constructor(adapter, target, content, generation,
47
+ // Compatibility at the construction boundary: legacy tests and embedders
48
+ // may still supply an untyped reply callback. ImRuntime always supplies a
49
+ // DeliveryReceipt-producing implementation.
50
+ reply, id, sender, metadata = Object.freeze({}),
35
51
  /**
36
52
  * 结构化段视图(与 `content` 纯文本视图同源,见 IncomingMessage.segments)。
37
53
  * Command dispatcher 优先使用此字段,以支持 mention、image 等结构化参数。
38
54
  */
39
- segments) {
55
+ segments,
56
+ /** Structured inbound conversation when supplied by a migrated adapter. */
57
+ conversation,
58
+ /** Structured inbound message identity when supplied by a migrated adapter. */
59
+ message) {
40
60
  this.adapter = adapter;
41
61
  this.target = target;
42
62
  this.content = content;
@@ -45,6 +65,8 @@ export class Message {
45
65
  this.sender = sender;
46
66
  this.metadata = metadata;
47
67
  this.segments = segments;
68
+ this.conversation = conversation;
69
+ this.message = message;
48
70
  this.$reply = (content) => reply(content);
49
71
  this.$replyFrom = (requester, content) => reply(content, requester);
50
72
  Object.freeze(this);
@@ -1,9 +1,10 @@
1
1
  import { Scope, type CapabilityId, type PluginId, type RuntimeSnapshot, type SnapshotStore } from '@zhin.js/plugin-runtime';
2
2
  import { type EndpointManagement, type EndpointManagementCapability } from '@zhin.js/adapter';
3
- import { Message, type ChannelParent, type IncomingMessage, type MessageDispatchResult, type MessageGateway, type SendRequest } from './contracts.js';
3
+ import { type ConversationRef, type DeliveryReceipt, type MessageRef } from '@zhin.js/im-contract';
4
+ import { Message, type ChannelParent, type DeliveryMessageGateway, type IncomingMessage, type MessageDispatchResult, type SendRequest } from './contracts.js';
4
5
  import { OutboundRenderer } from './outbound-renderer.js';
5
6
  import { type RuntimeInteractiveHandler } from './interactive.js';
6
- export declare const messageGatewayToken: import("@zhin.js/plugin-runtime").Token<MessageGateway>;
7
+ export declare const messageGatewayToken: import("@zhin.js/plugin-runtime").Token<DeliveryMessageGateway>;
7
8
  /** Console 实时消息事件(SSE 推送源;content 仅为截断预览,不含完整原始段)。 */
8
9
  export interface RuntimeMessageEvent {
9
10
  readonly direction: 'inbound' | 'outbound';
@@ -29,7 +30,7 @@ export interface ImRuntimeOptions {
29
30
  readonly commandPrefix?: string;
30
31
  readonly renderer?: OutboundRenderer;
31
32
  }
32
- export declare class ImRuntime implements MessageGateway {
33
+ export declare class ImRuntime implements DeliveryMessageGateway {
33
34
  #private;
34
35
  constructor(options?: ImRuntimeOptions);
35
36
  attach(snapshots: SnapshotStore): void;
@@ -51,7 +52,7 @@ export declare class ImRuntime implements MessageGateway {
51
52
  */
52
53
  onMessage(listener: (event: RuntimeMessageEvent) => void): () => void;
53
54
  receive(input: IncomingMessage): Promise<MessageDispatchResult>;
54
- send(request: SendRequest): Promise<unknown>;
55
+ send(request: SendRequest): Promise<DeliveryReceipt>;
55
56
  /** Console `endpoint.list` — empty until Adapter Feature projection is ready. */
56
57
  listEndpoints(): readonly {
57
58
  readonly name: string;
@@ -73,8 +74,9 @@ export declare class ImRuntime implements MessageGateway {
73
74
  sendEndpointMessage(input: {
74
75
  readonly adapter: string;
75
76
  readonly endpointId: string;
76
- readonly channelId: string;
77
- readonly channelType: string;
77
+ readonly conversation?: ConversationRef;
78
+ readonly channelId?: string;
79
+ readonly channelType?: string;
78
80
  readonly content: unknown;
79
81
  readonly parent?: ChannelParent;
80
82
  }): Promise<{
@@ -84,7 +86,8 @@ export declare class ImRuntime implements MessageGateway {
84
86
  addEndpointReaction(input: {
85
87
  readonly adapter: string;
86
88
  readonly endpointId: string;
87
- readonly messageId: string;
89
+ readonly message?: MessageRef;
90
+ readonly messageId?: string;
88
91
  readonly emoji: string;
89
92
  readonly sceneType?: string;
90
93
  readonly channelId?: string;
@@ -92,14 +95,30 @@ export declare class ImRuntime implements MessageGateway {
92
95
  removeEndpointReaction(input: {
93
96
  readonly adapter: string;
94
97
  readonly endpointId: string;
95
- readonly messageId: string;
98
+ readonly message?: MessageRef;
99
+ readonly messageId?: string;
96
100
  readonly reactionId: string;
97
101
  }): Promise<void>;
98
102
  /** Activity-feedback autoRemove: recall a previously sent status message. */
99
103
  recallEndpointMessage(input: {
100
104
  readonly adapter: string;
101
105
  readonly endpointId: string;
102
- readonly messageId: string;
106
+ readonly message?: MessageRef;
107
+ readonly messageId?: string;
108
+ }): Promise<void>;
109
+ editEndpointMessage(input: {
110
+ readonly adapter: string;
111
+ readonly endpointId: string;
112
+ readonly message?: MessageRef;
113
+ readonly messageId?: string;
114
+ readonly content: unknown;
115
+ }): Promise<string | null>;
116
+ setEndpointTyping(input: {
117
+ readonly adapter: string;
118
+ readonly endpointId: string;
119
+ readonly conversation?: ConversationRef;
120
+ readonly target?: string;
121
+ readonly active?: boolean;
103
122
  }): Promise<void>;
104
123
  /**
105
124
  * Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。
@@ -1,11 +1,13 @@
1
1
  import { createToken, htmlRendererToken, } from '@zhin.js/plugin-runtime';
2
- import { adapterFeatureId, isAdapterIndex, resolveEndpointManagement, } from '@zhin.js/adapter';
2
+ import { adapterFeatureId, isAdapterIndex, resolveEndpointControl, resolveEndpointManagement, } from '@zhin.js/adapter';
3
+ import { formatLegacyConversationRef, formatLegacyMessageRef, isDeliveryReceipt, } from '@zhin.js/im-contract';
3
4
  import { isMiddlewareIndex, middlewareFeatureId } from '@zhin.js/middleware';
4
5
  import { formatCompact, getLogger, truncatePreview } from '@zhin.js/logger';
5
6
  import { Message, createOutboundEnvelope, } from './contracts.js';
6
7
  import { defaultCommandPrefixResolver, MessageDispatcher } from './message-dispatcher.js';
7
8
  import { OutboundRenderer } from './outbound-renderer.js';
8
9
  import { applyOutboundInteractivePolicy, normalizeOutboundPayload, resolveOutboundInteractivePolicy, resolveOutboundMediaPolicy, } from './outbound-segments.js';
10
+ import { assertCanonicalSegments } from '../../built/segment-contract/assert.js';
9
11
  import { keyboardFallbackStore } from '../../built/interactive-segments/fallback-store.js';
10
12
  import { findRuntimeInteractiveHandler, resolveRuntimeInteractivePayload, runtimeInteractiveChannelKey, } from './interactive.js';
11
13
  const logger = getLogger('im');
@@ -92,11 +94,12 @@ export class ImRuntime {
92
94
  throw new Error('Message reply scope has ended');
93
95
  return this.#sendWithSnapshot({
94
96
  adapter: input.adapter,
97
+ ...(input.conversation ? { conversation: input.conversation } : {}),
95
98
  target: input.target,
96
99
  requester: replyRequester,
97
100
  content,
98
101
  }, lease.value);
99
- }, input.id, input.sender, Object.freeze({ ...input.metadata }), input.segments ? Object.freeze([...input.segments]) : undefined);
102
+ }, input.id ?? input.message?.id, input.sender, Object.freeze({ ...input.metadata }), input.segments ? Object.freeze([...input.segments]) : undefined, input.conversation, input.message);
100
103
  let result = Object.freeze({ matched: false });
101
104
  await runMiddleware(lease.value, message, async () => {
102
105
  result = await this.#dispatchInteractive(message, requester)
@@ -130,7 +133,9 @@ export class ImRuntime {
130
133
  ? { channelType: channelTypeOf(input.target) }
131
134
  : {}),
132
135
  contentPreview: previewText(input.content),
133
- ...(input.id !== undefined ? { messageId: input.id } : {}),
136
+ ...(input.id ?? input.message?.id
137
+ ? { messageId: input.id ?? input.message?.id }
138
+ : {}),
134
139
  timestamp: Date.now(),
135
140
  });
136
141
  return result;
@@ -210,16 +215,21 @@ export class ImRuntime {
210
215
  const capabilityId = index.resolve(input.adapter, input.endpointId);
211
216
  if (!capabilityId)
212
217
  throw new Error('endpoint not found');
213
- const target = composeSendTarget(input.channelType, input.channelId);
218
+ const target = input.conversation
219
+ ? formatLegacyConversationRef(input.conversation)
220
+ : composeSendTarget(input.channelType ?? '', input.channelId ?? '');
221
+ if (!target)
222
+ throw new TypeError('conversation or channelId is required');
214
223
  const content = normalizeConsoleContent(input.content);
215
224
  const result = await this.#sendWithSnapshot({
216
225
  adapter: capabilityId,
226
+ ...(input.conversation ? { conversation: input.conversation } : {}),
217
227
  target,
218
228
  requester: index.owner(capabilityId),
219
229
  content,
220
230
  ...(input.parent ? { parent: input.parent } : {}),
221
231
  }, lease.value);
222
- return { messageId: result == null ? '' : String(result) };
232
+ return { messageId: result.message?.id ?? result.legacyMessageId ?? '' };
223
233
  }
224
234
  finally {
225
235
  lease.release();
@@ -227,47 +237,33 @@ export class ImRuntime {
227
237
  }
228
238
  /** Activity-feedback: add a message reaction when the live Endpoint supports it. */
229
239
  async addEndpointReaction(input) {
230
- const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
231
- if (!endpoint)
232
- return null;
233
- if (typeof endpoint.addReaction === 'function') {
234
- return endpoint.addReaction(input.messageId, input.emoji, {
235
- sceneType: input.sceneType,
236
- channelId: input.channelId,
237
- });
238
- }
239
- if (typeof endpoint.$addReaction === 'function') {
240
- return endpoint.$addReaction(input.messageId, input.emoji, {
241
- sceneType: input.sceneType,
242
- channelId: input.channelId,
243
- });
244
- }
245
- return null;
240
+ const control = this.#liveEndpointControl(input.adapter, input.endpointId);
241
+ return control?.addReaction?.(legacyMessageTarget(input.message, input.messageId), input.emoji, {
242
+ sceneType: input.sceneType,
243
+ channelId: input.channelId,
244
+ }) ?? null;
246
245
  }
247
246
  async removeEndpointReaction(input) {
248
- const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
249
- if (!endpoint)
250
- return;
251
- if (typeof endpoint.removeReaction === 'function') {
252
- await endpoint.removeReaction(input.messageId, input.reactionId);
253
- return;
254
- }
255
- if (typeof endpoint.$removeReaction === 'function') {
256
- await endpoint.$removeReaction(input.messageId, input.reactionId);
257
- }
247
+ await this.#liveEndpointControl(input.adapter, input.endpointId)
248
+ ?.removeReaction?.(legacyMessageTarget(input.message, input.messageId), input.reactionId);
258
249
  }
259
250
  /** Activity-feedback autoRemove: recall a previously sent status message. */
260
251
  async recallEndpointMessage(input) {
261
- const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
262
- if (!endpoint)
263
- return;
264
- if (typeof endpoint.recallMessage === 'function') {
265
- await endpoint.recallMessage(input.messageId);
266
- return;
267
- }
268
- if (typeof endpoint.$recallMessage === 'function') {
269
- await endpoint.$recallMessage(input.messageId);
270
- }
252
+ await this.#liveEndpointControl(input.adapter, input.endpointId)
253
+ ?.recall?.(legacyMessageTarget(input.message, input.messageId));
254
+ }
255
+ async editEndpointMessage(input) {
256
+ return this.#liveEndpointControl(input.adapter, input.endpointId)
257
+ ?.edit?.(legacyMessageTarget(input.message, input.messageId), input.content) ?? null;
258
+ }
259
+ async setEndpointTyping(input) {
260
+ const target = input.conversation
261
+ ? formatLegacyConversationRef(input.conversation)
262
+ : input.target;
263
+ if (!target)
264
+ throw new TypeError('conversation or target is required');
265
+ await this.#liveEndpointControl(input.adapter, input.endpointId)
266
+ ?.typing?.(target, input.active);
271
267
  }
272
268
  #liveEndpoint(adapter, endpointId) {
273
269
  try {
@@ -284,6 +280,10 @@ export class ImRuntime {
284
280
  return null;
285
281
  }
286
282
  }
283
+ #liveEndpointControl(adapter, endpointId) {
284
+ const endpoint = this.#liveEndpoint(adapter, endpointId);
285
+ return resolveEndpointControl(endpoint) ?? null;
286
+ }
287
287
  /**
288
288
  * Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。
289
289
  * @deprecated Host callers should use `getEndpointManagement()`.
@@ -303,41 +303,72 @@ export class ImRuntime {
303
303
  return resolveEndpointManagement(endpoint) ?? Object.freeze({});
304
304
  }
305
305
  async #sendWithSnapshot(request, snapshot) {
306
- const rendered = await this.#renderer.render(request.content, request.requester, snapshot);
307
- // 单段对象 / html 段在此归一为适配器可消费的 canonical 段数组(含媒体能力协商);
308
- // sandbox 适配器(控制台 UI)直接消费 html 段,跳过规范化。
309
- let payload = isDirectHtmlConsumer(snapshot, request.adapter)
310
- ? rendered
311
- : await normalizeOutboundPayload(rendered, resolveHtmlRenderer(snapshot), {
312
- mediaPolicy: resolveOutboundMediaPolicy(request.adapter, snapshot),
313
- });
314
- // interactive 中央执行:'text' 端点 keyboard → 编号文本,fallback 映射写
315
- // 中央存储(入站数字回跳解析用);'native' 端点透传 keyboard。
316
- payload = applyOutboundInteractivePolicy(payload, resolveOutboundInteractivePolicy(request.adapter, snapshot), (map) => keyboardFallbackStore.remember(runtimeInteractiveChannelKey(String(request.adapter), request.target), map));
306
+ let initialPayload;
307
+ try {
308
+ const rendered = await this.#renderer.render(request.content, request.requester, snapshot);
309
+ initialPayload = await prepareOutboundPayload(rendered, request, snapshot);
310
+ }
311
+ catch {
312
+ return rejectedReceipt('outbound_payload_rejected');
313
+ }
317
314
  const envelope = createOutboundEnvelope({
318
315
  adapter: request.adapter,
316
+ ...(request.conversation ? { conversation: request.conversation } : {}),
319
317
  target: request.target,
320
318
  requester: request.requester,
321
319
  generation: snapshot.generation,
322
320
  ...(request.parent ? { parent: request.parent } : {}),
323
- }, payload);
324
- let result;
325
- await runMiddleware(snapshot, envelope, async () => {
326
- result = await requireAdapters(snapshot).send(request.adapter, {
327
- target: request.target,
328
- payload: envelope.payload,
329
- ...(request.parent ? { parent: request.parent } : {}),
330
- });
331
- }, 'outbound');
332
- this.#emitMessage({
333
- direction: 'outbound',
334
- adapter: request.adapter,
335
- target: request.target,
336
- requester: request.requester,
337
- contentPreview: previewText(envelope.payload),
338
- timestamp: Date.now(),
339
- });
340
- return result;
321
+ }, initialPayload);
322
+ let terminalEntered = false;
323
+ let receipt;
324
+ try {
325
+ await runMiddleware(snapshot, envelope, async () => {
326
+ terminalEntered = true;
327
+ let payload;
328
+ try {
329
+ // Middleware may replace a payload with legacy/wire segments. Normalize
330
+ // again at the transport boundary so it cannot bypass core policy.
331
+ payload = await prepareOutboundPayload(envelope.payload, request, snapshot, true);
332
+ }
333
+ catch {
334
+ receipt = rejectedReceipt('outbound_payload_rejected');
335
+ return;
336
+ }
337
+ try {
338
+ const result = await requireAdapters(snapshot).send(request.adapter, {
339
+ target: request.target,
340
+ ...(request.conversation ? { conversation: request.conversation } : {}),
341
+ payload,
342
+ ...(request.parent ? { parent: request.parent } : {}),
343
+ });
344
+ receipt = receiptFromEndpointResult(result);
345
+ }
346
+ catch (error) {
347
+ receipt = receiptFromEndpointError(error);
348
+ }
349
+ if (receipt?.status === 'sent') {
350
+ this.#emitMessage({
351
+ direction: 'outbound',
352
+ adapter: request.adapter,
353
+ target: request.target,
354
+ requester: request.requester,
355
+ contentPreview: previewText(payload),
356
+ ...(receipt.message?.id || receipt.legacyMessageId
357
+ ? { messageId: receipt.message?.id ?? receipt.legacyMessageId }
358
+ : {}),
359
+ timestamp: Date.now(),
360
+ });
361
+ }
362
+ }, 'outbound');
363
+ }
364
+ catch {
365
+ // If a middleware fails after the terminal, the endpoint has already
366
+ // produced its receipt (and, for a real send, its event). Keep that fact.
367
+ return receipt ?? failedReceipt('outbound_middleware_failed');
368
+ }
369
+ if (!terminalEntered)
370
+ return suppressedReceipt();
371
+ return receipt ?? failedReceipt('outbound_delivery_incomplete');
341
372
  }
342
373
  #acquire() {
343
374
  if (!this.#snapshots)
@@ -385,6 +416,80 @@ function isDirectHtmlConsumer(snapshot, adapter) {
385
416
  const owner = snapshot.capabilities.get(adapter)?.owner;
386
417
  return adapterTypeName(snapshot.tree.get(owner)?.packageName) === 'sandbox';
387
418
  }
419
+ function legacyMessageTarget(message, messageId) {
420
+ if (message)
421
+ return formatLegacyMessageRef(message);
422
+ if (messageId)
423
+ return messageId;
424
+ throw new TypeError('message or messageId is required');
425
+ }
426
+ async function prepareOutboundPayload(rendered, request, snapshot, finalizeInteractive = false) {
427
+ const directHtml = isDirectHtmlConsumer(snapshot, request.adapter);
428
+ let payload = directHtml
429
+ ? rendered
430
+ : await normalizeOutboundPayload(rendered, resolveHtmlRenderer(snapshot), {
431
+ mediaPolicy: resolveOutboundMediaPolicy(request.adapter, snapshot),
432
+ });
433
+ if (finalizeInteractive) {
434
+ payload = applyOutboundInteractivePolicy(payload, resolveOutboundInteractivePolicy(request.adapter, snapshot), (map) => keyboardFallbackStore.remember(runtimeInteractiveChannelKey(String(request.adapter), request.target), map));
435
+ }
436
+ // Segment arrays crossing the adapter boundary are canonical after every
437
+ // middleware transform. Direct html consumers retain their explicit wire API.
438
+ if (!directHtml && Array.isArray(payload))
439
+ assertCanonicalSegments(payload);
440
+ return payload;
441
+ }
442
+ function receiptFromEndpointResult(result) {
443
+ if (isDeliveryReceipt(result))
444
+ return result;
445
+ const legacyMessageId = legacyMessageIdOf(result);
446
+ return Object.freeze({
447
+ status: 'sent',
448
+ ...(legacyMessageId ? { legacyMessageId } : {}),
449
+ });
450
+ }
451
+ function legacyMessageIdOf(result) {
452
+ if (typeof result === 'string' || typeof result === 'number')
453
+ return String(result);
454
+ if (!result || typeof result !== 'object')
455
+ return undefined;
456
+ const id = result.id;
457
+ return typeof id === 'string' || typeof id === 'number' ? String(id) : undefined;
458
+ }
459
+ function receiptFromEndpointError(error) {
460
+ const message = error instanceof Error ? error.message : String(error);
461
+ if (/Unknown Adapter Endpoint|does not support outbound/u.test(message)) {
462
+ return unsupportedReceipt('outbound_unsupported');
463
+ }
464
+ if (/not active/u.test(message))
465
+ return failedReceipt('endpoint_inactive', true);
466
+ return failedReceipt('endpoint_send_failed', true);
467
+ }
468
+ function suppressedReceipt() {
469
+ return Object.freeze({ status: 'suppressed' });
470
+ }
471
+ function unsupportedReceipt(code) {
472
+ return Object.freeze({
473
+ status: 'unsupported',
474
+ failure: Object.freeze({ code, message: 'Outbound delivery is not supported.' }),
475
+ });
476
+ }
477
+ function rejectedReceipt(code) {
478
+ return Object.freeze({
479
+ status: 'rejected',
480
+ failure: Object.freeze({ code, message: 'Outbound payload was rejected.' }),
481
+ });
482
+ }
483
+ function failedReceipt(code, retryable = false) {
484
+ return Object.freeze({
485
+ status: 'failed',
486
+ failure: Object.freeze({
487
+ code,
488
+ message: 'Outbound delivery failed.',
489
+ ...(retryable ? { retryable: true } : {}),
490
+ }),
491
+ });
492
+ }
388
493
  /**
389
494
  * Build Adapter send target. If channelId already carries a scene prefix
390
495
  * (`private:uid` / `group:gid`), do not double-prefix.
@@ -1,5 +1,5 @@
1
1
  import { componentFeatureId, isComponentIndex, } from '@zhin.js/component';
2
- import { isComponentCall, isRawContent, } from './contracts.js';
2
+ import { isComponentCall, isRawContent, isSegmentContent, } from './contracts.js';
3
3
  const maxComponentDepth = 32;
4
4
  export class OutboundRenderer {
5
5
  async render(content, requester, snapshot) {
@@ -15,6 +15,9 @@ export class OutboundRenderer {
15
15
  }
16
16
  if (isRawContent(content))
17
17
  return content.payload;
18
+ // canonical Segment 一等公民:原样透传,由下游 normalizeOutboundPayload 归一与协商
19
+ if (isSegmentContent(content))
20
+ return content;
18
21
  if (isComponentCall(content)) {
19
22
  const rendered = await requireComponents(snapshot).render(requester, content.name, content.props);
20
23
  return this.#render(rendered, requester, snapshot, depth + 1);