@zhin.js/adapter-napcat 4.0.1 → 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,76 @@
1
1
  # @zhin.js/adapter-napcat
2
2
 
3
+ ## 5.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - cdf64e7: 多方向审计修复批(8 面 30+ bug):
8
+
9
+ - **安全**:钉钉 webhook 验签绕过修复(缺 timestamp/sign 一律 403 + ±1h 防重放);exec-policy fail-closed(换行/`$(`/反引号拒绝、管道逐段过白名单、env dump 复合拆段);wecom 验签改 timingSafeEqual;config:set 拒绝 `__proto__` 等魔术键、host 键优先防覆写。
10
+ - **P0 功能**:`zhin packages` scoped 包名解析为空导致 rm -rf 风险;命令数字参数解析失败炸断消息链路(dispatch 捕获 continue);TaskQueue 监听器首个事件自摘除导致 assistant 队列挂死(+超时后完成不覆盖终态);DatabaseHost 跨世代共享崩溃(define 幂等 + stop 改进程级);rss 按不存在 id 列删除改业务键。
11
+ - **Runtime/HMR**:native watcher 过滤忽略目录(lib/.zhin 不再触发重载风暴);host 段配置 patch(http.port 等)存在 installResources 时全量重建;capability 目录非 entry 支持文件升级进程重启;documentTransaction 失败回滚。
12
+ - **CLI 写侧**:config/setup 对 toml 静默假成功改统一 config-file(不支持格式报错);doctor/onboard 默认配置改新形态 plugins 映射;schedule add 改 --prompt;migrate engines/中文模板误伤/覆盖前备份。
13
+ - **适配器生命周期**:napcat/milky start 失败竞态与僵尸连接、心跳 close 清理、stop-during-connect settle;slack webhook 二次 writeHead + messageChannelMap LRU。
14
+ - **Host/向导**:logs/stats 与 inbox 查询改 DB 侧 count/orderBy/limit 下推;save-yaml 写入前校验;zhipu/moonshot baseUrl 必填预填;.env 写入转义 + 幂等合并;setup 数据库密码不再明文落 config;60s fetch 超时与 JSON 守卫。
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
+
53
+ ## 5.0.0
54
+
55
+ ### Patch Changes
56
+
57
+ - 7db69c1: 命令前缀改为适配器配置项:`MessageDispatcher` 不再硬编码 `/`,默认按消息所属适配器实例 config 的 `commandPrefix` 解析(默认 `''` 无前缀,任意文本按命令匹配),`endpoints[i].commandPrefix` 逐项覆盖;`ImRuntime({ commandPrefix })` 仍可设全局静态前缀。全部 20 个平台适配器 schema 新增 `commandPrefix` 属性。
58
+
59
+ BREAKING(行为变化):未配置时命令不再需要 `/` 前缀——原 `/zt` 写法不再命中,直接发 `zt` 即可;需要斜杠风格的适配器请在配置里显式设 `commandPrefix: '/'`。
60
+
61
+ - 713445c: 适配器配置格式定稿(不兼容旧格式):`plugins.<adapter>` 顶层仅共享字段 + `commandPrefix`,`endpoints[i]` 携带 endpoint 级字段(`name` + 凭据,各 schema 已类型化),`endpoints` 为必填(icqq 另需顶层 `master`);icqq 新增 `trusted` 列表(顶层/逐项均可)。scaffold-wizard 全部字段式与自定义 configure() 产出改为新格式,examples(full-bot / qq-games-bot)与 20 个适配器 README 同步迁移。
62
+ - Updated dependencies [7db69c1]
63
+ - Updated dependencies [e5c84ed]
64
+ - Updated dependencies [3ea84a0]
65
+ - Updated dependencies [1ddcd70]
66
+ - Updated dependencies [ac9da66]
67
+ - @zhin.js/core@1.4.0
68
+ - @zhin.js/adapter@1.1.0
69
+ - @zhin.js/plugin-runtime@1.1.0
70
+ - @zhin.js/agent@1.0.5
71
+ - @zhin.js/host-http@1.0.2
72
+ - zhin.js@5.0.0
73
+
3
74
  ## 4.0.1
4
75
 
5
76
  ### Patch Changes
package/README.md CHANGED
@@ -34,11 +34,12 @@ pnpm add @zhin.js/adapter-napcat
34
34
  plugins:
35
35
  napcat:
36
36
  connection: ws
37
- name: my-bot
38
- url: "ws://127.0.0.1:3001"
39
- access_token: "${NAPCAT_TOKEN}"
40
37
  reconnect_interval: 5000
41
38
  heartbeat_interval: 30000
39
+ endpoints:
40
+ - name: my-bot
41
+ url: "ws://127.0.0.1:3001"
42
+ access_token: "${NAPCAT_TOKEN}"
42
43
  ```
43
44
 
44
45
  根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-napcat`(`instanceKey: napcat`)。
@@ -8,6 +8,7 @@ import { NapCatHttpEndpoint } from '../src/http-endpoint.js';
8
8
  import { resolveNapCatConfig, type NapCatAdapterConfig } from '../src/protocol.js';
9
9
  import { NapCatWsEndpoint } from '../src/ws-endpoint.js';
10
10
  import { NapCatWssEndpoint } from '../src/wss-endpoint.js';
11
+ import { napcatRuntimeStateToken } from '../src/napcat-runtime-state.js';
11
12
 
