@zhin.js/core 1.3.5 → 1.4.1

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 +29 -43
  2. package/lib/adapter.js +17 -2
  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/login-assist.d.ts +19 -2
  14. package/lib/built/login-assist.js +43 -4
  15. package/lib/built/segment-contract/index.d.ts +3 -1
  16. package/lib/built/segment-contract/index.js +3 -1
  17. package/lib/built/segment-contract/json-schema.d.ts +28 -0
  18. package/lib/built/segment-contract/json-schema.js +128 -0
  19. package/lib/built/segment-contract/media.d.ts +11 -1
  20. package/lib/built/segment-contract/media.js +30 -0
  21. package/lib/built/segment-contract/text.d.ts +7 -0
  22. package/lib/built/segment-contract/text.js +34 -0
  23. package/lib/built/segment-contract/types.d.ts +6 -2
  24. package/lib/built/segment-contract/validate.js +1 -0
  25. package/lib/command.d.ts +5 -2
  26. package/lib/command.js +5 -2
  27. package/lib/endpoint.d.ts +4 -1
  28. package/lib/endpoint.js +1 -0
  29. package/lib/feature/adapter.d.ts +2 -0
  30. package/lib/feature/adapter.js +2 -0
  31. package/lib/feature/command.d.ts +2 -0
  32. package/lib/feature/command.js +2 -0
  33. package/lib/feature/component.d.ts +2 -0
  34. package/lib/feature/component.js +2 -0
  35. package/lib/feature/middleware.d.ts +2 -0
  36. package/lib/feature/middleware.js +2 -0
  37. package/lib/plugin-runtime/im/contracts.d.ts +37 -3
  38. package/lib/plugin-runtime/im/contracts.js +8 -1
  39. package/lib/plugin-runtime/im/im-runtime.d.ts +47 -2
  40. package/lib/plugin-runtime/im/im-runtime.js +176 -11
  41. package/lib/plugin-runtime/im/index.d.ts +1 -0
  42. package/lib/plugin-runtime/im/index.js +1 -0
  43. package/lib/plugin-runtime/im/interactive.d.ts +25 -0
  44. package/lib/plugin-runtime/im/interactive.js +41 -0
  45. package/lib/plugin-runtime/im/message-dispatcher.d.ts +12 -2
  46. package/lib/plugin-runtime/im/message-dispatcher.js +153 -12
  47. package/lib/plugin-runtime/im/outbound-segments.d.ts +53 -6
  48. package/lib/plugin-runtime/im/outbound-segments.js +173 -10
  49. package/lib/plugin.d.ts +6 -3
  50. package/lib/plugin.js +38 -24
  51. package/lib/tool-zod.d.ts +16 -1
  52. package/lib/tool-zod.js +138 -51
  53. package/lib/utils.d.ts +3 -0
  54. package/lib/utils.js +6 -3
  55. package/package.json +57 -10
@@ -1,18 +1,27 @@
1
1
  import { createToken, htmlRendererToken, } from '@zhin.js/plugin-runtime';
2
- import { adapterFeatureId, isAdapterIndex } from '@zhin.js/adapter';
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
- import { MessageDispatcher } from './message-dispatcher.js';
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');
13
+ export const messagePreviewLimit = 200;
9
14
  export class ImRuntime {
10
15
  #dispatcher;
11
16
  #renderer;
17
+ #messageListeners = new Set();
18
+ #interactiveHandlers = [];
12
19
  #snapshots;
13
20
  #unmatchedHandler;
14
21
  constructor(options = {}) {
15
- this.#dispatcher = new MessageDispatcher(options.commandPrefix);
22
+ this.#dispatcher = new MessageDispatcher(options.commandPrefix === undefined
23
+ ? defaultCommandPrefixResolver
24
+ : () => options.commandPrefix ?? '');
16
25
  this.#renderer = options.renderer ?? new OutboundRenderer();
17
26
  }
