@zhin.js/core 1.4.3 → 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.
@@ -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);
@@ -20,34 +20,33 @@ export interface OutboundSegment {
20
20
  }
21
21
  /**
22
22
  * 出站媒体能力策略(html→image 渲染产物与 base64/path 媒体段的投递方式):
23
- * - `base64`:平台接受 base64 直发(qq / icqq / slack / weixin-ilink);
23
+ * - `base64`:平台接受 base64 直发;
24
24
  * - `url-or-text`:平台仅接受 URL 媒体;本层无上传通道,base64/path → 文本降级;
25
- * - `passthrough`:adapter 自行物化媒体(napcat / onebot11 / onebot12),本层不动。
25
+ * - `passthrough`:adapter 自行物化媒体,本层不动。
26
26
  */
27
27
  export type OutboundMediaPolicy = 'base64' | 'url-or-text' | 'passthrough';
28
28
  /**
29
- * 任务 C `defineAdapter` segments policy 挂载形状(duck-typed 读取)。
30
- * 契约形状为 `outboundMedia: readonly ('url'|'path'|'base64'|'upload')[]`
31
- * (端点可消费的媒体来源形式,见 `@zhin.js/adapter` AdapterSegmentPolicy);
32
- * 兼容过渡期的单值策略字符串。Adapter definition 上声明
33
- * `segments: { outboundMedia }` 即覆盖内置表。
29
+ * adapter definition segments policy 声明(必选能力面)。
30
+ * `outboundMedia` 为端点可消费的媒体来源形式数组
31
+ * ('url' | 'path' | 'base64' | 'upload'),映射到投递策略:
32
+ * base64 可直发;无 base64 但端点可自行物化(upload/path)→ passthrough;
33
+ * url URL 媒体文本降级。未声明的 adapter 按 ['url'] 兜底。
34
34
  */
35
35
  export interface OutboundSegmentsPolicy {
36
- readonly outboundMedia?: OutboundMediaPolicy | readonly ('url' | 'path' | 'base64' | 'upload')[];
36
+ readonly outboundMedia: readonly ('url' | 'path' | 'base64' | 'upload')[];
37
37
  readonly interactive?: InteractivePolicy;
38
38
  }
39
39
  export interface NormalizeOutboundOptions {
40
- /** 缺省 `base64`(保持历史行为:html→image base64 直发)。 */
40
+ /** 缺省 `url-or-text`(未声明 adapter 的保守兜底:仅 URL 媒体可达)。 */
41
41
  readonly mediaPolicy?: OutboundMediaPolicy;
42
42
  }
43
- export declare function isOutboundSegment(value: unknown): value is OutboundSegment;
44
43
  /**
45
- * 解析端点的出站媒体策略:优先读 adapter definition 上声明的
46
- * `segments.outboundMedia`(任务 C 挂载点;多 endpoint 展开的 `slot~entry`
47
- * id 回退到 slot id 查声明),否则按平台名查内置表,
48
- * 未知平台回退 `base64`(历史行为)。
44
+ * 解析端点的出站媒体策略:读 adapter definition 声明的 `segments.outboundMedia`
45
+ * (多 endpoint 展开的 `slot~entry` id 回退到 slot id 查声明);
46
+ * 未声明回退 `url-or-text`(仅 URL 媒体可达,其余文本降级)。
49
47
  */
50
48
  export declare function resolveOutboundMediaPolicy(adapter: CapabilityId, snapshot: RuntimeSnapshot): OutboundMediaPolicy;
49
+ export declare function isOutboundSegment(value: unknown): value is OutboundSegment;
51
50
  /**
52
51
  * 解析端点的出站 interactive 策略:优先读 adapter definition 上声明的
53
52
  * `segments.interactive`,否则按平台名查内置表,未知平台回退 'text'。