@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.
- package/README.md +29 -43
- package/lib/adapter.js +17 -2
- package/lib/built/command.d.ts +5 -2
- package/lib/built/command.js +4 -1
- package/lib/built/interactive-segments/fallback-store.d.ts +29 -0
- package/lib/built/interactive-segments/fallback-store.js +63 -0
- package/lib/built/interactive-segments/handlers.d.ts +7 -0
- package/lib/built/interactive-segments/handlers.js +20 -4
- package/lib/built/interactive-segments/index.d.ts +1 -0
- package/lib/built/interactive-segments/index.js +1 -0
- package/lib/built/interactive-segments/resolve.d.ts +10 -1
- package/lib/built/interactive-segments/resolve.js +30 -1
- package/lib/built/login-assist.d.ts +19 -2
- package/lib/built/login-assist.js +43 -4
- package/lib/built/segment-contract/index.d.ts +3 -1
- package/lib/built/segment-contract/index.js +3 -1
- package/lib/built/segment-contract/json-schema.d.ts +28 -0
- package/lib/built/segment-contract/json-schema.js +128 -0
- package/lib/built/segment-contract/media.d.ts +11 -1
- package/lib/built/segment-contract/media.js +30 -0
- package/lib/built/segment-contract/text.d.ts +7 -0
- package/lib/built/segment-contract/text.js +34 -0
- package/lib/built/segment-contract/types.d.ts +6 -2
- package/lib/built/segment-contract/validate.js +1 -0
- package/lib/command.d.ts +5 -2
- package/lib/command.js +5 -2
- package/lib/endpoint.d.ts +4 -1
- package/lib/endpoint.js +1 -0
- package/lib/feature/adapter.d.ts +2 -0
- package/lib/feature/adapter.js +2 -0
- package/lib/feature/command.d.ts +2 -0
- package/lib/feature/command.js +2 -0
- package/lib/feature/component.d.ts +2 -0
- package/lib/feature/component.js +2 -0
- package/lib/feature/middleware.d.ts +2 -0
- package/lib/feature/middleware.js +2 -0
- package/lib/plugin-runtime/im/contracts.d.ts +37 -3
- package/lib/plugin-runtime/im/contracts.js +8 -1
- package/lib/plugin-runtime/im/im-runtime.d.ts +47 -2
- package/lib/plugin-runtime/im/im-runtime.js +176 -11
- package/lib/plugin-runtime/im/index.d.ts +1 -0
- package/lib/plugin-runtime/im/index.js +1 -0
- package/lib/plugin-runtime/im/interactive.d.ts +25 -0
- package/lib/plugin-runtime/im/interactive.js +41 -0
- package/lib/plugin-runtime/im/message-dispatcher.d.ts +12 -2
- package/lib/plugin-runtime/im/message-dispatcher.js +153 -12
- package/lib/plugin-runtime/im/outbound-segments.d.ts +53 -6
- package/lib/plugin-runtime/im/outbound-segments.js +173 -10
- package/lib/plugin.d.ts +6 -3
- package/lib/plugin.js +38 -24
- package/lib/tool-zod.d.ts +16 -1
- package/lib/tool-zod.js +138 -51
- package/lib/utils.d.ts +3 -0
- package/lib/utils.js +6 -3
- 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.#
|
|
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
|
-
/**
|
|
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 段在此归一为适配器可消费的
|
|
307
|
+
// 单段对象 / html 段在此归一为适配器可消费的 canonical 段数组(含媒体能力协商);
|
|
213
308
|
// sandbox 适配器(控制台 UI)直接消费 html 段,跳过规范化。
|
|
214
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
5
|
-
constructor(
|
|
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
|
-
|
|
4
|
-
constructor(
|
|
5
|
-
this.
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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>;
|