@zhin.js/adapter-napcat 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +674 -0
  2. package/README.md +60 -95
  3. package/adapters/napcat.js +47 -0
  4. package/adapters/napcat.ts +64 -0
  5. package/agent/PERMITS.md +19 -0
  6. package/{skills/napcat/SKILL.md → agent/skills/napcat.md} +6 -6
  7. package/agent/tools/ai_tts.ts +19 -0
  8. package/agent/tools/del_group_notice.ts +20 -0
  9. package/agent/tools/delete_essence_msg.ts +19 -0
  10. package/agent/tools/delete_friend.ts +18 -0
  11. package/agent/tools/download_file.ts +17 -0
  12. package/agent/tools/forward_single_msg.ts +28 -0
  13. package/agent/tools/get_ai_characters.ts +17 -0
  14. package/agent/tools/get_essence_list.ts +17 -0
  15. package/agent/tools/get_friend_msg_history.ts +18 -0
  16. package/agent/tools/get_group_file_url.ts +19 -0
  17. package/agent/tools/get_group_info_ex.ts +17 -0
  18. package/agent/tools/get_group_msg_history.ts +19 -0
  19. package/agent/tools/get_group_notice.ts +17 -0
  20. package/agent/tools/get_group_root_files.ts +17 -0
  21. package/agent/tools/get_group_shut_list.ts +17 -0
  22. package/agent/tools/get_mini_app_ark.ts +20 -0
  23. package/agent/tools/get_user_status.ts +16 -0
  24. package/agent/tools/group_sign.ts +18 -0
  25. package/agent/tools/mark_msg_as_read.ts +17 -0
  26. package/agent/tools/ocr_image.ts +16 -0
  27. package/agent/tools/send_forward_msg.ts +27 -0
  28. package/agent/tools/send_group_notice.ts +21 -0
  29. package/agent/tools/send_like.ts +18 -0
  30. package/agent/tools/send_poke.ts +18 -0
  31. package/agent/tools/set_avatar.ts +17 -0
  32. package/agent/tools/set_emoji_reaction.ts +18 -0
  33. package/agent/tools/set_essence_msg.ts +19 -0
  34. package/agent/tools/set_group_portrait.ts +20 -0
  35. package/agent/tools/set_online_status.ts +18 -0
  36. package/agent/tools/set_profile.ts +21 -0
  37. package/agent/tools/set_signature.ts +17 -0
  38. package/agent/tools/set_title.ts +21 -0
  39. package/agent/tools/translate.ts +16 -0
  40. package/agent/tools/upload_group_file.ts +21 -0
  41. package/commands/endpoint/add/[id].js +3 -0
  42. package/commands/endpoint/add/[id].ts +3 -0
  43. package/commands/endpoint/list.js +3 -0
  44. package/commands/endpoint/list.ts +3 -0
  45. package/commands/endpoint/remove/[id].js +3 -0
  46. package/commands/endpoint/remove/[id].ts +3 -0
  47. package/lib/client.d.ts +52 -0
  48. package/lib/client.js +62 -0
  49. package/lib/endpoint-management.d.ts +15 -0
  50. package/lib/endpoint-management.js +47 -0
  51. package/lib/http-endpoint.d.ts +27 -0
  52. package/lib/http-endpoint.js +177 -0
  53. package/lib/index.d.ts +11 -22
  54. package/lib/index.js +10 -58
  55. package/lib/napcat-endpoint-commands.d.ts +1 -0
  56. package/lib/napcat-endpoint-commands.js +28 -0
  57. package/lib/napcat-inbound.d.ts +12 -5
  58. package/lib/napcat-inbound.js +28 -13
  59. package/lib/napcat-runtime-state.d.ts +1 -0
  60. package/lib/napcat-runtime-state.js +6 -0
  61. package/lib/onebot-get-msg.d.ts +16 -3
  62. package/lib/onebot-get-msg.js +53 -1
  63. package/lib/protocol.d.ts +165 -0
  64. package/lib/protocol.js +305 -0
  65. package/lib/side-event-dispatch.d.ts +7 -0
  66. package/lib/side-event-dispatch.js +36 -0
  67. package/lib/webhook.d.ts +2 -0
  68. package/lib/webhook.js +14 -0
  69. package/lib/ws-endpoint.d.ts +27 -0
  70. package/lib/ws-endpoint.js +241 -0
  71. package/lib/ws-transport.d.ts +13 -0
  72. package/lib/ws-transport.js +75 -0
  73. package/lib/ws-types.d.ts +17 -0
  74. package/lib/ws-types.js +1 -0
  75. package/lib/wss-auth.d.ts +2 -0
  76. package/lib/wss-auth.js +16 -0
  77. package/lib/wss-endpoint.d.ts +26 -0
  78. package/lib/wss-endpoint.js +187 -0
  79. package/package.json +62 -30
  80. package/plugin.js +18 -0
  81. package/schema.json +110 -0
  82. package/src/client.ts +79 -0
  83. package/src/endpoint-management.ts +72 -0
  84. package/src/http-endpoint.ts +234 -0
  85. package/src/index.ts +63 -85
  86. package/src/napcat-endpoint-commands.ts +26 -0
  87. package/src/napcat-inbound.ts +34 -10
  88. package/src/napcat-runtime-state.ts +7 -0
  89. package/src/onebot-get-msg.ts +69 -5
  90. package/src/protocol.ts +468 -0
  91. package/src/side-event-dispatch.ts +48 -0
  92. package/src/webhook.ts +16 -0
  93. package/src/ws-endpoint.ts +301 -0
  94. package/src/ws-transport.ts +105 -0
  95. package/src/ws-types.ts +20 -0
  96. package/src/wss-auth.ts +17 -0
  97. package/src/wss-endpoint.ts +242 -0
  98. package/client/NapCatManagement.tsx +0 -113
  99. package/client/index.tsx +0 -11
  100. package/client/tsconfig.json +0 -7
  101. package/client/utils/api.ts +0 -30
  102. package/dist/index.js +0 -27
  103. package/lib/adapter.d.ts +0 -27
  104. package/lib/adapter.d.ts.map +0 -1
  105. package/lib/adapter.js +0 -93
  106. package/lib/adapter.js.map +0 -1
  107. package/lib/agent-prompt.d.ts +0 -3
  108. package/lib/agent-prompt.d.ts.map +0 -1
  109. package/lib/agent-prompt.js +0 -105
  110. package/lib/agent-prompt.js.map +0 -1
  111. package/lib/bot-base.d.ts +0 -143
  112. package/lib/bot-base.d.ts.map +0 -1
  113. package/lib/bot-base.js +0 -334
  114. package/lib/bot-base.js.map +0 -1
  115. package/lib/bot-http.d.ts +0 -18
  116. package/lib/bot-http.d.ts.map +0 -1
  117. package/lib/bot-http.js +0 -119
  118. package/lib/bot-http.js.map +0 -1
  119. package/lib/bot-ws-client.d.ts +0 -21
  120. package/lib/bot-ws-client.d.ts.map +0 -1
  121. package/lib/bot-ws-client.js +0 -165
  122. package/lib/bot-ws-client.js.map +0 -1
  123. package/lib/bot-ws-server.d.ts +0 -21
  124. package/lib/bot-ws-server.d.ts.map +0 -1
  125. package/lib/bot-ws-server.js +0 -150
  126. package/lib/bot-ws-server.js.map +0 -1
  127. package/lib/index.d.ts.map +0 -1
  128. package/lib/index.js.map +0 -1
  129. package/lib/napcat-inbound.d.ts.map +0 -1
  130. package/lib/napcat-inbound.js.map +0 -1
  131. package/lib/onebot-get-msg.d.ts.map +0 -1
  132. package/lib/onebot-get-msg.js.map +0 -1
  133. package/lib/routes.d.ts +0 -4
  134. package/lib/routes.d.ts.map +0 -1
  135. package/lib/routes.js +0 -61
  136. package/lib/routes.js.map +0 -1
  137. package/lib/tools.d.ts +0 -8
  138. package/lib/tools.d.ts.map +0 -1
  139. package/lib/tools.js +0 -605
  140. package/lib/tools.js.map +0 -1
  141. package/lib/types.d.ts +0 -293
  142. package/lib/types.d.ts.map +0 -1
  143. package/lib/types.js +0 -6
  144. package/lib/types.js.map +0 -1
  145. package/lib/typing-indicator.d.ts +0 -45
  146. package/lib/typing-indicator.d.ts.map +0 -1
  147. package/lib/typing-indicator.js +0 -132
  148. package/lib/typing-indicator.js.map +0 -1
  149. package/plugin.yml +0 -3
  150. package/src/adapter.ts +0 -99
  151. package/src/agent-prompt.ts +0 -114
  152. package/src/bot-base.ts +0 -376
  153. package/src/bot-http.ts +0 -109
  154. package/src/bot-ws-client.ts +0 -164
  155. package/src/bot-ws-server.ts +0 -150
  156. package/src/routes.ts +0 -61
  157. package/src/tools.ts +0 -632
  158. package/src/types.ts +0 -279
  159. package/src/typing-indicator.ts +0 -184
