@zhin.js/core 1.4.0 → 1.4.2

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 (45) hide show
  1. package/README.md +29 -43
  2. package/lib/adapter.js +9 -1
  3. package/lib/built/command.d.ts +5 -2
  4. package/lib/built/command.js +4 -1
  5. package/lib/built/interactive-segments/fallback-store.d.ts +29 -0
  6. package/lib/built/interactive-segments/fallback-store.js +63 -0
  7. package/lib/built/interactive-segments/handlers.d.ts +7 -0
  8. package/lib/built/interactive-segments/handlers.js +20 -4
  9. package/lib/built/interactive-segments/index.d.ts +1 -0
  10. package/lib/built/interactive-segments/index.js +1 -0
  11. package/lib/built/interactive-segments/resolve.d.ts +10 -1
  12. package/lib/built/interactive-segments/resolve.js +30 -1
  13. package/lib/built/segment-contract/index.d.ts +2 -1
  14. package/lib/built/segment-contract/index.js +2 -1
  15. package/lib/built/segment-contract/json-schema.js +2 -2
  16. package/lib/built/segment-contract/media.d.ts +11 -1
  17. package/lib/built/segment-contract/media.js +30 -0
  18. package/lib/built/segment-contract/text.d.ts +7 -0
  19. package/lib/built/segment-contract/text.js +34 -0
  20. package/lib/built/segment-contract/types.d.ts +6 -2
  21. package/lib/built/segment-contract/validate.js +1 -0
  22. package/lib/command.d.ts +5 -2
  23. package/lib/command.js +5 -2
  24. package/lib/feature/adapter.d.ts +2 -0
  25. package/lib/feature/adapter.js +2 -0
  26. package/lib/feature/command.d.ts +2 -0
  27. package/lib/feature/command.js +2 -0
  28. package/lib/feature/component.d.ts +2 -0
  29. package/lib/feature/component.js +2 -0
  30. package/lib/feature/middleware.d.ts +2 -0
  31. package/lib/feature/middleware.js +2 -0
  32. package/lib/plugin-runtime/im/contracts.d.ts +37 -3
  33. package/lib/plugin-runtime/im/contracts.js +8 -1
  34. package/lib/plugin-runtime/im/im-runtime.d.ts +6 -0
  35. package/lib/plugin-runtime/im/im-runtime.js +71 -6
  36. package/lib/plugin-runtime/im/index.d.ts +1 -0
  37. package/lib/plugin-runtime/im/index.js +1 -0
  38. package/lib/plugin-runtime/im/interactive.d.ts +25 -0
  39. package/lib/plugin-runtime/im/interactive.js +41 -0
  40. package/lib/plugin-runtime/im/message-dispatcher.js +122 -5
  41. package/lib/plugin-runtime/im/outbound-segments.d.ts +53 -6
  42. package/lib/plugin-runtime/im/outbound-segments.js +173 -10
  43. package/lib/plugin.d.ts +5 -3
  44. package/lib/plugin.js +38 -24
  45. package/package.json +56 -10
