@zhin.js/core 1.4.0 → 1.4.2

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 (45) hide show
  1. package/README.md +29 -43
  2. package/lib/adapter.js +9 -1
  3. package/lib/built/command.d.ts +5 -2
  4. package/lib/built/command.js +4 -1
  5. package/lib/built/interactive-segments/fallback-store.d.ts +29 -0
  6. package/lib/built/interactive-segments/fallback-store.js +63 -0
  7. package/lib/built/interactive-segments/handlers.d.ts +7 -0
  8. package/lib/built/interactive-segments/handlers.js +20 -4
  9. package/lib/built/interactive-segments/index.d.ts +1 -0
  10. package/lib/built/interactive-segments/index.js +1 -0
  11. package/lib/built/interactive-segments/resolve.d.ts +10 -1
  12. package/lib/built/interactive-segments/resolve.js +30 -1
  13. package/lib/built/segment-contract/index.d.ts +2 -1
  14. package/lib/built/segment-contract/index.js +2 -1
  15. package/lib/built/segment-contract/json-schema.js +2 -2
  16. package/lib/built/segment-contract/media.d.ts +11 -1
  17. package/lib/built/segment-contract/media.js +30 -0
  18. package/lib/built/segment-contract/text.d.ts +7 -0
  19. package/lib/built/segment-contract/text.js +34 -0
  20. package/lib/built/segment-contract/types.d.ts +6 -2
  21. package/lib/built/segment-contract/validate.js +1 -0
  22. package/lib/command.d.ts +5 -2
  23. package/lib/command.js +5 -2
  24. package/lib/feature/adapter.d.ts +2 -0
  25. package/lib/feature/adapter.js +2 -0
  26. package/lib/feature/command.d.ts +2 -0
  27. package/lib/feature/command.js +2 -0
  28. package/lib/feature/component.d.ts +2 -0
  29. package/lib/feature/component.js +2 -0
  30. package/lib/feature/middleware.d.ts +2 -0
  31. package/lib/feature/middleware.js +2 -0
  32. package/lib/plugin-runtime/im/contracts.d.ts +37 -3
  33. package/lib/plugin-runtime/im/contracts.js +8 -1
  34. package/lib/plugin-runtime/im/im-runtime.d.ts +6 -0
  35. package/lib/plugin-runtime/im/im-runtime.js +71 -6
  36. package/lib/plugin-runtime/im/index.d.ts +1 -0
  37. package/lib/plugin-runtime/im/index.js +1 -0
  38. package/lib/plugin-runtime/im/interactive.d.ts +25 -0
  39. package/lib/plugin-runtime/im/interactive.js +41 -0
  40. package/lib/plugin-runtime/im/message-dispatcher.js +122 -5
  41. package/lib/plugin-runtime/im/outbound-segments.d.ts +53 -6
  42. package/lib/plugin-runtime/im/outbound-segments.js +173 -10
  43. package/lib/plugin.d.ts +5 -3
  44. package/lib/plugin.js +38 -24
  45. package/package.json +56 -10
@@ -1,6 +1,30 @@
1
1
  import { htmlToFallbackText } from '../../built/html-to-text.js';
2
+ import { toCanonicalSegments } from '../../built/generic-segment-mapper.js';
3
+ import { effectiveKeyboardFallbackMap, renderKeyboardAsText, } from '../../built/interactive-segments/resolve.js';
4
+ import { DEFAULT_INTERACTIVE_POLICY, isKeyboardSegment, } from '../../built/interactive-segments/types.js';
5
+ import { isMediaRef } from '../../built/segment-contract/index.js';
2
6
  const DEFAULT_CARD_WIDTH = 540;
3
7
  const DEFAULT_CARD_FILENAME = 'card.png';
