@zhin.js/core 1.3.5 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +29 -43
  2. package/lib/adapter.js +17 -2
  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/login-assist.d.ts +19 -2
  14. package/lib/built/login-assist.js +43 -4
  15. package/lib/built/segment-contract/index.d.ts +3 -1
  16. package/lib/built/segment-contract/index.js +3 -1
  17. package/lib/built/segment-contract/json-schema.d.ts +28 -0
  18. package/lib/built/segment-contract/json-schema.js +128 -0
  19. package/lib/built/segment-contract/media.d.ts +11 -1
  20. package/lib/built/segment-contract/media.js +30 -0
  21. package/lib/built/segment-contract/text.d.ts +7 -0
  22. package/lib/built/segment-contract/text.js +34 -0
  23. package/lib/built/segment-contract/types.d.ts +6 -2
  24. package/lib/built/segment-contract/validate.js +1 -0
  25. package/lib/command.d.ts +5 -2
  26. package/lib/command.js +5 -2
  27. package/lib/endpoint.d.ts +4 -1
  28. package/lib/endpoint.js +1 -0
  29. package/lib/feature/adapter.d.ts +2 -0
  30. package/lib/feature/adapter.js +2 -0
  31. package/lib/feature/command.d.ts +2 -0
  32. package/lib/feature/command.js +2 -0
  33. package/lib/feature/component.d.ts +2 -0
  34. package/lib/feature/component.js +2 -0
  35. package/lib/feature/middleware.d.ts +2 -0
  36. package/lib/feature/middleware.js +2 -0
  37. package/lib/plugin-runtime/im/contracts.d.ts +37 -3
  38. package/lib/plugin-runtime/im/contracts.js +8 -1
  39. package/lib/plugin-runtime/im/im-runtime.d.ts +47 -2
  40. package/lib/plugin-runtime/im/im-runtime.js +176 -11
  41. package/lib/plugin-runtime/im/index.d.ts +1 -0
  42. package/lib/plugin-runtime/im/index.js +1 -0
  43. package/lib/plugin-runtime/im/interactive.d.ts +25 -0
  44. package/lib/plugin-runtime/im/interactive.js +41 -0
  45. package/lib/plugin-runtime/im/message-dispatcher.d.ts +12 -2
  46. package/lib/plugin-runtime/im/message-dispatcher.js +153 -12
  47. package/lib/plugin-runtime/im/outbound-segments.d.ts +53 -6
  48. package/lib/plugin-runtime/im/outbound-segments.js +173 -10
  49. package/lib/plugin.d.ts +6 -3
  50. package/lib/plugin.js +38 -24
  51. package/lib/tool-zod.d.ts +16 -1
  52. package/lib/tool-zod.js +138 -51
  53. package/lib/utils.d.ts +3 -0
  54. package/lib/utils.js +6 -3
  55. package/package.json +57 -10
package/README.md CHANGED
@@ -18,66 +18,52 @@ Zhin.js **IM/多通道运行时**包:Plugin、Adapter、**Endpoint**、Message
18
18
 
19
19
  ## 核心概念
20
20
 
21
- ### Plugin(插件)
21
+ ### Plugin(插件)— Plugin Runtime
22
22
 
23
- 插件是 Zhin.js 的基本组织单位。每个插件拥有独立的生命周期和上下文,通过 `usePlugin()` Hook 访问框架能力。
23
+ 唯一启动路径:`zhin runtime start`。`plugin.ts` 必须 default-export `definePlugin()`;能力按约定目录发现。
24
24
 
25
25
  ```typescript
26
- import { usePlugin, MessageCommand } from '@zhin.js/core'
26
+ // plugin.ts
27
+ import { definePlugin } from '@zhin.js/plugin-runtime'
27
28
 
28
- const { addCommand, addTool, addSchedule, onMounted, onDispose } = usePlugin()
29
-
30
- onMounted(() => console.log('插件已挂载'))
31
- onDispose(() => console.log('插件已卸载'))
32
-
33
- addCommand(
34
- new MessageCommand('hello <name:word>')
35
- .desc('打招呼')
36
- .action((_, result) => `Hello, ${result.params.name}!`)
37
- )
29
+ export default definePlugin({
30
+ name: 'hello-bot',
31
+ metadata: { displayName: 'Hello Bot' },
32
+ setup(context) {
33
+ context.lifecycle.add(() => { /* cleanup */ })
34
+ },
35
+ })
38
36
  ```
39
37
 