12
13
  export {
13
14
  NapCatHttpEndpoint,
@@ -25,9 +26,19 @@ export type { NapCatWsSocket, NapCatWsCreateOptions } from '../src/ws-types.js';
25
26
 
26
27
  export default defineAdapter<NapCatAdapterConfig>({
27
28
  capabilities: ['inbound', 'outbound'],
29
+ // OneBot file 参数原生消费 url / base64:// 媒体;无卡片交互面,交互段降级纯文本。
30
+ segments: {
31
+ outboundMedia: ['url', 'base64'],
32
+ interactive: 'text',
33
+ },
28
34
  create(context) {
29
35
  const config = resolveNapCatConfig(context.config);
30
36
  const gateway = context.use(messageGatewayToken);
37
+ // 注册到插件运行时状态(napcat endpoint list 的"运行中"数据源)
38
+ context.use(napcatRuntimeStateToken).endpoints.set(config.name, {
39
+ name: config.name,
40
+ mode: config.connection,
41
+ });
31
42
  if (config.connection === 'wss') {
32
43
  return new NapCatWssEndpoint({ id: context.id, gateway, http: context.use(httpHostToken), config });
33
44
  }
@@ -0,0 +1,3 @@
1
+ import { napcatEndpointCommands } from '../../../src/napcat-endpoint-commands.js';
2
+
3
+ export default napcatEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { napcatEndpointCommands } from '../../src/napcat-endpoint-commands.js';
2
+
3
+ export default napcatEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { napcatEndpointCommands } from '../../../src/napcat-endpoint-commands.js';
2
+
3
+ export default napcatEndpointCommands.remove;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * NapCat 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/http 三种传输各自实现)。 */
12
+ export interface NapCatManagementCaller {
13
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
14
+ }
15
+ export declare function createNapCatEndpointManagement(endpoint: NapCatManagementCaller): EndpointManagement;
@@ -0,0 +1,47 @@
1
+ export function createNapCatEndpointManagement(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(`napcat ${label} 必须是数字: ${String(value)}`);
45
+ }
46
+ return n;
47
+ }
@@ -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';
@@ -12,6 +12,7 @@ export interface NapCatHttpEndpointOptions {
12
12
  }
13
13
  export declare class NapCatHttpEndpoint implements EndpointInstance {
14
14
  #private;
15
+ readonly management: EndpointManagement;
15
16
  constructor(options: NapCatHttpEndpointOptions);
16
17
  start(): Promise<void>;
17
18
  open(): void;
@@ -1,4 +1,5 @@
1
1
  import { formatCompact, getLogger } from '@zhin.js/logger';
2
+ import { createNapCatEndpointManagement } from './endpoint-management.js';
2
3
  import { registerNapcatAgentEndpoint } from './napcat-agent-deps.js';
3
4
  import { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
4
5
  import { buildSendAction, callNapCatHttpAction, formatInboundContent, formatInboundTarget, formatOutboundSegments, isMessageEvent, senderNickname, senderUserId, } from './protocol.js';
@@ -8,6 +9,7 @@ const logger = getLogger('napcat');
8
9
  export class NapCatHttpEndpoint {
9
10
  #options;
10
11
  #inboundDeduper = new InboundMessageDeduper();
12
+ management = createNapCatEndpointManagement(this);
11
13
  #callHttpAction;
12
14
  #routeReleases = [];
13
15
  #open = false;
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { buildSendAction, buildWsConnectOptions, callNapCatHttpAction, formatInboundContent, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, parseSendTarget, resolveNapCatConfig, senderNickname, senderUserId, type MessageSegment, type NapCatActionRequest, type NapCatActionResponse, type NapCatAdapterConfig, type NapCatConfigBase, type NapCatEndpointConfig, type NapCatEvent, type NapCatHttpConfig, type NapCatMessageEvent, type NapCatSender, type NapCatWireSegment, type NapCatWsConfig, type NapCatWssConfig, type ParsedSendTarget, type ResolvedNapCatConfig, } from './protocol.js';
1
+ export { buildSendAction, buildWsConnectOptions, callNapCatHttpAction, formatInboundContent, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, mediaRefToOneBotFile, parseSendTarget, resolveNapCatConfig, senderNickname, senderUserId, type MessageSegment, type NapCatActionRequest, type NapCatActionResponse, type NapCatAdapterConfig, type NapCatConfigBase, type NapCatEndpointConfig, type NapCatEvent, type NapCatHttpConfig, type NapCatMessageEvent, type NapCatSender, type NapCatWireSegment, type NapCatWsConfig, type NapCatWssConfig, type ParsedSendTarget, type ResolvedNapCatConfig, } from './protocol.js';
2
2
  export { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
3
3
  export { getEndpoint, getNapcatAgentDeps, registerNapcatAgentEndpoint, setNapcatAgentDeps, type NapcatAgentDeps, type NapcatAgentEndpoint, } from './napcat-agent-deps.js';
4
4
  export { parseOneBotGetMsgResponse } from './onebot-get-msg.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { buildSendAction, buildWsConnectOptions, callNapCatHttpAction, formatInboundContent, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, parseSendTarget, resolveNapCatConfig, senderNickname, senderUserId, } from './protocol.js';
1
+ export { buildSendAction, buildWsConnectOptions, callNapCatHttpAction, formatInboundContent, formatInboundTarget, formatOutboundSegments, getChannelId, isMessageEvent, mediaRefToOneBotFile, parseSendTarget, resolveNapCatConfig, senderNickname, senderUserId, } from './protocol.js';
2
2
  export { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
3
3
  export { getEndpoint, getNapcatAgentDeps, registerNapcatAgentEndpoint, setNapcatAgentDeps, } from './napcat-agent-deps.js';
4
4
  export { parseOneBotGetMsgResponse } from './onebot-get-msg.js';
@@ -0,0 +1 @@
1
+ export declare const napcatEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `napcat 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 { napcatRuntimeStateToken } from './napcat-runtime-state.js';
8
+ export const napcatEndpointCommands = createEndpointCommands({
9
+ adapterKey: 'napcat',
10
+ adapterDisplayName: 'NapCat',
11
+ fields: [
12
+ { key: 'url', description: 'NapCat WebSocket URL(connection: ws 必填)' },
13
+ { key: 'path', description: 'reverse-wss 路径(connection: wss)' },
14
+ { key: 'http_url', description: 'HTTP API base URL(connection: http 出站)' },
15
+ { key: 'post_path', description: 'HTTP POST 事件路径(connection: http 入站)' },
16
+ { key: 'access_token', env: true, description: 'NapCat access token' },
17
+ ],
18
+ running: (use) => use(napcatRuntimeStateToken).endpoints.values(),
19
+ describeEntry: (entry) => {
20
+ if (entry.url)
21
+ return `url: ${String(entry.url)}`;
22
+ if (entry.path)
23
+ return `path: ${String(entry.path)}`;
24
+ if (entry.http_url)
25
+ return `http_url: ${String(entry.http_url)}`;
26
+ return '';
27
+ },
28
+ }, defineCommand);
@@ -0,0 +1 @@
1
+ export declare const napcatRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * NapCat 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `napcat endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+ export const napcatRuntimeStateToken = defineEndpointRuntimeStateToken('napcat');
package/lib/protocol.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * No legacy Adapter/Endpoint / segment-mapper.
4
4
  * Canonicalization is owned by gateway/core before endpoint.send.
5
5
  */
6
+ import { type MediaRef } from '@zhin.js/core';
6
7
  /** Transitional legacy endpoint row (`endpoints[]` with `context: napcat`). */
7
8
  export interface NapCatLegacyEndpointRow {
8
9
  readonly context?: string;
@@ -125,9 +126,15 @@ export declare function formatInboundContent(ev: NapCatEvent): string;
125
126
  export declare function senderUserId(ev: NapCatEvent): string;
126
127
  /** 显示名(群名片 card 优先于 nickname),没有则 undefined。 */
127
128
  export declare function senderNickname(ev: NapCatEvent): string | undefined;
129
+ /**
130
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
131
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)。
132
+ */
133
+ export declare function mediaRefToOneBotFile(media: MediaRef): string;
128
134
  /**
129
135
  * Wire-encode an already-rendered outbound payload into OneBot message segments.
130
- * Segment canonicalization is intentionally not done here.
136
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
137
+ * 旧 wire 形状(at / 裸 `{file,url,base64}`)保持兼容透传。
131
138
  */
132
139
  export declare function formatOutboundSegments(payload: unknown): MessageSegment[];
133
140
  export declare function buildSendAction(target: string, message: MessageSegment[]): {
package/lib/protocol.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * No legacy Adapter/Endpoint / segment-mapper.
4
4
  * Canonicalization is owned by gateway/core before endpoint.send.
5
5
  */
6
+ import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
6
7
  function normalizeConnection(connection) {
7
8
  if (connection === 'wss' || connection === 'http')
8
9
  return connection;
@@ -118,9 +119,74 @@ export function senderNickname(ev) {
118
119
  const name = ev.sender?.card || ev.sender?.nickname;
119
120
  return typeof name === 'string' && name ? name : undefined;
120
121
  }
122
+ /**
123
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
124
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)。
125
+ */
126
+ export function mediaRefToOneBotFile(media) {
127
+ if (media.kind === 'base64') {
128
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
129
+ }
130
+ if (media.kind === 'path') {
131
+ return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
132
+ }
133
+ return media.value;
134
+ }
135
+ /** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
136
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt', 'url', 'base64', 'file', 'mime_type']);
137
+ function oneBotMediaSegment(type, data) {
138
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
139
+ if (!media)
140
+ return { type, data };
141
+ const extra = {};
142
+ for (const [key, value] of Object.entries(data)) {
143
+ if (!MEDIA_DATA_SKIP_KEYS.has(key))
144
+ extra[key] = value;
145
+ }
146
+ return { type, data: { ...extra, file: mediaRefToOneBotFile(media) } };
147
+ }
148
+ /**
149
+ * canonical Segment → OneBot 11 数组段:
150
+ * - mention → at(`qq: target`);
151
+ * - reply(`message_id`)→ reply(`id`);
152
+ * - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file://);
153
+ * - face 取 `id`;
154
+ * - 其余(NapCat 扩展段、已是 wire 形状的段)原样透传。
155
+ */
156
+ function canonicalToOneBotSegment(segment) {
157
+ const data = segment.data ?? {};
158
+ switch (segment.type) {
159
+ case 'mention': {
160
+ const target = data.target ?? data.qq ?? data.id;
161
+ if (target == null)
162
+ return { type: segment.type, data };
163
+ return { type: 'at', data: { qq: String(target) } };
164
+ }
165
+ case 'reply': {
166
+ const messageId = data.message_id ?? data.id;
167
+ if (messageId == null)
168
+ return { type: segment.type, data };
169
+ return { type: 'reply', data: { id: String(messageId) } };
170
+ }
171
+ case 'face': {
172
+ if (data.id == null)
173
+ return { type: segment.type, data };
174
+ return { type: 'face', data: { id: data.id } };
175
+ }
176
+ case 'image':
177
+ case 'record':
178
+ case 'video':
179
+ return oneBotMediaSegment(segment.type, data);
180
+ case 'audio':
181
+ return oneBotMediaSegment('record', data);
182
+ default:
183
+ return { type: segment.type, data };
184
+ }
185
+ }
121
186
  /**
122
187
  * Wire-encode an already-rendered outbound payload into OneBot message segments.
123
- * Segment canonicalization is intentionally not done here.
188
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
189
+ * 旧 wire 形状(at / 裸 `{file,url,base64}`)保持兼容透传。
124
190
  */
125
191
  export function formatOutboundSegments(payload) {
126
192
  if (typeof payload === 'string') {
@@ -145,7 +211,7 @@ export function formatOutboundSegments(payload) {
145
211
  segs.push({ type: 'text', data: { text: item } });
146
212
  continue;
147
213
  }
148
- segs.push({ type: item.type, data: item.data ?? {} });
214
+ segs.push(canonicalToOneBotSegment(item));
149
215
  }
150
216
  return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
151
217
  }
@@ -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 NapCatEvent, type NapCatWsConfig } from './protocol.js';
@@ -11,6 +11,7 @@ export interface NapCatWsEndpointOptions {
11
11
  }
12
12
  export declare class NapCatWsEndpoint implements EndpointInstance {
13
13
  #private;
14
+ readonly management: EndpointManagement;
14
15
  constructor(options: NapCatWsEndpointOptions);
15
16
  start(): Promise<void>;
16
17
  open(): void;
@@ -2,39 +2,48 @@
2
2
  * NapCat WS client endpoint — outbound connect to NapCat.
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 { createNapCatEndpointManagement } from './endpoint-management.js';
7
8
  import { registerNapcatAgentEndpoint } from './napcat-agent-deps.js';
8
9
  import { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
9
10
  import { buildSendAction, buildWsConnectOptions, formatInboundContent, formatInboundTarget, formatOutboundSegments, isMessageEvent, senderNickname, senderUserId, } from './protocol.js';
10
- import { callNapCatWsAction, handleNapCatWsMessage, rejectAllPending, startNapCatHeartbeat, } from './ws-transport.js';
11
+ import { callNapCatWsAction, handleNapCatWsMessage, rejectAllPending, } from './ws-transport.js';
11
12
  const logger = getLogger('napcat');
12
13
  export class NapCatWsEndpoint {
13
14
  #options;
14
15
  #inboundDeduper = new InboundMessageDeduper();
16
+ management = createNapCatEndpointManagement(this);
17
+ #lifecycle;
15
18
  #ws;
16
- #reconnectTimer;
17
- #heartbeatTimer;
18
19
  #requestId = { value: 0 };
19
20
  #pending = new Map();
20
21
  #open = false;
21
- #started = false;
22
- #stopping = false;
23
22
  #unregisterAgent;
24
23
  constructor(options) {
25
24
  this.#options = options;
25
+ this.#lifecycle = createEndpointLifecycle({
26
+ name: options.config.name,
27
+ // reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
28
+ reconnect: {
29
+ initialIntervalMs: options.config.reconnect_interval,
30
+ multiplier: 1,
31
+ maxIntervalMs: Number.MAX_SAFE_INTEGER,
32
+ jitterMs: 0,
33
+ },
34
+ });
26
35
  }
27
36
  async start() {
28
- if (this.#started)
37
+ if (this.#lifecycle.started)
29
38
  return;
30
- this.#started = true;
31
- this.#stopping = false;
32
39
  this.#unregisterAgent = registerNapcatAgentEndpoint(this.#options.config.name, this);
33
40
  try {
34
- await this.#connect();
41
+ await this.#lifecycle.start((handle) => this.#connect(handle));
35
42
  }
36
43
  catch (err) {
37
- this.#started = false;
44
+ // start 失败复位由基座保证;agent 注册/反注册是适配器专有依赖,留在适配器侧
45
+ this.#unregisterAgent?.();
46
+ this.#unregisterAgent = undefined;
38
47
  throw err;
39
48
  }
40
49
  }
@@ -46,18 +55,9 @@ export class NapCatWsEndpoint {
46
55
  }
47
56
  async stop() {
48
57
  this.#open = false;
49
- this.#stopping = true;
50
- this.#started = false;
58
+ await this.#lifecycle.stop();
51
59
  this.#unregisterAgent?.();
52
60
  this.#unregisterAgent = undefined;
53
- if (this.#reconnectTimer) {
54
- clearTimeout(this.#reconnectTimer);
55
- this.#reconnectTimer = undefined;
56
- }
57
- if (this.#heartbeatTimer) {
58
- clearInterval(this.#heartbeatTimer);
59
- this.#heartbeatTimer = undefined;
60
- }
61
61
  rejectAllPending(this.#pending);
62
62
  this.#inboundDeduper.clear();
63
63
  if (this.#ws) {
@@ -69,7 +69,6 @@ export class NapCatWsEndpoint {
69
69
  }
70
70
  this.#ws = undefined;
71
71
  }
72
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
73
72
  }
74
73
  async send({ target, payload }) {
75
74
  const message = formatOutboundSegments(payload);
@@ -262,7 +261,7 @@ export class NapCatWsEndpoint {
262
261
  }));
263
262
  });
264
263
  }
265
- async #connect() {
264
+ async #connect(handle) {
266
265
  const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
267
266
  const create = this.#options.createWebSocket
268
267
  ?? ((connectUrl, options) => new WebSocket(connectUrl, { headers: options.headers }));
@@ -270,6 +269,14 @@ export class NapCatWsEndpoint {
270
269
  let settled = false;
271
270
  const ws = create(url, { headers });
272
271
  this.#ws = ws;
272
+ handle.onForceClose(() => {
273
+ try {
274
+ ws.close();
275
+ }
276
+ catch {
277
+ /* ignore */
278
+ }
279
+ });
273
280
  ws.on('open', () => {
274
281
  if (settled)
275
282
  return;
@@ -286,9 +293,17 @@ export class NapCatWsEndpoint {
286
293
  mode: 'ws',
287
294
  url: safeUrl,
288
295
  }));
289
- this.#heartbeatTimer = this.#stopping
290
- ? this.#heartbeatTimer
291
- : startNapCatHeartbeat(this.#ws, this.#options.config.heartbeat_interval, this.#heartbeatTimer);
296
+ // stop-during-connect 竞态:已停止则不再武装心跳(基座 stop 已清理定时器)
297
+ if (this.#lifecycle.started) {
298
+ this.#lifecycle.startHeartbeat(() => {
299
+ try {
300
+ ws.ping?.();
301
+ }
302
+ catch {
303
+ /* ignore */
304
+ }
305
+ }, this.#options.config.heartbeat_interval);
306
+ }
292
307
  resolve();
293
308
  });
294
309
  ws.on('message', (data) => {
@@ -317,11 +332,12 @@ export class NapCatWsEndpoint {
317
332
  error: `${reasonStr || 'closed'}${codeHint}`,
318
333
  reconnect_ms: this.#options.config.reconnect_interval,
319
334
  }));
335
+ // 基座语义:仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
336
+ handle.notifyClosed(new Error(`NapCat WS closed: ${codeNum} ${reasonStr}`));
320
337
  if (!settled) {
321
338
  settled = true;
322
339
  reject(new Error(`NapCat WS closed: ${codeNum} ${reasonStr}`));
323
340
  }
324
- this.#scheduleReconnect();
325
341
  });
326
342
  ws.on('error', (err) => {
327
343
  const error = err instanceof Error ? err : new Error(String(err));
@@ -338,20 +354,4 @@ export class NapCatWsEndpoint {
338
354
  });
339
355
  });
340
356
  }
341
- #scheduleReconnect() {
342
- if (this.#stopping || !this.#started || this.#reconnectTimer)
343
- return;
344
- const delay = this.#options.config.reconnect_interval;
345
- this.#reconnectTimer = setTimeout(() => {
346
- this.#reconnectTimer = undefined;
347
- void this.#connect().catch((err) => {
348
- logger.warn(formatCompact({
349
- op: 'reconnect',
350
- endpoint: this.#options.config.name,
351
- ok: false,
352
- error: err instanceof Error ? err.message : String(err),
353
- }));
354
- });
355
- }, delay);
356
- }
357
357
  }
@@ -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 NapCatWssEndpointOptions {
11
11
  }
12
12
  export declare class NapCatWssEndpoint implements EndpointInstance {
13
13
  #private;
14
+ readonly management: EndpointManagement;
14
15
  constructor(options: NapCatWssEndpointOptions);
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 { createNapCatEndpointManagement } from './endpoint-management.js';
6
7
  import { registerNapcatAgentEndpoint } from './napcat-agent-deps.js';
7
8
  import { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
8
9
  import { buildSendAction, formatInboundContent, formatInboundTarget, formatOutboundSegments, isMessageEvent, senderNickname, senderUserId, } from './protocol.js';
@@ -12,6 +13,7 @@ const logger = getLogger('napcat');
12
13
  export class NapCatWssEndpoint {
13
14
  #options;
14
15
  #inboundDeduper = new InboundMessageDeduper();
16
+ management = createNapCatEndpointManagement(this);
15
17
  #ws;
16
18
  #wsRelease;
17
19
  #heartbeatTimer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-napcat",
3
- "version": "4.0.1",
3
+ "version": "5.0.1",
4
4
  "description": "Zhin.js NapCat adapter for Plugin Runtime (WebSocket client, OneBot11 + NapCat extensions)",
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",
@@ -42,11 +43,12 @@
42
43
  },
43
44
  "dependencies": {
44
45
  "ws": "^8.21.0",
45
- "@zhin.js/adapter": "1.0.1",
46
- "@zhin.js/core": "1.3.5",
47
- "@zhin.js/host-http": "1.0.1",
46
+ "@zhin.js/adapter": "1.1.1",
47
+ "@zhin.js/command": "1.0.3",
48
+ "@zhin.js/core": "1.4.1",
49
+ "@zhin.js/host-http": "1.0.3",
48
50
  "@zhin.js/logger": "1.0.75",
49
- "@zhin.js/plugin-runtime": "1.0.1"
51
+ "@zhin.js/plugin-runtime": "1.1.1"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@types/node": "^26.1.0",
@@ -54,16 +56,16 @@
54
56
  "typescript": "^6.0.3",
55
57
  "vitest": "^4.1.10",
56
58
  "zod": "^4.4.3",
57
- "@zhin.js/agent": "1.0.4",
58
- "@zhin.js/host-http": "1.0.1"
59
+ "@zhin.js/agent": "1.0.6",
60
+ "@zhin.js/host-http": "1.0.3"
59
61
  },
60
62
  "peerDependencies": {
61
63
  "zod": "^4.0.0",
62
- "@zhin.js/adapter": "1.0.1",
63
- "@zhin.js/core": "1.3.5",
64
- "@zhin.js/plugin-runtime": "1.0.1",
65
- "zhin.js": "4.1.3",
66
- "@zhin.js/agent": "1.0.4"
64
+ "@zhin.js/adapter": "1.1.1",
65
+ "@zhin.js/core": "1.4.1",
66
+ "@zhin.js/plugin-runtime": "1.1.1",
67
+ "zhin.js": "5.0.1",
68
+ "@zhin.js/agent": "1.0.6"
67
69
  },
68
70
  "peerDependenciesMeta": {
69
71
  "zhin.js": {
@@ -98,6 +100,10 @@
98
100
  {
99
101
  "package": "@zhin.js/adapter",
100
102
  "api": "^1.0.0"
103
+ },
104
+ {
105
+ "package": "@zhin.js/command",
106
+ "api": "^1.0.0"
101
107
  }
102
108
  ],
103
109
  "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 { napcatRuntimeStateToken } from './src/napcat-runtime-state.js';
3
5
 
4
6
  export default definePlugin({
5
7
  name: 'napcat',
6
8
  metadata: {
7
9
  displayName: 'NapCat Adapter',
8
10
  },
9
- setup() {
11
+ setup(context) {
12
+ // 运行中 endpoint 注册表(napcat endpoint list 的"运行中"数据源)
13
+ context.resources.provide(napcatRuntimeStateToken, createEndpointRuntimeState());
10
14
  // 平台权限门禁:scene_admin / scene_owner 由 sender role 判定(见各 endpoint admit metadata)
11
15
  return registerDefaultScenePlatformPermitChecker('napcat');
12
16
  },
package/schema.json CHANGED
@@ -5,33 +5,14 @@
5
5
  "properties": {
6
6
  "connection": {
7
7
  "type": "string",
8
- "enum": ["ws", "wss", "http"],
8
+ "enum": [
9
+ "ws",
10
+ "wss",
11
+ "http"
12
+ ],
9
13
  "default": "ws",
10
14
  "description": "ws (default), wss (reverse WS), or http (POST webhook + HTTP API outbound)"
11
15
  },
12
- "name": {
13
- "type": "string",
14
- "default": "napcat-bot"
15
- },
16
- "url": {
17
- "type": "string",
18
- "description": "NapCat WebSocket URL (required for connection: ws)"
19
- },
20
- "path": {
21
- "type": "string",
22
- "description": "WS path for reverse-wss"
23
- },
24
- "http_url": {
25
- "type": "string",
26
- "description": "HTTP API base URL (connection: http outbound)"
27
- },
28
- "post_path": {
29
- "type": "string",
30
- "description": "HTTP POST event path (connection: http inbound)"
31
- },
32
- "access_token": {
33
- "type": "string"
34
- },
35
16
  "reconnect_interval": {
36
17
  "type": "number",
37
18
  "default": 5000
@@ -43,6 +24,51 @@
43
24
  "poll_interval": {
44
25
  "type": "number",
45
26
  "default": 30000
27
+ },
28
+ "endpoints": {
29
+ "type": "array",
30
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
31
+ "items": {
32
+ "type": "object",
33
+ "additionalProperties": true,
34
+ "properties": {
35
+ "name": {
36
+ "type": "string",
37
+ "description": "NapCat bot name"
38
+ },
39
+ "url": {
40
+ "type": "string",
41
+ "description": "NapCat WebSocket URL (required for connection: ws)"
42
+ },
43
+ "path": {
44
+ "type": "string",
45
+ "description": "WS path for reverse-wss"
46
+ },
47
+ "http_url": {
48
+ "type": "string",
49
+ "description": "HTTP API base URL (connection: http outbound)"
50
+ },
51
+ "post_path": {
52
+ "type": "string",
53
+ "description": "HTTP POST event path (connection: http inbound)"
54
+ },
55
+ "access_token": {
56
+ "type": "string",
57
+ "description": "NapCat access token"
58
+ }
59
+ },
60
+ "required": [
61
+ "name"
62
+ ]
63
+ }
64
+ },
65
+ "commandPrefix": {
66
+ "type": "string",
67
+ "default": "",
68
+ "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
46
69
  }
47
- }
70
+ },
71
+ "required": [
72
+ "endpoints"
73
+ ]
48
74
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * NapCat 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/http 三种传输各自实现)。 */
17
+ export interface NapCatManagementCaller {
18
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
19
+ }
20
+
21
+ export function createNapCatEndpointManagement(
22
+ endpoint: NapCatManagementCaller,
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(`napcat ${label} 必须是数字: ${String(value)}`);
70
+ }
71
+ return n;
72
+ }
@@ -2,11 +2,12 @@
2
2
  * NapCat HTTP endpoint — POST inbound events + HTTP API outbound.
3
3
  */
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
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, HttpRouteRegistration } 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 { createNapCatEndpointManagement } from './endpoint-management.js';
10
11
  import { registerNapcatAgentEndpoint } from './napcat-agent-deps.js';
11
12
  import {
12
13
  InboundMessageDeduper,
@@ -43,6 +44,7 @@ export interface NapCatHttpEndpointOptions {
43
44
  export class NapCatHttpEndpoint implements EndpointInstance {
44
45
  readonly #options: NapCatHttpEndpointOptions;
45
46
  readonly #inboundDeduper = new InboundMessageDeduper();
47
+ readonly management: EndpointManagement = createNapCatEndpointManagement(this);
46
48
  readonly #callHttpAction: typeof callNapCatHttpAction;
47
49
  #routeReleases: HttpRouteRegistration[] = [];
48
50
  #open = false;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  formatOutboundSegments,
8
8
  getChannelId,
9
9
  isMessageEvent,
10
+ mediaRefToOneBotFile,
10
11
  parseSendTarget,
11
12
  resolveNapCatConfig,
12
13
  senderNickname,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `napcat 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 { napcatRuntimeStateToken } from './napcat-runtime-state.js';
8
+
9
+ export const napcatEndpointCommands = createEndpointCommands({
10
+ adapterKey: 'napcat',
11
+ adapterDisplayName: 'NapCat',
12
+ fields: [
13
+ { key: 'url', description: 'NapCat WebSocket URL(connection: ws 必填)' },
14
+ { key: 'path', description: 'reverse-wss 路径(connection: wss)' },
15
+ { key: 'http_url', description: 'HTTP API base URL(connection: http 出站)' },
16
+ { key: 'post_path', description: 'HTTP POST 事件路径(connection: http 入站)' },
17
+ { key: 'access_token', env: true, description: 'NapCat access token' },
18
+ ],
19
+ running: (use) => use(napcatRuntimeStateToken).endpoints.values(),
20
+ describeEntry: (entry) => {
21
+ if (entry.url) return `url: ${String(entry.url)}`;
22
+ if (entry.path) return `path: ${String(entry.path)}`;
23
+ if (entry.http_url) return `http_url: ${String(entry.http_url)}`;
24
+ return '';
25
+ },
26
+ }, defineCommand);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * NapCat 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `napcat endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const napcatRuntimeStateToken = defineEndpointRuntimeStateToken('napcat');
package/src/protocol.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * No legacy Adapter/Endpoint / segment-mapper.
4
4
  * Canonicalization is owned by gateway/core before endpoint.send.
5
5
  */
6
+ import { isMediaRef, mediaRefFromLegacyData, type MediaRef } from '@zhin.js/core';
6
7
 
7
8
  /** Transitional legacy endpoint row (`endpoints[]` with `context: napcat`). */
8
9
  export interface NapCatLegacyEndpointRow {
@@ -253,9 +254,76 @@ export function senderNickname(ev: NapCatEvent): string | undefined {
253
254
  return typeof name === 'string' && name ? name : undefined;
254
255
  }
255
256
 
257
+ /**
258
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
259
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)。
260
+ */
261
+ export function mediaRefToOneBotFile(media: MediaRef): string {
262
+ if (media.kind === 'base64') {
263
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
264
+ }
265
+ if (media.kind === 'path') {
266
+ return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
267
+ }
268
+ return media.value;
269
+ }
270
+
271
+ /** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
272
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt', 'url', 'base64', 'file', 'mime_type']);
273
+
274
+ function oneBotMediaSegment(
275
+ type: 'image' | 'record' | 'video',
276
+ data: Record<string, unknown>,
277
+ ): MessageSegment {
278
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
279
+ if (!media) return { type, data };
280
+ const extra: Record<string, unknown> = {};
281
+ for (const [key, value] of Object.entries(data)) {
282
+ if (!MEDIA_DATA_SKIP_KEYS.has(key)) extra[key] = value;
283
+ }
284
+ return { type, data: { ...extra, file: mediaRefToOneBotFile(media) } };
285
+ }
286
+
287
+ /**
288
+ * canonical Segment → OneBot 11 数组段:
289
+ * - mention → at(`qq: target`);
290
+ * - reply(`message_id`)→ reply(`id`);
291
+ * - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file://);
292
+ * - face 取 `id`;
293
+ * - 其余(NapCat 扩展段、已是 wire 形状的段)原样透传。
294
+ */
295
+ function canonicalToOneBotSegment(segment: NapCatWireSegment): MessageSegment {
296
+ const data = segment.data ?? {};
297
+ switch (segment.type) {
298
+ case 'mention': {
299
+ const target = data.target ?? data.qq ?? data.id;
300
+ if (target == null) return { type: segment.type, data };
301
+ return { type: 'at', data: { qq: String(target) } };
302
+ }
303
+ case 'reply': {
304
+ const messageId = data.message_id ?? data.id;
305
+ if (messageId == null) return { type: segment.type, data };
306
+ return { type: 'reply', data: { id: String(messageId) } };
307
+ }
308
+ case 'face': {
309
+ if (data.id == null) return { type: segment.type, data };
310
+ return { type: 'face', data: { id: data.id } };
311
+ }
312
+ case 'image':
313
+ case 'record':
314
+ case 'video':
315
+ return oneBotMediaSegment(segment.type, data);
316
+ case 'audio':
317
+ return oneBotMediaSegment('record', data);
318
+ default:
319
+ return { type: segment.type, data };
320
+ }
321
+ }
322
+
256
323
  /**
257
324
  * Wire-encode an already-rendered outbound payload into OneBot message segments.
258
- * Segment canonicalization is intentionally not done here.
325
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
326
+ * 旧 wire 形状(at / 裸 `{file,url,base64}`)保持兼容透传。
259
327
  */
260
328
  export function formatOutboundSegments(payload: unknown): MessageSegment[] {
261
329
  if (typeof payload === 'string') {
@@ -283,7 +351,7 @@ export function formatOutboundSegments(payload: unknown): MessageSegment[] {
283
351
  segs.push({ type: 'text', data: { text: item } });
284
352
  continue;
285
353
  }
286
- segs.push({ type: item.type, data: item.data ?? {} });
354
+ segs.push(canonicalToOneBotSegment(item));
287
355
  }
288
356
  return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
289
357
  }
@@ -2,11 +2,17 @@
2
2
  * NapCat WS client endpoint — outbound connect to NapCat.
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 { createNapCatEndpointManagement } from './endpoint-management.js';
10
16
  import { registerNapcatAgentEndpoint } from './napcat-agent-deps.js';
11
17
  import {
12
18
  InboundMessageDeduper,
@@ -30,7 +36,6 @@ import {
30
36
  callNapCatWsAction,
31
37
  handleNapCatWsMessage,
32
38
  rejectAllPending,
33
- startNapCatHeartbeat,
34
39
  } from './ws-transport.js';
35
40
  import {
36
41
  type NapCatPendingAction,
@@ -53,29 +58,37 @@ export interface NapCatWsEndpointOptions {
53
58
  export class NapCatWsEndpoint implements EndpointInstance {
54
59
  readonly #options: NapCatWsEndpointOptions;
55
60
  readonly #inboundDeduper = new InboundMessageDeduper();
61
+ readonly management: EndpointManagement = createNapCatEndpointManagement(this);
62
+ readonly #lifecycle: EndpointLifecycle;
56
63
  #ws?: NapCatWsSocket;
57
- #reconnectTimer?: NodeJS.Timeout;
58
- #heartbeatTimer?: NodeJS.Timeout;
59
64
  #requestId = { value: 0 };
60
65
  #pending = new Map<string, NapCatPendingAction>();
61
66
  #open = false;
62
- #started = false;
63
- #stopping = false;
64
67
  #unregisterAgent?: () => void;
65
68
 
66
69
  constructor(options: NapCatWsEndpointOptions) {
67
70
  this.#options = options;
71
+ this.#lifecycle = createEndpointLifecycle({
72
+ name: options.config.name,
73
+ // reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
74
+ reconnect: {
75
+ initialIntervalMs: options.config.reconnect_interval,
76
+ multiplier: 1,
77
+ maxIntervalMs: Number.MAX_SAFE_INTEGER,
78
+ jitterMs: 0,
79
+ },
80
+ });
68
81
  }
69
82
 
70
83
  async start(): Promise<void> {
71
- if (this.#started) return;
72
- this.#started = true;
73
- this.#stopping = false;
84
+ if (this.#lifecycle.started) return;
74
85
  this.#unregisterAgent = registerNapcatAgentEndpoint(this.#options.config.name, this);
75
86
  try {
76
- await this.#connect();
87
+ await this.#lifecycle.start((handle) => this.#connect(handle));
77
88
  } catch (err) {
78
- this.#started = false;
89
+ // start 失败复位由基座保证;agent 注册/反注册是适配器专有依赖,留在适配器侧
90
+ this.#unregisterAgent?.();
91
+ this.#unregisterAgent = undefined;
79
92
  throw err;
80
93
  }
81
94
  }
@@ -90,18 +103,9 @@ export class NapCatWsEndpoint implements EndpointInstance {
90
103
 
91
104
  async stop(): Promise<void> {
92
105
  this.#open = false;
93
- this.#stopping = true;
94
- this.#started = false;
106
+ await this.#lifecycle.stop();
95
107
  this.#unregisterAgent?.();
96
108
  this.#unregisterAgent = undefined;
97
- if (this.#reconnectTimer) {
98
- clearTimeout(this.#reconnectTimer);
99
- this.#reconnectTimer = undefined;
100
- }
101
- if (this.#heartbeatTimer) {
102
- clearInterval(this.#heartbeatTimer);
103
- this.#heartbeatTimer = undefined;
104
- }
105
109
  rejectAllPending(this.#pending);
106
110
  this.#inboundDeduper.clear();
107
111
  if (this.#ws) {
@@ -112,7 +116,6 @@ export class NapCatWsEndpoint implements EndpointInstance {
112
116
  }
113
117
  this.#ws = undefined;
114
118
  }
115
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
116
119
  }
117
120
 
118
121
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
@@ -348,7 +351,7 @@ export class NapCatWsEndpoint implements EndpointInstance {
348
351
  });
349
352
  }
350
353
 
351
- async #connect(): Promise<void> {
354
+ async #connect(handle: EndpointConnectHandle): Promise<void> {
352
355
  const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
353
356
  const create = this.#options.createWebSocket
354
357
  ?? ((connectUrl: string, options: NapCatWsCreateOptions) =>
@@ -358,6 +361,13 @@ export class NapCatWsEndpoint implements EndpointInstance {
358
361
  let settled = false;
359
362
  const ws = create(url, { headers });
360
363
  this.#ws = ws;
364
+ handle.onForceClose(() => {
365
+ try {
366
+ ws.close();
367
+ } catch {
368
+ /* ignore */
369
+ }
370
+ });
361
371
 
362
372
  ws.on('open', () => {
363
373
  if (settled) return;
@@ -374,13 +384,16 @@ export class NapCatWsEndpoint implements EndpointInstance {
374
384
  mode: 'ws',
375
385
  url: safeUrl,
376
386
  }));
377
- this.#heartbeatTimer = this.#stopping
378
- ? this.#heartbeatTimer
379
- : startNapCatHeartbeat(
380
- this.#ws,
381
- this.#options.config.heartbeat_interval,
382
- this.#heartbeatTimer,
383
- );
387
+ // stop-during-connect 竞态:已停止则不再武装心跳(基座 stop 已清理定时器)
388
+ if (this.#lifecycle.started) {
389
+ this.#lifecycle.startHeartbeat(() => {
390
+ try {
391
+ ws.ping?.();
392
+ } catch {
393
+ /* ignore */
394
+ }
395
+ }, this.#options.config.heartbeat_interval);
396
+ }
384
397
  resolve();
385
398
  });
386
399
 
@@ -411,11 +424,12 @@ export class NapCatWsEndpoint implements EndpointInstance {
411
424
  error: `${reasonStr || 'closed'}${codeHint}`,
412
425
  reconnect_ms: this.#options.config.reconnect_interval,
413
426
  }));
427
+ // 基座语义:仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
428
+ handle.notifyClosed(new Error(`NapCat WS closed: ${codeNum} ${reasonStr}`));
414
429
  if (!settled) {
415
430
  settled = true;
416
431
  reject(new Error(`NapCat WS closed: ${codeNum} ${reasonStr}`));
417
432
  }
418
- this.#scheduleReconnect();
419
433
  });
420
434
 
421
435
  ws.on('error', (err) => {
@@ -433,20 +447,4 @@ export class NapCatWsEndpoint implements EndpointInstance {
433
447
  });
434
448
  });
435
449
  }
436
-
437
- #scheduleReconnect(): void {
438
- if (this.#stopping || !this.#started || this.#reconnectTimer) return;
439
- const delay = this.#options.config.reconnect_interval;
440
- this.#reconnectTimer = setTimeout(() => {
441
- this.#reconnectTimer = undefined;
442
- void this.#connect().catch((err) => {
443
- logger.warn(formatCompact({
444
- op: 'reconnect',
445
- endpoint: this.#options.config.name,
446
- ok: false,
447
- error: err instanceof Error ? err.message : String(err),
448
- }));
449
- });
450
- }, delay);
451
- }
452
450
  }
@@ -2,11 +2,12 @@
2
2
  * NapCat reverse WSS endpoint — accepts inbound WebSocket from NapCat.
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 { createNapCatEndpointManagement } from './endpoint-management.js';
10
11
  import { registerNapcatAgentEndpoint } from './napcat-agent-deps.js';
11
12
  import {
12
13
  InboundMessageDeduper,
@@ -50,6 +51,7 @@ export interface NapCatWssEndpointOptions {
50
51
  export class NapCatWssEndpoint implements EndpointInstance {
51
52
  readonly #options: NapCatWssEndpointOptions;
52
53
  readonly #inboundDeduper = new InboundMessageDeduper();
54
+ readonly management: EndpointManagement = createNapCatEndpointManagement(this);
53
55
  #ws?: NapCatWsSocket;
54
56
  #wsRelease?: () => void;
55
57
  #heartbeatTimer?: NodeJS.Timeout;