18
27
  attach(snapshots) {
@@ -32,11 +41,52 @@ export class ImRuntime {
32
41
  install(resources) {
33
42
  resources.provide(messageGatewayToken, this);
34
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
+ }
57
+ /**
58
+ * 订阅消息事件(入站 dispatch 完成后 / 出站发送成功后回调)。
59
+ * 返回注销函数。listener 抛错不会阻断消息链路。
60
+ */
61
+ onMessage(listener) {
62
+ this.#messageListeners.add(listener);
63
+ return () => { this.#messageListeners.delete(listener); };
64
+ }
65
+ #emitMessage(event) {
66
+ for (const listener of this.#messageListeners) {
67
+ try {
68
+ listener(event);
69
+ }
70
+ catch {
71
+ // listener 异常不得影响消息收发
72
+ }
73
+ }
74
+ }
35
75
  async receive(input) {
36
76
  const lease = this.#acquire();
37
77
  let active = true;
38
78
  try {
39
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
+ }));
40
90
  const message = new Message(input.adapter, input.target, input.content, lease.value.generation, (content, replyRequester = requester) => {
41
91
  if (!active)
42
92
  throw new Error('Message reply scope has ended');
@@ -46,17 +96,43 @@ export class ImRuntime {
46
96
  requester: replyRequester,
47
97
  content,
48
98
  }, lease.value);
49
- }, input.id, input.sender, Object.freeze({ ...input.metadata }));
99
+ }, input.id, input.sender, Object.freeze({ ...input.metadata }), input.segments ? Object.freeze([...input.segments]) : undefined);
50
100
  let result = Object.freeze({ matched: false });
51
101
  await runMiddleware(lease.value, message, async () => {
52
- result = await this.#dispatcher.dispatch(message, lease.value);
102
+ result = await this.#dispatchInteractive(message, requester)
103
+ ?? await this.#dispatcher.dispatch(message, lease.value);
53
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
+ }));
54
110
  const handled = await this.#unmatchedHandler(message, lease.value, requester);
55
111
  if (handled) {
56
112
  result = Object.freeze({ matched: true, command: 'ai', owner: requester });
57
113
  }
58
114
  }
59
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
+ }));
124
+ this.#emitMessage({
125
+ direction: 'inbound',
126
+ adapter: input.adapter,
127
+ target: input.target,
128
+ ...(input.sender !== undefined ? { sender: input.sender } : {}),
129
+ ...(channelTypeOf(input.target)
130
+ ? { channelType: channelTypeOf(input.target) }
131
+ : {}),
132
+ contentPreview: previewText(input.content),
133
+ ...(input.id !== undefined ? { messageId: input.id } : {}),
134
+ timestamp: Date.now(),
135
+ });
60
136
  return result;
61
137
  }
62
138
  finally {
@@ -82,9 +158,11 @@ export class ImRuntime {
82
158
  name: row.name,
83
159
  // adapter 列显示平台类型(owner 包名去 scope/adapter- 前缀),不是 slot localName
84
160
  adapter: adapterTypeName(lease.value.tree.get(row.owner)?.packageName) ?? row.name,
161
+ owner: row.owner,
85
162
  connected: row.connected,
86
163
  status: row.status,
87
164
  phase: row.phase,
165
+ managementCapabilities: row.managementCapabilities,
88
166
  }));
89
167
  }
