@zhin.js/adapter-onebot11 5.0.0 → 5.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @zhin.js/adapter-onebot11
2
2
 
3
+ ## 5.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
8
+
9
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
10
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
11
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
12
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
13
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
14
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
15
+
16
+ - 078e3f7: 架构统一批(AURA):
17
+
18
+ - **EndpointLifecycle 基座**(@zhin.js/adapter 新增 `createEndpointLifecycle`):WS/SSE 端点的 start 失败复位、仅曾 open 才退避重连(指数+jitter 可配)、stop 不重连、PONG 看门狗、定时器集中清理、陈旧事件防叠套;napcat/milky/onebot11/onebot12/satori 已迁移(删除各自手写状态机),从此同类竞态在结构上不可能再犯。
19
+ - **Generation-store**(@zhin.js/plugin-runtime 新增 `createGenerationStore`):模块级运行时状态的一等能力,provide 自动挂 lifecycle 反注册(代际结束自动清理);lottery deps 与 rss db 已迁移,公开 API 兼容。
20
+ - **Resolver 管线收敛**(@zhin.js/runtime):解析规则统一为 local path → workspace → node_modules 单管线;optional 引用对所有 PackageResolutionError 容错(消除 message 前缀补丁)。
21
+ - **工具目录准入统一**(@zhin.js/agent):RegisteredToolSource 与 ExternalToolSource 共用同一 `canAccessTool` 准入(platforms/scopes/permissions/hidden 四元组全链路透传),同名覆盖 warn;AgentToolRegistration 补 platforms/scopes。两条注册通道(静态约定 vs 动态注册)职责边界已文档化。
22
+
23
+ - 9c997b2: 通用 endpoint 管理命令套件:`@zhin.js/adapter` 新增 `createEndpointCommands(spec, defineCommand)`——`<adapter> endpoint list / add <name> key=value... / remove <name>` 三件套,含 kv 解析、`.env` 凭据派生(`<ADAPTER>_<NAME>_<FIELD>`)、yaml 写回保留注释、master 权限门禁(通用 `isEndpointOperator`)、自定义 bindFlow 钩子。QQ 迁移至套件(行为与扫码绑定流程不变);napcat / onebot11 / onebot12 / milky / slack / telegram 接入(字段对齐各自 schema,features 补 @zhin.js/command)。
24
+ - 09d4f25: Console 社交读取面(management 语义端口)多平台落地:napcat/onebot11/onebot12/milky(好友+群+群成员,OneBot 标准动作);discord/kook/satori(guild+频道+成员,分页聚合,id 保精度留字符串);slack(workspace 成员+public channels+conversations.members);line(群/room 成员分页+profile 回退);wechat-mp(followers openid);weixin-ilink(context_token 对端推导);lark(chats+members 全分页)。`EndpointFriend.user_id`/`EndpointGroup.group_id` 放宽为 `number | string`(雪花 id 不丢精度)。telegram/wecom/dingtalk/github/email/sandbox 注明平台无列表面暂不接。
25
+ - 6cb6152: 统一消息元素通道(UNI-Channel)落地:
26
+
27
+ - **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
28
+ - **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
29
+ - **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
30
+ - **首批迁移**:icqq 全保真出入站(CQ ↔ canonical,quote→reply 段);milky/telegram/discord 入站媒体段恢复(附件/贴纸/callback action);napcat/onebot11/onebot12 出站 canonical→OneBot 数组段;wechat-mp/wecom `/cgi-bin/media/upload` 与 lark `/im/v1/images` 上传通路(base64/URL 图片不再静默丢图,失败降级文本)。
31
+
32
+ - Updated dependencies [cdf64e7]
33
+ - Updated dependencies [2d0a159]
34
+ - Updated dependencies [5691aba]
35
+ - Updated dependencies [078e3f7]
36
+ - Updated dependencies [50497a5]
37
+ - Updated dependencies [9c997b2]
38
+ - Updated dependencies [09d4f25]
39
+ - Updated dependencies [43485a9]
40
+ - Updated dependencies [f0ec5ab]
41
+ - Updated dependencies [3e925d0]
42
+ - Updated dependencies [fa66c4c]
43
+ - Updated dependencies [fa66c4c]
44
+ - Updated dependencies [6cb6152]
45
+ - @zhin.js/command@1.0.3
46
+ - @zhin.js/agent@1.0.6
47
+ - @zhin.js/plugin-runtime@1.1.1
48
+ - @zhin.js/host-http@1.0.3
49
+ - zhin.js@5.0.1
50
+ - @zhin.js/adapter@1.1.1
51
+ - @zhin.js/core@1.4.1
52
+
3
53
  ## 5.0.0
4
54
 
5
55
  ### Patch Changes
@@ -10,6 +10,7 @@ import {
10
10
  resolveOneBot11Config,
11
11
  type OneBot11AdapterConfig,
12
12
  } from '../src/protocol.js';
13
+ import { onebot11RuntimeStateToken } from '../src/onebot11-runtime-state.js';
13
14
 
14
15
  export { OneBot11WsEndpoint } from '../src/ws-endpoint.js';
15
16
  export type { OneBot11WsEndpointOptions } from '../src/ws-endpoint.js';
@@ -19,9 +20,19 @@ export type { OneBot11WsSocket, OneBot11WsCreateOptions } from '../src/ws-types.
19
20
 