@@ -0,0 +1,305 @@
1
+ /**
2
+ * NapCat protocol helpers (OneBot 11 + NapCat extensions).
3
+ * No legacy Adapter/Endpoint / segment-mapper.
4
+ * Canonicalization is owned by gateway/core before endpoint.send.
5
+ */
6
+ import { isMediaRef } from '@zhin.js/core';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ const logger = getLogger('napcat');
9
+ function normalizeConnection(connection) {
10
+ if (connection === 'wss' || connection === 'http')
11
+ return connection;
12
+ return 'ws';
13
+ }
14
+ export function resolveNapCatConfig(config = {}) {
15
+ const entry = config.endpoints?.find((item) => item.context === 'napcat');
16
+ const connection = normalizeConnection(config.connection ?? entry?.connection);
17
+ const id = (typeof config.id === 'string' && config.id)
18
+ || (typeof entry?.id === 'string' && entry.id)
19
+ || process.env.NAPCAT_BOT_NAME
20
+ || 'napcat-bot';
21
+ const access_token = config.access_token ?? entry?.access_token;
22
+ if (connection === 'ws') {
23
+ const url = config.url ?? entry?.url;
24
+ if (!url) {
25
+ throw new TypeError('NapCat connection:ws requires url (plugins.<key>.url or endpoints with context: napcat)');
26
+ }
27
+ return {
28
+ context: 'napcat',
29
+ connection: 'ws',
30
+ id,
31
+ access_token,
32
+ url,
33
+ reconnect_interval: config.reconnect_interval ?? entry?.reconnect_interval ?? 5000,
34
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
35
+ };
36
+ }
37
+ if (connection === 'wss') {
38
+ const path = config.path ?? entry?.path;
39
+ if (!path)
40
+ throw new TypeError('NapCat connection:wss requires path');
41
+ return {
42
+ context: 'napcat',
43
+ connection: 'wss',
44
+ id,
45
+ access_token,
46
+ path,
47
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
48
+ };
49
+ }
50
+ if (connection === 'http') {
51
+ const http_url = config.http_url ?? entry?.http_url;
52
+ const post_path = config.post_path ?? entry?.post_path;
53
+ if (!http_url || !post_path) {
54
+ throw new TypeError('NapCat connection:http requires http_url and post_path');
55
+ }
56
+ return {
57
+ context: 'napcat',
58
+ connection: 'http',
59
+ id,
60
+ access_token,
61
+ http_url,
62
+ post_path,
63
+ poll_interval: config.poll_interval ?? entry?.poll_interval ?? 30_000,
64
+ };
65
+ }
66
+ throw new TypeError(`Unknown NapCat connection: ${String(connection)}`);
67
+ }
68
+ export function isMessageEvent(ev) {
69
+ return (ev.post_type === 'message' || ev.post_type === 'message_sent')
70
+ && ev.message_id != null;
71
+ }
72
+ export function getChannelId(ev) {
73
+ if (ev.message_type === 'group' && ev.group_id != null)
74
+ return String(ev.group_id);
75
+ if (ev.group_id != null && ev.message_type !== 'private')
76
+ return String(ev.group_id);
77
+ if (ev.user_id != null)
78
+ return String(ev.user_id);
79
+ return '';
80
+ }
81
+ /**
82
+ * 入站归一化 → ConversationRef:群消息 → kind 'group';私聊临时会话
83
+ * (sub_type 'group')→ kind 'private' + 群容器进 `parent`;其余私聊 → 'private'。
84
+ */
85
+ export function napcatInboundConversation(endpointKey, ev) {
86
+ const endpoint = { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey };
87
+ const isGroup = ev.message_type === 'group' || (ev.group_id != null && ev.message_type !== 'private');
88
+ if (isGroup && ev.group_id != null) {
89
+ return { endpoint, kind: 'group', id: String(ev.group_id) };
90
+ }
91
+ if (ev.sub_type === 'group' && ev.group_id != null) {
92
+ return {
93
+ endpoint,
94
+ kind: 'private',
95
+ id: ev.user_id != null ? String(ev.user_id) : '',
96
+ parent: { kind: 'group', id: String(ev.group_id) },
97
+ };
98
+ }
99
+ return { endpoint, kind: 'private', id: ev.user_id != null ? String(ev.user_id) : '' };
100
+ }
101
+ /** 出站:ConversationRef → OneBot 私聊/群聊目标。 */
102
+ export function napcatOutboundTarget(conversation) {
103
+ return {
104
+ message_type: conversation.kind === 'group' ? 'group' : 'private',
105
+ id: conversation.id,
106
+ };
107
+ }
108
+ export function formatInboundContent(ev) {
109
+ if (Array.isArray(ev.message)) {
110
+ return ev.message
111
+ .map((seg) => (seg.type === 'text' ? String(seg.data?.text ?? '') : ''))
112
+ .join('');
113
+ }
114
+ if (typeof ev.message === 'string')
115
+ return ev.message.replace(/\[CQ:[^\]]+\]/g, '').trim();
116
+ return typeof ev.raw_message === 'string'
117
+ ? ev.raw_message.replace(/\[CQ:[^\]]+\]/g, '').trim()
118
+ : '';
119
+ }
120
+ /**
121
+ * 入站 sender 语义:必须是用户 ID(Runtime Message contract)。
122
+ * 显示名走 metadata.nickname,见 senderNickname。
123
+ */
124
+ export function senderUserId(ev) {
125
+ return ev.user_id != null ? String(ev.user_id) : '';
126
+ }
127
+ /** 显示名(群名片 card 优先于 nickname),没有则 undefined。 */
128
+ export function senderNickname(ev) {
129
+ const name = ev.sender?.card || ev.sender?.nickname;
130
+ return typeof name === 'string' && name ? name : undefined;
131
+ }
132
+ /**
133
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
134
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)、kind=file 不透明引用原样透传。
135
+ */
136
+ export function mediaRefToOneBotFile(media) {
137
+ if (media.kind === 'base64') {
138
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
139
+ }
140
+ if (media.kind === 'path') {
141
+ return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
142
+ }
143
+ return media.value;
144
+ }
145
+ /** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
146
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt', 'url', 'base64', 'file', 'mime_type']);
147
+ /**
148
+ * 媒体段 → OneBot wire:媒体来源唯一认 canonical `data.media`(MediaRef-only,
149
+ * 无 legacy 字段回读)。缺失或形状非法时 warn + 丢弃(返回 null)。
150
+ */
151
+ function oneBotMediaSegment(type, data) {
152
+ if (!isMediaRef(data.media)) {
153
+ logger.warn(formatCompact({
154
+ op: 'napcat_outbound_media_dropped',
155
+ type,
156
+ reason: 'missing_media_ref',
157
+ }));
158
+ return null;
159
+ }
160
+ const extra = {};
161
+ for (const [key, value] of Object.entries(data)) {
162
+ if (!MEDIA_DATA_SKIP_KEYS.has(key))
163
+ extra[key] = value;
164
+ }
165
+ return { type, data: { ...extra, file: mediaRefToOneBotFile(data.media) } };
166
+ }
167
+ /**
168
+ * canonical Segment → OneBot 11 数组段:
169
+ * - mention → at(`qq: target`);
170
+ * - reply(`message_id`)→ reply(`id`);
171
+ * - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file://),
172
+ * 无 canonical `data.media` 的媒体段丢弃(返回 null);
173
+ * - face 取 `id`;
174
+ * - 其余(NapCat 扩展段、已是 wire 形状的段)原样透传。
175
+ */
176
+ function canonicalToOneBotSegment(segment) {
177
+ const data = segment.data ?? {};
178
+ switch (segment.type) {
179
+ case 'mention': {
180
+ const target = data.target ?? data.qq ?? data.id;
181
+ if (target == null)
182
+ return { type: segment.type, data };
183
+ return { type: 'at', data: { qq: String(target) } };
184
+ }
185
+ case 'reply': {
186
+ const messageId = data.message_id ?? data.id;
187
+ if (messageId == null)
188
+ return { type: segment.type, data };
189
+ return { type: 'reply', data: { id: String(messageId) } };
190
+ }
191
+ case 'face': {
192
+ if (data.id == null)
193
+ return { type: segment.type, data };
194
+ return { type: 'face', data: { id: data.id } };
195
+ }
196
+ case 'image':
197
+ case 'record':
198
+ case 'video':
199
+ return oneBotMediaSegment(segment.type, data);
200
+ case 'audio':
201
+ return oneBotMediaSegment('record', data);
202
+ default:
203
+ return { type: segment.type, data };
204
+ }
205
+ }
206
+ /**
207
+ * Wire-encode an already-rendered outbound payload into OneBot message segments.
208
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
209
+ * 媒体段(image/audio/video)仅认 canonical `data.media`,缺失则 warn + 丢弃;
210
+ * 非媒体的 wire 段(at 等)原样透传。
211
+ */
212
+ export function formatOutboundSegments(payload) {
213
+ if (typeof payload === 'string') {
214
+ return [{ type: 'text', data: { text: payload } }];
215
+ }
216
+ const items = Array.isArray(payload)
217
+ ? payload
218
+ : payload && typeof payload === 'object' && 'type' in payload
219
+ ? [payload]
220
+ : [];
221
+ if (items.length === 0) {
222
+ const text = payload == null
223
+ ? ''
224
+ : typeof payload === 'object'
225
+ ? JSON.stringify(payload)
226
+ : String(payload);
227
+ return [{ type: 'text', data: { text } }];
228
+ }
229
+ const segs = [];
230
+ for (const item of items) {
231
+ if (typeof item === 'string') {
232
+ segs.push({ type: 'text', data: { text: item } });
233
+ continue;
234
+ }
235
+ const seg = canonicalToOneBotSegment(item);
236
+ if (seg)
237
+ segs.push(seg);
238
+ }
239
+ return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
240
+ }
241
+ export function buildSendAction(target, message) {
242
+ if (target.message_type === 'group') {
243
+ return {
244
+ action: 'send_group_msg',
245
+ params: {
246
+ group_id: Number(target.id) || target.id,
247
+ message,
248
+ },
249
+ };
250
+ }
251
+ return {
252
+ action: 'send_private_msg',
253
+ params: {
254
+ user_id: Number(target.id) || target.id,
255
+ message,
256
+ },
257
+ };
258
+ }
259
+ /** Build WS connect URL + headers (access_token via Bearer + query). */
260
+ export function buildWsConnectOptions(config) {
261
+ const headers = {};
262
+ let connectUrl = config.url;
263
+ if (config.access_token) {
264
+ headers.Authorization = `Bearer ${config.access_token}`;
265
+ const url = new URL(config.url);
266
+ url.searchParams.set('access_token', config.access_token);
267
+ connectUrl = url.toString();
268
+ }
269
+ const safeUrl = new URL(connectUrl);
270
+ safeUrl.searchParams.delete('access_token');
271
+ return { url: connectUrl, headers, safeUrl: safeUrl.toString() };
272
+ }
273
+ /**
274
+ * 向 NapCat HTTP API 发送动作请求:POST {http_url}/{action}。
275
+ * 供 connection:http 出站与纯协议测试使用。
276
+ */
277
+ export async function callNapCatHttpAction(options, action, params = {}) {
278
+ const base = options.http_url.replace(/\/$/, '');
279
+ const url = `${base}/${action}`;
280
+ const headers = { 'Content-Type': 'application/json' };
281
+ if (options.access_token) {
282
+ headers.Authorization = `Bearer ${options.access_token}`;
283
+ }
284
+ const res = await fetch(url, {
285
+ method: 'POST',
286
+ headers,
287
+ body: JSON.stringify(params),
288
+ });
289
+ const text = await res.text();
290
+ if (res.status === 401)
291
+ throw new Error(`NapCat HTTP auth failed: ${text}`);
292
+ if (res.status !== 200)
293
+ throw new Error(`NapCat HTTP ${res.status}: ${text}`);
294
+ let data;
295
+ try {
296
+ data = JSON.parse(text);
297
+ }
298
+ catch {
299
+ throw new Error(`NapCat HTTP invalid response: ${text.slice(0, 200)}`);
300
+ }
301
+ if (data.status !== 'ok' && data.retcode !== 0) {
302
+ throw new Error(`NapCat HTTP action failed [${data.retcode}]: ${data.message || data.wording || 'unknown'}`);
303
+ }
304
+ return data;
305
+ }
@@ -0,0 +1,7 @@
1
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
2
+ import { type getAdapterLogger } from '@zhin.js/logger';
3
+ import type { NapCatEvent } from './protocol.js';
4
+ export interface NapCatSideEventCaller {
5
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
6
+ }
7
+ export declare function receiveNapCatSideEvent(emit: EndpointEventEmitter, endpointKey: string, caller: NapCatSideEventCaller, raw: NapCatEvent, logger: ReturnType<typeof getAdapterLogger>): void;
@@ -0,0 +1,36 @@
1
+ import { receiveOneBotLikeSideEvent } from '@zhin.js/core';
2
+ import { formatCompact } from '@zhin.js/logger';
3
+ export function receiveNapCatSideEvent(emit, endpointKey, caller, raw, logger) {
4
+ if (!emit)
5
+ return;
6
+ const record = raw;
7
+ const postType = String(record.post_type ?? '');
8
+ const requestType = String(record.request_type ?? '');
9
+ const isRequest = postType === 'request' || postType.startsWith('request.');
10
+ const isFriend = requestType === 'friend' || postType.includes('friend');
11
+ void receiveOneBotLikeSideEvent(emit, {
12
+ adapter: 'napcat',
13
+ endpointKey,
14
+ platform: 'napcat',
15
+ raw: record,
16
+ ...(isRequest ? {
17
+ approve: async (flag, remark) => {
18
+ await (isFriend
19
+ ? caller.callApi('set_friend_add_request', { flag, approve: true, remark })
20
+ : caller.callApi('set_group_add_request', { flag, approve: true, reason: remark }));
21
+ },
22
+ reject: async (flag, reason) => {
23
+ await (isFriend
24
+ ? caller.callApi('set_friend_add_request', { flag, approve: false })
25
+ : caller.callApi('set_group_add_request', { flag, approve: false, reason }));
26
+ },
27
+ } : {}),
28
+ }).catch((err) => {
29
+ logger.warn(formatCompact({
30
+ op: 'napcat_side_event_failed',
31
+ endpoint: endpointKey,
32
+ post_type: postType || undefined,
33
+ error: err instanceof Error ? err.message : String(err),
34
+ }));
35
+ });
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ export declare function readRequestBody(request: IncomingMessage): Promise<string>;
package/lib/webhook.js ADDED
@@ -0,0 +1,14 @@
1
+ export async function readRequestBody(request) {
2
+ const chunks = [];
3
+ let size = 0;
4
+ for await (const chunk of request) {
5
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
6
+ size += buffer.length;
7
+ if (size > 1_048_576) {
8
+ request.destroy();
9
+ throw new Error('Request body exceeds 1MB');
10
+ }
11
+ chunks.push(buffer);
12
+ }
13
+ return Buffer.concat(chunks).toString('utf8');
14
+ }
@@ -0,0 +1,27 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ import { type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
3
+ import type { CapabilityId } from 'zhin.js';
4
+ import { type NapCatEvent, type NapCatWsConfig } from './protocol.js';
5
+ import { type NapCatWsCreateOptions, type NapCatWsSocket } from './ws-types.js';
6
+ import { NapcatClient } from './client.js';
7
+ export interface NapCatWsEndpointOptions {
8
+ readonly id: CapabilityId;
9
+ readonly config: NapCatWsConfig;
10
+ readonly createWebSocket?: (url: string, options: NapCatWsCreateOptions) => NapCatWsSocket;
11
+ }
12
+ export declare class NapCatWsEndpoint extends Endpoint<NapcatClient> {
13
+ #private;
14
+ readonly client: NapcatClient;
15
+ readonly management: EndpointManagement;
16
+ readonly control: EndpointControl;
17
+ readonly content: import("@zhin.js/adapter").EndpointContentPort;
18
+ constructor(options: NapCatWsEndpointOptions);
19
+ start(): Promise<void>;
20
+ open(): void;
21
+ close(): void;
22
+ stop(): Promise<void>;
23
+ send({ conversation, payload }: EndpointSendRequest): Promise<string>;
24
+ recallMessage(messageId: string): Promise<void>;
25
+ /** Test / internal: admit a parsed event when the endpoint is open. */
26
+ admit(ev: NapCatEvent): void;
27
+ }
@@ -0,0 +1,241 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ /**
3
+ * NapCat WS client endpoint — outbound connect to NapCat.
4
+ */
5
+ import WebSocket from 'ws';
6
+ import { createRecallEndpointControl, createEndpointLifecycle, } from 'zhin.js/adapter';
7
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
+ import { createNapCatEndpointManagement } from './endpoint-management.js';
9
+ import { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
10
+ import { buildSendAction, buildWsConnectOptions, formatInboundContent, formatOutboundSegments, isMessageEvent, napcatInboundConversation, napcatOutboundTarget, senderNickname, senderUserId, } from './protocol.js';
11
+ import { receiveNapCatSideEvent } from './side-event-dispatch.js';
12
+ import { callNapCatWsAction, handleNapCatWsMessage, rejectAllPending, } from './ws-transport.js';
13
+ import { createNapCatContentPort } from './onebot-get-msg.js';
14
+ import { NapcatClient } from './client.js';
15
+ export class NapCatWsEndpoint extends Endpoint {
16
+ client = new NapcatClient((action, params) => this.#callApi(action, params));
17
+ #logger;
18
+ #options;
19
+ #inboundDeduper = new InboundMessageDeduper();
20
+ management = createNapCatEndpointManagement(this.client);
21
+ control = createRecallEndpointControl((id) => this.recallMessage(id));
22
+ content = createNapCatContentPort((action, params) => this.client.callApi(action, params));
23
+ #lifecycle;
24
+ #ws;
25
+ #requestId = { value: 0 };
26
+ #pending = new Map();
27
+ #open = false;
28
+ constructor(options) {
29
+ super();
30
+ this.#logger = getAdapterLogger('napcat', options.config.id);
31
+ this.#options = options;
32
+ this.#lifecycle = createEndpointLifecycle({
33
+ name: options.config.id,
34
+ // reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
35
+ reconnect: {
36
+ initialIntervalMs: options.config.reconnect_interval,
37
+ multiplier: 1,
38
+ maxIntervalMs: Number.MAX_SAFE_INTEGER,
39
+ jitterMs: 0,
40
+ },
41
+ });
42
+ }
43
+ async start() {
44
+ if (this.#lifecycle.started)
45
+ return;
46
+ await this.#lifecycle.start((handle) => this.#connect(handle));
47
+ }
48
+ open() {
49
+ this.#open = true;
50
+ }
51
+ close() {
52
+ this.#open = false;
53
+ }
54
+ async stop() {
55
+ this.#open = false;
56
+ await this.#lifecycle.stop();
57
+ rejectAllPending(this.#pending);
58
+ this.#inboundDeduper.clear();
59
+ if (this.#ws) {
60
+ try {
61
+ this.#ws.close();
62
+ }
63
+ catch {
64
+ /* ignore */
65
+ }
66
+ this.#ws = undefined;
67
+ }
68
+ }
69
+ async send({ conversation, payload }) {
70
+ const message = formatOutboundSegments(payload);
71
+ const { action, params } = buildSendAction(napcatOutboundTarget(conversation), message);
72
+ const data = await this.client.callApi(action, params);
73
+ const messageId = data?.message_id != null ? String(data.message_id) : '';
74
+ this.#logger.debug(formatCompact({
75
+ op: 'napcat_send',
76
+ endpoint: this.#options.config.id,
77
+ target: `${conversation.kind}:${conversation.id}`,
78
+ messageId,
79
+ }));
80
+ return messageId;
81
+ }
82
+ async recallMessage(messageId) {
83
+ if (!messageId)
84
+ return;
85
+ await this.client.callApi('delete_msg', { message_id: Number(messageId) });
86
+ }
87
+ #callApi(action, params = {}) {
88
+ return callNapCatWsAction(this.#ws, this.#pending, this.#requestId, action, params);
89
+ }
90
+ /** Test / internal: admit a parsed event when the endpoint is open. */
91
+ admit(ev) {
92
+ if (!this.#open)
93
+ return;
94
+ void this.emitPlatform(napCatPlatformEventName(ev), ev).catch((error) => {
95
+ this.#logger.warn(formatCompact({
96
+ op: 'napcat_platform_event_failed',
97
+ error: error instanceof Error ? error.message : String(error),
98
+ }));
99
+ });
100
+ if (!isMessageEvent(ev)) {
101
+ receiveNapCatSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, this.client, ev, this.#logger);
102
+ return;
103
+ }
104
+ if (isSelfMessage(ev))
105
+ return;
106
+ const msgId = String(ev.message_id);
107
+ if (!this.#inboundDeduper.shouldProcess(msgId))
108
+ return;
109
+ if (Array.isArray(ev.message) || typeof ev.message === 'string') {
110
+ ev = { ...ev, message: normalizeMessage(ev.message) };
111
+ }
112
+ const conversation = napcatInboundConversation(String(this.#options.id), ev);
113
+ const content = formatInboundContent(ev);
114
+ const nickname = senderNickname(ev);
115
+ const mentioned = isNapCatBotMentioned(ev);
116
+ void this.emit('message.receive', {
117
+ conversation,
118
+ message: { conversation, id: msgId },
119
+ content,
120
+ sender: {
121
+ id: senderUserId(ev),
122
+ name: nickname,
123
+ ...(ev.sender?.role ? { roles: [ev.sender.role] } : {}),
124
+ },
125
+ endpointId: this.#options.config.id,
126
+ ...(mentioned ? { mentioned: true } : {}),
127
+ metadata: Object.freeze({
128
+ message_type: ev.message_type,
129
+ user_id: ev.user_id != null ? String(ev.user_id) : undefined,
130
+ group_id: ev.group_id != null ? String(ev.group_id) : undefined,
131
+ time: ev.time,
132
+ self_id: ev.self_id != null ? String(ev.self_id) : undefined,
133
+ role: ev.sender?.role,
134
+ ...(nickname ? { nickname } : {}),
135
+ }),
136
+ }).catch((err) => {
137
+ this.#logger.warn(formatCompact({
138
+ op: 'napcat_gateway_receive_failed',
139
+ target: `${conversation.kind}:${conversation.id}`,
140
+ error: err instanceof Error ? err.message : String(err),
141
+ }));
142
+ });
143
+ }
144
+ async #connect(handle) {
145
+ const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
146
+ const create = this.#options.createWebSocket
147
+ ?? ((connectUrl, options) => new WebSocket(connectUrl, { headers: options.headers }));
148
+ await new Promise((resolve, reject) => {
149
+ let settled = false;
150
+ const ws = create(url, { headers });
151
+ this.#ws = ws;
152
+ handle.onForceClose(() => {
153
+ try {
154
+ ws.close();
155
+ }
156
+ catch {
157
+ /* ignore */
158
+ }
159
+ });
160
+ ws.on('open', () => {
161
+ if (settled)
162
+ return;
163
+ settled = true;
164
+ if (!this.#options.config.access_token) {
165
+ this.#logger.warn(formatCompact({
166
+ endpoint: this.#options.config.id,
167
+ ok: false,
168
+ error: 'missing access_token',
169
+ }));
170
+ }
171
+ this.#logger.debug(formatCompact({
172
+ endpoint: this.#options.config.id,
173
+ mode: 'ws',
174
+ url: safeUrl,
175
+ }));
176
+ // stop-during-connect 竞态:已停止则不再武装心跳(基座 stop 已清理定时器)
177
+ if (this.#lifecycle.started) {
178
+ this.#lifecycle.startHeartbeat(() => {
179
+ try {
180
+ ws.ping?.();
181
+ }
182
+ catch {
183
+ /* ignore */
184
+ }
185
+ }, this.#options.config.heartbeat_interval);
186
+ }
187
+ resolve();
188
+ });
189
+ ws.on('message', (data) => {
190
+ handleNapCatWsMessage(data, {
191
+ endpointId: this.#options.config.id,
192
+ pending: this.#pending,
193
+ admit: (event) => this.admit(event),
194
+ });
195
+ });
196
+ ws.on('close', (code, reason) => {
197
+ const reasonStr = typeof reason === 'string'
198
+ ? reason
199
+ : Buffer.isBuffer(reason)
200
+ ? reason.toString()
201
+ : String(reason ?? '');
202
+ const codeNum = typeof code === 'number' ? code : Number(code ?? 0);
203
+ const codeHint = codeNum === 1005
204
+ ? ' [no status]'
205
+ : codeNum === 1006
206
+ ? ' [abnormal]'
207
+ : '';
208
+ this.#logger.warn(formatCompact({
209
+ op: 'disconnect',
210
+ code: codeNum,
211
+ error: `${reasonStr || 'closed'}${codeHint}`,
212
+ reconnect_ms: this.#options.config.reconnect_interval,
213
+ }));
214
+ // 基座语义:仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
215
+ handle.notifyClosed(new Error(`NapCat WS closed: ${codeNum} ${reasonStr}`));
216
+ if (!settled) {
217
+ settled = true;
218
+ reject(new Error(`NapCat WS closed: ${codeNum} ${reasonStr}`));
219
+ }
220
+ });
221
+ ws.on('error', (err) => {
222
+ const error = err instanceof Error ? err : new Error(String(err));
223
+ this.#logger.warn(formatCompact({
224
+ op: 'ws_error',
225
+ endpoint: this.#options.config.id,
226
+ ok: false,
227
+ error: error.message,
228
+ }));
229
+ if (!settled) {
230
+ settled = true;
231
+ reject(error);
232
+ }
233
+ });
234
+ });
235
+ }
236
+ }
237
+ function napCatPlatformEventName(ev) {
238
+ return [ev.post_type, ev.message_type ?? ev.notice_type ?? ev.request_type, ev.sub_type]
239
+ .filter(Boolean)
240
+ .join('.');
241
+ }
@@ -0,0 +1,13 @@
1
+ import { type NapCatEvent } from './protocol.js';
2
+ import { type NapCatPendingAction, type NapCatWsSocket } from './ws-types.js';
3
+ export declare function decodeWsPayload(data: unknown): string;
4
+ export declare function handleNapCatWsMessage(data: unknown, options: {
5
+ readonly endpointId: string;
6
+ readonly pending: Map<string, NapCatPendingAction>;
7
+ readonly admit: (ev: NapCatEvent) => void;
8
+ }): void;
9
+ export declare function callNapCatWsAction(ws: NapCatWsSocket | undefined, pending: Map<string, NapCatPendingAction>, requestId: {
10
+ value: number;
11
+ }, action: string, params: Record<string, unknown>): Promise<unknown>;
12
+ export declare function startNapCatHeartbeat(ws: NapCatWsSocket | undefined, intervalMs: number, existingTimer?: NodeJS.Timeout): NodeJS.Timeout;
13
+ export declare function rejectAllPending(pending: Map<string, NapCatPendingAction>, message?: string): void;