90
168
  finally {
@@ -106,12 +184,15 @@ export class ImRuntime {
106
184
  const row = index.describe().find((item) => item.id === id);
107
185
  if (!row)
108
186
  return null;
187
+ // adapter 与 listEndpoints 对齐:平台类型(owner 包名去 scope/adapter- 前缀),
188
+ // 不是 live name(如 ICQQ uin)。此前误写 row.name 导致 endpoint.info 与 list 不一致。
109
189
  return Object.freeze({
110
190
  name: row.name,
111
- adapter: row.name,
191
+ adapter: adapterTypeName(lease.value.tree.get(row.owner)?.packageName) ?? row.name,
112
192
  connected: row.connected,
113
193
  status: row.status,
114
194
  phase: row.phase,
195
+ managementCapabilities: row.managementCapabilities,
115
196
  });
116
197
  }
117
198
  finally {
@@ -203,17 +284,36 @@ export class ImRuntime {
203
284
  return null;
204
285
  }
205
286
  }
206
- /** Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。 */
287
+ /**
288
+ * Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。
289
+ * @deprecated Host callers should use `getEndpointManagement()`.
290
+ */
207
291
  getLiveEndpoint(adapter, endpointId) {
208
292
  return this.#liveEndpoint(adapter, endpointId);
209
293
  }
294
+ /**
295
+ * Narrow Host seam for Console social/group management. An empty object means
296
+ * the Endpoint exists but implements no management operations; null means it
297
+ * cannot be resolved.
298
+ */
299
+ getEndpointManagement(adapter, endpointId) {
300
+ const endpoint = this.#liveEndpoint(adapter, endpointId);
301
+ if (!endpoint)
302
+ return null;
303
+ return resolveEndpointManagement(endpoint) ?? Object.freeze({});
304
+ }
210
305
  async #sendWithSnapshot(request, snapshot) {
211
306
  const rendered = await this.#renderer.render(request.content, request.requester, snapshot);
212
- // 单段对象 / html 段在此归一为适配器可消费的 wire 段数组;
307
+ // 单段对象 / html 段在此归一为适配器可消费的 canonical 段数组(含媒体能力协商);
213
308
  // sandbox 适配器(控制台 UI)直接消费 html 段,跳过规范化。
214
- const payload = isDirectHtmlConsumer(snapshot, request.adapter)
309
+ let payload = isDirectHtmlConsumer(snapshot, request.adapter)
215
310
  ? rendered
216
- : 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));
217
317
  const envelope = createOutboundEnvelope({
218
318
  adapter: request.adapter,
219
319
  target: request.target,
@@ -229,6 +329,14 @@ export class ImRuntime {
229
329
  ...(request.parent ? { parent: request.parent } : {}),
230
330
  });
231
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
+ });
232
340
  return result;
233
341
  }
234
342
  #acquire() {
@@ -236,6 +344,24 @@ export class ImRuntime {
236
344
  throw new Error('ImRuntime is not attached to a Root');
237
345
  return this.#snapshots.acquire();
238
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
+ }
239
365
  }