40
- 插件名称默认从文件路径推导,也可以显式声明:
41
-
42
38
  ```typescript
43
- // 方式 1: 导出 pluginName 常量
44
- export const pluginName = 'my-awesome-plugin'
45
-
46
- // 方式 2: 使用 definePlugin 声明式 API
47
- import { definePlugin } from '@zhin.js/core'
39
+ // commands/hello/[name:string].ts
40
+ import { defineCommand } from '@zhin.js/command'
48
41
 
49
- export default definePlugin({
50
- name: 'my-awesome-plugin',
51
- setup(plugin) {
52
- // 在这里使用 plugin 注册命令、工具等
42
+ export default defineCommand({
43
+ description: '打招呼',
44
+ execute({ params }) {
45
+ return `Hello, ${params.name}!`
53
46
  },
54
47
  })
55
-
56
- // 方式 3: 手动设置
57
- const plugin = usePlugin()
58
- plugin.setName('my-awesome-plugin')
59
48
  ```
60
49
 
50
+ > **已弃用**:`usePlugin()` / `MessageCommand` / `Plugin.addCommand` 仅经典路径(`zhin.js/node`)残留,新代码勿用。见 [public-api-surface](../../docs/contributing/public-api-surface.md)。
51
+
52
+ 本包仍提供 IM 运行时契约(Message / Adapter / Endpoint);创作面在 Feature 包与 `@zhin.js/plugin-runtime`。
53
+
61
54
  ### Feature(特性抽象)
62
55
 
63
- Feature Zhin.js 的核心扩展机制。所有内置功能均继承自 `Feature` 抽象基类,提供统一的注册/注销、插件归属追踪、JSON 序列化和变更事件通知能力。
56
+ 约定式 Feature(Command / Middleware / Component / Adapter…)由独立包提供 definition + 约定发现;Core Runtime 消费 snapshot。
57
+
58
+ 经典 `CommandFeature` / `addCommand` 等仍挂在 `Plugin.prototype`,**已 deprecated**,仅兼容 Agent init / game-kit:
64
59
 
65
60
  ```
66
- Feature (抽象基类)
67
- ├── CommandFeature — 消息命令 addCommand()
68
- ├── ToolFeature — AI 可调用工具 addTool()
69
- ├── SkillFeature — 技能记录 Agent 从 SKILL.md 注入
70
- ├── ScheduleFeature — 调度任务 addSchedule()
71
- ├── DatabaseFeature — 数据模型 defineModel()
72
- ├── ComponentFeature — 消息组件 addComponent()
73
- ├── ConfigFeature — 插件配置 addConfig()
74
- └── PermissionFeature — 权限管理
61
+ Feature (抽象基类) — legacy 注册表仍在
62
+ ├── CommandFeature — MessageCommand(→ defineCommand)
63
+ ├── ToolFeature — AI 工具(→ defineAgentTool)
64
+ ├──
75
65
  ```
76
66
 
