@zhin.js/core 1.3.3 → 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.
Files changed (50) hide show
  1. package/README.md +12 -0
  2. package/lib/adapter.js +2 -2
  3. package/lib/built/ai-outbound/parse.js +26 -12
  4. package/lib/built/authorization.js +2 -2
  5. package/lib/built/component.d.ts +1 -2
  6. package/lib/built/component.js +1 -1
  7. package/lib/built/database.d.ts +1 -2
  8. package/lib/built/database.js +1 -1
  9. package/lib/built/dispatcher.d.ts +1 -1
  10. package/lib/built/endpoint-lifecycle-service.js +2 -1
  11. package/lib/built/endpoint-lifecycle.js +4 -3
  12. package/lib/built/html-to-text.js +15 -14
  13. package/lib/built/inbound-pipeline.d.ts +1 -1
  14. package/lib/built/inbound-pipeline.js +1 -1
  15. package/lib/built/interactive-segments/action.js +16 -1
  16. package/lib/built/message-enrich.d.ts +1 -1
  17. package/lib/built/permit-check.d.ts +1 -1
  18. package/lib/built/platform-permit.js +35 -10
  19. package/lib/built/schedule.d.ts +1 -2
  20. package/lib/built/schedule.js +1 -1
  21. package/lib/component.js +28 -11
  22. package/lib/index.d.ts +1 -0
  23. package/lib/index.js +1 -0
  24. package/lib/notice.d.ts +15 -58
  25. package/lib/notice.js +1 -3
  26. package/lib/plugin-runtime/im/contracts.d.ts +71 -0
  27. package/lib/plugin-runtime/im/contracts.js +55 -0
  28. package/lib/plugin-runtime/im/im-runtime.d.ts +70 -0
  29. package/lib/plugin-runtime/im/im-runtime.js +299 -0
  30. package/lib/plugin-runtime/im/index.d.ts +5 -0
  31. package/lib/plugin-runtime/im/index.js +5 -0
  32. package/lib/plugin-runtime/im/message-dispatcher.d.ts +7 -0
  33. package/lib/plugin-runtime/im/message-dispatcher.js +26 -0
  34. package/lib/plugin-runtime/im/outbound-renderer.d.ts +6 -0
  35. package/lib/plugin-runtime/im/outbound-renderer.js +31 -0
  36. package/lib/plugin-runtime/im/outbound-segments.d.ts +22 -0
  37. package/lib/plugin-runtime/im/outbound-segments.js +59 -0
  38. package/lib/plugin.d.ts +2 -4
  39. package/lib/plugin.js +12 -8
  40. package/lib/request.d.ts +16 -59
  41. package/lib/request.js +0 -3
  42. package/lib/side-event/base.d.ts +45 -0
  43. package/lib/side-event/base.js +30 -0
  44. package/lib/side-event/index.d.ts +3 -0
  45. package/lib/side-event/index.js +3 -0
  46. package/lib/side-event/normalize.d.ts +40 -0
  47. package/lib/side-event/normalize.js +117 -0
  48. package/lib/side-event/types.d.ts +25 -0
  49. package/lib/side-event/types.js +55 -0
  50. package/package.json +15 -5
@@ -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/lib/plugin.d.ts CHANGED
@@ -6,12 +6,10 @@
6
6
  * - 生命周期事件类型安全(Plugin.Lifecycle)
7
7
  * - 中间件系统、适配器管理、useContext 等
8
8
  */
9
- import type { PluginManifest } from "./types.js";
9
+ import { MessageMiddleware, RegisteredAdapter, MaybePromise, ArrayItem, SendOptions, MessageSendPayload, type PluginManifest } from './types.js';
10
10
  import type { InteractiveHandler } from "./built/interactive-segments/types.js";
11
- import { MessageMiddleware, RegisteredAdapter, MaybePromise, ArrayItem, SendOptions, MessageSendPayload } from "./types.js";
12
11
  import { Adapter, Adapters } from "./adapter.js";
13
- import { PluginBase, PluginBaseLifecycle } from "@zhin.js/kernel";
14
- import type { PluginLike } from "@zhin.js/kernel";
12
+ import { PluginBase, PluginBaseLifecycle, type PluginLike } from '@zhin.js/kernel';
15
13
  export { getPlugin } from "./plugin-context.js";
16
14
  export { storage, getCurrentFile } from "./plugin-context.js";
17
15
  export { setHostRootPlugin, getHostRootPlugin } from "./host-plugin-registry.js";
package/lib/plugin.js CHANGED
@@ -13,8 +13,7 @@ import { formatCompact } from "@zhin.js/logger";
13
13
  import { compose, remove, resolveEntry } from "./utils.js";
14
14
  import { ensureInteractiveMiddleware, registerInteractiveHandler as registerInteractiveHandlerCore, } from "./built/interactive-segments/handlers.js";
15
15
  import { Adapter } from "./adapter.js";
16
- import { Feature } from "@zhin.js/kernel";
17
- import { PluginBase, resolvePluginResolveDir as _resolvePluginResolveDir, pluginCreateRequire as _pluginCreateRequire, getFileHash, watchFile, registerExtension, unregisterExtensions, installExtensionProxy, } from "@zhin.js/kernel";
16
+ import { Feature, PluginBase, resolvePluginResolveDir as _resolvePluginResolveDir, pluginCreateRequire as _pluginCreateRequire, getFileHash, watchFile, registerExtension, unregisterExtensions, installExtensionProxy } from '@zhin.js/kernel';
18
17
  import { storage, getCurrentFile } from "./plugin-context.js";
19
18
  // Re-export getPlugin from plugin-context for backward compatibility
20
19
  export { getPlugin } from "./plugin-context.js";
@@ -121,15 +120,20 @@ export class Plugin extends PluginBase {
121
120
  get name() {
122
121
  if (this.#cachedName)
123
122
  return this.#cachedName;
124
- this.#cachedName = path
123
+ let name = path
125
124
  .relative(process.cwd(), this.filePath)
126
125
  .replace(/\?t=\d+$/, "")
127
126
  .replace(/\\/g, "/")
128
127
  .replace(/\/index\.(js|ts)x?$/, "")
129
- .replace(/\/(lib|src|dist)$/, "")
130
- .replace(/.*\/node_modules\//, "")
131
- .replace(/.*\//, "")
132
- .replace(/\.(js|ts)x?$/, "");
128
+ .replace(/\/(lib|src|dist)$/, "");
129
+ const nodeModulesIndex = name.indexOf("node_modules/");
130
+ if (nodeModulesIndex !== -1)
131
+ name = name.slice(nodeModulesIndex + "node_modules/".length);
132
+ const slash = name.lastIndexOf("/");
133
+ if (slash !== -1)
134
+ name = name.slice(slash + 1);
135
+ name = name.replace(/\.(js|ts)x?$/, "");
136
+ this.#cachedName = name;
133
137
  return this.#cachedName;
134
138
  }
135
139
  /**
@@ -426,7 +430,7 @@ export class Plugin extends PluginBase {
426
430
  installExtensionProxy(Plugin.prototype);
427
431
  for (const [name, fn] of Object.entries(context.extensions)) {
428
432
  if (typeof fn === 'function') {
429
- registerExtension(name, fn); // eslint-disable-line @typescript-eslint/no-explicit-any
433
+ registerExtension(name, fn);
430
434
  }
431
435
  }
432
436
  }