8
+ const DEFAULT_MEDIA_POLICY = 'base64';
9
+ /** 平台名 → 出站媒体策略(任务 C 声明式 policy 落地前的内置来源)。 */
10
+ const OUTBOUND_MEDIA_POLICY_BY_ADAPTER = { qq: 'base64',
11
+ icqq: 'base64',
12
+ slack: 'base64',
13
+ 'weixin-ilink': 'base64',
14
+ telegram: 'url-or-text',
15
+ line: 'url-or-text',
16
+ lark: 'url-or-text',
17
+ kook: 'url-or-text',
18
+ dingtalk: 'url-or-text',
19
+ 'wechat-mp': 'url-or-text',
20
+ wecom: 'url-or-text',
21
+ email: 'url-or-text',
22
+ github: 'url-or-text',
23
+ milky: 'url-or-text',
24
+ napcat: 'passthrough',
25
+ onebot11: 'passthrough',
26
+ onebot12: 'passthrough',
27
+ };
4
28
  export function isOutboundSegment(value) {
5
29
  return typeof value === 'object'
6
30
  && value !== null
@@ -8,26 +32,142 @@ export function isOutboundSegment(value) {
8
32
  && typeof value.type === 'string';
9
33
  }
10
34
  /**
11
- * Normalize a rendered outbound payload to wire segments:
12
- * - segment arrays stay arrays (html segments converted per element);
35
+ * 解析端点的出站媒体策略:优先读 adapter definition 上声明的
36
+ * `segments.outboundMedia`(任务 C 挂载点;多 endpoint 展开的 `slot~entry`
37
+ * id 回退到 slot id 查声明),否则按平台名查内置表,
38
+ * 未知平台回退 `base64`(历史行为)。
39
+ */
40
+ export function resolveOutboundMediaPolicy(adapter, snapshot) {
41
+ const slot = snapshot.capabilities.get(adapter)
42
+ ?? snapshot.capabilities.get(baseSlotCapabilityId(adapter));
43
+ const declared = readDeclaredMediaPolicy(slot?.definition);
44
+ if (declared)
45
+ return declared;
46
+ const packageName = slot ? snapshot.tree.get(slot.owner)?.packageName : undefined;
47
+ const adapterType = adapterTypeName(packageName);
48
+ return (adapterType ? OUTBOUND_MEDIA_POLICY_BY_ADAPTER[adapterType] : undefined)
49
+ ?? DEFAULT_MEDIA_POLICY;
50
+ }
51
+ function readDeclaredMediaPolicy(definition) {
52
+ if (!definition || typeof definition !== 'object')
53
+ return undefined;
54
+ const segments = definition.segments;
55
+ if (!segments || typeof segments !== 'object')
56
+ return undefined;
57
+ const declared = segments.outboundMedia;
58
+ // 过渡期单值策略字符串
59
+ if (declared === 'base64' || declared === 'url-or-text' || declared === 'passthrough') {
60
+ return declared;
61
+ }
62
+ // 任务 C 契约:媒体来源形式数组 → 投递策略。
63
+ // base64 可直发;无 base64 但端点可自行物化(upload/path)→ passthrough;
64
+ // 仅 url → 非 URL 媒体文本降级。
65
+ if (Array.isArray(declared)) {
66
+ if (declared.includes('base64'))
67
+ return 'base64';
68
+ if (declared.includes('upload') || declared.includes('path'))
69
+ return 'passthrough';
70
+ if (declared.includes('url'))
71
+ return 'url-or-text';
72
+ }
73
+ return undefined;
74
+ }
75
+ /**
76
+ * 平台名 → 出站 interactive 策略(adapter 未声明 `segments.interactive` 时
77
+ * 的内置来源):telegram / discord 端点自行把 keyboard 编码为原生按钮;
78
+ * 其余平台默认 'text'(编号文本降级,对齐旧轨 DEFAULT_INTERACTIVE_POLICY)。
79
+ */
80
+ const OUTBOUND_INTERACTIVE_POLICY_BY_ADAPTER = {
81
+ telegram: 'native',
82
+ discord: 'native',
83
+ };
84
+ /**
85
+ * 解析端点的出站 interactive 策略:优先读 adapter definition 上声明的
86
+ * `segments.interactive`,否则按平台名查内置表,未知平台回退 'text'。
87
+ */
88
+ export function resolveOutboundInteractivePolicy(adapter, snapshot) {
89
+ const slot = snapshot.capabilities.get(adapter)
90
+ ?? snapshot.capabilities.get(baseSlotCapabilityId(adapter));
91
+ const declared = readDeclaredInteractivePolicy(slot?.definition);
92
+ if (declared)
93
+ return declared;
94
+ const packageName = slot ? snapshot.tree.get(slot.owner)?.packageName : undefined;
95
+ const adapterType = adapterTypeName(packageName);
96
+ return (adapterType ? OUTBOUND_INTERACTIVE_POLICY_BY_ADAPTER[adapterType] : undefined)
97
+ ?? DEFAULT_INTERACTIVE_POLICY;
98
+ }
99
+ function readDeclaredInteractivePolicy(definition) {
100
+ if (!definition || typeof definition !== 'object')
101
+ return undefined;
102
+ const segments = definition.segments;
103
+ if (!segments || typeof segments !== 'object')
104
+ return undefined;
105
+ const declared = segments.interactive;
106
+ return declared === 'native' || declared === 'text' ? declared : undefined;
107
+ }
108
+ /**
109
+ * keyboard 段中央降级:'text' 端点把 keyboard 渲染为编号文本(复用旧轨
110
+ * `renderKeyboardAsText`),并把有效 fallback 映射经 `remember` 回调写入
111
+ * 中央存储(供入站数字回跳解析);'native' 端点透传 keyboard。
112
+ */
113
+ export function applyOutboundInteractivePolicy(payload, policy, remember) {
114
+ if (policy !== 'text' || !Array.isArray(payload))
115
+ return payload;
116
+ return payload.map((item) => {
117
+ if (!isKeyboardSegment(item))
118
+ return item;
119
+ const data = item.data;
120
+ remember?.(effectiveKeyboardFallbackMap(data));
121
+ return { type: 'text', data: { text: renderKeyboardAsText(data) } };
122
+ });
123
+ }
124
+ /** 多 endpoint 展开的 record id(`slot.id~entry`)→ slot id(entry 名禁含 `~`,见 adapter-index)。 */
125
+ function baseSlotCapabilityId(adapter) {
126
+ const tilde = adapter.indexOf('~');
127
+ return (tilde === -1 ? adapter : adapter.slice(0, tilde));
128
+ }
129
+ /** `@zhin.js/adapter-icqq` → `icqq`;非 adapter 包名原样返回。 */
130
+ function adapterTypeName(packageName) {
131
+ if (!packageName)
132
+ return undefined;
133
+ return packageName.replace(/^@[^/]+\/adapter-/, '');
134
+ }
135
+ /**
136
+ * Normalize a rendered outbound payload toward canonical wire segments:
137
+ * - segment arrays stay arrays (html segments converted per element,
138
+ * 其余段经 `toCanonicalSegments` 归一为 canonical Segment);
13
139
  * - a single segment object is wrapped into a one-element array;
14
140
  * - anything else (plain strings, legacy `{ text }` shorthands) passes through.
15
141
  */
16
- export async function normalizeOutboundPayload(payload, renderer) {
142
+ export async function normalizeOutboundPayload(payload, renderer, options) {
143
+ const mediaPolicy = options?.mediaPolicy ?? DEFAULT_MEDIA_POLICY;
17
144
  if (Array.isArray(payload)) {
18
- return Promise.all(payload.map((item) => normalizeOutboundSegment(item, renderer)));
145
+ const resolved = await Promise.all(payload.map((item) => normalizeOneSegment(item, renderer, mediaPolicy)));
146
+ return applyOutboundMediaPolicy(resolved, mediaPolicy);
19
147
  }
20
148
  if (isOutboundSegment(payload)) {
21
- return [await normalizeOutboundSegment(payload, renderer)];
149
+ return applyOutboundMediaPolicy([await normalizeOneSegment(payload, renderer, mediaPolicy)], mediaPolicy);
22
150
  }
23
151
  return payload;
24
152
  }
25
- async function normalizeOutboundSegment(segment, renderer) {
26
- if (!isOutboundSegment(segment) || segment.type !== 'html')
27
- return segment;
153
+ /**
154
+ * 单个出站项 canonical Segment:
155
+ * - html 段走渲染/文本降级专线(产物已是 canonical 兼容形状,不再过
156
+ * `toCanonicalSegments`,以免 legacy 双写字段被 strip);
157
+ * - 其余项经 `toCanonicalSegments` 归一(字符串→text、at→mention、
158
+ * 旧 wire 字段 `{url,file,base64}`→MediaRef)。
159
+ */
160
+ async function normalizeOneSegment(item, renderer, mediaPolicy) {
161
+ if (isOutboundSegment(item) && item.type === 'html') {
162
+ return renderHtmlSegment(item, renderer, mediaPolicy);
163
+ }
164
+ return toCanonicalSegments([item])[0];
165
+ }
166
+ async function renderHtmlSegment(segment, renderer, mediaPolicy) {
28
167
  const data = segment.data ?? {};
29
168
  const html = typeof data.html === 'string' ? data.html : '';
30
- if (html && renderer) {
169
+ // url-or-text 端点无法投递 base64 图片(本层无上传通道),直接文本降级,跳过渲染。
170
+ if (html && renderer && mediaPolicy !== 'url-or-text') {
31
171
  try {
32
172
  const result = await renderer.render(html, {
33
173
  width: typeof data.width === 'number' ? data.width : DEFAULT_CARD_WIDTH,
@@ -37,10 +177,14 @@ async function normalizeOutboundSegment(segment, renderer) {
37
177
  : {}),
38
178
  });
39
179
  if (result.format === 'png' && result.data && typeof result.data === 'object') {
180
+ const base64 = Buffer.from(result.data).toString('base64');
40
181
  return {
41
182
  type: 'image',
42
183
  data: {
43
- base64: Buffer.from(result.data).toString('base64'),
184
+ // canonical MediaRef 为主,legacy `base64`/`name` 双写保持旧 adapter 可读
185
+ // (Wave 2 适配器迁移完成后移除 legacy 字段)。
186
+ media: { kind: 'base64', value: base64, mime_type: 'image/png' },
187
+ base64,
44
188
  name: typeof data.fileName === 'string' ? data.fileName : DEFAULT_CARD_FILENAME,
45
189
  },
46
190
  };
@@ -52,6 +196,25 @@ async function normalizeOutboundSegment(segment, renderer) {
52
196
  }
53
197
  return { type: 'text', data: { text: htmlSegmentFallbackText(data, html) } };
54
198
  }
199
+ /**
200
+ * 媒体协商:仅 `url-or-text` 需要在本层动作——image 段的非 URL MediaRef
201
+ * (base64 / 本地路径)无法投递,降级为文本(alt 优先)。
202
+ */
203
+ function applyOutboundMediaPolicy(segments, mediaPolicy) {
204
+ if (mediaPolicy !== 'url-or-text')
205
+ return segments;
206
+ return segments.map((segment) => {
207
+ if (segment.type !== 'image')
208
+ return segment;
209
+ const media = segment.data.media;
210
+ if (!isMediaRef(media) || media.kind === 'url')
211
+ return segment;
212
+ const alt = typeof segment.data.alt === 'string' && segment.data.alt
213
+ ? segment.data.alt
214
+ : '[image]';
215
+ return { type: 'text', data: { text: alt } };
216
+ });
217
+ }
55
218
  function htmlSegmentFallbackText(data, html) {
56
219
  if (typeof data.text === 'string' && data.text.length > 0)
57
220
  return data.text;
package/lib/plugin.d.ts CHANGED
@@ -22,9 +22,11 @@ export type DisposeFn<A> = (context: ArrayItem<A>) => MaybePromise<void>;
22
22
  export type ContextList<CS extends (keyof Plugin.Contexts)[]> = CS extends [infer L, ...infer R] ? R extends (keyof Plugin.Contexts)[] ? [ContextItem<L>, ...ContextList<R>] : never[] : never[];
23
23
  type ContextItem<L> = L extends keyof Plugin.Contexts ? Plugin.Contexts[L] : never;
24
24
  /**
25
- * usePlugin - 获取或创建当前插件实例
26
- * 类似 React Hooks 的设计,根据调用文件自动创建插件树
27
- * 同一上下文中同一文件多次调用返回同一实例
25
+ * 获取当前插件实例(经典 AsyncLocalStorage 路径)。
26
+ * 同一上下文中同一文件多次调用返回同一实例。
27
+ *
28
+ * @deprecated 仅 `zhin.js/node`(`bootstrapNode`)可用;`zhin runtime start` 下不工作。
29
+ * 新插件请用 `definePlugin` + 约定目录。见 `docs/contributing/public-api-surface.md`。
28
30
  */
29
31
  export declare function usePlugin(): Plugin;
30
32
  export interface Plugin extends Plugin.Extensions {
package/lib/plugin.js CHANGED
@@ -31,9 +31,11 @@ function pluginCreateRequire() {
31
31
  // usePlugin — 获取或创建当前插件实例
32
32
  // ============================================================================
33
33
  /**
34
- * usePlugin - 获取或创建当前插件实例
35
- * 类似 React Hooks 的设计,根据调用文件自动创建插件树
36
- * 同一上下文中同一文件多次调用返回同一实例
34
+ * 获取当前插件实例(经典 AsyncLocalStorage 路径)。
35
+ * 同一上下文中同一文件多次调用返回同一实例。
36
+ *
37
+ * @deprecated 仅 `zhin.js/node`(`bootstrapNode`)可用;`zhin runtime start` 下不工作。
38
+ * 新插件请用 `definePlugin` + 约定目录。见 `docs/contributing/public-api-surface.md`。
37
39
  */
38
40
  export function usePlugin() {
39
41
  // 必须传入 plugin.ts 的 import.meta.url:getCurrentFile 默认锚定在 plugin-context.ts,
@@ -477,30 +479,42 @@ export class Plugin extends PluginBase {
477
479
  */
478
480
  async reload(plugin = this) {
479
481
  const p = plugin;
480
- this.logger.info(formatCompact({ name: p.name, reload: true }));
481
- const now = Date.now();
482
- if (!p.parent) {
483
- // 根插件重载 = 退出进程(由 CLI 重启)
484
- return process.exit(51);
485
- }
486
- const entry = p.filePath;
487
- const parent = p.parent;
488
- await p.stop();
489
- let fresh;
482
+ // A burst of file events for one logical save (or an overlapping edit
483
+ // while a reload is still in flight) must not start a second concurrent
484
+ // stop()+import() cycle for the same plugin.
485
+ if (p._reloading)
486
+ return;
487
+ p._reloading = true;
490
488
  try {
491
- fresh = await parent.import(entry, now);
489
+ this.logger.info(formatCompact({ name: p.name, reload: true }));
490
+ const now = Date.now();
491
+ if (!p.parent) {
492
+ // 根插件重载 = 退出进程(由 CLI 重启)
493
+ process.exit(51);
494
+ return;
495
+ }
496
+ const entry = p.filePath;
497
+ const parent = p.parent;
498
+ await p.stop();
499
+ let fresh;
500
+ try {
501
+ fresh = await parent.import(entry, now);
502
+ }
503
+ catch (err) {
504
+ this.logger.error(formatCompact({
505
+ name: p.name,
506
+ reload: false,
507
+ error: err instanceof Error ? err.message : String(err),
508
+ hint: 'full_restart',
509
+ }));
510
+ throw err;
511
+ }
512
+ await fresh.broadcast('mounted');
513
+ this.logger.debug(formatCompact({ name: fresh.name, reload_ms: Date.now() - now }));
492
514
  }
493
- catch (err) {
494
- this.logger.error(formatCompact({
495
- name: p.name,
496
- reload: false,
497
- error: err instanceof Error ? err.message : String(err),
498
- hint: 'full_restart',
499
- }));
500
- throw err;
515
+ finally {
516
+ p._reloading = false;
501
517
  }
502
- await fresh.broadcast('mounted');
503
- this.logger.debug(formatCompact({ name: fresh.name, reload_ms: Date.now() - now }));
504
518
  }
505
519
  /**
506
520
  * 监听文件变化(Plugin 类型窄化版本)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/core",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Zhin机器人核心框架",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -40,6 +40,26 @@
40
40
  "types": "./lib/jsx-dev-runtime.d.ts",
41
41
  "development": "./src/jsx-dev-runtime.ts",
42
42
  "import": "./lib/jsx-dev-runtime.js"
43
+ },
44
+ "./feature/adapter": {
45
+ "types": "./lib/feature/adapter.d.ts",
46
+ "development": "./src/feature/adapter.ts",
47
+ "import": "./lib/feature/adapter.js"
48
+ },
49
+ "./feature/command": {
50
+ "types": "./lib/feature/command.d.ts",
51
+ "development": "./src/feature/command.ts",
52
+ "import": "./lib/feature/command.js"
53
+ },
54
+ "./feature/component": {
55
+ "types": "./lib/feature/component.d.ts",
56
+ "development": "./src/feature/component.ts",
57
+ "import": "./lib/feature/component.js"
58
+ },
59
+ "./feature/middleware": {
60
+ "types": "./lib/feature/middleware.d.ts",
61
+ "development": "./src/feature/middleware.ts",
62
+ "import": "./lib/feature/middleware.js"
43
63
  }
44
64
  },
45
65
  "files": [
@@ -50,15 +70,15 @@
50
70
  "segment-matcher": "^1.0.5",
51
71
  "smol-toml": "^1.7.0",
52
72
  "yaml": "^2.9.0",
53
- "@zhin.js/adapter": "1.1.0",
54
- "@zhin.js/command": "1.0.2",
55
- "@zhin.js/component": "1.0.2",
56
- "@zhin.js/database": "1.0.77",
57
- "@zhin.js/kernel": "1.0.4",
73
+ "@zhin.js/adapter": "1.1.2",
74
+ "@zhin.js/command": "1.0.4",
75
+ "@zhin.js/component": "1.0.4",
76
+ "@zhin.js/database": "1.0.78",
77
+ "@zhin.js/kernel": "1.0.5",
58
78
  "@zhin.js/logger": "1.0.75",
59
- "@zhin.js/middleware": "1.0.2",
60
- "@zhin.js/plugin-runtime": "1.1.0",
61
- "@zhin.js/schema": "1.0.71"
79
+ "@zhin.js/middleware": "1.0.4",
80
+ "@zhin.js/plugin-runtime": "1.1.1",
81
+ "@zhin.js/schema": "1.0.72"
62
82
  },
63
83
  "peerDependencies": {
64
84
  "zod": "^4.0.0"
@@ -73,7 +93,7 @@
73
93
  "@types/qrcode": "^1.5.5",
74
94
  "ajv": "8.18.0",
75
95
  "typescript": "^6.0.3",
76
- "@zhin.js/ai": "1.4.5"
96
+ "@zhin.js/ai": "1.4.6"
77
97
  },
78
98
  "repository": {
79
99
  "type": "git",
@@ -93,6 +113,32 @@
93
113
  "engines": {
94
114
  "node": "^20.19.0 || >=22.12.0"
95
115
  },
116
+ "zhin": {
117
+ "protocol": 1,
118
+ "type": "plugin",
119
+ "entry": "./lib/index.js",
120
+ "engine": "^1.0.0",
121
+ "runtime": "trusted",
122
+ "features": [
123
+ {
124
+ "package": "@zhin.js/adapter",
125
+ "api": "^1.0.0"
126
+ },
127
+ {
128
+ "package": "@zhin.js/command",
129
+ "api": "^1.0.0"
130
+ },
131
+ {
132
+ "package": "@zhin.js/component",
133
+ "api": "^1.0.0"
134
+ },
135
+ {
136
+ "package": "@zhin.js/middleware",
137
+ "api": "^1.0.0"
138
+ }
139
+ ],
140
+ "plugins": []
141
+ },
96
142
  "scripts": {
97
143
  "build": "tsc",
98
144
  "clean": "rimraf lib"