@zhin.js/core 1.3.4 → 1.3.5

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @zhin.js/core
2
2
 
3
+ ## Plugin Runtime 子路径
4
+
5
+ 新的 owner-aware IM 能力已经并入 Core,作为 Plugin Runtime 的正式领域接口:
6
+
7
+ - `@zhin.js/core/runtime`
8
+
9
+ `@zhin.js/adapter`、`@zhin.js/command`、`@zhin.js/component` 与
10
+ `@zhin.js/middleware` 提供纯 definition、约定发现 provider 和 generation projection;
11
+ Core Runtime 只消费它们发布的 snapshot。
12
+ 旧根入口的 `addCommand`、`addComponent`、`addMiddleware` 暂时作为作者兼容接口保留,后续
13
+ 只向 RuntimeSnapshot 投影,不再维护第二套运行时权威。
14
+
3
15
  Zhin.js **IM/多通道运行时**包:Plugin、Adapter、**Endpoint**、MessageDispatcher 与统一出站链。**AI 编排(ZhinAgent、工具安全、MCP)在 [`@zhin.js/agent`](../agent/README.md)**;本包仅 selective re-export `@zhin.js/ai` 的 Provider / Agent 原语供插件直接使用。
4
16
 
5
17
  领域词汇见 [CONTEXT.md](./CONTEXT.md);入站/出站流程见 [消息如何流转](../../docs/essentials/message-flow.md)。
@@ -1,6 +1,6 @@
1
1
  import { mergeAITriggerConfig, resolveSenderRoles, } from './ai-trigger.js';