240
366
  function requireAdapters(snapshot) {
241
367
  const projection = snapshot.projections.get(adapterFeatureId);
@@ -297,3 +423,42 @@ function normalizeConsoleContent(content) {
297
423
  return content;
298
424
  return String(content);
299
425
  }
426
+ /** target 前缀场景:`group:123` → `group`;无前缀返回 undefined。 */
427
+ function channelTypeOf(target) {
428
+ const match = /^([a-z0-9-]+):/iu.exec(target);
429
+ return match?.[1];
430
+ }
431
+ /** 消息内容 → 预览文本(截断 200 字);wire 段取 `data.text`,其余段记 `[type]`。 */
432
+ function previewText(content) {
433
+ const text = flattenContent(content);
434
+ return text.length > messagePreviewLimit
435
+ ? `${text.slice(0, messagePreviewLimit)}…`
436
+ : text;
437
+ }
438
+ function flattenContent(content) {
439
+ if (typeof content === 'string')
440
+ return content;
441
+ if (content == null)
442
+ return '';
443
+ if (Array.isArray(content)) {
444
+ return content.map((item) => flattenContent(item)).join('');
445
+ }
446
+ if (typeof content === 'object') {
447
+ const record = content;
448
+ const data = record.data;
449
+ if (typeof record.type === 'string') {
450
+ if (data && typeof data.text === 'string')
451
+ return data.text;
452
+ return `[${record.type}]`;
453
+ }
454
+ if (typeof record.text === 'string')
455
+ return record.text;
456
+ try {
457
+ return JSON.stringify(content) ?? '';
458
+ }
459
+ catch {
460
+ return String(content);
461
+ }
462
+ }
463
+ return String(content);
464
+ }
@@ -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,7 +1,17 @@
1
1
  import type { RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
2
  import type { Message, MessageDispatchResult } from './contracts.js';
3
+ /**
4
+ * 命令前缀解析器:返回该消息要求的命令前缀。
5
+ * `''` 表示无前缀(任意文本都尝试按命令匹配)。
6
+ */
7
+ export type CommandPrefixResolver = (message: Message, snapshot: RuntimeSnapshot) => string;
8
+ /**
9
+ * 默认解析:读消息所属适配器实例 config 的 `commandPrefix`(默认 `''`);
10
+ * 实例声明 `endpoints` 数组时,按消息 endpoint 名找 entry,`entry.commandPrefix` 覆盖顶层。
11
+ */
12
+ export declare const defaultCommandPrefixResolver: CommandPrefixResolver;
3
13
  export declare class MessageDispatcher {
4
- private readonly prefix;
5
- constructor(prefix?: string);
14
+ private readonly resolvePrefix;
15
+ constructor(resolvePrefix?: CommandPrefixResolver);
6
16
  dispatch(message: Message, snapshot: RuntimeSnapshot): Promise<MessageDispatchResult>;
7
17
  }
@@ -1,26 +1,167 @@
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');
4
+ function ownerOfMessage(message) {
5
+ return String(message.adapter).split('\0')[0];
6
+ }
7
+ /**
8
+ * 默认解析:读消息所属适配器实例 config 的 `commandPrefix`(默认 `''`);
9
+ * 实例声明 `endpoints` 数组时,按消息 endpoint 名找 entry,`entry.commandPrefix` 覆盖顶层。
10
+ */
11
+ export const defaultCommandPrefixResolver = (message, snapshot) => {
12
+ const config = snapshot.config.get(ownerOfMessage(message));
13
+ if (!config)
14
+ return '';
15
+ const endpointName = typeof message.metadata?.endpoint === 'string'
16
+ ? message.metadata.endpoint
17
+ : undefined;
18
+ if (endpointName && Array.isArray(config.endpoints)) {
19
+ const entry = config.endpoints.find((item) => !!item && typeof item === 'object'
20
+ && item.name === endpointName);
21
+ if (typeof entry?.commandPrefix === 'string')
22
+ return entry.commandPrefix;
23
+ }
24
+ return typeof config.commandPrefix === 'string' ? config.commandPrefix : '';
25
+ };
2
26
  export class MessageDispatcher {
3
- prefix;
4
- constructor(prefix = '/') {
5
- this.prefix = prefix;
6
- if (!prefix)
7
- throw new TypeError('Command prefix cannot be empty');
27
+ resolvePrefix;
28
+ constructor(resolvePrefix = defaultCommandPrefixResolver) {
29
+ this.resolvePrefix = resolvePrefix;
8
30
  }
9
31
  async dispatch(message, snapshot) {
10
- if (!message.content.startsWith(this.prefix))
11
- return Object.freeze({ matched: false });
12
- const input = message.content.slice(this.prefix.length).trim();
13
- if (!input)
32
+ const prefix = this.resolvePrefix(message, snapshot);
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
+ }));
44
+ if (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
+ }));
52
+ return Object.freeze({ matched: false });
53
+ }
54
+ input = input.slice(prefix.length).trim();
55
+ }
56
+ if (!input) {
57
+ logger.debug(formatCompact({
58
+ op: 'command_dispatch_miss',
59
+ reason: 'empty_after_prefix',
60
+ prefix: prefix || '(none)',
61
+ }));
14
62
  return Object.freeze({ matched: false });
63
+ }
15
64
  const commands = snapshot.projections.get(commandFeatureId);
16
- if (!isCommandIndex(commands))
65
+ if (!isCommandIndex(commands)) {
66
+ logger.debug(formatCompact({
67
+ op: 'command_dispatch_miss',
68
+ reason: 'no_command_index',
69
+ }));
17
70
  return Object.freeze({ matched: false });
18
- 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);
19
92
  if (result.matched && result.value !== undefined) {
20
93
  if (!result.owner)
21
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
+ }));
22
100
  await message.$replyFrom(result.owner, result.value);
23
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
+ }
24
109
  return result;
25
110
  }
26
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>;