@@ -1,4 +1,6 @@
1
- import type { CapabilityId, PluginId } from '@zhin.js/plugin-runtime';
1
+ import type { CapabilityId, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import type { MediaRef, Segment } from '../../built/segment-contract/types.js';
3
+ export type { MediaRef, Segment };
2
4
  declare const componentCallBrand: "zhin.component-call/1";
3
5
  declare const rawContentBrand: "zhin.raw-content/1";
4
6
  export interface ComponentCall<TProps = unknown> {
@@ -18,7 +20,18 @@ export declare function isRawContent(value: SendContent): value is RawContent;
18
20
  export interface IncomingMessage {
19
21
  readonly adapter: CapabilityId;
20
22
  readonly target: string;
23
+ /**
24
+ * 纯文本视图:与 `segments` 同源(adapter 从同一份入站载荷派生二者)。
25
+ * 触发判定与 Console 预览读取此字段;命令匹配在有 segments 时优先使用结构化视图。
26
+ */
21
27
  readonly content: string;
28
+ /**
29
+ * 结构化段视图(canonical Segment SSOT,见 built/segment-contract)。
30
+ * 与 `content` 同源:segments 承载纯文本无法表达的媒体(image/audio/video/file
31
+ * 的 MediaRef)、mention、reply 等信息。旧 adapter 未迁移时可缺省,
32
+ * 读取方必须容忍 undefined。
33
+ */
34
+ readonly segments?: readonly Segment[];
22
35
  readonly id?: string;
23
36
  readonly sender?: string;
24
37
  readonly metadata?: Readonly<Record<string, unknown>>;
@@ -48,6 +61,18 @@ export interface OutboundEnvelope {
48
61
  export interface MessageGateway {
49
62
  receive(input: IncomingMessage): Promise<MessageDispatchResult>;
50
63
  send(request: SendRequest): Promise<unknown>;
64
+ /**
65
+ * 注册 interactive action 回跳 handler(prefix 最长匹配;返回注销函数)。
66
+ * 平台 callback 的 action 段、'text' 端点的数字回跳与指令预填直出
67
+ * payload 都会路由到这里。
68
+ */
69
+ registerInteractiveHandler(prefix: string, handler: (message: Message) => Promise<boolean> | boolean): () => void;
70
+ /**
71
+ * Command miss(或非前缀文本)后的回退处理:Host AI 对话、单文件 bot 用。
72
+ * 返回 true 表示已处理(回复已发送);后注册者覆盖前者。
73
+ * `requester` 是消息所属 Adapter Endpoint 的 owner(用于 CapabilityIngress 继承)。
74
+ */
75
+ setUnmatchedHandler(handler: (message: Message, snapshot: RuntimeSnapshot, requester: PluginId) => Promise<boolean>): void;
51
76
  }
52
77
  export interface MessageDispatchResult {
53
78
  readonly matched: boolean;
@@ -63,9 +88,18 @@ export declare class Message {
63
88
  readonly id?: string | undefined;
64
89
  readonly sender?: string | undefined;
65
90
  readonly metadata: Readonly<Record<string, unknown>>;
66
- 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>>);
91
+ /**
92
+ * 结构化段视图(与 `content` 纯文本视图同源,见 IncomingMessage.segments)。
93
+ * Command dispatcher 优先使用此字段,以支持 mention、image 等结构化参数。
94
+ */
95
+ readonly segments?: readonly Segment[] | undefined;
96
+ 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
+ /**
98
+ * 结构化段视图(与 `content` 纯文本视图同源,见 IncomingMessage.segments)。
99
+ * Command dispatcher 优先使用此字段,以支持 mention、image 等结构化参数。
100
+ */
101
+ segments?: readonly Segment[] | undefined);
67
102
  readonly $reply: (content: SendContent) => Promise<unknown>;
68
103
  readonly $replyFrom: (requester: PluginId, content: SendContent) => Promise<unknown>;
69
104
  }
70
105
  export declare function createOutboundEnvelope(request: Omit<OutboundEnvelope, 'payload' | 'replace'>, initialPayload: unknown): OutboundEnvelope;
71
- export {};
@@ -30,7 +30,13 @@ export class Message {
30
30
  id;
31
31
  sender;
32
32
  metadata;
33
- constructor(adapter, target, content, generation, reply, id, sender, metadata = Object.freeze({})) {
33
+ segments;
34
+ constructor(adapter, target, content, generation, reply, id, sender, metadata = Object.freeze({}),
35
+ /**
36
+ * 结构化段视图(与 `content` 纯文本视图同源,见 IncomingMessage.segments)。
37
+ * Command dispatcher 优先使用此字段,以支持 mention、image 等结构化参数。
38
+ */
39
+ segments) {
34
40
  this.adapter = adapter;
35
41
  this.target = target;
36
42
  this.content = content;
@@ -38,6 +44,7 @@ export class Message {
38
44
  this.id = id;
39
45
  this.sender = sender;
40
46
  this.metadata = metadata;
47
+ this.segments = segments;
41
48
  this.$reply = (content) => reply(content);
42
49
  this.$replyFrom = (requester, content) => reply(content, requester);
43
50
  Object.freeze(this);
@@ -2,6 +2,7 @@ import { Scope, type CapabilityId, type PluginId, type RuntimeSnapshot, type Sna
2
2
  import { type EndpointManagement, type EndpointManagementCapability } from '@zhin.js/adapter';
3
3
  import { Message, type ChannelParent, type IncomingMessage, type MessageDispatchResult, type MessageGateway, type SendRequest } from './contracts.js';
4
4
  import { OutboundRenderer } from './outbound-renderer.js';
5
+ import { type RuntimeInteractiveHandler } from './interactive.js';
5
6
  export declare const messageGatewayToken: import("@zhin.js/plugin-runtime").Token<MessageGateway>;
6
7
  /** Console 实时消息事件(SSE 推送源;content 仅为截断预览,不含完整原始段)。 */
7
8
  export interface RuntimeMessageEvent {
@@ -39,6 +40,11 @@ export declare class ImRuntime implements MessageGateway {
39
40
  */
40
41
  setUnmatchedHandler(handler: (message: Message, snapshot: RuntimeSnapshot, requester: PluginId) => Promise<boolean>): void;
41
42
  install(resources: Scope): void;
43
+ /**
44
+ * 注册 interactive action 回跳 handler(prefix 最长匹配;返回注销函数)。
45
+ * 在 Command dispatch 之前路由:action 段 / 数字回跳 / 指令预填 payload。
46
+ */
47
+ registerInteractiveHandler(prefix: string, handler: RuntimeInteractiveHandler): () => void;
42
48
  /**
43
49
  * 订阅消息事件(入站 dispatch 完成后 / 出站发送成功后回调)。
44
50
  * 返回注销函数。listener 抛错不会阻断消息链路。
@@ -1,16 +1,21 @@
1
1
  import { createToken, htmlRendererToken, } from '@zhin.js/plugin-runtime';
2
2
  import { adapterFeatureId, isAdapterIndex, resolveEndpointManagement, } from '@zhin.js/adapter';
3
3
  import { isMiddlewareIndex, middlewareFeatureId } from '@zhin.js/middleware';
4
+ import { formatCompact, getLogger, truncatePreview } from '@zhin.js/logger';
4
5
  import { Message, createOutboundEnvelope, } from './contracts.js';
5
6
  import { defaultCommandPrefixResolver, MessageDispatcher } from './message-dispatcher.js';
6
7
  import { OutboundRenderer } from './outbound-renderer.js';
7
- import { normalizeOutboundPayload } from './outbound-segments.js';
8
+ import { applyOutboundInteractivePolicy, normalizeOutboundPayload, resolveOutboundInteractivePolicy, resolveOutboundMediaPolicy, } from './outbound-segments.js';
9
+ import { keyboardFallbackStore } from '../../built/interactive-segments/fallback-store.js';
10
+ import { findRuntimeInteractiveHandler, resolveRuntimeInteractivePayload, runtimeInteractiveChannelKey, } from './interactive.js';
11
+ const logger = getLogger('im');
8
12
  export const messageGatewayToken = createToken('zhin.im.message-gateway');
9
13
  export const messagePreviewLimit = 200;
10
14
  export class ImRuntime {
11
15
  #dispatcher;
12
16
  #renderer;
13
17
  #messageListeners = new Set();
18
+ #interactiveHandlers = [];
14
19
  #snapshots;
15
20
  #unmatchedHandler;
16
21
  constructor(options = {}) {
@@ -36,6 +41,19 @@ export class ImRuntime {
36
41
  install(resources) {
37
42
  resources.provide(messageGatewayToken, this);
38
43
  }
44
+ /**
45
+ * 注册 interactive action 回跳 handler(prefix 最长匹配;返回注销函数)。
46
+ * 在 Command dispatch 之前路由:action 段 / 数字回跳 / 指令预填 payload。
47
+ */
48
+ registerInteractiveHandler(prefix, handler) {
49
+ const entry = Object.freeze({ prefix, handler });
50
+ this.#interactiveHandlers.push(entry);
51
+ return () => {
52
+ const index = this.#interactiveHandlers.indexOf(entry);
53
+ if (index >= 0)
54
+ this.#interactiveHandlers.splice(index, 1);
55
+ };
56
+ }
39
57
  /**
40
58
  * 订阅消息事件(入站 dispatch 完成后 / 出站发送成功后回调)。
41
59
  * 返回注销函数。listener 抛错不会阻断消息链路。
@@ -59,6 +77,16 @@ export class ImRuntime {
59
77
  let active = true;
60
78
  try {
61
79
  const requester = requireAdapters(lease.value).owner(input.adapter);
80
+ logger.debug(formatCompact({
81
+ op: 'im_inbound_receive',
82
+ adapter: String(input.adapter).split('\0').pop() ?? String(input.adapter),
83
+ target: input.target,
84
+ sender: input.sender,
85
+ id: input.id,
86
+ preview: truncatePreview(input.content),
87
+ segments: input.segments?.length,
88
+ generation: lease.value.generation,
89
+ }));
62
90
  const message = new Message(input.adapter, input.target, input.content, lease.value.generation, (content, replyRequester = requester) => {
63
91
  if (!active)
64
92
  throw new Error('Message reply scope has ended');
@@ -68,17 +96,31 @@ export class ImRuntime {
68
96
  requester: replyRequester,
69
97
  content,
70
98
  }, lease.value);
71
- }, input.id, input.sender, Object.freeze({ ...input.metadata }));
99
+ }, input.id, input.sender, Object.freeze({ ...input.metadata }), input.segments ? Object.freeze([...input.segments]) : undefined);
72
100
  let result = Object.freeze({ matched: false });
73
101
  await runMiddleware(lease.value, message, async () => {
74
- result = await this.#dispatcher.dispatch(message, lease.value);
102
+ result = await this.#dispatchInteractive(message, requester)
103
+ ?? await this.#dispatcher.dispatch(message, lease.value);
75
104
  if (!result.matched && this.#unmatchedHandler) {
105
+ logger.debug(formatCompact({
106
+ op: 'im_inbound_unmatched_handler',
107
+ target: input.target,
108
+ id: input.id,
109
+ }));
76
110
  const handled = await this.#unmatchedHandler(message, lease.value, requester);
77
111
  if (handled) {
78
112
  result = Object.freeze({ matched: true, command: 'ai', owner: requester });
79
113
  }
80
114
  }
81
115
  }, 'inbound');
116
+ logger.debug(formatCompact({
117
+ op: 'im_inbound_done',
118
+ target: input.target,
119
+ id: input.id,
120
+ matched: result.matched,
121
+ command: result.command,
122
+ owner: result.owner,
123
+ }));
82
124
  this.#emitMessage({
83
125
  direction: 'inbound',
84
126
  adapter: input.adapter,
@@ -262,11 +304,16 @@ export class ImRuntime {
262
304
  }
263
305
  async #sendWithSnapshot(request, snapshot) {
264
306
  const rendered = await this.#renderer.render(request.content, request.requester, snapshot);
265
- // 单段对象 / html 段在此归一为适配器可消费的 wire 段数组;
307
+ // 单段对象 / html 段在此归一为适配器可消费的 canonical 段数组(含媒体能力协商);
266
308
  // sandbox 适配器(控制台 UI)直接消费 html 段,跳过规范化。
267
- const payload = isDirectHtmlConsumer(snapshot, request.adapter)
309
+ let payload = isDirectHtmlConsumer(snapshot, request.adapter)
268
310
  ? rendered
269
- : await normalizeOutboundPayload(rendered, resolveHtmlRenderer(snapshot));
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));
270
317
  const envelope = createOutboundEnvelope({
271
318
  adapter: request.adapter,
272
319
  target: request.target,
@@ -297,6 +344,24 @@ export class ImRuntime {
297
344
  throw new Error('ImRuntime is not attached to a Root');
298
345
  return this.#snapshots.acquire();
299
346
  }
347
+ /**
348
+ * interactive 回跳分发(Command dispatch 之前):action 段 / 中央 fallback
349
+ * 数字回跳 / 指令预填 payload → prefix 最长匹配 handler。
350
+ */
351
+ async #dispatchInteractive(message, requester) {
352
+ if (this.#interactiveHandlers.length === 0)
353
+ return undefined;
354
+ const payload = resolveRuntimeInteractivePayload(message);
355
+ if (!payload)
356
+ return undefined;
357
+ const handler = findRuntimeInteractiveHandler(this.#interactiveHandlers, payload);
358
+ if (!handler)
359
+ return undefined;
360
+ const handled = await handler(message);
361
+ return handled
362
+ ? Object.freeze({ matched: true, command: 'interactive', owner: requester })
363
+ : undefined;
364
+ }
300
365
  }
301
366
  function requireAdapters(snapshot) {
302
367
  const projection = snapshot.projections.get(adapterFeatureId);
@@ -1,5 +1,6 @@
1
1
  export * from './contracts.js';
2
2
  export * from './im-runtime.js';
3
+ export * from './interactive.js';
3
4
  export * from './message-dispatcher.js';
4
5
  export * from './outbound-renderer.js';
5
6
  export * from './outbound-segments.js';
@@ -1,5 +1,6 @@
1
1
  export * from './contracts.js';
2
2
  export * from './im-runtime.js';
3
+ export * from './interactive.js';
3
4
  export * from './message-dispatcher.js';
4
5
  export * from './outbound-renderer.js';
5
6
  export * from './outbound-segments.js';
@@ -0,0 +1,25 @@
1
+ import type { Message } from './contracts.js';
2
+ /**
3
+ * Plugin Runtime IM 管线的 interactive action 回跳(旧轨
4
+ * `built/interactive-segments/handlers.ts` 的等价物):
5
+ * - 平台 callback 以 canonical action 段入站(telegram / discord,Wave 1 C 约定
6
+ * `{type:'action', data:{id, payload, sourceMessageId?}}`);
7
+ * - 'text' 端点的数字回跳:出站降级写入中央 fallback 存储的映射在此解析回 payload;
8
+ * - QQ 指令预填等直出 `prefix:session:id` 文本同样识别。
9
+ * payload 按 prefix 最长匹配路由给 `ImRuntime.registerInteractiveHandler`
10
+ * 注册的 handler。
11
+ */
12
+ export type RuntimeInteractiveHandler = (message: Message) => Promise<boolean> | boolean;
13
+ export interface RegisteredRuntimeInteractiveHandler {
14
+ readonly prefix: string;
15
+ readonly handler: RuntimeInteractiveHandler;
16
+ }
17
+ /** 频道键:出站降级写入与入站回跳读取共用(`adapter~target`)。 */
18
+ export declare function runtimeInteractiveChannelKey(adapter: string, target: string): string;
19
+ /** prefix 最长匹配(与旧轨 findHandler 一致)。 */
20
+ export declare function findRuntimeInteractiveHandler(handlers: readonly RegisteredRuntimeInteractiveHandler[], payload: string): RuntimeInteractiveHandler | undefined;
21
+ /**
22
+ * 从入站消息解析 interactive payload:
23
+ * action 段 → 中央 fallback map(裸数字)→ 指令预填直出 payload。
24
+ */
25
+ export declare function resolveRuntimeInteractivePayload(message: Message): string | undefined;
@@ -0,0 +1,41 @@
1
+ import { resolvePayloadFromText } from '../../built/interactive-segments/action.js';
2
+ import { keyboardFallbackStore } from '../../built/interactive-segments/fallback-store.js';
3
+ /** 频道键:出站降级写入与入站回跳读取共用(`adapter~target`)。 */
4
+ export function runtimeInteractiveChannelKey(adapter, target) {
5
+ return `${adapter}~${target}`;
6
+ }
7
+ /** prefix 最长匹配(与旧轨 findHandler 一致)。 */
8
+ export function findRuntimeInteractiveHandler(handlers, payload) {
9
+ let match;
10
+ for (const entry of handlers) {
11
+ if (payload.startsWith(entry.prefix)) {
12
+ if (!match || entry.prefix.length > match.prefix.length) {
13
+ match = entry;
14
+ }
15
+ }
16
+ }
17
+ return match?.handler;
18
+ }
19
+ /**
20
+ * 从入站消息解析 interactive payload:
21
+ * action 段 → 中央 fallback map(裸数字)→ 指令预填直出 payload。
22
+ */
23
+ export function resolveRuntimeInteractivePayload(message) {
24
+ const fromSegments = actionPayloadFromSegments(message.segments);
25
+ if (fromSegments)
26
+ return fromSegments;
27
+ const raw = message.content.trim();
28
+ if (!raw)
29
+ return undefined;
30
+ return resolvePayloadFromText(raw, keyboardFallbackStore.mapFor(runtimeInteractiveChannelKey(String(message.adapter), message.target)));
31
+ }
32
+ function actionPayloadFromSegments(segments) {
33
+ for (const seg of segments ?? []) {
34
+ if (seg.type !== 'action')
35
+ continue;
36
+ const payload = seg.data?.payload;
37
+ if (typeof payload === 'string' && payload)
38
+ return payload;
39
+ }
40
+ return undefined;
41
+ }
@@ -1,4 +1,6 @@
1
- import { commandFeatureId, isCommandIndex } from '@zhin.js/command';
1
+ import { commandFeatureId, isCommandIndex, } from '@zhin.js/command';
2
+ import { formatCompact, getLogger, truncatePreview } from '@zhin.js/logger';
3
+ const logger = getLogger('command');
2
4
  function ownerOfMessage(message) {
3
5
  return String(message.adapter).split('\0')[0];
4
6
  }
@@ -29,22 +31,137 @@ export class MessageDispatcher {
29
31
  async dispatch(message, snapshot) {
30
32
  const prefix = this.resolvePrefix(message, snapshot);
31
33
  let input = message.content.trim();
34
+ logger.debug(formatCompact({
35
+ op: 'command_dispatch_start',
36
+ adapter: ownerOfMessage(message),
37
+ endpoint: typeof message.metadata?.endpoint === 'string'
38
+ ? message.metadata.endpoint
39
+ : undefined,
40
+ prefix: prefix || '(none)',
41
+ preview: truncatePreview(input),
42
+ segments: summarizeSegmentTypes(message.segments),
43
+ }));
32
44
  if (prefix) {
33
- if (!input.startsWith(prefix))
45
+ if (!input.startsWith(prefix)) {
46
+ logger.debug(formatCompact({
47
+ op: 'command_dispatch_miss',
48
+ reason: 'prefix_miss',
49
+ prefix,
50
+ preview: truncatePreview(input),
51
+ }));
34
52
  return Object.freeze({ matched: false });
53
+ }
35
54
  input = input.slice(prefix.length).trim();
36
55
  }
37
- if (!input)
56
+ if (!input) {
57
+ logger.debug(formatCompact({
58
+ op: 'command_dispatch_miss',
59
+ reason: 'empty_after_prefix',
60
+ prefix: prefix || '(none)',
61
+ }));
38
62
  return Object.freeze({ matched: false });
63
+ }
39
64
  const commands = snapshot.projections.get(commandFeatureId);
40
- if (!isCommandIndex(commands))
65
+ if (!isCommandIndex(commands)) {
66
+ logger.debug(formatCompact({
67
+ op: 'command_dispatch_miss',
68
+ reason: 'no_command_index',
69
+ }));
41
70
  return Object.freeze({ matched: false });
42
- const result = await commands.dispatch(input, message);
71
+ }
72
+ const structuredInput = message.segments
73
+ ? stripCommandPrefix(message.segments, prefix)
74
+ : undefined;
75
+ if (message.segments && structuredInput === undefined) {
76
+ logger.debug(formatCompact({
77
+ op: 'command_dispatch_fallback_text',
78
+ reason: 'strip_prefix_failed',
79
+ prefix: prefix || '(none)',
80
+ segments: summarizeSegmentTypes(message.segments),
81
+ }));
82
+ }
83
+ const matchInput = structuredInput ?? input;
84
+ logger.debug(formatCompact({
85
+ op: 'command_dispatch_match_input',
86
+ mode: typeof matchInput === 'string' ? 'text' : 'segments',
87
+ preview: typeof matchInput === 'string'
88
+ ? truncatePreview(matchInput)
89
+ : summarizeSegmentTypes(matchInput),
90
+ }));
91
+ const result = await commands.dispatch(matchInput, message);
43
92
  if (result.matched && result.value !== undefined) {
44
93
  if (!result.owner)
45
94
  throw new Error('Matched Command is missing its owner');
95
+ logger.debug(formatCompact({
96
+ op: 'command_dispatch_hit',
97
+ command: result.command,
98
+ owner: result.owner,
99
+ }));
46
100
  await message.$replyFrom(result.owner, result.value);
47
101
  }
102
+ else {
103
+ logger.debug(formatCompact({
104
+ op: 'command_dispatch_miss',
105
+ reason: result.matched ? 'empty_value' : 'no_match',
106
+ command: result.command,
107
+ }));
108
+ }
48
109
  return result;
49
110
  }
50
111
  }
112
+ /**
113
+ * Keep the text and structured views aligned. Falling back to `content` is
114
+ * intentional when an adapter supplies inconsistent segment data.
115
+ */
116
+ function stripCommandPrefix(segments, prefix) {
117
+ let pendingPrefix = prefix;
118
+ let atStart = true;
119
+ const result = [];
120
+ for (const segment of segments) {
121
+ if (!atStart) {
122
+ result.push(segment);
123
+ continue;
124
+ }
125
+ if (segment.type !== 'text' || typeof segment.data.text !== 'string') {
126
+ if (pendingPrefix)
127
+ return undefined;
128
+ atStart = false;
129
+ result.push(segment);
130
+ continue;
131
+ }
132
+ let text = segment.data.text;
133
+ if (pendingPrefix) {
134
+ if (text.startsWith(pendingPrefix)) {
135
+ text = text.slice(pendingPrefix.length);
136
+ pendingPrefix = '';
137
+ }
138
+ else if (pendingPrefix.startsWith(text)) {
139
+ pendingPrefix = pendingPrefix.slice(text.length);
140
+ continue;
141
+ }
142
+ else {
143
+ return undefined;
144
+ }
145
+ }
146
+ text = text.trimStart();
147
+ if (!text)
148
+ continue;
149
+ atStart = false;
150
+ result.push({ ...segment, data: { ...segment.data, text } });
151
+ }
152
+ return pendingPrefix ? undefined : result;
153
+ }
154
+ function summarizeSegmentTypes(segments) {
155
+ if (!segments?.length)
156
+ return undefined;
157
+ return segments
158
+ .map((segment) => {
159
+ if (typeof segment.type === 'string')
160
+ return segment.type;
161
+ if (segment.type && typeof segment.type === 'object' && 'name' in segment.type) {
162
+ return String(segment.type.name);
163
+ }
164
+ return '?';
165
+ })
166
+ .join(',');
167
+ }
@@ -1,22 +1,69 @@
1
- import type { HtmlRendererHost } from '@zhin.js/plugin-runtime';
1
+ import type { CapabilityId, HtmlRendererHost, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import { type InteractivePolicy } from '../../built/interactive-segments/types.js';
2
3
  /**
3
4
  * Outbound payload normalization for the Plugin Runtime IM pipeline.
4
5
  *
5
6
  * `raw()` payloads reach adapters as-is; adapters only understand wire
6
7
  * segments (`{ type, data }` arrays). A single segment object (non-array)
7
8
  * would otherwise fall through to `String(payload)` → '[object Object]'.
8
- * `html` segments additionally need a Host renderer: image when
9
- * `@zhin.js/html-renderer` is installed, plain-text fallback otherwise.
9
+ *
10
+ * Segment payloads are normalized to the canonical Segment SSOT
11
+ * (`built/segment-contract`,复用 `toCanonicalSegments`:at→mention、
12
+ * 旧 wire 字段 `{url,file,base64}`→MediaRef)。`html` segments additionally
13
+ * need a Host renderer: image when `@zhin.js/html-renderer` is installed,
14
+ * plain-text fallback otherwise. 渲染产出的 base64 图片按端点声明的媒体
15
+ * 能力(`resolveOutboundMediaPolicy`)协商降级。
10
16
  */
11
17
  export interface OutboundSegment {
12
18
  readonly type: string;
13
19
  readonly data?: Record<string, unknown>;
14
20
  }
21
+ /**
22
+ * 出站媒体能力策略(html→image 渲染产物与 base64/path 媒体段的投递方式):
23
+ * - `base64`:平台接受 base64 直发(qq / icqq / slack / weixin-ilink);
24
+ * - `url-or-text`:平台仅接受 URL 媒体;本层无上传通道,base64/path → 文本降级;
25
+ * - `passthrough`:adapter 自行物化媒体(napcat / onebot11 / onebot12),本层不动。
26
+ */
27
+ export type OutboundMediaPolicy = 'base64' | 'url-or-text' | 'passthrough';
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 }` 即覆盖内置表。
34
+ */
35
+ export interface OutboundSegmentsPolicy {
36
+ readonly outboundMedia?: OutboundMediaPolicy | readonly ('url' | 'path' | 'base64' | 'upload')[];
37
+ readonly interactive?: InteractivePolicy;
38
+ }
39
+ export interface NormalizeOutboundOptions {
40
+ /** 缺省 `base64`(保持历史行为:html→image base64 直发)。 */
41
+ readonly mediaPolicy?: OutboundMediaPolicy;
42
+ }
15
43
  export declare function isOutboundSegment(value: unknown): value is OutboundSegment;
16
44
  /**
17
- * Normalize a rendered outbound payload to wire segments:
18
- * - segment arrays stay arrays (html segments converted per element);
45
+ * 解析端点的出站媒体策略:优先读 adapter definition 上声明的
46
+ * `segments.outboundMedia`(任务 C 挂载点;多 endpoint 展开的 `slot~entry`
47
+ * id 回退到 slot id 查声明),否则按平台名查内置表,
48
+ * 未知平台回退 `base64`(历史行为)。
49
+ */
50
+ export declare function resolveOutboundMediaPolicy(adapter: CapabilityId, snapshot: RuntimeSnapshot): OutboundMediaPolicy;
51
+ /**
52
+ * 解析端点的出站 interactive 策略:优先读 adapter definition 上声明的
53
+ * `segments.interactive`,否则按平台名查内置表,未知平台回退 'text'。
54
+ */
55
+ export declare function resolveOutboundInteractivePolicy(adapter: CapabilityId, snapshot: RuntimeSnapshot): InteractivePolicy;
56
+ /**
57
+ * keyboard 段中央降级:'text' 端点把 keyboard 渲染为编号文本(复用旧轨
58
+ * `renderKeyboardAsText`),并把有效 fallback 映射经 `remember` 回调写入
59
+ * 中央存储(供入站数字回跳解析);'native' 端点透传 keyboard。
60
+ */
61
+ export declare function applyOutboundInteractivePolicy(payload: unknown, policy: InteractivePolicy, remember?: (map: Record<string, string>) => void): unknown;
62
+ /**
63
+ * Normalize a rendered outbound payload toward canonical wire segments:
64
+ * - segment arrays stay arrays (html segments converted per element,
65
+ * 其余段经 `toCanonicalSegments` 归一为 canonical Segment);
19
66
  * - a single segment object is wrapped into a one-element array;
20
67
  * - anything else (plain strings, legacy `{ text }` shorthands) passes through.
21
68
  */
22
- export declare function normalizeOutboundPayload(payload: unknown, renderer?: HtmlRendererHost): Promise<unknown>;
69
+ export declare function normalizeOutboundPayload(payload: unknown, renderer?: HtmlRendererHost, options?: NormalizeOutboundOptions): Promise<unknown>;