@zhin.js/core 1.3.4 → 1.4.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.
- package/README.md +12 -0
- package/lib/adapter.js +8 -1
- package/lib/built/authorization.js +2 -2
- package/lib/built/login-assist.d.ts +19 -2
- package/lib/built/login-assist.js +43 -4
- package/lib/built/platform-permit.js +35 -10
- package/lib/built/segment-contract/index.d.ts +1 -0
- package/lib/built/segment-contract/index.js +1 -0
- package/lib/built/segment-contract/json-schema.d.ts +28 -0
- package/lib/built/segment-contract/json-schema.js +128 -0
- package/lib/endpoint.d.ts +4 -1
- package/lib/endpoint.js +1 -0
- package/lib/plugin-runtime/im/contracts.d.ts +71 -0
- package/lib/plugin-runtime/im/contracts.js +55 -0
- package/lib/plugin-runtime/im/im-runtime.d.ts +109 -0
- package/lib/plugin-runtime/im/im-runtime.js +399 -0
- package/lib/plugin-runtime/im/index.d.ts +5 -0
- package/lib/plugin-runtime/im/index.js +5 -0
- package/lib/plugin-runtime/im/message-dispatcher.d.ts +17 -0
- package/lib/plugin-runtime/im/message-dispatcher.js +50 -0
- package/lib/plugin-runtime/im/outbound-renderer.d.ts +6 -0
- package/lib/plugin-runtime/im/outbound-renderer.js +31 -0
- package/lib/plugin-runtime/im/outbound-segments.d.ts +22 -0
- package/lib/plugin-runtime/im/outbound-segments.js +59 -0
- package/lib/plugin.d.ts +1 -0
- 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 +16 -5
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Scope, type CapabilityId, type PluginId, type RuntimeSnapshot, type SnapshotStore } from '@zhin.js/plugin-runtime';
|
|
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';
|
|
4
|
+
import { OutboundRenderer } from './outbound-renderer.js';
|
|
5
|
+
export declare const messageGatewayToken: import("@zhin.js/plugin-runtime").Token<MessageGateway>;
|
|
6
|
+
/** Console 实时消息事件(SSE 推送源;content 仅为截断预览,不含完整原始段)。 */
|
|
7
|
+
export interface RuntimeMessageEvent {
|
|
8
|
+
readonly direction: 'inbound' | 'outbound';
|
|
9
|
+
readonly adapter: CapabilityId;
|
|
10
|
+
readonly target: string;
|
|
11
|
+
/** inbound:发送者 id。 */
|
|
12
|
+
readonly sender?: string;
|
|
13
|
+
/** outbound:发起方插件。 */
|
|
14
|
+
readonly requester?: PluginId;
|
|
15
|
+
/** inbound:从 target 前缀解析的场景(`group:xx` → `group`)。 */
|
|
16
|
+
readonly channelType?: string;
|
|
17
|
+
/** 预览文本,截断至 200 字。 */
|
|
18
|
+
readonly contentPreview: string;
|
|
19
|
+
readonly messageId?: string;
|
|
20
|
+
readonly timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const messagePreviewLimit = 200;
|
|
23
|
+
export interface ImRuntimeOptions {
|
|
24
|
+
/**
|
|
25
|
+
* 全局静态命令前缀(如 `'/'`)。缺省时按适配器实例 config 的
|
|
26
|
+
* `commandPrefix` 解析(`endpoints[i]` 可逐项覆盖),默认 `''` 无前缀。
|
|
27
|
+
*/
|
|
28
|
+
readonly commandPrefix?: string;
|
|
29
|
+
readonly renderer?: OutboundRenderer;
|
|
30
|
+
}
|
|
31
|
+
export declare class ImRuntime implements MessageGateway {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(options?: ImRuntimeOptions);
|
|
34
|
+
attach(snapshots: SnapshotStore): void;
|
|
35
|
+
/**
|
|
36
|
+
* Optional Host AI / fallback path after Command miss (or non-prefixed text).
|
|
37
|
+
* Return true when the message was handled (reply already sent).
|
|
38
|
+
* `requester` is the Adapter Endpoint owner (for CapabilityIngress inheritance).
|
|
39
|
+
*/
|
|
40
|
+
setUnmatchedHandler(handler: (message: Message, snapshot: RuntimeSnapshot, requester: PluginId) => Promise<boolean>): void;
|
|
41
|
+
install(resources: Scope): void;
|
|
42
|
+
/**
|
|
43
|
+
* 订阅消息事件(入站 dispatch 完成后 / 出站发送成功后回调)。
|
|
44
|
+
* 返回注销函数。listener 抛错不会阻断消息链路。
|
|
45
|
+
*/
|
|
46
|
+
onMessage(listener: (event: RuntimeMessageEvent) => void): () => void;
|
|
47
|
+
receive(input: IncomingMessage): Promise<MessageDispatchResult>;
|
|
48
|
+
send(request: SendRequest): Promise<unknown>;
|
|
49
|
+
/** Console `endpoint.list` — empty until Adapter Feature projection is ready. */
|
|
50
|
+
listEndpoints(): readonly {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly adapter: string;
|
|
53
|
+
readonly owner: string;
|
|
54
|
+
readonly connected: boolean;
|
|
55
|
+
readonly status: 'online' | 'offline';
|
|
56
|
+
readonly phase: 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
|
|
57
|
+
readonly managementCapabilities: readonly EndpointManagementCapability[];
|
|
58
|
+
}[];
|
|
59
|
+
getEndpoint(adapter: string, endpointId: string): {
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly adapter: string;
|
|
62
|
+
readonly connected: boolean;
|
|
63
|
+
readonly status: 'online' | 'offline';
|
|
64
|
+
readonly phase: 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
|
|
65
|
+
readonly managementCapabilities: readonly EndpointManagementCapability[];
|
|
66
|
+
} | null;
|
|
67
|
+
sendEndpointMessage(input: {
|
|
68
|
+
readonly adapter: string;
|
|
69
|
+
readonly endpointId: string;
|
|
70
|
+
readonly channelId: string;
|
|
71
|
+
readonly channelType: string;
|
|
72
|
+
readonly content: unknown;
|
|
73
|
+
readonly parent?: ChannelParent;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
messageId: string;
|
|
76
|
+
}>;
|
|
77
|
+
/** Activity-feedback: add a message reaction when the live Endpoint supports it. */
|
|
78
|
+
addEndpointReaction(input: {
|
|
79
|
+
readonly adapter: string;
|
|
80
|
+
readonly endpointId: string;
|
|
81
|
+
readonly messageId: string;
|
|
82
|
+
readonly emoji: string;
|
|
83
|
+
readonly sceneType?: string;
|
|
84
|
+
readonly channelId?: string;
|
|
85
|
+
}): Promise<string | null>;
|
|
86
|
+
removeEndpointReaction(input: {
|
|
87
|
+
readonly adapter: string;
|
|
88
|
+
readonly endpointId: string;
|
|
89
|
+
readonly messageId: string;
|
|
90
|
+
readonly reactionId: string;
|
|
91
|
+
}): Promise<void>;
|
|
92
|
+
/** Activity-feedback autoRemove: recall a previously sent status message. */
|
|
93
|
+
recallEndpointMessage(input: {
|
|
94
|
+
readonly adapter: string;
|
|
95
|
+
readonly endpointId: string;
|
|
96
|
+
readonly messageId: string;
|
|
97
|
+
}): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。
|
|
100
|
+
* @deprecated Host callers should use `getEndpointManagement()`.
|
|
101
|
+
*/
|
|
102
|
+
getLiveEndpoint(adapter: string, endpointId: string): unknown | null;
|
|
103
|
+
/**
|
|
104
|
+
* Narrow Host seam for Console social/group management. An empty object means
|
|
105
|
+
* the Endpoint exists but implements no management operations; null means it
|
|
106
|
+
* cannot be resolved.
|
|
107
|
+
*/
|
|
108
|
+
getEndpointManagement(adapter: string, endpointId: string): EndpointManagement | null;
|
|
109
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { createToken, htmlRendererToken, } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { adapterFeatureId, isAdapterIndex, resolveEndpointManagement, } from '@zhin.js/adapter';
|
|
3
|
+
import { isMiddlewareIndex, middlewareFeatureId } from '@zhin.js/middleware';
|
|
4
|
+
import { Message, createOutboundEnvelope, } from './contracts.js';
|
|
5
|
+
import { defaultCommandPrefixResolver, MessageDispatcher } from './message-dispatcher.js';
|
|
6
|
+
import { OutboundRenderer } from './outbound-renderer.js';
|
|
7
|
+
import { normalizeOutboundPayload } from './outbound-segments.js';
|
|
8
|
+
export const messageGatewayToken = createToken('zhin.im.message-gateway');
|
|
9
|
+
export const messagePreviewLimit = 200;
|
|
10
|
+
export class ImRuntime {
|
|
11
|
+
#dispatcher;
|
|
12
|
+
#renderer;
|
|
13
|
+
#messageListeners = new Set();
|
|
14
|
+
#snapshots;
|
|
15
|
+
#unmatchedHandler;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.#dispatcher = new MessageDispatcher(options.commandPrefix === undefined
|
|
18
|
+
? defaultCommandPrefixResolver
|
|
19
|
+
: () => options.commandPrefix ?? '');
|
|
20
|
+
this.#renderer = options.renderer ?? new OutboundRenderer();
|
|
21
|
+
}
|
|
22
|
+
attach(snapshots) {
|
|
23
|
+
if (this.#snapshots && this.#snapshots !== snapshots) {
|
|
24
|
+
throw new Error('ImRuntime is already attached to another Root');
|
|
25
|
+
}
|
|
26
|
+
this.#snapshots = snapshots;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Optional Host AI / fallback path after Command miss (or non-prefixed text).
|
|
30
|
+
* Return true when the message was handled (reply already sent).
|
|
31
|
+
* `requester` is the Adapter Endpoint owner (for CapabilityIngress inheritance).
|
|
32
|
+
*/
|
|
33
|
+
setUnmatchedHandler(handler) {
|
|
34
|
+
this.#unmatchedHandler = handler;
|
|
35
|
+
}
|
|
36
|
+
install(resources) {
|
|
37
|
+
resources.provide(messageGatewayToken, this);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 订阅消息事件(入站 dispatch 完成后 / 出站发送成功后回调)。
|
|
41
|
+
* 返回注销函数。listener 抛错不会阻断消息链路。
|
|
42
|
+
*/
|
|
43
|
+
onMessage(listener) {
|
|
44
|
+
this.#messageListeners.add(listener);
|
|
45
|
+
return () => { this.#messageListeners.delete(listener); };
|
|
46
|
+
}
|
|
47
|
+
#emitMessage(event) {
|
|
48
|
+
for (const listener of this.#messageListeners) {
|
|
49
|
+
try {
|
|
50
|
+
listener(event);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// listener 异常不得影响消息收发
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async receive(input) {
|
|
58
|
+
const lease = this.#acquire();
|
|
59
|
+
let active = true;
|
|
60
|
+
try {
|
|
61
|
+
const requester = requireAdapters(lease.value).owner(input.adapter);
|
|
62
|
+
const message = new Message(input.adapter, input.target, input.content, lease.value.generation, (content, replyRequester = requester) => {
|
|
63
|
+
if (!active)
|
|
64
|
+
throw new Error('Message reply scope has ended');
|
|
65
|
+
return this.#sendWithSnapshot({
|
|
66
|
+
adapter: input.adapter,
|
|
67
|
+
target: input.target,
|
|
68
|
+
requester: replyRequester,
|
|
69
|
+
content,
|
|
70
|
+
}, lease.value);
|
|
71
|
+
}, input.id, input.sender, Object.freeze({ ...input.metadata }));
|
|
72
|
+
let result = Object.freeze({ matched: false });
|
|
73
|
+
await runMiddleware(lease.value, message, async () => {
|
|
74
|
+
result = await this.#dispatcher.dispatch(message, lease.value);
|
|
75
|
+
if (!result.matched && this.#unmatchedHandler) {
|
|
76
|
+
const handled = await this.#unmatchedHandler(message, lease.value, requester);
|
|
77
|
+
if (handled) {
|
|
78
|
+
result = Object.freeze({ matched: true, command: 'ai', owner: requester });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, 'inbound');
|
|
82
|
+
this.#emitMessage({
|
|
83
|
+
direction: 'inbound',
|
|
84
|
+
adapter: input.adapter,
|
|
85
|
+
target: input.target,
|
|
86
|
+
...(input.sender !== undefined ? { sender: input.sender } : {}),
|
|
87
|
+
...(channelTypeOf(input.target)
|
|
88
|
+
? { channelType: channelTypeOf(input.target) }
|
|
89
|
+
: {}),
|
|
90
|
+
contentPreview: previewText(input.content),
|
|
91
|
+
...(input.id !== undefined ? { messageId: input.id } : {}),
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
});
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
active = false;
|
|
98
|
+
lease.release();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async send(request) {
|
|
102
|
+
const lease = this.#acquire();
|
|
103
|
+
try {
|
|
104
|
+
return await this.#sendWithSnapshot(request, lease.value);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
lease.release();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Console `endpoint.list` — empty until Adapter Feature projection is ready. */
|
|
111
|
+
listEndpoints() {
|
|
112
|
+
try {
|
|
113
|
+
const lease = this.#acquire();
|
|
114
|
+
try {
|
|
115
|
+
return requireAdapters(lease.value).describe().map((row) => Object.freeze({
|
|
116
|
+
name: row.name,
|
|
117
|
+
// adapter 列显示平台类型(owner 包名去 scope/adapter- 前缀),不是 slot localName
|
|
118
|
+
adapter: adapterTypeName(lease.value.tree.get(row.owner)?.packageName) ?? row.name,
|
|
119
|
+
owner: row.owner,
|
|
120
|
+
connected: row.connected,
|
|
121
|
+
status: row.status,
|
|
122
|
+
phase: row.phase,
|
|
123
|
+
managementCapabilities: row.managementCapabilities,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
lease.release();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return Object.freeze([]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
getEndpoint(adapter, endpointId) {
|
|
135
|
+
try {
|
|
136
|
+
const lease = this.#acquire();
|
|
137
|
+
try {
|
|
138
|
+
const index = requireAdapters(lease.value);
|
|
139
|
+
const id = index.resolve(adapter, endpointId);
|
|
140
|
+
if (!id)
|
|
141
|
+
return null;
|
|
142
|
+
const row = index.describe().find((item) => item.id === id);
|
|
143
|
+
if (!row)
|
|
144
|
+
return null;
|
|
145
|
+
// adapter 与 listEndpoints 对齐:平台类型(owner 包名去 scope/adapter- 前缀),
|
|
146
|
+
// 不是 live name(如 ICQQ uin)。此前误写 row.name 导致 endpoint.info 与 list 不一致。
|
|
147
|
+
return Object.freeze({
|
|
148
|
+
name: row.name,
|
|
149
|
+
adapter: adapterTypeName(lease.value.tree.get(row.owner)?.packageName) ?? row.name,
|
|
150
|
+
connected: row.connected,
|
|
151
|
+
status: row.status,
|
|
152
|
+
phase: row.phase,
|
|
153
|
+
managementCapabilities: row.managementCapabilities,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
lease.release();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async sendEndpointMessage(input) {
|
|
165
|
+
const lease = this.#acquire();
|
|
166
|
+
try {
|
|
167
|
+
const index = requireAdapters(lease.value);
|
|
168
|
+
const capabilityId = index.resolve(input.adapter, input.endpointId);
|
|
169
|
+
if (!capabilityId)
|
|
170
|
+
throw new Error('endpoint not found');
|
|
171
|
+
const target = composeSendTarget(input.channelType, input.channelId);
|
|
172
|
+
const content = normalizeConsoleContent(input.content);
|
|
173
|
+
const result = await this.#sendWithSnapshot({
|
|
174
|
+
adapter: capabilityId,
|
|
175
|
+
target,
|
|
176
|
+
requester: index.owner(capabilityId),
|
|
177
|
+
content,
|
|
178
|
+
...(input.parent ? { parent: input.parent } : {}),
|
|
179
|
+
}, lease.value);
|
|
180
|
+
return { messageId: result == null ? '' : String(result) };
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
lease.release();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Activity-feedback: add a message reaction when the live Endpoint supports it. */
|
|
187
|
+
async addEndpointReaction(input) {
|
|
188
|
+
const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
|
|
189
|
+
if (!endpoint)
|
|
190
|
+
return null;
|
|
191
|
+
if (typeof endpoint.addReaction === 'function') {
|
|
192
|
+
return endpoint.addReaction(input.messageId, input.emoji, {
|
|
193
|
+
sceneType: input.sceneType,
|
|
194
|
+
channelId: input.channelId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (typeof endpoint.$addReaction === 'function') {
|
|
198
|
+
return endpoint.$addReaction(input.messageId, input.emoji, {
|
|
199
|
+
sceneType: input.sceneType,
|
|
200
|
+
channelId: input.channelId,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
async removeEndpointReaction(input) {
|
|
206
|
+
const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
|
|
207
|
+
if (!endpoint)
|
|
208
|
+
return;
|
|
209
|
+
if (typeof endpoint.removeReaction === 'function') {
|
|
210
|
+
await endpoint.removeReaction(input.messageId, input.reactionId);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (typeof endpoint.$removeReaction === 'function') {
|
|
214
|
+
await endpoint.$removeReaction(input.messageId, input.reactionId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Activity-feedback autoRemove: recall a previously sent status message. */
|
|
218
|
+
async recallEndpointMessage(input) {
|
|
219
|
+
const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
|
|
220
|
+
if (!endpoint)
|
|
221
|
+
return;
|
|
222
|
+
if (typeof endpoint.recallMessage === 'function') {
|
|
223
|
+
await endpoint.recallMessage(input.messageId);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (typeof endpoint.$recallMessage === 'function') {
|
|
227
|
+
await endpoint.$recallMessage(input.messageId);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
#liveEndpoint(adapter, endpointId) {
|
|
231
|
+
try {
|
|
232
|
+
const lease = this.#acquire();
|
|
233
|
+
try {
|
|
234
|
+
const endpoint = requireAdapters(lease.value).instance(adapter, endpointId);
|
|
235
|
+
return endpoint ?? null;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
lease.release();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。
|
|
247
|
+
* @deprecated Host callers should use `getEndpointManagement()`.
|
|
248
|
+
*/
|
|
249
|
+
getLiveEndpoint(adapter, endpointId) {
|
|
250
|
+
return this.#liveEndpoint(adapter, endpointId);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Narrow Host seam for Console social/group management. An empty object means
|
|
254
|
+
* the Endpoint exists but implements no management operations; null means it
|
|
255
|
+
* cannot be resolved.
|
|
256
|
+
*/
|
|
257
|
+
getEndpointManagement(adapter, endpointId) {
|
|
258
|
+
const endpoint = this.#liveEndpoint(adapter, endpointId);
|
|
259
|
+
if (!endpoint)
|
|
260
|
+
return null;
|
|
261
|
+
return resolveEndpointManagement(endpoint) ?? Object.freeze({});
|
|
262
|
+
}
|
|
263
|
+
async #sendWithSnapshot(request, snapshot) {
|
|
264
|
+
const rendered = await this.#renderer.render(request.content, request.requester, snapshot);
|
|
265
|
+
// 单段对象 / html 段在此归一为适配器可消费的 wire 段数组;
|
|
266
|
+
// sandbox 适配器(控制台 UI)直接消费 html 段,跳过规范化。
|
|
267
|
+
const payload = isDirectHtmlConsumer(snapshot, request.adapter)
|
|
268
|
+
? rendered
|
|
269
|
+
: await normalizeOutboundPayload(rendered, resolveHtmlRenderer(snapshot));
|
|
270
|
+
const envelope = createOutboundEnvelope({
|
|
271
|
+
adapter: request.adapter,
|
|
272
|
+
target: request.target,
|
|
273
|
+
requester: request.requester,
|
|
274
|
+
generation: snapshot.generation,
|
|
275
|
+
...(request.parent ? { parent: request.parent } : {}),
|
|
276
|
+
}, payload);
|
|
277
|
+
let result;
|
|
278
|
+
await runMiddleware(snapshot, envelope, async () => {
|
|
279
|
+
result = await requireAdapters(snapshot).send(request.adapter, {
|
|
280
|
+
target: request.target,
|
|
281
|
+
payload: envelope.payload,
|
|
282
|
+
...(request.parent ? { parent: request.parent } : {}),
|
|
283
|
+
});
|
|
284
|
+
}, 'outbound');
|
|
285
|
+
this.#emitMessage({
|
|
286
|
+
direction: 'outbound',
|
|
287
|
+
adapter: request.adapter,
|
|
288
|
+
target: request.target,
|
|
289
|
+
requester: request.requester,
|
|
290
|
+
contentPreview: previewText(envelope.payload),
|
|
291
|
+
timestamp: Date.now(),
|
|
292
|
+
});
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
#acquire() {
|
|
296
|
+
if (!this.#snapshots)
|
|
297
|
+
throw new Error('ImRuntime is not attached to a Root');
|
|
298
|
+
return this.#snapshots.acquire();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function requireAdapters(snapshot) {
|
|
302
|
+
const projection = snapshot.projections.get(adapterFeatureId);
|
|
303
|
+
if (!isAdapterIndex(projection)) {
|
|
304
|
+
throw new Error('Adapter Feature projection is not installed');
|
|
305
|
+
}
|
|
306
|
+
return projection;
|
|
307
|
+
}
|
|
308
|
+
/** Root resources 上的可选 html-renderer Host(未安装时降级为文本)。 */
|
|
309
|
+
function resolveHtmlRenderer(snapshot) {
|
|
310
|
+
const host = snapshot.resources.get(snapshot.root)?.get(htmlRendererToken.id);
|
|
311
|
+
return host && typeof host.render === 'function'
|
|
312
|
+
? host
|
|
313
|
+
: undefined;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Sandbox(控制台 UI)按设计直接消费 html 段,不做 html→image/text 规范化。
|
|
317
|
+
* 通过 adapter 能力 slot 的 owner 包名判断平台类型。
|
|
318
|
+
*/
|
|
319
|
+
function isDirectHtmlConsumer(snapshot, adapter) {
|
|
320
|
+
const owner = snapshot.capabilities.get(adapter)?.owner;
|
|
321
|
+
return adapterTypeName(snapshot.tree.get(owner)?.packageName) === 'sandbox';
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Build Adapter send target. If channelId already carries a scene prefix
|
|
325
|
+
* (`private:uid` / `group:gid`), do not double-prefix.
|
|
326
|
+
*/
|
|
327
|
+
function composeSendTarget(channelType, channelId) {
|
|
328
|
+
const id = channelId.trim();
|
|
329
|
+
if (!id)
|
|
330
|
+
return channelType || '';
|
|
331
|
+
if (/^(private|group|channel|direct|c2c|temp):/iu.test(id))
|
|
332
|
+
return id;
|
|
333
|
+
return channelType ? `${channelType}:${id}` : id;
|
|
334
|
+
}
|
|
335
|
+
/** `@zhin.js/adapter-icqq` → `icqq`;非 adapter 包名原样返回。 */
|
|
336
|
+
function adapterTypeName(packageName) {
|
|
337
|
+
if (!packageName)
|
|
338
|
+
return undefined;
|
|
339
|
+
return packageName.replace(/^@[^/]+\/adapter-/, '');
|
|
340
|
+
}
|
|
341
|
+
function middleware(snapshot) {
|
|
342
|
+
const projection = snapshot.projections.get(middlewareFeatureId);
|
|
343
|
+
return isMiddlewareIndex(projection) ? projection : undefined;
|
|
344
|
+
}
|
|
345
|
+
async function runMiddleware(snapshot, input, terminal, target) {
|
|
346
|
+
const index = middleware(snapshot);
|
|
347
|
+
if (index)
|
|
348
|
+
await index.run(input, terminal, target);
|
|
349
|
+
else
|
|
350
|
+
await terminal();
|
|
351
|
+
}
|
|
352
|
+
function normalizeConsoleContent(content) {
|
|
353
|
+
if (typeof content === 'string')
|
|
354
|
+
return content;
|
|
355
|
+
// Array content passes through untouched, matching the legacy console RPC
|
|
356
|
+
// contract (element arrays must not be stringified to '[object Object]').
|
|
357
|
+
if (Array.isArray(content))
|
|
358
|
+
return content;
|
|
359
|
+
return String(content);
|
|
360
|
+
}
|
|
361
|
+
/** target 前缀场景:`group:123` → `group`;无前缀返回 undefined。 */
|
|
362
|
+
function channelTypeOf(target) {
|
|
363
|
+
const match = /^([a-z0-9-]+):/iu.exec(target);
|
|
364
|
+
return match?.[1];
|
|
365
|
+
}
|
|
366
|
+
/** 消息内容 → 预览文本(截断 200 字);wire 段取 `data.text`,其余段记 `[type]`。 */
|
|
367
|
+
function previewText(content) {
|
|
368
|
+
const text = flattenContent(content);
|
|
369
|
+
return text.length > messagePreviewLimit
|
|
370
|
+
? `${text.slice(0, messagePreviewLimit)}…`
|
|
371
|
+
: text;
|
|
372
|
+
}
|
|
373
|
+
function flattenContent(content) {
|
|
374
|
+
if (typeof content === 'string')
|
|
375
|
+
return content;
|
|
376
|
+
if (content == null)
|
|
377
|
+
return '';
|
|
378
|
+
if (Array.isArray(content)) {
|
|
379
|
+
return content.map((item) => flattenContent(item)).join('');
|
|
380
|
+
}
|
|
381
|
+
if (typeof content === 'object') {
|
|
382
|
+
const record = content;
|
|
383
|
+
const data = record.data;
|
|
384
|
+
if (typeof record.type === 'string') {
|
|
385
|
+
if (data && typeof data.text === 'string')
|
|
386
|
+
return data.text;
|
|
387
|
+
return `[${record.type}]`;
|
|
388
|
+
}
|
|
389
|
+
if (typeof record.text === 'string')
|
|
390
|
+
return record.text;
|
|
391
|
+
try {
|
|
392
|
+
return JSON.stringify(content) ?? '';
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return String(content);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return String(content);
|
|
399
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
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;
|
|
13
|
+
export declare class MessageDispatcher {
|
|
14
|
+
private readonly resolvePrefix;
|
|
15
|
+
constructor(resolvePrefix?: CommandPrefixResolver);
|
|
16
|
+
dispatch(message: Message, snapshot: RuntimeSnapshot): Promise<MessageDispatchResult>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { commandFeatureId, isCommandIndex } from '@zhin.js/command';
|
|
2
|
+
function ownerOfMessage(message) {
|
|
3
|
+
return String(message.adapter).split('\0')[0];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* 默认解析:读消息所属适配器实例 config 的 `commandPrefix`(默认 `''`);
|
|
7
|
+
* 实例声明 `endpoints` 数组时,按消息 endpoint 名找 entry,`entry.commandPrefix` 覆盖顶层。
|
|
8
|
+
*/
|
|
9
|
+
export const defaultCommandPrefixResolver = (message, snapshot) => {
|
|
10
|
+
const config = snapshot.config.get(ownerOfMessage(message));
|
|
11
|
+
if (!config)
|
|
12
|
+
return '';
|
|
13
|
+
const endpointName = typeof message.metadata?.endpoint === 'string'
|
|
14
|
+
? message.metadata.endpoint
|
|
15
|
+
: undefined;
|
|
16
|
+
if (endpointName && Array.isArray(config.endpoints)) {
|
|
17
|
+
const entry = config.endpoints.find((item) => !!item && typeof item === 'object'
|
|
18
|
+
&& item.name === endpointName);
|
|
19
|
+
if (typeof entry?.commandPrefix === 'string')
|
|
20
|
+
return entry.commandPrefix;
|
|
21
|
+
}
|
|
22
|
+
return typeof config.commandPrefix === 'string' ? config.commandPrefix : '';
|
|
23
|
+
};
|
|
24
|
+
export class MessageDispatcher {
|
|
25
|
+
resolvePrefix;
|
|
26
|
+
constructor(resolvePrefix = defaultCommandPrefixResolver) {
|
|
27
|
+
this.resolvePrefix = resolvePrefix;
|
|
28
|
+
}
|
|
29
|
+
async dispatch(message, snapshot) {
|
|
30
|
+
const prefix = this.resolvePrefix(message, snapshot);
|
|
31
|
+
let input = message.content.trim();
|
|
32
|
+
if (prefix) {
|
|
33
|
+
if (!input.startsWith(prefix))
|
|
34
|
+
return Object.freeze({ matched: false });
|
|
35
|
+
input = input.slice(prefix.length).trim();
|
|
36
|
+
}
|
|
37
|
+
if (!input)
|
|
38
|
+
return Object.freeze({ matched: false });
|
|
39
|
+
const commands = snapshot.projections.get(commandFeatureId);
|
|
40
|
+
if (!isCommandIndex(commands))
|
|
41
|
+
return Object.freeze({ matched: false });
|
|
42
|
+
const result = await commands.dispatch(input, message);
|
|
43
|
+
if (result.matched && result.value !== undefined) {
|
|
44
|
+
if (!result.owner)
|
|
45
|
+
throw new Error('Matched Command is missing its owner');
|
|
46
|
+
await message.$replyFrom(result.owner, result.value);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { type SendContent } from './contracts.js';
|
|
3
|
+
export declare class OutboundRenderer {
|
|
4
|
+
#private;
|
|
5
|
+
render(content: SendContent, requester: PluginId, snapshot: RuntimeSnapshot): Promise<unknown>;
|
|
6
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { componentFeatureId, isComponentIndex, } from '@zhin.js/component';
|
|
2
|
+
import { isComponentCall, isRawContent, } from './contracts.js';
|
|
3
|
+
const maxComponentDepth = 32;
|
|
4
|
+
export class OutboundRenderer {
|
|
5
|
+
async render(content, requester, snapshot) {
|
|
6
|
+
return this.#render(content, requester, snapshot, 0);
|
|
7
|
+
}
|
|
8
|
+
async #render(content, requester, snapshot, depth) {
|
|
9
|
+
if (depth > maxComponentDepth)
|
|
10
|
+
throw new Error('Component render depth exceeded 32');
|
|
11
|
+
if (typeof content === 'string')
|
|
12
|
+
return content;
|
|
13
|
+
if (Array.isArray(content)) {
|
|
14
|
+
return Promise.all(content.map((item) => this.#render(item, requester, snapshot, depth)));
|
|
15
|
+
}
|
|
16
|
+
if (isRawContent(content))
|
|
17
|
+
return content.payload;
|
|
18
|
+
if (isComponentCall(content)) {
|
|
19
|
+
const rendered = await requireComponents(snapshot).render(requester, content.name, content.props);
|
|
20
|
+
return this.#render(rendered, requester, snapshot, depth + 1);
|
|
21
|
+
}
|
|
22
|
+
throw new TypeError('Unsupported SendContent');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function requireComponents(snapshot) {
|
|
26
|
+
const projection = snapshot.projections.get(componentFeatureId);
|
|
27
|
+
if (!isComponentIndex(projection)) {
|
|
28
|
+
throw new Error('Component Feature projection is not installed');
|
|
29
|
+
}
|
|
30
|
+
return projection;
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HtmlRendererHost } from '@zhin.js/plugin-runtime';
|
|
2
|
+
/**
|
|
3
|
+
* Outbound payload normalization for the Plugin Runtime IM pipeline.
|
|
4
|
+
*
|
|
5
|
+
* `raw()` payloads reach adapters as-is; adapters only understand wire
|
|
6
|
+
* segments (`{ type, data }` arrays). A single segment object (non-array)
|
|
7
|
+
* 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.
|
|
10
|
+
*/
|
|
11
|
+
export interface OutboundSegment {
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly data?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export declare function isOutboundSegment(value: unknown): value is OutboundSegment;
|
|
16
|
+
/**
|
|
17
|
+
* Normalize a rendered outbound payload to wire segments:
|
|
18
|
+
* - segment arrays stay arrays (html segments converted per element);
|
|
19
|
+
* - a single segment object is wrapped into a one-element array;
|
|
20
|
+
* - anything else (plain strings, legacy `{ text }` shorthands) passes through.
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizeOutboundPayload(payload: unknown, renderer?: HtmlRendererHost): Promise<unknown>;
|