20
21
  export default defineAdapter<OneBot11AdapterConfig>({
21
22
  capabilities: ['inbound', 'outbound'],
23
+ // OneBot file 参数原生消费 url / base64:// 媒体;无卡片交互面,交互段降级纯文本。
24
+ segments: {
25
+ outboundMedia: ['url', 'base64'],
26
+ interactive: 'text',
27
+ },
22
28
  create(context) {
23
29
  const config = resolveOneBot11Config(context.config);
24
30
  const gateway = context.use(messageGatewayToken);
31
+ // 注册到插件运行时状态(onebot11 endpoint list 的"运行中"数据源)
32
+ context.use(onebot11RuntimeStateToken).endpoints.set(config.name, {
33
+ name: config.name,
34
+ mode: config.connection,
35
+ });
25
36
  if (config.connection === 'wss') {
26
37
  return new OneBot11WssEndpoint({
27
38
  id: context.id,
@@ -0,0 +1,3 @@
1
+ import { onebot11EndpointCommands } from '../../../src/onebot11-endpoint-commands.js';
2
+
3
+ export default onebot11EndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { onebot11EndpointCommands } from '../../src/onebot11-endpoint-commands.js';
2
+
3
+ export default onebot11EndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { onebot11EndpointCommands } from '../../../src/onebot11-endpoint-commands.js';
2
+
3
+ export default onebot11EndpointCommands.remove;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * OneBot11 endpoint management 语义端口(Console 社交面 RPC 消费,见
3
+ * packages/im/adapter/src/endpoint-management.ts)。
4
+ *
5
+ * 归一化取舍:
6
+ * - friend → {user_id:number, nickname, remark: remark ?? ''}
7
+ * - group → {group_id:number, name: group_name ?? name}
8
+ * - 群成员列表保持 OneBot11 原生形状,仅保证数组
9
+ */
10
+ import type { EndpointManagement } from '@zhin.js/adapter';
11
+ /** 管理面只依赖 endpoint 的 callApi(ws/wss 两种传输各自实现)。 */
12
+ export interface OneBot11ManagementCaller {
13
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
14
+ }
15
+ export declare function createOneBot11EndpointManagement(endpoint: OneBot11ManagementCaller): EndpointManagement;
@@ -0,0 +1,47 @@
1
+ export function createOneBot11EndpointManagement(endpoint) {
2
+ return Object.freeze({
3
+ async listFriends() {
4
+ const data = await endpoint.callApi('get_friend_list');
5
+ return toArray(data).map((value) => {
6
+ const friend = asRecord(value);
7
+ return {
8
+ user_id: toNumberId(friend.user_id, 'user_id'),
9
+ nickname: String(friend.nickname ?? ''),
10
+ remark: String(friend.remark ?? ''),
11
+ };
12
+ });
13
+ },
14
+ async listGroups() {
15
+ const data = await endpoint.callApi('get_group_list');
16
+ return toArray(data).map((value) => {
17
+ const group = asRecord(value);
18
+ return {
19
+ group_id: toNumberId(group.group_id, 'group_id'),
20
+ name: String(group.group_name ?? group.name ?? ''),
21
+ };
22
+ });
23
+ },
24
+ async listGroupMembers(groupId) {
25
+ const data = await endpoint.callApi('get_group_member_list', {
26
+ group_id: toNumberId(groupId, 'group_id'),
27
+ });
28
+ return toArray(data);
29
+ },
30
+ });
31
+ }
32
+ function asRecord(value) {
33
+ return value !== null && typeof value === 'object'
34
+ ? value
35
+ : {};
36
+ }
37
+ function toArray(value) {
38
+ return Array.isArray(value) ? value : [];
39
+ }
40
+ /** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
41
+ function toNumberId(value, label) {
42
+ const n = Number(value);
43
+ if (!Number.isFinite(n) || String(value ?? '').trim() === '') {
44
+ throw new TypeError(`onebot11 ${label} 必须是数字: ${String(value)}`);
45
+ }
46
+ return n;
47
+ }
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { buildSendAction, buildWsConnectOptions, extractQuoteId, formatInboundContent, formatInboundMetadata, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, isOneBot11BotMentioned, parseSendTarget, resolveOneBot11Config, senderDisplayName, senderNickname, senderUserId, type OneBot11ActionRequest, type OneBot11ActionResponse, type OneBot11AdapterConfig, type OneBot11ConfigBase, type OneBot11EndpointConfig, type OneBot11Event, type OneBot11Segment, type OneBot11Sender, type OneBot11WireSegment, type OneBot11WsConfig, type OneBot11WssConfig, type ParsedSendTarget, type ResolvedOneBot11Config, } from './protocol.js';
1
+ export { buildSendAction, buildWsConnectOptions, extractQuoteId, formatInboundContent, formatInboundMetadata, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, isOneBot11BotMentioned, mediaRefToOneBotFile, parseSendTarget, resolveOneBot11Config, senderDisplayName, senderNickname, senderUserId, type OneBot11ActionRequest, type OneBot11ActionResponse, type OneBot11AdapterConfig, type OneBot11ConfigBase, type OneBot11EndpointConfig, type OneBot11Event, type OneBot11Segment, type OneBot11Sender, type OneBot11WireSegment, type OneBot11WsConfig, type OneBot11WssConfig, type ParsedSendTarget, type ResolvedOneBot11Config, } from './protocol.js';
2
2
  export { OneBot11WsEndpoint, type OneBot11WsEndpointOptions, } from './ws-endpoint.js';
3
3
  export { OneBot11WssEndpoint, type OneBot11WssEndpointOptions, } from './wss-endpoint.js';
4
4
  export type { OneBot11WsSocket, OneBot11WsCreateOptions } from './ws-types.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { buildSendAction, buildWsConnectOptions, extractQuoteId, formatInboundContent, formatInboundMetadata, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, isOneBot11BotMentioned, parseSendTarget, resolveOneBot11Config, senderDisplayName, senderNickname, senderUserId, } from './protocol.js';
1
+ export { buildSendAction, buildWsConnectOptions, extractQuoteId, formatInboundContent, formatInboundMetadata, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, isOneBot11BotMentioned, mediaRefToOneBotFile, parseSendTarget, resolveOneBot11Config, senderDisplayName, senderNickname, senderUserId, } from './protocol.js';
2
2
  export { OneBot11WsEndpoint, } from './ws-endpoint.js';
3
3
  export { OneBot11WssEndpoint, } from './wss-endpoint.js';
4
4
  export { callOneBot11WsAction, decodeWsPayload, handleOneBot11WsMessage, rejectAllPending, startOneBot11Heartbeat, } from './ws-transport.js';
@@ -0,0 +1 @@
1
+ export declare const onebot11EndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `onebot11 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ */
5
+ import { createEndpointCommands } from '@zhin.js/adapter';
6
+ import { defineCommand } from '@zhin.js/command';
7
+ import { onebot11RuntimeStateToken } from './onebot11-runtime-state.js';
8
+ export const onebot11EndpointCommands = createEndpointCommands({
9
+ adapterKey: 'onebot11',
10
+ adapterDisplayName: 'OneBot 11',
11
+ fields: [
12
+ { key: 'url', description: 'OneBot 实现 WebSocket URL(connection: ws 必填)' },
13
+ { key: 'path', description: 'reverse-wss 路径(connection: wss)' },
14
+ { key: 'access_token', env: true, description: 'OneBot access token' },
15
+ ],
16
+ running: (use) => use(onebot11RuntimeStateToken).endpoints.values(),
17
+ describeEntry: (entry) => {
18
+ if (entry.url)
19
+ return `url: ${String(entry.url)}`;
20
+ if (entry.path)
21
+ return `path: ${String(entry.path)}`;
22
+ return '';
23
+ },
24
+ }, defineCommand);
@@ -0,0 +1 @@
1
+ export declare const onebot11RuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * OneBot11 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `onebot11 endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+ export const onebot11RuntimeStateToken = defineEndpointRuntimeStateToken('onebot11');
package/lib/protocol.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  * Spec: https://github.com/botuniverse/onebot-11
5
5
  */
6
+ import { type MediaRef } from '@zhin.js/core';
6
7
  /** Transitional legacy endpoint row (`endpoints[]` with `context: onebot11`). */
7
8
  export interface OneBot11LegacyEndpointRow {
8
9
  readonly context?: string;
@@ -127,9 +128,15 @@ export declare function isOneBot11BotMentioned(input: {
127
128
  }): boolean;
128
129
  /** 构造 gateway.receive 的 metadata(ws / wss 两个 endpoint 共用)。 */
129
130
  export declare function formatInboundMetadata(ev: OneBot11Event, endpoint: string): Readonly<Record<string, unknown>>;
131
+ /**
132
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
133
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)。
134
+ */
135
+ export declare function mediaRefToOneBotFile(media: MediaRef): string;
130
136
  /**
131
137
  * Wire-encode an already-rendered outbound payload into OneBot 11 message segments.
132
- * Segment canonicalization is intentionally not done here.
138
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
139
+ * 旧 wire 形状(at / 裸 `{file,url,base64}`)保持兼容透传。
133
140
  */
134
141
  export declare function formatOutboundSegments(payload: unknown): OneBot11Segment[];
135
142
  export declare function buildSendAction(target: string, message: OneBot11Segment[]): {
package/lib/protocol.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  * Spec: https://github.com/botuniverse/onebot-11
5
5
  */
6
+ import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
6
7
  function normalizeConnection(connection, legacyType) {
7
8
  if (connection === 'ws' || connection === 'wss')
8
9
  return connection;
@@ -169,9 +170,74 @@ export function formatInboundMetadata(ev, endpoint) {
169
170
  ...(mentioned ? { mentioned: true } : {}),
170
171
  });
171
172
  }
173
+ /**
174
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
175
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)。
176
+ */
177
+ export function mediaRefToOneBotFile(media) {
178
+ if (media.kind === 'base64') {
179
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
180
+ }
181
+ if (media.kind === 'path') {
182
+ return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
183
+ }
184
+ return media.value;
185
+ }
186
+ /** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
187
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt', 'url', 'base64', 'file', 'mime_type']);
188
+ function oneBotMediaSegment(type, data) {
189
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
190
+ if (!media)
191
+ return { type, data };
192
+ const extra = {};
193
+ for (const [key, value] of Object.entries(data)) {
194
+ if (!MEDIA_DATA_SKIP_KEYS.has(key))
195
+ extra[key] = value;
196
+ }
197
+ return { type, data: { ...extra, file: mediaRefToOneBotFile(media) } };
198
+ }
199
+ /**
200
+ * canonical Segment → OneBot 11 数组段:
201
+ * - mention → at(`qq: target`);
202
+ * - reply(`message_id`)→ reply(`id`);
203
+ * - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file://);
204
+ * - face 取 `id`;
205
+ * - 其余(已是 wire 形状的段、平台扩展段)原样透传。
206
+ */
207
+ function canonicalToOneBotSegment(segment) {
208
+ const data = segment.data ?? {};
209
+ switch (segment.type) {
210
+ case 'mention': {
211
+ const target = data.target ?? data.qq ?? data.id;
212
+ if (target == null)
213
+ return { type: segment.type, data };
214
+ return { type: 'at', data: { qq: String(target) } };
215
+ }
216
+ case 'reply': {
217
+ const messageId = data.message_id ?? data.id;
218
+ if (messageId == null)
219
+ return { type: segment.type, data };
220
+ return { type: 'reply', data: { id: String(messageId) } };
221
+ }
222
+ case 'face': {
223
+ if (data.id == null)
224
+ return { type: segment.type, data };
225
+ return { type: 'face', data: { id: data.id } };
226
+ }
227
+ case 'image':
228
+ case 'record':
229
+ case 'video':
230
+ return oneBotMediaSegment(segment.type, data);
231
+ case 'audio':
232
+ return oneBotMediaSegment('record', data);
233
+ default:
234
+ return { type: segment.type, data };
235
+ }
236
+ }
172
237
  /**
173
238
  * Wire-encode an already-rendered outbound payload into OneBot 11 message segments.
174
- * Segment canonicalization is intentionally not done here.
239
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
240
+ * 旧 wire 形状(at / 裸 `{file,url,base64}`)保持兼容透传。
175
241
  */
176
242
  export function formatOutboundSegments(payload) {
177
243
  if (typeof payload === 'string') {
@@ -196,7 +262,7 @@ export function formatOutboundSegments(payload) {
196
262
  segs.push({ type: 'text', data: { text: item } });
197
263
  continue;
198
264
  }
199
- segs.push({ type: item.type, data: item.data ?? {} });
265
+ segs.push(canonicalToOneBotSegment(item));
200
266
  }
201
267
  return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
202
268
  }
@@ -1,4 +1,4 @@
1
- import type { EndpointInstance } from '@zhin.js/adapter';
1
+ import { type EndpointInstance, type EndpointManagement } from '@zhin.js/adapter';
2
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
3
3
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
4
4
  import { type OneBot11Event, type OneBot11WsConfig } from './protocol.js';
@@ -11,6 +11,7 @@ export interface OneBot11WsEndpointOptions {
11
11
  }
12
12
  export declare class OneBot11WsEndpoint implements EndpointInstance {
13
13
  #private;
14
+ readonly management: EndpointManagement;
14
15
  constructor(options: OneBot11WsEndpointOptions);
15
16
  start(): Promise<void>;
16
17
  open(): void;
@@ -2,37 +2,58 @@
2
2
  * OneBot11 WS client endpoint — outbound connect to OneBot implementation.
3
3
  */
4
4
  import WebSocket from 'ws';
5
- import { clearInterval, clearTimeout, setTimeout } from 'node:timers';
5
+ import { createEndpointLifecycle, } from '@zhin.js/adapter';
6
6
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import { createOneBot11EndpointManagement } from './endpoint-management.js';
7
8
  import { registerOnebot11AgentEndpoint } from './onebot11-agent-deps.js';
8
9
  import { buildSendAction, buildWsConnectOptions, formatInboundContent, formatInboundMetadata, formatInboundTarget, formatOutboundSegments, isMessageEvent, senderUserId, } from './protocol.js';
9
- import { callOneBot11WsAction, handleOneBot11WsMessage, rejectAllPending, startOneBot11Heartbeat, } from './ws-transport.js';
10
+ import { callOneBot11WsAction, handleOneBot11WsMessage, rejectAllPending, } from './ws-transport.js';
10
11
  const logger = getLogger('onebot11');
11
12
  export class OneBot11WsEndpoint {
12
13
  #options;
14
+ management = createOneBot11EndpointManagement(this);
15
+ #lifecycle;
13
16
  #ws;
14
- #reconnectTimer;
15
- #heartbeatTimer;
16
17
  #requestId = { value: 0 };
17
18
  #pending = new Map();
18
19
  #open = false;
19
- #started = false;
20
- #stopping = false;
21
20
  #unregisterAgent;
22
21
  constructor(options) {
23
22
  this.#options = options;
23
+ const { config } = options;
24
+ this.#lifecycle = createEndpointLifecycle({
25
+ name: config.name,
26
+ reconnect: {
27
+ initialIntervalMs: config.reconnect_interval,
28
+ // 固定间隔(multiplier 1、无抖动),对齐旧 reconnect_interval 语义
29
+ multiplier: 1,
30
+ maxIntervalMs: config.reconnect_interval,
31
+ jitterMs: 0,
32
+ },
33
+ heartbeat: { intervalMs: config.heartbeat_interval },
34
+ });
24
35
  }
25
36
  async start() {
26
- if (this.#started)
37
+ if (this.#lifecycle.started)
27
38
  return;
28
- this.#started = true;
29
- this.#stopping = false;
39
+ // agent 注册/反注册是适配器专有依赖,留在适配器侧(见 endpoint-lifecycle 迁移指引)
30
40
  this.#unregisterAgent = registerOnebot11AgentEndpoint(this.#options.config.name, this);
31
41
  try {
32
- await this.#connect();
42
+ await this.#lifecycle.start((handle) => this.#connect(handle));
33
43
  }
34
44
  catch (err) {
35
- this.#started = false;
45
+ // start 失败必须清理现场(状态复位由基座保证),避免 agent 注册泄漏
46
+ if (this.#ws) {
47
+ try {
48
+ this.#ws.close();
49
+ }
50
+ catch {
51
+ /* ignore */
52
+ }
53
+ this.#ws = undefined;
54
+ }
55
+ this.#unregisterAgent?.();
56
+ this.#unregisterAgent = undefined;
36
57
  throw err;
37
58
  }
38
59
  }
@@ -44,29 +65,12 @@ export class OneBot11WsEndpoint {
44
65
  }
45
66
  async stop() {
46
67
  this.#open = false;
47
- this.#stopping = true;
48
- this.#started = false;
68
+ // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
69
+ await this.#lifecycle.stop();
49
70
  this.#unregisterAgent?.();
50
71
  this.#unregisterAgent = undefined;
51
- if (this.#reconnectTimer) {
52
- clearTimeout(this.#reconnectTimer);
53
- this.#reconnectTimer = undefined;
54
- }
55
- if (this.#heartbeatTimer) {
56
- clearInterval(this.#heartbeatTimer);
57
- this.#heartbeatTimer = undefined;
58
- }
59
72
  rejectAllPending(this.#pending);
60
- if (this.#ws) {
61
- try {
62
- this.#ws.close();
63
- }
64
- catch {
65
- /* ignore */
66
- }
67
- this.#ws = undefined;
68
- }
69
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
73
+ this.#ws = undefined;
70
74
  }
71
75
  async send({ target, payload }) {
72
76
  const message = formatOutboundSegments(payload);
@@ -115,7 +119,7 @@ export class OneBot11WsEndpoint {
115
119
  }));
116
120
  });
117
121
  }
118
- async #connect() {
122
+ async #connect(handle) {
119
123
  const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
120
124
  const create = this.#options.createWebSocket
121
125
  ?? ((connectUrl, options) => new WebSocket(connectUrl, { headers: options.headers }));
@@ -123,6 +127,14 @@ export class OneBot11WsEndpoint {
123
127
  let settled = false;
124
128
  const ws = create(url, { headers });
125
129
  this.#ws = ws;
130
+ handle.onForceClose(() => {
131
+ try {
132
+ ws.close();
133
+ }
134
+ catch {
135
+ /* ignore */
136
+ }
137
+ });
126
138
  ws.on('open', () => {
127
139
  if (settled)
128
140
  return;
@@ -139,9 +151,7 @@ export class OneBot11WsEndpoint {
139
151
  mode: 'ws',
140
152
  url: safeUrl,
141
153
  }));
142
- this.#heartbeatTimer = this.#stopping
143
- ? this.#heartbeatTimer
144
- : startOneBot11Heartbeat(this.#ws, this.#options.config.heartbeat_interval, this.#heartbeatTimer);
154
+ this.#lifecycle.startHeartbeat(() => this.#ws?.ping?.());
145
155
  resolve();
146
156
  });
147
157
  ws.on('message', (data) => {
@@ -163,18 +173,13 @@ export class OneBot11WsEndpoint {
163
173
  : codeNum === 1006
164
174
  ? ' [异常关闭]'
165
175
  : '';
166
- logger.warn(formatCompact({
167
- op: 'disconnect',
168
- endpoint: this.#options.config.name,
169
- code: codeNum,
170
- error: `${reasonStr || 'closed'}${codeHint}`,
171
- reconnect_ms: this.#options.config.reconnect_interval,
172
- }));
173
176
  if (!settled) {
174
177
  settled = true;
175
178
  reject(new Error(`OneBot11 WS 关闭: ${codeNum} ${reasonStr}`));
176
179
  }
177
- this.#scheduleReconnect();
180
+ // 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
181
+ // 初始连接失败由 start() 的拒绝路径复位,不产生僵尸重连。
182
+ handle.notifyClosed(`OneBot11 WS 关闭: ${codeNum} ${reasonStr || 'closed'}${codeHint}`);
178
183
  });
179
184
  ws.on('error', (err) => {
180
185
  const error = err instanceof Error ? err : new Error(String(err));
@@ -191,20 +196,4 @@ export class OneBot11WsEndpoint {
191
196
  });
192
197
  });
193
198
  }
194
- #scheduleReconnect() {
195
- if (this.#stopping || !this.#started || this.#reconnectTimer)
196
- return;
197
- const delay = this.#options.config.reconnect_interval;
198
- this.#reconnectTimer = setTimeout(() => {
199
- this.#reconnectTimer = undefined;
200
- void this.#connect().catch((err) => {
201
- logger.warn(formatCompact({
202
- op: 'reconnect',
203
- endpoint: this.#options.config.name,
204
- ok: false,
205
- error: err instanceof Error ? err.message : String(err),
206
- }));
207
- });
208
- }, delay);
209
- }
210
199
  }
@@ -1,4 +1,4 @@
1
- import type { EndpointInstance } from '@zhin.js/adapter';
1
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
2
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
@@ -11,6 +11,7 @@ export interface OneBot11WssEndpointOptions {
11
11
  }
12
12
  export declare class OneBot11WssEndpoint implements EndpointInstance {
13
13
  #private;
14
+ readonly management: EndpointManagement;
14
15
  constructor(options: OneBot11WssEndpointOptions);
15
16
  start(): Promise<void>;
16
17
  open(): void;
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { clearInterval } from 'node:timers';
5
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
6
+ import { createOneBot11EndpointManagement } from './endpoint-management.js';
6
7
  import { registerOnebot11AgentEndpoint } from './onebot11-agent-deps.js';
7
8
  import { buildSendAction, formatInboundContent, formatInboundMetadata, formatInboundTarget, formatOutboundSegments, isMessageEvent, senderUserId, } from './protocol.js';
8
9
  import { verifyOneBotAccessToken } from './wss-auth.js';
@@ -10,6 +11,7 @@ import { callOneBot11WsAction, handleOneBot11WsMessage, rejectAllPending, startO
10
11
  const logger = getLogger('onebot11');
11
12
  export class OneBot11WssEndpoint {
12
13
  #options;
14
+ management = createOneBot11EndpointManagement(this);
13
15
  #ws;
14
16
  #wsRelease;
15
17
  #heartbeatTimer;
@@ -25,6 +27,15 @@ export class OneBot11WssEndpoint {
25
27
  if (this.#started)
26
28
  return;
27
29
  this.#started = true;
30
+ if (!this.#options.config.access_token) {
31
+ // wss 模式未配 access_token 时任何连接都会被放行(verifyOneBotAccessToken 直接 return true)
32
+ logger.warn(formatCompact({
33
+ endpoint: this.#options.config.name,
34
+ mode: 'wss',
35
+ ok: false,
36
+ error: 'missing access_token',
37
+ }));
38
+ }
28
39
  this.#unregisterAgent = registerOnebot11AgentEndpoint(this.#options.config.name, this);
29
40
  const handle = this.#options.http.ws(this.#options.config.path);
30
41
  this.#wsRelease = handle.onConnection((connection) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-onebot11",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "Zhin.js OneBot 11 adapter for Plugin Runtime (WebSocket client)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "adapters",
17
+ "commands",
17
18
  "plugin.ts",
18
19
  "schema.json",
19
20
  "src",
@@ -40,11 +41,12 @@
40
41
  },
41
42
  "dependencies": {
42
43
  "ws": "^8.21.0",
43
- "@zhin.js/adapter": "1.1.0",
44
- "@zhin.js/core": "1.4.0",
45
- "@zhin.js/host-http": "1.0.2",
46
- "@zhin.js/plugin-runtime": "1.1.0",
47
- "@zhin.js/logger": "1.0.75"
44
+ "@zhin.js/adapter": "1.1.1",
45
+ "@zhin.js/command": "1.0.3",
46
+ "@zhin.js/core": "1.4.1",
47
+ "@zhin.js/host-http": "1.0.3",
48
+ "@zhin.js/logger": "1.0.75",
49
+ "@zhin.js/plugin-runtime": "1.1.1"
48
50
  },
49
51
  "devDependencies": {
50
52
  "@types/node": "^26.1.0",
@@ -52,16 +54,16 @@
52
54
  "typescript": "^6.0.3",
53
55
  "vitest": "^4.1.10",
54
56
  "zod": "^4.4.3",
55
- "@zhin.js/agent": "1.0.5",
56
- "@zhin.js/host-http": "1.0.2"
57
+ "@zhin.js/agent": "1.0.6",
58
+ "@zhin.js/host-http": "1.0.3"
57
59
  },
58
60
  "peerDependencies": {
59
61
  "zod": "^4.0.0",
60
- "@zhin.js/adapter": "1.1.0",
61
- "@zhin.js/core": "1.4.0",
62
- "@zhin.js/plugin-runtime": "1.1.0",
63
- "zhin.js": "5.0.0",
64
- "@zhin.js/agent": "1.0.5"
62
+ "@zhin.js/adapter": "1.1.1",
63
+ "@zhin.js/core": "1.4.1",
64
+ "@zhin.js/plugin-runtime": "1.1.1",
65
+ "zhin.js": "5.0.1",
66
+ "@zhin.js/agent": "1.0.6"
65
67
  },
66
68
  "peerDependenciesMeta": {
67
69
  "zhin.js": {
@@ -96,6 +98,10 @@
96
98
  {
97
99
  "package": "@zhin.js/adapter",
98
100
  "api": "^1.0.0"
101
+ },
102
+ {
103
+ "package": "@zhin.js/command",
104
+ "api": "^1.0.0"
99
105
  }
100
106
  ],
101
107
  "plugins": []
package/plugin.ts CHANGED
@@ -1,12 +1,16 @@
1
+ import { createEndpointRuntimeState } from '@zhin.js/adapter';
1
2
  import { definePlugin } from '@zhin.js/plugin-runtime';
2
3
  import { registerDefaultScenePlatformPermitChecker } from '@zhin.js/core';
4
+ import { onebot11RuntimeStateToken } from './src/onebot11-runtime-state.js';
3
5
 
4
6
  export default definePlugin({
5
7
  name: 'onebot11',
6
8
  metadata: {
7
9
  displayName: 'OneBot 11 Adapter',
8
10
  },
9
- setup() {
11
+ setup(context) {
12
+ // 运行中 endpoint 注册表(onebot11 endpoint list 的"运行中"数据源)
13
+ context.resources.provide(onebot11RuntimeStateToken, createEndpointRuntimeState());
10
14
  // 平台权限门禁:scene_admin / scene_owner 由 sender role 判定(见 ws/wss-endpoint admit metadata)
11
15
  return registerDefaultScenePlatformPermitChecker('onebot11');
12
16
  },
@@ -0,0 +1,72 @@
1
+ /**
2
+ * OneBot11 endpoint management 语义端口(Console 社交面 RPC 消费,见
3
+ * packages/im/adapter/src/endpoint-management.ts)。
4
+ *
5
+ * 归一化取舍:
6
+ * - friend → {user_id:number, nickname, remark: remark ?? ''}
7
+ * - group → {group_id:number, name: group_name ?? name}
8
+ * - 群成员列表保持 OneBot11 原生形状,仅保证数组
9
+ */
10
+ import type {
11
+ EndpointFriend,
12
+ EndpointGroup,
13
+ EndpointManagement,
14
+ } from '@zhin.js/adapter';
15
+
16
+ /** 管理面只依赖 endpoint 的 callApi(ws/wss 两种传输各自实现)。 */
17
+ export interface OneBot11ManagementCaller {
18
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
19
+ }
20
+
21
+ export function createOneBot11EndpointManagement(
22
+ endpoint: OneBot11ManagementCaller,
23
+ ): EndpointManagement {
24
+ return Object.freeze<EndpointManagement>({
25
+ async listFriends(): Promise<readonly EndpointFriend[]> {
26
+ const data = await endpoint.callApi('get_friend_list');
27
+ return toArray(data).map((value) => {
28
+ const friend = asRecord(value);
29
+ return {
30
+ user_id: toNumberId(friend.user_id, 'user_id'),
31
+ nickname: String(friend.nickname ?? ''),
32
+ remark: String(friend.remark ?? ''),
33
+ };
34
+ });
35
+ },
36
+ async listGroups(): Promise<readonly EndpointGroup[]> {
37
+ const data = await endpoint.callApi('get_group_list');
38
+ return toArray(data).map((value) => {
39
+ const group = asRecord(value);
40
+ return {
41
+ group_id: toNumberId(group.group_id, 'group_id'),
42
+ name: String(group.group_name ?? group.name ?? ''),
43
+ };
44
+ });
45
+ },
46
+ async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
47
+ const data = await endpoint.callApi('get_group_member_list', {
48
+ group_id: toNumberId(groupId, 'group_id'),
49
+ });
50
+ return toArray(data);
51
+ },
52
+ });
53
+ }
54
+
55
+ function asRecord(value: unknown): Record<string, unknown> {
56
+ return value !== null && typeof value === 'object'
57
+ ? value as Record<string, unknown>
58
+ : {};
59
+ }
60
+
61
+ function toArray(value: unknown): readonly unknown[] {
62
+ return Array.isArray(value) ? value : [];
63
+ }
64
+
65
+ /** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
66
+ function toNumberId(value: unknown, label: string): number {
67
+ const n = Number(value);
68
+ if (!Number.isFinite(n) || String(value ?? '').trim() === '') {
69
+ throw new TypeError(`onebot11 ${label} 必须是数字: ${String(value)}`);
70
+ }
71
+ return n;
72
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  getChannelId,
10
10
  isMessageEvent,
11
11
  isOneBot11BotMentioned,
12
+ mediaRefToOneBotFile,
12
13
  parseSendTarget,
13
14
  resolveOneBot11Config,
14
15
  senderDisplayName,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `onebot11 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ */
5
+ import { createEndpointCommands } from '@zhin.js/adapter';
6
+ import { defineCommand } from '@zhin.js/command';
7
+ import { onebot11RuntimeStateToken } from './onebot11-runtime-state.js';
8
+
9
+ export const onebot11EndpointCommands = createEndpointCommands({
10
+ adapterKey: 'onebot11',
11
+ adapterDisplayName: 'OneBot 11',
12
+ fields: [
13
+ { key: 'url', description: 'OneBot 实现 WebSocket URL(connection: ws 必填)' },
14
+ { key: 'path', description: 'reverse-wss 路径(connection: wss)' },
15
+ { key: 'access_token', env: true, description: 'OneBot access token' },
16
+ ],
17
+ running: (use) => use(onebot11RuntimeStateToken).endpoints.values(),
18
+ describeEntry: (entry) => {
19
+ if (entry.url) return `url: ${String(entry.url)}`;
20
+ if (entry.path) return `path: ${String(entry.path)}`;
21
+ return '';
22
+ },
23
+ }, defineCommand);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * OneBot11 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `onebot11 endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const onebot11RuntimeStateToken = defineEndpointRuntimeStateToken('onebot11');
package/src/protocol.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  * Spec: https://github.com/botuniverse/onebot-11
5
5
  */
6
+ import { isMediaRef, mediaRefFromLegacyData, type MediaRef } from '@zhin.js/core';
6
7
 
7
8
  /** Transitional legacy endpoint row (`endpoints[]` with `context: onebot11`). */
8
9
  export interface OneBot11LegacyEndpointRow {
@@ -292,9 +293,76 @@ export function formatInboundMetadata(
292
293
  });
293
294
  }
294
295
 
296
+ /**
297
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
298
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)。
299
+ */
300
+ export function mediaRefToOneBotFile(media: MediaRef): string {
301
+ if (media.kind === 'base64') {
302
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
303
+ }
304
+ if (media.kind === 'path') {
305
+ return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
306
+ }
307
+ return media.value;
308
+ }
309
+
310
+ /** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
311
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt', 'url', 'base64', 'file', 'mime_type']);
312
+
313
+ function oneBotMediaSegment(
314
+ type: 'image' | 'record' | 'video',
315
+ data: Record<string, unknown>,
316
+ ): OneBot11Segment {
317
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
318
+ if (!media) return { type, data };
319
+ const extra: Record<string, unknown> = {};
320
+ for (const [key, value] of Object.entries(data)) {
321
+ if (!MEDIA_DATA_SKIP_KEYS.has(key)) extra[key] = value;
322
+ }
323
+ return { type, data: { ...extra, file: mediaRefToOneBotFile(media) } };
324
+ }
325
+
326
+ /**
327
+ * canonical Segment → OneBot 11 数组段:
328
+ * - mention → at(`qq: target`);
329
+ * - reply(`message_id`)→ reply(`id`);
330
+ * - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file://);
331
+ * - face 取 `id`;
332
+ * - 其余(已是 wire 形状的段、平台扩展段)原样透传。
333
+ */
334
+ function canonicalToOneBotSegment(segment: OneBot11WireSegment): OneBot11Segment {
335
+ const data = segment.data ?? {};
336
+ switch (segment.type) {
337
+ case 'mention': {
338
+ const target = data.target ?? data.qq ?? data.id;
339
+ if (target == null) return { type: segment.type, data };
340
+ return { type: 'at', data: { qq: String(target) } };
341
+ }
342
+ case 'reply': {
343
+ const messageId = data.message_id ?? data.id;
344
+ if (messageId == null) return { type: segment.type, data };
345
+ return { type: 'reply', data: { id: String(messageId) } };
346
+ }
347
+ case 'face': {
348
+ if (data.id == null) return { type: segment.type, data };
349
+ return { type: 'face', data: { id: data.id } };
350
+ }
351
+ case 'image':
352
+ case 'record':
353
+ case 'video':
354
+ return oneBotMediaSegment(segment.type, data);
355
+ case 'audio':
356
+ return oneBotMediaSegment('record', data);
357
+ default:
358
+ return { type: segment.type, data };
359
+ }
360
+ }
361
+
295
362
  /**
296
363
  * Wire-encode an already-rendered outbound payload into OneBot 11 message segments.
297
- * Segment canonicalization is intentionally not done here.
364
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
365
+ * 旧 wire 形状(at / 裸 `{file,url,base64}`)保持兼容透传。
298
366
  */
299
367
  export function formatOutboundSegments(payload: unknown): OneBot11Segment[] {
300
368
  if (typeof payload === 'string') {
@@ -322,7 +390,7 @@ export function formatOutboundSegments(payload: unknown): OneBot11Segment[] {
322
390
  segs.push({ type: 'text', data: { text: item } });
323
391
  continue;
324
392
  }
325
- segs.push({ type: item.type, data: item.data ?? {} });
393
+ segs.push(canonicalToOneBotSegment(item));
326
394
  }
327
395
  return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
328
396
  }
@@ -2,11 +2,17 @@
2
2
  * OneBot11 WS client endpoint — outbound connect to OneBot implementation.
3
3
  */
4
4
  import WebSocket from 'ws';
5
- import { clearInterval, clearTimeout, setTimeout } from 'node:timers';
6
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import {
6
+ createEndpointLifecycle,
7
+ type EndpointConnectHandle,
8
+ type EndpointInstance,
9
+ type EndpointLifecycle,
10
+ type EndpointManagement,
11
+ } from '@zhin.js/adapter';
7
12
  import type { MessageGateway } from '@zhin.js/core/runtime';
8
13
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
14
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
15
+ import { createOneBot11EndpointManagement } from './endpoint-management.js';
10
16
  import { registerOnebot11AgentEndpoint } from './onebot11-agent-deps.js';
11
17
  import {
12
18
  buildSendAction,
@@ -24,7 +30,6 @@ import {
24
30
  callOneBot11WsAction,
25
31
  handleOneBot11WsMessage,
26
32
  rejectAllPending,
27
- startOneBot11Heartbeat,
28
33
  } from './ws-transport.js';
29
34
  import {
30
35
  type OneBot11PendingAction,
@@ -46,29 +51,48 @@ export interface OneBot11WsEndpointOptions {
46
51
 
47
52
  export class OneBot11WsEndpoint implements EndpointInstance {
48
53
  readonly #options: OneBot11WsEndpointOptions;
54
+ readonly management: EndpointManagement = createOneBot11EndpointManagement(this);
55
+ readonly #lifecycle: EndpointLifecycle;
49
56
  #ws?: OneBot11WsSocket;
50
- #reconnectTimer?: NodeJS.Timeout;
51
- #heartbeatTimer?: NodeJS.Timeout;
52
57
  #requestId = { value: 0 };
53
58
  #pending = new Map<string, OneBot11PendingAction>();
54
59
  #open = false;
55
- #started = false;
56
- #stopping = false;
57
60
  #unregisterAgent?: () => void;
58
61
 
59
62
  constructor(options: OneBot11WsEndpointOptions) {
60
63
  this.#options = options;
64
+ const { config } = options;
65
+ this.#lifecycle = createEndpointLifecycle({
66
+ name: config.name,
67
+ reconnect: {
68
+ initialIntervalMs: config.reconnect_interval,
69
+ // 固定间隔(multiplier 1、无抖动),对齐旧 reconnect_interval 语义
70
+ multiplier: 1,
71
+ maxIntervalMs: config.reconnect_interval,
72
+ jitterMs: 0,
73
+ },
74
+ heartbeat: { intervalMs: config.heartbeat_interval },
75
+ });
61
76
  }
62
77
 
63
78
  async start(): Promise<void> {
64
- if (this.#started) return;
65
- this.#started = true;
66
- this.#stopping = false;
79
+ if (this.#lifecycle.started) return;
80
+ // agent 注册/反注册是适配器专有依赖,留在适配器侧(见 endpoint-lifecycle 迁移指引)
67
81
  this.#unregisterAgent = registerOnebot11AgentEndpoint(this.#options.config.name, this);
68
82
  try {
69
- await this.#connect();
83
+ await this.#lifecycle.start((handle) => this.#connect(handle));
70
84
  } catch (err) {
71
- this.#started = false;
85
+ // start 失败必须清理现场(状态复位由基座保证),避免 agent 注册泄漏
86
+ if (this.#ws) {
87
+ try {
88
+ this.#ws.close();
89
+ } catch {
90
+ /* ignore */
91
+ }
92
+ this.#ws = undefined;
93
+ }
94
+ this.#unregisterAgent?.();
95
+ this.#unregisterAgent = undefined;
72
96
  throw err;
73
97
  }
74
98
  }
@@ -83,28 +107,12 @@ export class OneBot11WsEndpoint implements EndpointInstance {
83
107
 
84
108
  async stop(): Promise<void> {
85
109
  this.#open = false;
86
- this.#stopping = true;
87
- this.#started = false;
110
+ // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
111
+ await this.#lifecycle.stop();
88
112
  this.#unregisterAgent?.();
89
113
  this.#unregisterAgent = undefined;
90
- if (this.#reconnectTimer) {
91
- clearTimeout(this.#reconnectTimer);
92
- this.#reconnectTimer = undefined;
93
- }
94
- if (this.#heartbeatTimer) {
95
- clearInterval(this.#heartbeatTimer);
96
- this.#heartbeatTimer = undefined;
97
- }
98
114
  rejectAllPending(this.#pending);
99
- if (this.#ws) {
100
- try {
101
- this.#ws.close();
102
- } catch {
103
- /* ignore */
104
- }
105
- this.#ws = undefined;
106
- }
107
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
115
+ this.#ws = undefined;
108
116
  }
109
117
 
110
118
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
@@ -162,7 +170,7 @@ export class OneBot11WsEndpoint implements EndpointInstance {
162
170
  });
163
171
  }
164
172
 
165
- async #connect(): Promise<void> {
173
+ async #connect(handle: EndpointConnectHandle): Promise<void> {
166
174
  const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
167
175
  const create = this.#options.createWebSocket
168
176
  ?? ((connectUrl: string, options: OneBot11WsCreateOptions) =>
@@ -172,6 +180,13 @@ export class OneBot11WsEndpoint implements EndpointInstance {
172
180
  let settled = false;
173
181
  const ws = create(url, { headers });
174
182
  this.#ws = ws;
183
+ handle.onForceClose(() => {
184
+ try {
185
+ ws.close();
186
+ } catch {
187
+ /* ignore */
188
+ }
189
+ });
175
190
 
176
191
  ws.on('open', () => {
177
192
  if (settled) return;
@@ -188,13 +203,7 @@ export class OneBot11WsEndpoint implements EndpointInstance {
188
203
  mode: 'ws',
189
204
  url: safeUrl,
190
205
  }));
191
- this.#heartbeatTimer = this.#stopping
192
- ? this.#heartbeatTimer
193
- : startOneBot11Heartbeat(
194
- this.#ws,
195
- this.#options.config.heartbeat_interval,
196
- this.#heartbeatTimer,
197
- );
206
+ this.#lifecycle.startHeartbeat(() => this.#ws?.ping?.());
198
207
  resolve();
199
208
  });
200
209
 
@@ -218,18 +227,13 @@ export class OneBot11WsEndpoint implements EndpointInstance {
218
227
  : codeNum === 1006
219
228
  ? ' [异常关闭]'
220
229
  : '';
221
- logger.warn(formatCompact({
222
- op: 'disconnect',
223
- endpoint: this.#options.config.name,
224
- code: codeNum,
225
- error: `${reasonStr || 'closed'}${codeHint}`,
226
- reconnect_ms: this.#options.config.reconnect_interval,
227
- }));
228
230
  if (!settled) {
229
231
  settled = true;
230
232
  reject(new Error(`OneBot11 WS 关闭: ${codeNum} ${reasonStr}`));
231
233
  }
232
- this.#scheduleReconnect();
234
+ // 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
235
+ // 初始连接失败由 start() 的拒绝路径复位,不产生僵尸重连。
236
+ handle.notifyClosed(`OneBot11 WS 关闭: ${codeNum} ${reasonStr || 'closed'}${codeHint}`);
233
237
  });
234
238
 
235
239
  ws.on('error', (err) => {
@@ -247,20 +251,4 @@ export class OneBot11WsEndpoint implements EndpointInstance {
247
251
  });
248
252
  });
249
253
  }
250
-
251
- #scheduleReconnect(): void {
252
- if (this.#stopping || !this.#started || this.#reconnectTimer) return;
253
- const delay = this.#options.config.reconnect_interval;
254
- this.#reconnectTimer = setTimeout(() => {
255
- this.#reconnectTimer = undefined;
256
- void this.#connect().catch((err) => {
257
- logger.warn(formatCompact({
258
- op: 'reconnect',
259
- endpoint: this.#options.config.name,
260
- ok: false,
261
- error: err instanceof Error ? err.message : String(err),
262
- }));
263
- });
264
- }, delay);
265
- }
266
254
  }
@@ -2,11 +2,12 @@
2
2
  * OneBot11 reverse WSS endpoint — accepts inbound WebSocket from OneBot implementation.
3
3
  */
4
4
  import { clearInterval } from 'node:timers';
5
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
6
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, WsConnection } from '@zhin.js/host-http';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
9
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import { createOneBot11EndpointManagement } from './endpoint-management.js';
10
11
  import { registerOnebot11AgentEndpoint } from './onebot11-agent-deps.js';
11
12
  import {
12
13
  buildSendAction,
@@ -43,6 +44,7 @@ export interface OneBot11WssEndpointOptions {
43
44
 
44
45
  export class OneBot11WssEndpoint implements EndpointInstance {
45
46
  readonly #options: OneBot11WssEndpointOptions;
47
+ readonly management: EndpointManagement = createOneBot11EndpointManagement(this);
46
48
  #ws?: OneBot11WsSocket;
47
49
  #wsRelease?: () => void;
48
50
  #heartbeatTimer?: NodeJS.Timeout;
@@ -59,6 +61,15 @@ export class OneBot11WssEndpoint implements EndpointInstance {
59
61
  async start(): Promise<void> {
60
62
  if (this.#started) return;
61
63
  this.#started = true;
64
+ if (!this.#options.config.access_token) {
65
+ // wss 模式未配 access_token 时任何连接都会被放行(verifyOneBotAccessToken 直接 return true)
66
+ logger.warn(formatCompact({
67
+ endpoint: this.#options.config.name,
68
+ mode: 'wss',
69
+ ok: false,
70
+ error: 'missing access_token',
71
+ }));
72
+ }
62
73
  this.#unregisterAgent = registerOnebot11AgentEndpoint(
63
74
  this.#options.config.name,
64
75
  this as unknown as OneBot11WsEndpoint,