77
- 每个 Feature 都会在 `Plugin.prototype` 上注入对应的扩展方法(如 `addCommand`、`addTool`),插件通过 `usePlugin()` 获取这些方法。
78
-
79
- Feature 支持变更事件监听,依赖方可实时响应 item 的增删:
80
-
81
67
  ```typescript
82
68
  const toolFeature = plugin.inject('tool')
83
69
  const off = toolFeature.on('add', (tool, pluginName) => {
@@ -229,7 +215,7 @@ export { CommandFeature, ToolFeature, SkillFeature, ScheduleFeature, DatabaseFea
229
215
  // 消息路由
230
216
  export { createMessageDispatcher } from './built/dispatcher.js'
231
217
 
232
- // 适配器与消息
218
+ // 适配器与消息(MessageCommand 已 deprecated)
233
219
  export { Adapter, Message, MessageCommand, Endpoint, segment, ... } from './'
234
220
 
235
221
  // 富媒体出站段
package/lib/adapter.js CHANGED
@@ -3,7 +3,7 @@ import { connectEndpointInstance, disconnectEndpointInstance } from "./built/con
3
3
  import { EventEmitter } from "node:events";
4
4
  import { getOutboundReplyStore } from "./built/dispatcher.js";
5
5
  import { DEFAULT_OUTBOUND_RICH_SEGMENT_POLICY, resolveRichSegments, } from "./built/rich-segments/index.js";
6
- import { DEFAULT_INTERACTIVE_POLICY, resolveInteractiveSegments, } from "./built/interactive-segments/index.js";
6
+ import { DEFAULT_INTERACTIVE_POLICY, collectKeyboardFallbackMaps, keyboardFallbackStore, resolveInteractiveSegments, } from "./built/interactive-segments/index.js";
7
7
  import { DEFAULT_AI_OUTBOUND_CAPABILITIES, } from "./built/ai-outbound/index.js";
8
8
  import { createRichSegmentRenderContext } from "./built/rich-segments/capabilities.js";
9
9
  import { collectOutboundMediaKinds } from "./built/outbound-media-utils.js";
@@ -77,7 +77,11 @@ export class Adapter extends EventEmitter {
77
77
  logger: this.logger,
78
78
  getMaxConcurrentMessages: () => this.maxConcurrentMessages,
79
79
  getPendingMessages: () => this.#pendingMessages,
80
- decrementPending: () => { this.#pendingMessages--; },
80
+ decrementPending: () => {
81
+ // stop() may zero the counter while in-flight receives still finish;
82
+ // never let the budget go negative.
83
+ this.#pendingMessages = Math.max(0, this.#pendingMessages - 1);
84
+ },
81
85
  });
82
86
  }
83
87
  /** 入站消息管线(替代 emit override 的隐式管线) */
@@ -166,6 +170,14 @@ export class Adapter extends EventEmitter {
166
170
  },
167
171
  })),
168
172
  };
173
+ // 'text' 策略端点:keyboard 即将降级为编号文本,先把有效 fallback
174
+ // 映射写入中央存储(频道键与入站回跳解析一致),供数字回跳路由。
175
+ if (this.getInteractivePolicy() === 'text') {
176
+ const channelKey = `${this.name}-${options.endpoint}-${options.type}:${options.id}`;
177
+ for (const map of collectKeyboardFallbackMaps(options.content)) {
178
+ keyboardFallbackStore.remember(channelKey, map);
179
+ }
180
+ }
169
181
  options = {
170
182
  ...options,
171
183
  content: resolveInteractiveSegments(options.content, this.getInteractivePolicy()),
@@ -291,6 +303,9 @@ export class Adapter extends EventEmitter {
291
303
  }
292
304
  // 无论是否有错误,始终完成清理
293
305
  this.endpoints.clear();
306
+ // Drop in-flight concurrency counter so a subsequent start() does not
307
+ // inherit a stale backpressure budget from the previous generation.
308
+ this.#pendingMessages = 0;
294
309
  // 从 adapters 数组中移除(可能因重复 start 出现多条同名,需全部删掉)
295
310
  const rootAdapters = this.plugin.root.adapters;
296
311
  for (let i = rootAdapters.length - 1; i >= 0; i--) {
@@ -15,7 +15,7 @@ import type { RegisteredAdapter, AdapterMessage } from "../types.js";
15
15
  * CommandContext 扩展方法类型
16
16
  */
17
17
  export interface CommandContextExtensions {
18
- /** 添加命令 */
18
+ /** @deprecated 使用 `defineCommand` + `commands/`;勿在新代码调用。 */
19
19
  addCommand<T extends RegisteredAdapter>(command: MessageCommand<T>): () => void;
20
20
  }
21
21
  declare module "../plugin.js" {
@@ -28,7 +28,10 @@ declare module "../plugin.js" {
28
28
  }
29
29
  }
30
30
  /**
31
- * 命令服务 Feature
31
+ * 命令服务 Feature(经典 MessageCommand 注册表)。
32
+ *
33
+ * @deprecated 新命令走 `@zhin.js/command` 的 `defineCommand` + 约定目录发现。
34
+ * 本 Feature 仍服务 Agent / game-kit / legacy Plugin.addCommand。
32
35
  */
33
36
  export declare class CommandFeature extends Feature<MessageCommand<RegisteredAdapter>> {
34
37
  readonly name: "command";
@@ -20,7 +20,10 @@ export function compareCommandPatterns(a, b) {
20
20
  return tokB - tokA;
21
21
  }
22
22
  /**
23
- * 命令服务 Feature
23
+ * 命令服务 Feature(经典 MessageCommand 注册表)。
24
+ *
25
+ * @deprecated 新命令走 `@zhin.js/command` 的 `defineCommand` + 约定目录发现。
26
+ * 本 Feature 仍服务 Agent / game-kit / legacy Plugin.addCommand。
24
27
  */
25
28
  export class CommandFeature extends Feature {
26
29
  name = 'command';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * keyboard 文本降级 fallback map 的中央存储。
3
+ *
4
+ * 出站:端点 interactive 策略为 'text' 时,框架把 keyboard 段降级为编号文本,
5
+ * 同时把「数字 → payload」映射按频道写入本存储(后写覆盖先写,等价
6
+ * game-kit lastMenus 的“最近一张键盘”语义)。
7
+ * 入站:框架中间件 / dispatcher 钩子用本存储把用户回复的裸数字解析回
8
+ * payload,再按 prefix 最长匹配路由给注册的 interactive handler。
9
+ *
10
+ * 模块级共享实例与 handlers.ts 的 handler 注册表同例:状态按频道键控、
11
+ * TTL 有界(对齐 game-hub 菜单上下文的 1h),无裸悬挂业务对象。
12
+ */
13
+ export declare const KEYBOARD_FALLBACK_TTL_MS: number;
14
+ export declare class KeyboardFallbackStore {
15
+ #private;
16
+ private readonly defaultTtlMs;
17
+ constructor(defaultTtlMs?: number);
18
+ /** 记录频道最近一次 keyboard 降级的数字→payload 映射(空 map 忽略)。 */
19
+ remember(channelKey: string, map: Record<string, string>, ttlMs?: number): void;
20
+ /** 频道当前有效的 fallback map(过期即删并返回 undefined)。 */
21
+ mapFor(channelKey: string): Record<string, string> | undefined;
22
+ /** 裸数字 / fallback 键 → payload(无映射或过期返回 undefined)。 */
23
+ resolve(channelKey: string, raw: string): string | undefined;
24
+ clear(): void;
25
+ }
26
+ /** 中央共享实例:出站降级写入、入站回跳读取。 */
27
+ export declare const keyboardFallbackStore: KeyboardFallbackStore;
28
+ /** 测试专用:清空中央 fallback 存储。 */
29
+ export declare function resetKeyboardFallbackStoreForTests(): void;
@@ -0,0 +1,63 @@
1
+ import { resolveTextFallbackPayload } from './action.js';
2
+ /**
3
+ * keyboard 文本降级 fallback map 的中央存储。
4
+ *
5
+ * 出站:端点 interactive 策略为 'text' 时,框架把 keyboard 段降级为编号文本,
6
+ * 同时把「数字 → payload」映射按频道写入本存储(后写覆盖先写,等价
7
+ * game-kit lastMenus 的“最近一张键盘”语义)。
8
+ * 入站:框架中间件 / dispatcher 钩子用本存储把用户回复的裸数字解析回
9
+ * payload,再按 prefix 最长匹配路由给注册的 interactive handler。
10
+ *
11
+ * 模块级共享实例与 handlers.ts 的 handler 注册表同例:状态按频道键控、
12
+ * TTL 有界(对齐 game-hub 菜单上下文的 1h),无裸悬挂业务对象。
13
+ */
14
+ export const KEYBOARD_FALLBACK_TTL_MS = 60 * 60 * 1000;
15
+ export class KeyboardFallbackStore {
16
+ defaultTtlMs;
17
+ #entries = new Map();
18
+ constructor(defaultTtlMs = KEYBOARD_FALLBACK_TTL_MS) {
19
+ this.defaultTtlMs = defaultTtlMs;
20
+ }
21
+ /** 记录频道最近一次 keyboard 降级的数字→payload 映射(空 map 忽略)。 */
22
+ remember(channelKey, map, ttlMs = this.defaultTtlMs) {
23
+ if (Object.keys(map).length === 0)
24
+ return;
25
+ this.#prune();
26
+ this.#entries.set(channelKey, {
27
+ map: Object.freeze({ ...map }),
28
+ expiresAt: Date.now() + ttlMs,
29
+ });
30
+ }
31
+ /** 频道当前有效的 fallback map(过期即删并返回 undefined)。 */
32
+ mapFor(channelKey) {
33
+ const entry = this.#entries.get(channelKey);
34
+ if (!entry)
35
+ return undefined;
36
+ if (entry.expiresAt < Date.now()) {
37
+ this.#entries.delete(channelKey);
38
+ return undefined;
39
+ }
40
+ return entry.map;
41
+ }
42
+ /** 裸数字 / fallback 键 → payload(无映射或过期返回 undefined)。 */
43
+ resolve(channelKey, raw) {
44
+ const map = this.mapFor(channelKey);
45
+ return map ? resolveTextFallbackPayload(raw, map) : undefined;
46
+ }
47
+ clear() {
48
+ this.#entries.clear();
49
+ }
50
+ #prune() {
51
+ const now = Date.now();
52
+ for (const [key, entry] of this.#entries) {
53
+ if (entry.expiresAt < now)
54
+ this.#entries.delete(key);
55
+ }
56
+ }
57
+ }
58
+ /** 中央共享实例:出站降级写入、入站回跳读取。 */
59
+ export const keyboardFallbackStore = new KeyboardFallbackStore();
60
+ /** 测试专用:清空中央 fallback 存储。 */
61
+ export function resetKeyboardFallbackStoreForTests() {
62
+ keyboardFallbackStore.clear();
63
+ }
@@ -1,6 +1,13 @@
1
1
  import type { Message } from '../../message.js';
2
2
  import type { MessageMiddleware } from '../../types.js';
3
3
  import type { InteractiveHandler, RegisteredInteractiveHandler } from './types.js';
4
+ /** 旧轨频道键:与出站降级(Adapter.renderSendMessage)及 game-kit channelKey 一致。 */
5
+ export declare function interactiveChannelKey(message: Message<any>): string;
6
+ /**
7
+ * 文本回跳解析:中央 fallback map(裸数字)→ payload;
8
+ * 或 QQ 指令预填等直出 `prefix:session:id` payload。
9
+ */
10
+ export declare function resolveInboundTextPayload(message: Message<any>): string | undefined;
4
11
  export declare function registerInteractiveHandler(prefix: string, handler: InteractiveHandler): () => void;
5
12
  export declare function getInteractiveHandlers(): readonly RegisteredInteractiveHandler[];
6
13
  export declare function resetInteractiveHandlersForTests(): void;
@@ -1,4 +1,5 @@
1
- import { getActionFromMessage } from './action.js';
1
+ import { getActionFromMessage, resolvePayloadFromText } from './action.js';
2
+ import { keyboardFallbackStore } from './fallback-store.js';
2
3
  const handlers = [];
3
4
  let middlewareInstalled = false;
4
5
  function findHandler(payload) {
@@ -12,6 +13,20 @@ function findHandler(payload) {
12
13
  }
13
14
  return match?.handler;
14
15
  }
16
+ /** 旧轨频道键:与出站降级(Adapter.renderSendMessage)及 game-kit channelKey 一致。 */
17
+ export function interactiveChannelKey(message) {
18
+ return `${String(message.$adapter)}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
19
+ }
20
+ /**
21
+ * 文本回跳解析:中央 fallback map(裸数字)→ payload;
22
+ * 或 QQ 指令预填等直出 `prefix:session:id` payload。
23
+ */
24
+ export function resolveInboundTextPayload(message) {
25
+ const raw = message.$raw?.trim() ?? '';
26
+ if (!raw)
27
+ return undefined;
28
+ return resolvePayloadFromText(raw, keyboardFallbackStore.mapFor(interactiveChannelKey(message)));
29
+ }
15
30
  export function registerInteractiveHandler(prefix, handler) {
16
31
  const entry = { prefix, handler };
17
32
  handlers.push(entry);
@@ -35,10 +50,11 @@ export function ensureInteractiveMiddleware(addMiddleware) {
35
50
  return;
36
51
  middlewareInstalled = true;
37
52
  addMiddleware(async (message, next) => {
38
- const action = getActionFromMessage(message);
39
- if (!action)
53
+ const payload = getActionFromMessage(message)?.payload
54
+ ?? resolveInboundTextPayload(message);
55
+ if (!payload)
40
56
  return next();
41
- const handler = findHandler(action.payload);
57
+ const handler = findHandler(payload);
42
58
  if (!handler)
43
59
  return next();
44
60
  const handled = await handler(message);
@@ -3,5 +3,6 @@ export * from './button-spec.js';
3
3
  export * from './action.js';
4
4
  export * from './resolve.js';
5
5
  export * from './handlers.js';
6
+ export * from './fallback-store.js';
6
7
  export { KeyboardSegment, InteractiveSegment } from './keyboard-segment.js';
7
8
  export * from './onebot-keyboard.js';
@@ -3,5 +3,6 @@ export * from './button-spec.js';
3
3
  export * from './action.js';
4
4
  export * from './resolve.js';
5
5
  export * from './handlers.js';
6
+ export * from './fallback-store.js';
6
7
  export { KeyboardSegment, InteractiveSegment } from './keyboard-segment.js';
7
8
  export * from './onebot-keyboard.js';
@@ -1,5 +1,14 @@
1
1
  import type { SendContent } from '../../types.js';
2
- import { type InteractivePolicy } from './types.js';
2
+ import { type InteractivePolicy, type KeyboardSegmentData } from './types.js';
3
+ /**
4
+ * keyboard 的有效 fallback 映射:显式 `fallback.map` 优先;否则按按钮顺序
5
+ * 自动编号(与 {@link renderKeyboardAsText} 的自动编号一致,含 disabled 按钮)。
6
+ */
7
+ export declare function effectiveKeyboardFallbackMap(data: KeyboardSegmentData): Record<string, string>;
8
+ /** 收集 content 中所有 keyboard 段的有效 fallback 映射(文本降级前调用)。 */
9
+ export declare function collectKeyboardFallbackMaps(content: SendContent | undefined): Record<string, string>[];
10
+ /** keyboard 段 → 编号文本('text' 策略端点的中央降级渲染)。 */
11
+ export declare function renderKeyboardAsText(data: KeyboardSegmentData): string;
3
12
  export declare function hasKeyboardSegment(content: SendContent | undefined): boolean;
4
13
  /** @deprecated 使用 {@link hasKeyboardSegment} */
5
14
  export declare const hasInteractiveSegment: typeof hasKeyboardSegment;
@@ -21,7 +21,36 @@ function packSegments(out) {
21
21
  return out[0];
22
22
  return out;
23
23
  }
24
- function renderKeyboardAsText(data) {
24
+ /**
25
+ * keyboard 的有效 fallback 映射:显式 `fallback.map` 优先;否则按按钮顺序
26
+ * 自动编号(与 {@link renderKeyboardAsText} 的自动编号一致,含 disabled 按钮)。
27
+ */
28
+ export function effectiveKeyboardFallbackMap(data) {
29
+ const map = data.fallback?.map ?? {};
30
+ if (Object.keys(map).length > 0)
31
+ return { ...map };
32
+ const out = {};
33
+ data.rows.flat().forEach((btn, idx) => {
34
+ out[String(idx + 1)] = btn.payload;
35
+ });
36
+ return out;
37
+ }
38
+ /** 收集 content 中所有 keyboard 段的有效 fallback 映射(文本降级前调用)。 */
39
+ export function collectKeyboardFallbackMaps(content) {
40
+ if (content == null)
41
+ return [];
42
+ const maps = [];
43
+ for (const item of asArray(content)) {
44
+ if (typeof item === 'string')
45
+ continue;
46
+ const data = asKeyboardData(item);
47
+ if (data)
48
+ maps.push(effectiveKeyboardFallbackMap(data));
49
+ }
50
+ return maps;
51
+ }
52
+ /** keyboard 段 → 编号文本('text' 策略端点的中央降级渲染)。 */
53
+ export function renderKeyboardAsText(data) {
25
54
  const lines = [];
26
55
  const flat = data.rows.flat();
27
56
  if (data.fallback?.hint) {
@@ -7,9 +7,12 @@
7
7
  *
8
8
  * 事件:
9
9
  * endpoint.login.pending — 有新待办时触发,payload: PendingLoginTask
10
+ * endpoint.login.expired — 超时自动取消时触发,payload: PendingLoginTask
10
11
  */
11
12
  import type { Plugin } from '../plugin.js';
12
13
  export type LoginAssistType = 'qrcode' | 'sms' | 'slider' | 'device' | 'auth' | 'other';
14
+ /** 默认 5 分钟;扫码/滑块长期挂起会泄漏 Promise 与 Map 条目 */
15
+ export declare const DEFAULT_LOGIN_ASSIST_TIMEOUT_MS: number;
13
16
  export interface PendingLoginTaskPayload {
14
17
  /** 说明文案(如「请扫码登录」) */
15
18
  message?: string;
@@ -27,16 +30,30 @@ export interface PendingLoginTask {
27
30
  type: LoginAssistType;
28
31
  payload: PendingLoginTaskPayload;
29
32
  createdAt: number;
33
+ /** 到期时间戳(ms);无超时则为 undefined */
34
+ expiresAt?: number;
35
+ }
36
+ export interface WaitForInputOptions {
37
+ /**
38
+ * 超时毫秒。默认 {@link DEFAULT_LOGIN_ASSIST_TIMEOUT_MS}。
39
+ * 传 `0` / `Infinity` / 负数表示不超时(仅测试或显式长驻场景)。
40
+ */
41
+ timeoutMs?: number;
30
42
  }
31
43
  export declare class LoginAssist {
32
44
  private readonly plugin;
33
45
  private readonly pending;
34
46
  private idSeq;
35
- constructor(plugin: Plugin);
47
+ /** 构造时默认超时;可被 waitForInput options 覆盖 */
48
+ private readonly defaultTimeoutMs;
49
+ constructor(plugin: Plugin, options?: {
50
+ defaultTimeoutMs?: number;
51
+ });
36
52
  /**
37
53
  * 生产者:等待用户输入后 resolve。会发出 endpoint.login.pending 事件,未消费前可被 listPending 拉取(刷新后可继续消费)。
54
+ * 超时后 reject 并 emit `endpoint.login.expired`。
38
55
  */
39
- waitForInput(adapter: string, endpointId: string, type: LoginAssistType, payload?: PendingLoginTaskPayload): Promise<string | Record<string, unknown>>;
56
+ waitForInput(adapter: string, endpointId: string, type: LoginAssistType, payload?: PendingLoginTaskPayload, options?: WaitForInputOptions): Promise<string | Record<string, unknown>>;
40
57
  /**
41
58
  * 消费者:提交结果,对应 waitForInput 的 Promise 会 resolve。
42
59
  */
@@ -7,7 +7,10 @@
7
7
  *
8
8
  * 事件:
9
9
  * endpoint.login.pending — 有新待办时触发,payload: PendingLoginTask
10
+ * endpoint.login.expired — 超时自动取消时触发,payload: PendingLoginTask
10
11
  */
12
+ /** 默认 5 分钟;扫码/滑块长期挂起会泄漏 Promise 与 Map 条目 */
13
+ export const DEFAULT_LOGIN_ASSIST_TIMEOUT_MS = 5 * 60 * 1000;
11
14
  // ============================================================================
12
15
  // LoginAssist 服务
13
16
  // ============================================================================
@@ -15,24 +18,48 @@ export class LoginAssist {
15
18
  plugin;
16
19
  pending = new Map();
17
20
  idSeq = 0;
18
- constructor(plugin) {
21
+ /** 构造时默认超时;可被 waitForInput options 覆盖 */
22
+ defaultTimeoutMs;
23
+ constructor(plugin, options) {
19
24
  this.plugin = plugin;
25
+ this.defaultTimeoutMs = options?.defaultTimeoutMs ?? DEFAULT_LOGIN_ASSIST_TIMEOUT_MS;
20
26
  }
21
27
  /**
22
28
  * 生产者:等待用户输入后 resolve。会发出 endpoint.login.pending 事件,未消费前可被 listPending 拉取(刷新后可继续消费)。
29
+ * 超时后 reject 并 emit `endpoint.login.expired`。
23
30
  */
24
- waitForInput(adapter, endpointId, type, payload = {}) {
31
+ waitForInput(adapter, endpointId, type, payload = {}, options) {
25
32
  const id = `login-${Date.now()}-${++this.idSeq}`;
33
+ const timeoutMs = resolveTimeoutMs(options?.timeoutMs, this.defaultTimeoutMs);
34
+ const createdAt = Date.now();
26
35
  const task = {
27
36
  id,
28
37
  adapter,
29
38
  endpointId,
30
39
  type,
31
40
  payload: { message: payload.message ?? '', ...payload },
32
- createdAt: Date.now(),
41
+ createdAt,
42
+ ...(timeoutMs != null ? { expiresAt: createdAt + timeoutMs } : {}),
33
43
  };
34
44
  const promise = new Promise((resolve, reject) => {
35
- this.pending.set(id, { task, resolve, reject });
45
+ const entry = { task, resolve, reject };
46
+ if (timeoutMs != null) {
47
+ entry.timer = setTimeout(() => {
48
+ if (!this.pending.has(id))
49
+ return;
50
+ this.pending.delete(id);
51
+ try {
52
+ this.plugin.emit('endpoint.login.expired', task);
53
+ }
54
+ catch {
55
+ /* observer failure must not leave the producer hanging forever */
56
+ }
57
+ reject(new Error(`LoginAssist task timed out after ${timeoutMs}ms`));
58
+ }, timeoutMs);
59
+ // Node: 不阻止进程退出
60
+ entry.timer.unref?.();
61
+ }
62
+ this.pending.set(id, entry);
36
63
  this.plugin.emit('endpoint.login.pending', task);
37
64
  });
38
65
  return promise;
@@ -45,6 +72,8 @@ export class LoginAssist {
45
72
  if (!entry)
46
73
  return false;
47
74
  this.pending.delete(id);
75
+ if (entry.timer)
76
+ clearTimeout(entry.timer);
48
77
  entry.resolve(value);
49
78
  return true;
50
79
  }
@@ -56,6 +85,8 @@ export class LoginAssist {
56
85
  if (!entry)
57
86
  return false;
58
87
  this.pending.delete(id);
88
+ if (entry.timer)
89
+ clearTimeout(entry.timer);
59
90
  entry.reject(new Error(reason));
60
91
  return true;
61
92
  }
@@ -67,8 +98,16 @@ export class LoginAssist {
67
98
  }
68
99
  dispose() {
69
100
  for (const [, entry] of this.pending) {
101
+ if (entry.timer)
102
+ clearTimeout(entry.timer);
70
103
  entry.reject(new Error('LoginAssist disposed'));
71
104
  }
72
105
  this.pending.clear();
73
106
  }
74
107
  }
108
+ function resolveTimeoutMs(explicit, fallback) {
109
+ const raw = explicit === undefined ? fallback : explicit;
110
+ if (!Number.isFinite(raw) || raw <= 0)
111
+ return undefined;
112
+ return raw;
113
+ }
@@ -1,8 +1,10 @@
1
1
  export type { Segment, SegmentBase, MediaRef, TextSegment, MentionSegment, ImageSegment, ReplySegment, ForwardSegment, FaceSegment, DiceSegment, RpsSegment, } from './types.js';
2
2
  export { mediaRefSchema, textSegmentSchema, mentionSegmentSchema, imageSegmentSchema, replySegmentSchema, forwardSegmentSchema, faceSegmentSchema, diceSegmentSchema, rpsSegmentSchema, canonicalSegmentSchema, segmentArraySchema, } from './validate.js';
3
3
  export { assertCanonicalSegments, isCanonicalSegment } from './assert.js';
4
+ export { mediaRefJsonSchema, outboundSegmentJsonSchema, aiOutboundJsonSchema, STRICT_OUTBOUND_SEGMENT_TYPES, } from './json-schema.js';
4
5
  export { segmentsForImDelivery } from './delivery.js';
5
- export { isMediaRef, mediaRefFromLegacyData, mediaRefToLegacyFields } from './media.js';
6
+ export { isMediaRef, mediaRefFromLegacyData, mediaRefToLegacyFields, collectSegmentMedia, type SegmentMediaRef, } from './media.js';
6
7
  export { createImageSegment } from './image.js';
7
8
  export { formatSegmentPreview } from './preview.js';
9
+ export { segmentsToPlainText } from './text.js';
8
10
  export { readMentionTarget, readMentionName, readMentionSegmentTarget } from './mention.js';
@@ -1,7 +1,9 @@
1
1
  export { mediaRefSchema, textSegmentSchema, mentionSegmentSchema, imageSegmentSchema, replySegmentSchema, forwardSegmentSchema, faceSegmentSchema, diceSegmentSchema, rpsSegmentSchema, canonicalSegmentSchema, segmentArraySchema, } from './validate.js';
2
2
  export { assertCanonicalSegments, isCanonicalSegment } from './assert.js';
3
+ export { mediaRefJsonSchema, outboundSegmentJsonSchema, aiOutboundJsonSchema, STRICT_OUTBOUND_SEGMENT_TYPES, } from './json-schema.js';
3
4
  export { segmentsForImDelivery } from './delivery.js';
4
- export { isMediaRef, mediaRefFromLegacyData, mediaRefToLegacyFields } from './media.js';
5
+ export { isMediaRef, mediaRefFromLegacyData, mediaRefToLegacyFields, collectSegmentMedia, } from './media.js';
5
6
  export { createImageSegment } from './image.js';
6
7
  export { formatSegmentPreview } from './preview.js';
8
+ export { segmentsToPlainText } from './text.js';
7
9
  export { readMentionTarget, readMentionName, readMentionSegmentTarget } from './mention.js';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Canonical outbound segment 的 JSON Schema SSOT —— 供 AI structured output
3
+ * (AI SDK `Output.object({ schema })`)约束模型直接输出 zhin 消息段数组。
4
+ *
5
+ * 与 validate.ts(@zhin.js/schema 运行时校验)表达同一组约束的 JSON Schema 形态;
6
+ * 严格段类型集合必须与 assert.ts 的 STRICT_CANONICAL_TYPES 保持一致
7
+ * (tests/segment-contract/json-schema.test.ts 有交叉防漂移测试)。
8
+ *
9
+ * 注意:解析侧 parseOutboundSegment 对严格段要求 canonical 形态
10
+ * (如 image 必须携带 data.media: MediaRef),本 schema 与之对齐。
11
+ */
12
+ type JsonSchemaObject = Record<string, unknown>;
13
+ /** MediaRef(canonical 媒体引用):types.ts 的 MediaRef 接口 */
14
+ export declare const mediaRefJsonSchema: JsonSchemaObject;
15
+ /** 严格段类型集合(SSOT:assert.ts STRICT_CANONICAL_TYPES) */
16
+ export declare const STRICT_OUTBOUND_SEGMENT_TYPES: readonly ["text", "mention", "image", "reply", "forward", "face", "dice", "rps"];
17
+ /**
18
+ * 单条 outbound 消息段的 JSON Schema。
19
+ * 严格段镜像 validate.ts 的 data 约束;宽松段走 generic 分支。
20
+ */
21
+ export declare const outboundSegmentJsonSchema: JsonSchemaObject;
22
+ /**
23
+ * AI 结构化出站根对象(ADR 0025 JSON DSL 的 schema 形态)。
24
+ * 与 parseAiOutboundJson / ZhinAiOutboundPayload 对齐:text、mentions、segments
25
+ * 均为可选(provider strict 模式兼容性考虑),"至少一项"由 prompt 与下游校验兜底。
26
+ */
27
+ export declare const aiOutboundJsonSchema: JsonSchemaObject;
28
+ export {};