2
- import { formatCompact, Logger } from '@zhin.js/logger';
3
- const logger = new Logger(null, 'Authorization');
2
+ import { formatCompact, getLogger } from '@zhin.js/logger';
3
+ const logger = getLogger('Authorization');
4
4
  function findEndpointEntryFromConfig(config, adapter, endpointId) {
5
5
  const endpoints = config.endpoints;
6
6
  if (!Array.isArray(endpoints))
@@ -1,18 +1,25 @@
1
1
  import { parsePlatformPermitName, isPlatformPermit } from './permit-parse.js';
2
2
  const checkers = new Map();
3
- const registeredDefaultSceneCheckers = new Set();
3
+ const defaultSceneRegistrations = new Map();
4
4
  export function registerPlatformPermitChecker(adapter, checker) {
5
5
  const key = String(adapter);
6
- checkers.set(key, checker);
6
+ const registrations = checkers.get(key) ?? [];
7
+ registrations.push(checker);
8
+ checkers.set(key, registrations);
7
9
  return () => {
8
- if (checkers.get(key) === checker) {
10
+ const current = checkers.get(key);
11
+ if (!current)
12
+ return;
13
+ const index = current.lastIndexOf(checker);
14
+ if (index >= 0)
15
+ current.splice(index, 1);
16
+ if (current.length === 0)
9
17
  checkers.delete(key);
10
- }
11
18
  };
12
19
  }
13
20
  export function clearPlatformPermitCheckers() {
14
21
  checkers.clear();
15
- registeredDefaultSceneCheckers.clear();
22
+ defaultSceneRegistrations.clear();
16
23
  }
17
24
  export function checkPlatformPermit(name, message) {
18
25
  const parsed = parsePlatformPermitName(name);
@@ -20,7 +27,8 @@ export function checkPlatformPermit(name, message) {
20
27
  return false;
21
28
  if (String(message.$adapter) !== parsed.adapter)
22
29
  return false;
23
- const checker = checkers.get(parsed.adapter);
30
+ const registrations = checkers.get(parsed.adapter);
31
+ const checker = registrations?.[registrations.length - 1];
24
32
  if (!checker)
25
33
  return false;
26
34
  return checker(parsed.perm, message);
@@ -58,9 +66,26 @@ export function createSceneRolePlatformChecker() {
58
66
  /** 为适配器注册默认场景治理 platform checker(幂等) */
59
67
  export function registerDefaultScenePlatformPermitChecker(adapter) {
60
68
  const key = String(adapter);
61
- if (registeredDefaultSceneCheckers.has(key)) {
62
- return () => { };
69
+ const existing = defaultSceneRegistrations.get(key);
70
+ if (existing) {
71
+ existing.references += 1;
72
+ return () => releaseDefaultSceneChecker(key, existing);
63
73
  }
64
- registeredDefaultSceneCheckers.add(key);
65
- return registerPlatformPermitChecker(key, createSceneRolePlatformChecker());
74
+ const checker = createSceneRolePlatformChecker();
75
+ const registration = {
76
+ checker,
77
+ dispose: registerPlatformPermitChecker(key, checker),
78
+ references: 1,
79
+ };
80
+ defaultSceneRegistrations.set(key, registration);
81
+ return () => releaseDefaultSceneChecker(key, registration);
82
+ }
83
+ function releaseDefaultSceneChecker(key, registration) {
84
+ if (defaultSceneRegistrations.get(key) !== registration)
85
+ return;
86
+ registration.references -= 1;
87
+ if (registration.references > 0)
88
+ return;
89
+ defaultSceneRegistrations.delete(key);
90
+ registration.dispose();
66
91
  }
@@ -0,0 +1,71 @@
1
+ import type { CapabilityId, PluginId } from '@zhin.js/plugin-runtime';
2
+ declare const componentCallBrand: "zhin.component-call/1";
3
+ declare const rawContentBrand: "zhin.raw-content/1";
4
+ export interface ComponentCall<TProps = unknown> {
5
+ readonly $content: typeof componentCallBrand;
6
+ readonly name: string;
7
+ readonly props: TProps;
8
+ }
9
+ export interface RawContent<TPayload = unknown> {
10
+ readonly $content: typeof rawContentBrand;
11
+ readonly payload: TPayload;
12
+ }
13
+ export type SendContent = string | ComponentCall | RawContent | readonly SendContent[];
14
+ export declare function component<TProps>(name: string, props: TProps): ComponentCall<TProps>;
15
+ export declare function raw<TPayload>(payload: TPayload): RawContent<TPayload>;
16
+ export declare function isComponentCall(value: SendContent): value is ComponentCall;
17
+ export declare function isRawContent(value: SendContent): value is RawContent;
18
+ export interface IncomingMessage {
19
+ readonly adapter: CapabilityId;
20
+ readonly target: string;
21
+ readonly content: string;
22
+ readonly id?: string;
23
+ readonly sender?: string;
24
+ readonly metadata?: Readonly<Record<string, unknown>>;
25
+ }
26
+ export interface SendRequest {
27
+ readonly adapter: CapabilityId;
28
+ readonly target: string;
29
+ readonly requester: PluginId;
30
+ readonly content: SendContent;
31
+ readonly parent?: ChannelParent;
32
+ }
33
+ /** Console 通道的来源场景(群临时会话 parent.group / QQ 子频道 parent.guild)。 */
34
+ export interface ChannelParent {
35
+ readonly type?: string;
36
+ readonly id?: string;
37
+ readonly name?: string;
38
+ }
39
+ export interface OutboundEnvelope {
40
+ readonly adapter: CapabilityId;
41
+ readonly target: string;
42
+ readonly requester: PluginId;
43
+ readonly generation: number;
44
+ readonly payload: unknown;
45
+ readonly parent?: ChannelParent;
46
+ replace(payload: unknown): void;
47
+ }
48
+ export interface MessageGateway {
49
+ receive(input: IncomingMessage): Promise<MessageDispatchResult>;
50
+ send(request: SendRequest): Promise<unknown>;
51
+ }
52
+ export interface MessageDispatchResult {
53
+ readonly matched: boolean;
54
+ readonly command?: string;
55
+ readonly owner?: PluginId;
56
+ readonly value?: unknown;
57
+ }
58
+ export declare class Message {
59
+ readonly adapter: CapabilityId;
60
+ readonly target: string;
61
+ readonly content: string;
62
+ readonly generation: number;
63
+ readonly id?: string | undefined;
64
+ readonly sender?: string | undefined;
65
+ 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>>);
67
+ readonly $reply: (content: SendContent) => Promise<unknown>;
68
+ readonly $replyFrom: (requester: PluginId, content: SendContent) => Promise<unknown>;
69
+ }
70
+ export declare function createOutboundEnvelope(request: Omit<OutboundEnvelope, 'payload' | 'replace'>, initialPayload: unknown): OutboundEnvelope;
71
+ export {};
@@ -0,0 +1,55 @@
1
+ const componentCallBrand = 'zhin.component-call/1';
2
+ const rawContentBrand = 'zhin.raw-content/1';
3
+ export function component(name, props) {
4
+ if (!name.trim())
5
+ throw new TypeError('Component name cannot be empty');
6
+ return Object.freeze({ $content: componentCallBrand, name, props });
7
+ }
8
+ export function raw(payload) {
9
+ return Object.freeze({ $content: rawContentBrand, payload });
10
+ }
11
+ export function isComponentCall(value) {
12
+ return !Array.isArray(value)
13
+ && typeof value === 'object'
14
+ && value !== null
15
+ && '$content' in value
16
+ && value.$content === componentCallBrand;
17
+ }
18
+ export function isRawContent(value) {
19
+ return !Array.isArray(value)
20
+ && typeof value === 'object'
21
+ && value !== null
22
+ && '$content' in value
23
+ && value.$content === rawContentBrand;
24
+ }
25
+ export class Message {
26
+ adapter;
27
+ target;
28
+ content;
29
+ generation;
30
+ id;
31
+ sender;
32
+ metadata;
33
+ constructor(adapter, target, content, generation, reply, id, sender, metadata = Object.freeze({})) {
34
+ this.adapter = adapter;
35
+ this.target = target;
36
+ this.content = content;
37
+ this.generation = generation;
38
+ this.id = id;
39
+ this.sender = sender;
40
+ this.metadata = metadata;
41
+ this.$reply = (content) => reply(content);
42
+ this.$replyFrom = (requester, content) => reply(content, requester);
43
+ Object.freeze(this);
44
+ }
45
+ $reply;
46
+ $replyFrom;
47
+ }
48
+ export function createOutboundEnvelope(request, initialPayload) {
49
+ let payload = initialPayload;
50
+ return Object.freeze({
51
+ ...request,
52
+ get payload() { return payload; },
53
+ replace(next) { payload = next; },
54
+ });
55
+ }
@@ -0,0 +1,70 @@
1
+ import { Scope, type PluginId, type RuntimeSnapshot, type SnapshotStore } from '@zhin.js/plugin-runtime';
2
+ import { Message, type ChannelParent, type IncomingMessage, type MessageDispatchResult, type MessageGateway, type SendRequest } from './contracts.js';
3
+ import { OutboundRenderer } from './outbound-renderer.js';
4
+ export declare const messageGatewayToken: import("@zhin.js/plugin-runtime").Token<MessageGateway>;
5
+ export interface ImRuntimeOptions {
6
+ readonly commandPrefix?: string;
7
+ readonly renderer?: OutboundRenderer;
8
+ }
9
+ export declare class ImRuntime implements MessageGateway {
10
+ #private;
11
+ constructor(options?: ImRuntimeOptions);
12
+ attach(snapshots: SnapshotStore): void;
13
+ /**
14
+ * Optional Host AI / fallback path after Command miss (or non-prefixed text).
15
+ * Return true when the message was handled (reply already sent).
16
+ * `requester` is the Adapter Endpoint owner (for CapabilityIngress inheritance).
17
+ */
18
+ setUnmatchedHandler(handler: (message: Message, snapshot: RuntimeSnapshot, requester: PluginId) => Promise<boolean>): void;
19
+ install(resources: Scope): void;
20
+ receive(input: IncomingMessage): Promise<MessageDispatchResult>;
21
+ send(request: SendRequest): Promise<unknown>;
22
+ /** Console `endpoint.list` — empty until Adapter Feature projection is ready. */
23
+ listEndpoints(): readonly {
24
+ readonly name: string;
25
+ readonly adapter: string;
26
+ readonly connected: boolean;
27
+ readonly status: 'online' | 'offline';
28
+ readonly phase: 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
29
+ }[];
30
+ getEndpoint(adapter: string, endpointId: string): {
31
+ readonly name: string;
32
+ readonly adapter: string;
33
+ readonly connected: boolean;
34
+ readonly status: 'online' | 'offline';
35
+ readonly phase: 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
36
+ } | null;
37
+ sendEndpointMessage(input: {
38
+ readonly adapter: string;
39
+ readonly endpointId: string;
40
+ readonly channelId: string;
41
+ readonly channelType: string;
42
+ readonly content: unknown;
43
+ readonly parent?: ChannelParent;
44
+ }): Promise<{
45
+ messageId: string;
46
+ }>;
47
+ /** Activity-feedback: add a message reaction when the live Endpoint supports it. */
48
+ addEndpointReaction(input: {
49
+ readonly adapter: string;
50
+ readonly endpointId: string;
51
+ readonly messageId: string;
52
+ readonly emoji: string;
53
+ readonly sceneType?: string;
54
+ readonly channelId?: string;
55
+ }): Promise<string | null>;
56
+ removeEndpointReaction(input: {
57
+ readonly adapter: string;
58
+ readonly endpointId: string;
59
+ readonly messageId: string;
60
+ readonly reactionId: string;
61
+ }): Promise<void>;
62
+ /** Activity-feedback autoRemove: recall a previously sent status message. */
63
+ recallEndpointMessage(input: {
64
+ readonly adapter: string;
65
+ readonly endpointId: string;
66
+ readonly messageId: string;
67
+ }): Promise<void>;
68
+ /** Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。 */
69
+ getLiveEndpoint(adapter: string, endpointId: string): unknown | null;
70
+ }
@@ -0,0 +1,299 @@
1
+ import { createToken, htmlRendererToken, } from '@zhin.js/plugin-runtime';
2
+ import { adapterFeatureId, isAdapterIndex } from '@zhin.js/adapter';
3
+ import { isMiddlewareIndex, middlewareFeatureId } from '@zhin.js/middleware';
4
+ import { Message, createOutboundEnvelope, } from './contracts.js';
5
+ import { 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 class ImRuntime {
10
+ #dispatcher;
11
+ #renderer;
12
+ #snapshots;
13
+ #unmatchedHandler;
14
+ constructor(options = {}) {
15
+ this.#dispatcher = new MessageDispatcher(options.commandPrefix);
16
+ this.#renderer = options.renderer ?? new OutboundRenderer();
17
+ }
18
+ attach(snapshots) {
19
+ if (this.#snapshots && this.#snapshots !== snapshots) {
20
+ throw new Error('ImRuntime is already attached to another Root');
21
+ }
22
+ this.#snapshots = snapshots;
23
+ }
24
+ /**
25
+ * Optional Host AI / fallback path after Command miss (or non-prefixed text).
26
+ * Return true when the message was handled (reply already sent).
27
+ * `requester` is the Adapter Endpoint owner (for CapabilityIngress inheritance).
28
+ */
29
+ setUnmatchedHandler(handler) {
30
+ this.#unmatchedHandler = handler;
31
+ }
32
+ install(resources) {
33
+ resources.provide(messageGatewayToken, this);
34
+ }
35
+ async receive(input) {
36
+ const lease = this.#acquire();
37
+ let active = true;
38
+ try {
39
+ const requester = requireAdapters(lease.value).owner(input.adapter);
40
+ const message = new Message(input.adapter, input.target, input.content, lease.value.generation, (content, replyRequester = requester) => {
41
+ if (!active)
42
+ throw new Error('Message reply scope has ended');
43
+ return this.#sendWithSnapshot({
44
+ adapter: input.adapter,
45
+ target: input.target,
46
+ requester: replyRequester,
47
+ content,
48
+ }, lease.value);
49
+ }, input.id, input.sender, Object.freeze({ ...input.metadata }));
50
+ let result = Object.freeze({ matched: false });
51
+ await runMiddleware(lease.value, message, async () => {
52
+ result = await this.#dispatcher.dispatch(message, lease.value);
53
+ if (!result.matched && this.#unmatchedHandler) {
54
+ const handled = await this.#unmatchedHandler(message, lease.value, requester);
55
+ if (handled) {
56
+ result = Object.freeze({ matched: true, command: 'ai', owner: requester });
57
+ }
58
+ }
59
+ }, 'inbound');
60
+ return result;
61
+ }
62
+ finally {
63
+ active = false;
64
+ lease.release();
65
+ }
66
+ }
67
+ async send(request) {
68
+ const lease = this.#acquire();
69
+ try {
70
+ return await this.#sendWithSnapshot(request, lease.value);
71
+ }
72
+ finally {
73
+ lease.release();
74
+ }
75
+ }
76
+ /** Console `endpoint.list` — empty until Adapter Feature projection is ready. */
77
+ listEndpoints() {
78
+ try {
79
+ const lease = this.#acquire();
80
+ try {
81
+ return requireAdapters(lease.value).describe().map((row) => Object.freeze({
82
+ name: row.name,
83
+ // adapter 列显示平台类型(owner 包名去 scope/adapter- 前缀),不是 slot localName
84
+ adapter: adapterTypeName(lease.value.tree.get(row.owner)?.packageName) ?? row.name,
85
+ connected: row.connected,
86
+ status: row.status,
87
+ phase: row.phase,
88
+ }));
89
+ }
90
+ finally {
91
+ lease.release();
92
+ }
93
+ }
94
+ catch {
95
+ return Object.freeze([]);
96
+ }
97
+ }
98
+ getEndpoint(adapter, endpointId) {
99
+ try {
100
+ const lease = this.#acquire();
101
+ try {
102
+ const index = requireAdapters(lease.value);
103
+ const id = index.resolve(adapter, endpointId);
104
+ if (!id)
105
+ return null;
106
+ const row = index.describe().find((item) => item.id === id);
107
+ if (!row)
108
+ return null;
109
+ return Object.freeze({
110
+ name: row.name,
111
+ adapter: row.name,
112
+ connected: row.connected,
113
+ status: row.status,
114
+ phase: row.phase,
115
+ });
116
+ }
117
+ finally {
118
+ lease.release();
119
+ }
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ async sendEndpointMessage(input) {
126
+ const lease = this.#acquire();
127
+ try {
128
+ const index = requireAdapters(lease.value);
129
+ const capabilityId = index.resolve(input.adapter, input.endpointId);
130
+ if (!capabilityId)
131
+ throw new Error('endpoint not found');
132
+ const target = composeSendTarget(input.channelType, input.channelId);
133
+ const content = normalizeConsoleContent(input.content);
134
+ const result = await this.#sendWithSnapshot({
135
+ adapter: capabilityId,
136
+ target,
137
+ requester: index.owner(capabilityId),
138
+ content,
139
+ ...(input.parent ? { parent: input.parent } : {}),
140
+ }, lease.value);
141
+ return { messageId: result == null ? '' : String(result) };
142
+ }
143
+ finally {
144
+ lease.release();
145
+ }
146
+ }
147
+ /** Activity-feedback: add a message reaction when the live Endpoint supports it. */
148
+ async addEndpointReaction(input) {
149
+ const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
150
+ if (!endpoint)
151
+ return null;
152
+ if (typeof endpoint.addReaction === 'function') {
153
+ return endpoint.addReaction(input.messageId, input.emoji, {
154
+ sceneType: input.sceneType,
155
+ channelId: input.channelId,
156
+ });
157
+ }
158
+ if (typeof endpoint.$addReaction === 'function') {
159
+ return endpoint.$addReaction(input.messageId, input.emoji, {
160
+ sceneType: input.sceneType,
161
+ channelId: input.channelId,
162
+ });
163
+ }
164
+ return null;
165
+ }
166
+ async removeEndpointReaction(input) {
167
+ const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
168
+ if (!endpoint)
169
+ return;
170
+ if (typeof endpoint.removeReaction === 'function') {
171
+ await endpoint.removeReaction(input.messageId, input.reactionId);
172
+ return;
173
+ }
174
+ if (typeof endpoint.$removeReaction === 'function') {
175
+ await endpoint.$removeReaction(input.messageId, input.reactionId);
176
+ }
177
+ }
178
+ /** Activity-feedback autoRemove: recall a previously sent status message. */
179
+ async recallEndpointMessage(input) {
180
+ const endpoint = this.#liveEndpoint(input.adapter, input.endpointId);
181
+ if (!endpoint)
182
+ return;
183
+ if (typeof endpoint.recallMessage === 'function') {
184
+ await endpoint.recallMessage(input.messageId);
185
+ return;
186
+ }
187
+ if (typeof endpoint.$recallMessage === 'function') {
188
+ await endpoint.$recallMessage(input.messageId);
189
+ }
190
+ }
191
+ #liveEndpoint(adapter, endpointId) {
192
+ try {
193
+ const lease = this.#acquire();
194
+ try {
195
+ const endpoint = requireAdapters(lease.value).instance(adapter, endpointId);
196
+ return endpoint ?? null;
197
+ }
198
+ finally {
199
+ lease.release();
200
+ }
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ /** Console endpoint 社交/群管 RPC:解析 live Endpoint 实例(无则 null)。 */
207
+ getLiveEndpoint(adapter, endpointId) {
208
+ return this.#liveEndpoint(adapter, endpointId);
209
+ }
210
+ async #sendWithSnapshot(request, snapshot) {
211
+ const rendered = await this.#renderer.render(request.content, request.requester, snapshot);
212
+ // 单段对象 / html 段在此归一为适配器可消费的 wire 段数组;
213
+ // sandbox 适配器(控制台 UI)直接消费 html 段,跳过规范化。
214
+ const payload = isDirectHtmlConsumer(snapshot, request.adapter)
215
+ ? rendered
216
+ : await normalizeOutboundPayload(rendered, resolveHtmlRenderer(snapshot));
217
+ const envelope = createOutboundEnvelope({
218
+ adapter: request.adapter,
219
+ target: request.target,
220
+ requester: request.requester,
221
+ generation: snapshot.generation,
222
+ ...(request.parent ? { parent: request.parent } : {}),
223
+ }, payload);
224
+ let result;
225
+ await runMiddleware(snapshot, envelope, async () => {
226
+ result = await requireAdapters(snapshot).send(request.adapter, {
227
+ target: request.target,
228
+ payload: envelope.payload,
229
+ ...(request.parent ? { parent: request.parent } : {}),
230
+ });
231
+ }, 'outbound');
232
+ return result;
233
+ }
234
+ #acquire() {
235
+ if (!this.#snapshots)
236
+ throw new Error('ImRuntime is not attached to a Root');
237
+ return this.#snapshots.acquire();
238
+ }
239
+ }
240
+ function requireAdapters(snapshot) {
241
+ const projection = snapshot.projections.get(adapterFeatureId);
242
+ if (!isAdapterIndex(projection)) {
243
+ throw new Error('Adapter Feature projection is not installed');
244
+ }
245
+ return projection;
246
+ }
247
+ /** Root resources 上的可选 html-renderer Host(未安装时降级为文本)。 */
248
+ function resolveHtmlRenderer(snapshot) {
249
+ const host = snapshot.resources.get(snapshot.root)?.get(htmlRendererToken.id);
250
+ return host && typeof host.render === 'function'
251
+ ? host
252
+ : undefined;
253
+ }
254
+ /**
255
+ * Sandbox(控制台 UI)按设计直接消费 html 段,不做 html→image/text 规范化。
256
+ * 通过 adapter 能力 slot 的 owner 包名判断平台类型。
257
+ */
258
+ function isDirectHtmlConsumer(snapshot, adapter) {
259
+ const owner = snapshot.capabilities.get(adapter)?.owner;
260
+ return adapterTypeName(snapshot.tree.get(owner)?.packageName) === 'sandbox';
261
+ }
262
+ /**
263
+ * Build Adapter send target. If channelId already carries a scene prefix
264
+ * (`private:uid` / `group:gid`), do not double-prefix.
265
+ */
266
+ function composeSendTarget(channelType, channelId) {
267
+ const id = channelId.trim();
268
+ if (!id)
269
+ return channelType || '';
270
+ if (/^(private|group|channel|direct|c2c|temp):/iu.test(id))
271
+ return id;
272
+ return channelType ? `${channelType}:${id}` : id;
273
+ }
274
+ /** `@zhin.js/adapter-icqq` → `icqq`;非 adapter 包名原样返回。 */
275
+ function adapterTypeName(packageName) {
276
+ if (!packageName)
277
+ return undefined;
278
+ return packageName.replace(/^@[^/]+\/adapter-/, '');
279
+ }
280
+ function middleware(snapshot) {
281
+ const projection = snapshot.projections.get(middlewareFeatureId);
282
+ return isMiddlewareIndex(projection) ? projection : undefined;
283
+ }
284
+ async function runMiddleware(snapshot, input, terminal, target) {
285
+ const index = middleware(snapshot);
286
+ if (index)
287
+ await index.run(input, terminal, target);
288
+ else
289
+ await terminal();
290
+ }
291
+ function normalizeConsoleContent(content) {
292
+ if (typeof content === 'string')
293
+ return content;
294
+ // Array content passes through untouched, matching the legacy console RPC
295
+ // contract (element arrays must not be stringified to '[object Object]').
296
+ if (Array.isArray(content))
297
+ return content;
298
+ return String(content);
299
+ }
@@ -0,0 +1,5 @@
1
+ export * from './contracts.js';
2
+ export * from './im-runtime.js';
3
+ export * from './message-dispatcher.js';
4
+ export * from './outbound-renderer.js';
5
+ export * from './outbound-segments.js';
@@ -0,0 +1,5 @@
1
+ export * from './contracts.js';
2
+ export * from './im-runtime.js';
3
+ export * from './message-dispatcher.js';
4
+ export * from './outbound-renderer.js';
5
+ export * from './outbound-segments.js';
@@ -0,0 +1,7 @@
1
+ import type { RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import type { Message, MessageDispatchResult } from './contracts.js';
3
+ export declare class MessageDispatcher {
4
+ private readonly prefix;
5
+ constructor(prefix?: string);
6
+ dispatch(message: Message, snapshot: RuntimeSnapshot): Promise<MessageDispatchResult>;
7
+ }
@@ -0,0 +1,26 @@
1
+ import { commandFeatureId, isCommandIndex } from '@zhin.js/command';
2
+ export class MessageDispatcher {
3
+ prefix;
4
+ constructor(prefix = '/') {
5
+ this.prefix = prefix;
6
+ if (!prefix)
7
+ throw new TypeError('Command prefix cannot be empty');
8
+ }
9
+ 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)
14
+ return Object.freeze({ matched: false });
15
+ const commands = snapshot.projections.get(commandFeatureId);
16
+ if (!isCommandIndex(commands))
17
+ return Object.freeze({ matched: false });
18
+ const result = await commands.dispatch(input, message);
19
+ if (result.matched && result.value !== undefined) {
20
+ if (!result.owner)
21
+ throw new Error('Matched Command is missing its owner');
22
+ await message.$replyFrom(result.owner, result.value);
23
+ }
24
+ return result;
25
+ }
26
+ }
@@ -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>;
@@ -0,0 +1,59 @@
1
+ import { htmlToFallbackText } from '../../built/html-to-text.js';
2
+ const DEFAULT_CARD_WIDTH = 540;
3
+ const DEFAULT_CARD_FILENAME = 'card.png';
4
+ export function isOutboundSegment(value) {
5
+ return typeof value === 'object'
6
+ && value !== null
7
+ && !Array.isArray(value)
8
+ && typeof value.type === 'string';
9
+ }
10
+ /**
11
+ * Normalize a rendered outbound payload to wire segments:
12
+ * - segment arrays stay arrays (html segments converted per element);
13
+ * - a single segment object is wrapped into a one-element array;
14
+ * - anything else (plain strings, legacy `{ text }` shorthands) passes through.
15
+ */
16
+ export async function normalizeOutboundPayload(payload, renderer) {
17
+ if (Array.isArray(payload)) {
18
+ return Promise.all(payload.map((item) => normalizeOutboundSegment(item, renderer)));
19
+ }
20
+ if (isOutboundSegment(payload)) {
21
+ return [await normalizeOutboundSegment(payload, renderer)];
22
+ }
23
+ return payload;
24
+ }
25
+ async function normalizeOutboundSegment(segment, renderer) {
26
+ if (!isOutboundSegment(segment) || segment.type !== 'html')
27
+ return segment;
28
+ const data = segment.data ?? {};
29
+ const html = typeof data.html === 'string' ? data.html : '';
30
+ if (html && renderer) {
31
+ try {
32
+ const result = await renderer.render(html, {
33
+ width: typeof data.width === 'number' ? data.width : DEFAULT_CARD_WIDTH,
34
+ format: 'png',
35
+ ...(typeof data.backgroundColor === 'string'
36
+ ? { backgroundColor: data.backgroundColor }
37
+ : {}),
38
+ });
39
+ if (result.format === 'png' && result.data && typeof result.data === 'object') {
40
+ return {
41
+ type: 'image',
42
+ data: {
43
+ base64: Buffer.from(result.data).toString('base64'),
44
+ name: typeof data.fileName === 'string' ? data.fileName : DEFAULT_CARD_FILENAME,
45
+ },
46
+ };
47
+ }
48
+ }
49
+ catch {
50
+ // 渲染失败 → 文本降级
51
+ }
52
+ }
53
+ return { type: 'text', data: { text: htmlSegmentFallbackText(data, html) } };
54
+ }
55
+ function htmlSegmentFallbackText(data, html) {
56
+ if (typeof data.text === 'string' && data.text.length > 0)
57
+ return data.text;
58
+ return html ? htmlToFallbackText(html) : '';
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/core",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "Zhin机器人核心框架",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -21,6 +21,11 @@
21
21
  "development": "./src/built/queue-im-field-contract.ts",
22
22
  "import": "./lib/built/queue-im-field-contract.js"
23
23
  },
24
+ "./runtime": {
25
+ "types": "./lib/plugin-runtime/im/index.d.ts",
26
+ "development": "./src/plugin-runtime/im/index.ts",
27
+ "import": "./lib/plugin-runtime/im/index.js"
28
+ },
24
29
  "./jsx": {
25
30
  "types": "./lib/jsx.d.ts",
26
31
  "development": "./src/jsx.ts",
@@ -45,9 +50,14 @@
45
50
  "segment-matcher": "^1.0.5",
46
51
  "smol-toml": "^1.7.0",
47
52
  "yaml": "^2.9.0",
48
- "@zhin.js/kernel": "1.0.3",
49
- "@zhin.js/database": "1.0.76",
50
- "@zhin.js/logger": "1.0.74",
53
+ "@zhin.js/adapter": "1.0.1",
54
+ "@zhin.js/command": "1.0.1",
55
+ "@zhin.js/component": "1.0.1",
56
+ "@zhin.js/kernel": "1.0.4",
57
+ "@zhin.js/logger": "1.0.75",
58
+ "@zhin.js/middleware": "1.0.1",
59
+ "@zhin.js/plugin-runtime": "1.0.1",
60
+ "@zhin.js/database": "1.0.77",
51
61
  "@zhin.js/schema": "1.0.71"
52
62
  },
53
63
  "peerDependencies": {
@@ -62,7 +72,7 @@
62
72
  "@types/node": "^26.1.0",
63
73
  "@types/qrcode": "^1.5.5",
64
74
  "typescript": "^6.0.3",
65
- "@zhin.js/ai": "1.4.4"
75
+ "@zhin.js/ai": "1.4.5"
66
76
  },
67
77
  "repository": {
68
78
  "type": "git",