@zhin.js/adapter-napcat 0.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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * NapCat Bot 抽象基类
3
+ * 子类只需实现 callApi / $connect / $disconnect,其余 API 方法和事件处理全部复用。
4
+ */
5
+ import { EventEmitter } from 'events';
6
+ import {
7
+ Bot,
8
+ Message,
9
+ SendOptions,
10
+ segment,
11
+ Notice,
12
+ Request,
13
+ } from 'zhin.js';
14
+ import type { NapCatBotConfig, NapCatMessageEvent, MessageSegment, ApiResponse } from './types.js';
15
+ import type { NapCatAdapter } from './adapter.js';
16
+
17
+ export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBotConfig, NapCatMessageEvent> {
18
+ $connected = false;
19
+
20
+ get logger() { return this.adapter.plugin.logger; }
21
+ get $id() { return this.$config.name; }
22
+
23
+ constructor(public adapter: NapCatAdapter, public $config: NapCatBotConfig) {
24
+ super();
25
+ }
26
+
27
+ abstract $connect(): Promise<void>;
28
+ abstract $disconnect(): Promise<void>;
29
+ abstract callApi<T = any>(action: string, params?: Record<string, any>): Promise<T>;
30
+
31
+ // ══════════════════════════════════════════════════════════════════
32
+ // 消息格式化
33
+ // ══════════════════════════════════════════════════════════════════
34
+
35
+ $formatMessage(ev: NapCatMessageEvent): Message<NapCatMessageEvent> {
36
+ const message = Message.from(ev, {
37
+ $id: ev.message_id.toString(),
38
+ $adapter: 'napcat',
39
+ $bot: this.$config.name,
40
+ $sender: {
41
+ id: ev.user_id.toString(),
42
+ name: ev.sender?.nickname || ev.user_id.toString(),
43
+ role: ev.sender?.role,
44
+ },
45
+ $channel: {
46
+ id: (ev.group_id || ev.user_id).toString(),
47
+ type: ev.group_id ? 'group' : 'private',
48
+ },
49
+ $content: ev.message,
50
+ $raw: ev.raw_message,
51
+ $timestamp: ev.time,
52
+ $recall: async () => { await this.deleteMsg(ev.message_id); },
53
+ $reply: async (content: any[], quote?: boolean | string): Promise<string> => {
54
+ if (quote) content.unshift({ type: 'reply', data: { message_id: ev.message_id.toString() } });
55
+ return await this.adapter.sendMessage({
56
+ id: (ev.group_id || ev.user_id).toString(),
57
+ type: ev.group_id ? 'group' : 'private',
58
+ context: 'napcat',
59
+ bot: this.$config.name,
60
+ content,
61
+ });
62
+ },
63
+ });
64
+ return message;
65
+ }
66
+
67
+ async $sendMessage(options: SendOptions): Promise<string> {
68
+ const msg: any = { message: options.content };
69
+ if (options.type === 'group') {
70
+ const result = await this.callApi<{ message_id: number }>('send_group_msg', { group_id: parseInt(options.id), ...msg });
71
+ this.logger.debug(`${this.$id} send group(${options.id}):${segment.raw(options.content)}`);
72
+ return result.message_id.toString();
73
+ }
74
+ if (options.type === 'private') {
75
+ const result = await this.callApi<{ message_id: number }>('send_private_msg', { user_id: parseInt(options.id), ...msg });
76
+ this.logger.debug(`${this.$id} send private(${options.id}):${segment.raw(options.content)}`);
77
+ return result.message_id.toString();
78
+ }
79
+ throw new Error('Either group_id or user_id must be provided');
80
+ }
81
+
82
+ async $recallMessage(id: string): Promise<void> {
83
+ await this.deleteMsg(parseInt(id));
84
+ }
85
+
86
+ // ══════════════════════════════════════════════════════════════════
87
+ // 事件分发
88
+ // ══════════════════════════════════════════════════════════════════
89
+
90
+ protected dispatchEvent(event: any): void {
91
+ switch (event.post_type) {
92
+ case 'message':
93
+ case 'message_sent':
94
+ return this.handleMessage(event);
95
+ case 'notice':
96
+ return this.handleNotice(event);
97
+ case 'request':
98
+ return this.handleRequest(event);
99
+ case 'meta_event':
100
+ return this.handleMeta(event);
101
+ }
102
+ }
103
+
104
+ private handleMessage(ev: NapCatMessageEvent): void {
105
+ const message = this.$formatMessage(ev);
106
+ this.adapter.emit('message.receive', message);
107
+ this.logger.debug(`${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`);
108
+ }
109
+
110
+ private handleNotice(event: any): void {
111
+ const noticeTypeMap: Record<string, string> = {
112
+ group_increase: 'group_member_increase',
113
+ group_decrease: 'group_member_decrease',
114
+ group_admin: 'group_admin_change',
115
+ group_ban: 'group_ban',
116
+ group_recall: 'group_recall',
117
+ friend_recall: 'friend_recall',
118
+ friend_add: 'friend_add',
119
+ group_upload: 'group_upload',
120
+ group_card: 'group_card_change',
121
+ essence: event.sub_type === 'add' ? 'essence_add' : 'essence_delete',
122
+ notify: this.resolveNotifyType(event),
123
+ group_msg_emoji_like: 'group_emoji_reaction',
124
+ };
125
+ const $type = noticeTypeMap[event.notice_type] || event.notice_type;
126
+ const isGroup = !!event.group_id;
127
+ const notice = Notice.from(event, {
128
+ $id: `${event.time}_${event.notice_type}_${event.group_id || event.user_id}`,
129
+ $adapter: 'napcat',
130
+ $bot: this.$config.name,
131
+ $type,
132
+ $subType: event.sub_type,
133
+ $channel: {
134
+ id: (event.group_id || event.user_id)?.toString() || '',
135
+ type: isGroup ? 'group' : 'private',
136
+ },
137
+ $operator: event.operator_id ? { id: event.operator_id.toString(), name: event.operator_id.toString() } : undefined,
138
+ $target: event.user_id ? { id: event.user_id.toString(), name: event.user_id.toString() } : undefined,
139
+ $timestamp: event.time || Math.floor(Date.now() / 1000),
140
+ });
141
+ this.adapter.emit('notice.receive', notice);
142
+ }
143
+
144
+ private resolveNotifyType(event: any): string {
145
+ switch (event.sub_type) {
146
+ case 'poke': return event.group_id ? 'group_poke' : 'friend_poke';
147
+ case 'input_status': return 'input_status';
148
+ case 'title': return 'title_change';
149
+ case 'profile_like': return 'profile_like';
150
+ default: return `notify_${event.sub_type}`;
151
+ }
152
+ }
153
+
154
+ private handleRequest(event: any): void {
155
+ const typeMap: Record<string, string> = {
156
+ friend: 'friend_add',
157
+ group: event.sub_type === 'invite' ? 'group_invite' : 'group_add',
158
+ };
159
+ const $type = typeMap[event.request_type] || event.request_type;
160
+ const request = Request.from(event, {
161
+ $id: event.flag || `${event.time}_${event.request_type}_${event.user_id}`,
162
+ $adapter: 'napcat',
163
+ $bot: this.$config.name,
164
+ $type,
165
+ $subType: event.sub_type,
166
+ $channel: {
167
+ id: (event.group_id || event.user_id)?.toString() || '',
168
+ type: event.group_id ? 'group' : 'private',
169
+ },
170
+ $sender: { id: event.user_id?.toString() || '', name: event.user_id?.toString() || '' },
171
+ $comment: event.comment,
172
+ $timestamp: event.time || Math.floor(Date.now() / 1000),
173
+ $approve: async (remark?: string) => {
174
+ await this.callApi(
175
+ event.request_type === 'friend' ? 'set_friend_add_request' : 'set_group_add_request',
176
+ { flag: event.flag, approve: true, remark },
177
+ );
178
+ },
179
+ $reject: async (reason?: string) => {
180
+ await this.callApi(
181
+ event.request_type === 'friend' ? 'set_friend_add_request' : 'set_group_add_request',
182
+ { flag: event.flag, approve: false, reason },
183
+ );
184
+ },
185
+ });
186
+ this.adapter.emit('request.receive', request);
187
+ }
188
+
189
+ protected handleMeta(event: any): void {
190
+ // subclass may override for lifecycle handling
191
+ }
192
+
193
+ // ══════════════════════════════════════════════════════════════════
194
+ // OneBot11 标准 API
195
+ // ══════════════════════════════════════════════════════════════════
196
+
197
+ async sendMsg(messageType: 'private' | 'group', id: number, message: MessageSegment[]) {
198
+ return this.callApi<{ message_id: number }>('send_msg', { message_type: messageType, [messageType === 'group' ? 'group_id' : 'user_id']: id, message });
199
+ }
200
+ async deleteMsg(messageId: number) { return this.callApi('delete_msg', { message_id: messageId }); }
201
+ async getMsg(messageId: number) { return this.callApi('get_msg', { message_id: messageId }); }
202
+ async getForwardMsg(id: string) { return this.callApi('get_forward_msg', { id }); }
203
+ async sendLike(userId: number, times = 1) { return this.callApi('send_like', { user_id: userId, times }); }
204
+
205
+ // 群管理
206
+ async setGroupKick(groupId: number, userId: number, rejectAddRequest = false) { return this.callApi('set_group_kick', { group_id: groupId, user_id: userId, reject_add_request: rejectAddRequest }); }
207
+ async setGroupBan(groupId: number, userId: number, duration = 600) { return this.callApi('set_group_ban', { group_id: groupId, user_id: userId, duration }); }
208
+ async setGroupWholeBan(groupId: number, enable = true) { return this.callApi('set_group_whole_ban', { group_id: groupId, enable }); }
209
+ async setGroupAdmin(groupId: number, userId: number, enable = true) { return this.callApi('set_group_admin', { group_id: groupId, user_id: userId, enable }); }
210
+ async setGroupCard(groupId: number, userId: number, card: string) { return this.callApi('set_group_card', { group_id: groupId, user_id: userId, card }); }
211
+ async setGroupName(groupId: number, groupName: string) { return this.callApi('set_group_name', { group_id: groupId, group_name: groupName }); }
212
+ async setGroupLeave(groupId: number, isDismiss = false) { return this.callApi('set_group_leave', { group_id: groupId, is_dismiss: isDismiss }); }
213
+ async setGroupSpecialTitle(groupId: number, userId: number, specialTitle: string, duration = -1) { return this.callApi('set_group_special_title', { group_id: groupId, user_id: userId, special_title: specialTitle, duration }); }
214
+
215
+ // 好友/群请求
216
+ async setFriendAddRequest(flag: string, approve = true, remark?: string) { return this.callApi('set_friend_add_request', { flag, approve, remark }); }
217
+ async setGroupAddRequest(flag: string, subType: string, approve = true, reason?: string) { return this.callApi('set_group_add_request', { flag, sub_type: subType, approve, reason }); }
218
+
219
+ // 信息查询
220
+ async getLoginInfo() { return this.callApi('get_login_info'); }
221
+ async getStrangerInfo(userId: number, noCache = false) { return this.callApi('get_stranger_info', { user_id: userId, no_cache: noCache }); }
222
+ async getFriendList() { return this.callApi('get_friend_list'); }
223
+ async getGroupInfo(groupId: number, noCache = false) { return this.callApi('get_group_info', { group_id: groupId, no_cache: noCache }); }
224
+ async getGroupList() { return this.callApi('get_group_list'); }
225
+ async getGroupMemberInfo(groupId: number, userId: number, noCache = false) { return this.callApi('get_group_member_info', { group_id: groupId, user_id: userId, no_cache: noCache }); }
226
+ async getGroupMemberList(groupId: number) { return this.callApi('get_group_member_list', { group_id: groupId }); }
227
+ async getGroupHonorInfo(groupId: number, type: string) { return this.callApi('get_group_honor_info', { group_id: groupId, type }); }
228
+
229
+ // 凭证
230
+ async getCookies(domain?: string) { return this.callApi('get_cookies', { domain }); }
231
+ async getCsrfToken() { return this.callApi('get_csrf_token'); }
232
+ async getCredentials(domain?: string) { return this.callApi('get_credentials', { domain }); }
233
+
234
+ // 媒体
235
+ async getRecord(file: string, outFormat: string) { return this.callApi('get_record', { file, out_format: outFormat }); }
236
+ async getImage(file: string) { return this.callApi('get_image', { file }); }
237
+ async canSendImage() { return this.callApi('can_send_image'); }
238
+ async canSendRecord() { return this.callApi('can_send_record'); }
239
+
240
+ // 系统
241
+ async getStatus() { return this.callApi('get_status'); }
242
+ async getVersionInfo() { return this.callApi('get_version_info'); }
243
+ async cleanCache() { return this.callApi('clean_cache'); }
244
+
245
+ // ══════════════════════════════════════════════════════════════════
246
+ // go-cqhttp 扩展 API
247
+ // ══════════════════════════════════════════════════════════════════
248
+
249
+ async setQQProfile(nickname: string, company?: string, email?: string, college?: string, personalNote?: string) {
250
+ return this.callApi('set_qq_profile', { nickname, company, email, college, personal_note: personalNote });
251
+ }
252
+ async getOnlineClients(noCache = false) { return this.callApi('get_online_clients', { no_cache: noCache }); }
253
+ async deleteFriend(userId: number) { return this.callApi('delete_friend', { user_id: userId }); }
254
+ async markMsgAsRead(messageId: number) { return this.callApi('mark_msg_as_read', { message_id: messageId }); }
255
+ async sendGroupForwardMsg(groupId: number, messages: any[]) { return this.callApi('send_group_forward_msg', { group_id: groupId, messages }); }
256
+ async sendPrivateForwardMsg(userId: number, messages: any[]) { return this.callApi('send_private_forward_msg', { user_id: userId, messages }); }
257
+ async getGroupMsgHistory(groupId: number, messageSeq?: number, count?: number) { return this.callApi('get_group_msg_history', { group_id: groupId, message_seq: messageSeq, count }); }
258
+ async ocrImage(image: string) { return this.callApi('ocr_image', { image }); }
259
+ async getGroupSystemMsg() { return this.callApi('get_group_system_msg'); }
260
+ async getEssenceMsgList(groupId: number) { return this.callApi('get_essence_msg_list', { group_id: groupId }); }
261
+ async getGroupAtAllRemain(groupId: number) { return this.callApi('get_group_at_all_remain', { group_id: groupId }); }
262
+ async setGroupPortrait(groupId: number, file: string) { return this.callApi('set_group_portrait', { group_id: groupId, file }); }
263
+ async setEssenceMsg(messageId: number) { return this.callApi('set_essence_msg', { message_id: messageId }); }
264
+ async deleteEssenceMsg(messageId: number) { return this.callApi('delete_essence_msg', { message_id: messageId }); }
265
+ async sendGroupSign(groupId: number) { return this.callApi('send_group_sign', { group_id: groupId }); }
266
+ async sendGroupNotice(groupId: number, content: string, image?: string) { return this.callApi('_send_group_notice', { group_id: groupId, content, image }); }
267
+ async getGroupNotice(groupId: number) { return this.callApi('_get_group_notice', { group_id: groupId }); }
268
+ async deleteGroupNotice(groupId: number, noticeId: string) { return this.callApi('_del_group_notice', { group_id: groupId, notice_id: noticeId }); }
269
+ async uploadGroupFile(groupId: number, file: string, name: string, folder?: string) { return this.callApi('upload_group_file', { group_id: groupId, file, name, folder }); }
270
+ async deleteGroupFile(groupId: number, fileId: string, busid: number) { return this.callApi('delete_group_file', { group_id: groupId, file_id: fileId, busid }); }
271
+ async createGroupFileFolder(groupId: number, name: string, parentId = '/') { return this.callApi('create_group_file_folder', { group_id: groupId, name, parent_id: parentId }); }
272
+ async deleteGroupFolder(groupId: number, folderId: string) { return this.callApi('delete_group_folder', { group_id: groupId, folder_id: folderId }); }
273
+ async getGroupFileSystemInfo(groupId: number) { return this.callApi('get_group_file_system_info', { group_id: groupId }); }
274
+ async getGroupRootFiles(groupId: number) { return this.callApi('get_group_root_files', { group_id: groupId }); }
275
+ async getGroupFilesByFolder(groupId: number, folderId: string) { return this.callApi('get_group_files_by_folder', { group_id: groupId, folder_id: folderId }); }
276
+ async getGroupFileUrl(groupId: number, fileId: string, busid: number) { return this.callApi('get_group_file_url', { group_id: groupId, file_id: fileId, busid }); }
277
+ async uploadPrivateFile(userId: number, file: string, name: string) { return this.callApi('upload_private_file', { user_id: userId, file, name }); }
278
+ async downloadFile(url: string, threadCount = 1, headers?: string[]) { return this.callApi('download_file', { url, thread_count: threadCount, headers }); }
279
+ async checkUrlSafely(url: string) { return this.callApi('check_url_safely', { url }); }
280
+
281
+ // ══════════════════════════════════════════════════════════════════
282
+ // NapCat 独有 API
283
+ // ══════════════════════════════════════════════════════════════════
284
+
285
+ async setGroupSign(groupId: number) { return this.callApi('set_group_sign', { group_id: groupId }); }
286
+ async arkSharePeer(userId: number) { return this.callApi('ArkSharePeer', { user_id: userId }); }
287
+ async arkShareGroup(groupId: number) { return this.callApi('ArkShareGroup', { group_id: groupId }); }
288
+ async getRobotUinRange() { return this.callApi('get_robot_uin_range'); }
289
+ async setOnlineStatus(status: number, extStatus: number) { return this.callApi('set_online_status', { status, ext_status: extStatus }); }
290
+ async getFriendsWithCategory() { return this.callApi('get_friends_with_category'); }
291
+ async setQQAvatar(file: string) { return this.callApi('set_qq_avatar', { file }); }
292
+ async getFile(fileId: string) { return this.callApi('get_file', { file_id: fileId }); }
293
+ async forwardFriendSingleMsg(userId: number, messageId: number) { return this.callApi('forward_friend_single_msg', { user_id: userId, message_id: messageId }); }
294
+ async forwardGroupSingleMsg(groupId: number, messageId: number) { return this.callApi('forward_group_single_msg', { group_id: groupId, message_id: messageId }); }
295
+ async translateEn2Zh(sourceText: string) { return this.callApi('translate_en2zh', { source_text: sourceText }); }
296
+ async setMsgEmojiLike(messageId: number, emojiId: string) { return this.callApi('set_msg_emoji_like', { message_id: messageId, emoji_id: emojiId }); }
297
+ async sendForwardMsg(messageType: 'private' | 'group', id: number, messages: any[]) {
298
+ return this.callApi('send_forward_msg', { message_type: messageType, [messageType === 'group' ? 'group_id' : 'user_id']: id, messages });
299
+ }
300
+ async markPrivateMsgAsRead(userId: number) { return this.callApi('mark_private_msg_as_read', { user_id: userId }); }
301
+ async markGroupMsgAsRead(groupId: number) { return this.callApi('mark_group_msg_as_read', { group_id: groupId }); }
302
+ async getFriendMsgHistory(userId: number, messageSeq?: number, count?: number) { return this.callApi('get_friend_msg_history', { user_id: userId, message_seq: messageSeq, count }); }
303
+ async createCollection(briefContent: string, rawData: string) { return this.callApi('create_collection', { brief: briefContent, rawData }); }
304
+ async getCollectionList(page = 0, limit = 20) { return this.callApi('get_collection_list', { page, limit }); }
305
+ async setSelfLongnick(longnick: string) { return this.callApi('set_self_longnick', { longNick: longnick }); }
306
+ async getRecentContact(count = 10) { return this.callApi('get_recent_contact', { count }); }
307
+ async markAllAsRead() { return this.callApi('_mark_all_as_read'); }
308
+ async getProfileLike() { return this.callApi('get_profile_like'); }
309
+ async fetchCustomFace() { return this.callApi('fetch_custom_face'); }
310
+ async fetchEmojiLike(messageId: number, emojiId: string, emojiType: string) { return this.callApi('fetch_emoji_like', { message_id: messageId, emoji_id: emojiId, emoji_type: emojiType }); }
311
+ async setInputStatus(userId: number, eventType: string) { return this.callApi('set_input_status', { user_id: userId, event_type: eventType }); }
312
+ async getGroupInfoEx(groupId: number) { return this.callApi('get_group_info_ex', { group_id: groupId }); }
313
+ async getGroupIgnoreAddRequest(groupId: number) { return this.callApi('get_group_ignore_add_request', { group_id: groupId }); }
314
+ async friendPoke(userId: number) { return this.callApi('friend_poke', { user_id: userId }); }
315
+ async groupPoke(groupId: number, userId: number) { return this.callApi('group_poke', { group_id: groupId, user_id: userId }); }
316
+ async sendPoke(userId: number, groupId?: number) { return this.callApi('send_poke', { user_id: userId, group_id: groupId }); }
317
+ async ncGetPacketStatus() { return this.callApi('nc_get_packet_status'); }
318
+ async ncGetUserStatus(userId: number) { return this.callApi('nc_get_user_status', { user_id: userId }); }
319
+ async ncGetRkey() { return this.callApi('nc_get_rkey'); }
320
+ async getGroupShutList(groupId: number) { return this.callApi('get_group_shut_list', { group_id: groupId }); }
321
+ async getMiniAppArk(type: string, title: string, desc: string, picUrl: string, jumpUrl: string) {
322
+ return this.callApi('get_mini_app_ark', { type, title, desc, picUrl, jumpUrl });
323
+ }
324
+ async getAiRecord(groupId: number, characterId: string, text: string) { return this.callApi('get_ai_record', { group_id: groupId, character: characterId, text }); }
325
+ async getAiCharacters(groupId: number) { return this.callApi('get_ai_characters', { group_id: groupId }); }
326
+ async sendGroupAiRecord(groupId: number, characterId: string, text: string) { return this.callApi('send_group_ai_record', { group_id: groupId, character: characterId, text }); }
327
+
328
+ // ══════════════════════════════════════════════════════════════════
329
+ // Adapter 群管理接口适配
330
+ // ══════════════════════════════════════════════════════════════════
331
+
332
+ async kickMember(groupId: number, userId: number, reject = false) { await this.setGroupKick(groupId, userId, reject); return true; }
333
+ async muteMember(groupId: number, userId: number, duration = 600) { await this.setGroupBan(groupId, userId, duration); return true; }
334
+ async muteAll(groupId: number, enable = true) { await this.setGroupWholeBan(groupId, enable); return true; }
335
+ async setAdmin(groupId: number, userId: number, enable = true) { await this.setGroupAdmin(groupId, userId, enable); return true; }
336
+ async setCard(groupId: number, userId: number, card: string) { await this.setGroupCard(groupId, userId, card); return true; }
337
+ async setTitle(groupId: number, userId: number, title: string, duration = -1) { await this.setGroupSpecialTitle(groupId, userId, title, duration); return true; }
338
+ async getMemberList(groupId: number) { return this.getGroupMemberList(groupId); }
339
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * NapCat HTTP 连接方式
3
+ * 出站:HTTP POST 调用 NapCat API
4
+ * 入站:挂载 webhook 路由接收 NapCat 的 HTTP POST 事件上报
5
+ */
6
+ import { NapCatBotBase } from './bot-base.js';
7
+ import type { NapCatHttpConfig, ApiResponse } from './types.js';
8
+ import type { NapCatAdapter } from './adapter.js';
9
+ import type { Router } from '@zhin.js/http';
10
+ import * as crypto from 'crypto';
11
+
12
+ export class NapCatHttpBot extends NapCatBotBase {
13
+ private pollTimer?: NodeJS.Timeout;
14
+
15
+ declare $config: NapCatHttpConfig;
16
+
17
+ constructor(adapter: NapCatAdapter, public router: Router, config: NapCatHttpConfig) {
18
+ super(adapter, config);
19
+ }
20
+
21
+ async $connect(): Promise<void> {
22
+ this.mountWebhook();
23
+ await this.checkConnection();
24
+ this.startPoll();
25
+ this.$connected = true;
26
+ this.logger.info(`${this.$id} HTTP mode started (api: ${this.$config.http_url}, post: ${this.$config.post_path})`);
27
+ }
28
+
29
+ async $disconnect(): Promise<void> {
30
+ if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = undefined; }
31
+ this.$connected = false;
32
+ }
33
+
34
+ async callApi<T = any>(action: string, params: Record<string, any> = {}): Promise<T> {
35
+ const url = `${this.$config.http_url.replace(/\/$/, '')}/${action}`;
36
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
37
+ if (this.$config.access_token) headers['Authorization'] = `Bearer ${this.$config.access_token}`;
38
+
39
+ const resp = await fetch(url, { method: 'POST', headers, body: JSON.stringify(params) });
40
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText} for ${action}`);
41
+ const json = await resp.json() as ApiResponse<T>;
42
+ if (json.status !== 'ok' && json.retcode !== 0) {
43
+ throw new Error(`API error [${json.retcode}]: ${json.message || json.wording || 'unknown'}`);
44
+ }
45
+ return json.data;
46
+ }
47
+
48
+ private mountWebhook(): void {
49
+ const postPath = this.$config.post_path;
50
+ this.router.post(postPath, async (ctx: any) => {
51
+ const body = ctx.request.body;
52
+ if (!body || typeof body !== 'object') { ctx.status = 400; ctx.body = { error: 'invalid body' }; return; }
53
+
54
+ if (this.$config.access_token) {
55
+ const sig = ctx.headers['x-signature'];
56
+ if (sig) {
57
+ const expected = 'sha1=' + crypto.createHmac('sha1', this.$config.access_token).update(JSON.stringify(body)).digest('hex');
58
+ if (sig !== expected) { ctx.status = 403; ctx.body = { error: 'signature mismatch' }; return; }
59
+ }
60
+ }
61
+
62
+ ctx.status = 204;
63
+ ctx.body = '';
64
+ try { this.dispatchEvent(body); } catch (e) { this.logger.warn(`${this.$id} HTTP event dispatch error: ${e}`); }
65
+ });
66
+ this.logger.info(`${this.$id} webhook mounted at ${postPath}`);
67
+ }
68
+
69
+ private async checkConnection(): Promise<void> {
70
+ try {
71
+ await this.callApi('get_login_info');
72
+ } catch (e) {
73
+ throw new Error(`${this.$id} cannot connect to NapCat HTTP API at ${this.$config.http_url}: ${e}`);
74
+ }
75
+ }
76
+
77
+ private startPoll(): void {
78
+ const interval = this.$config.poll_interval || 30000;
79
+ this.pollTimer = setInterval(async () => {
80
+ try {
81
+ await this.callApi('get_status');
82
+ } catch {
83
+ this.$connected = false;
84
+ this.logger.warn(`${this.$id} HTTP heartbeat failed, marking as disconnected`);
85
+ }
86
+ }, interval);
87
+ }
88
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * NapCat 正向 WebSocket 连接
3
+ */
4
+ import WebSocket from 'ws';
5
+ import { NapCatBotBase } from './bot-base.js';
6
+ import type { NapCatWsClientConfig, ApiResponse } from './types.js';
7
+ import type { NapCatAdapter } from './adapter.js';
8
+
9
+ export class NapCatWsClient extends NapCatBotBase {
10
+ private ws?: WebSocket;
11
+ private reconnectTimer?: NodeJS.Timeout;
12
+ private heartbeatTimer?: NodeJS.Timeout;
13
+ private requestId = 0;
14
+ private pendingRequests = new Map<string, {
15
+ resolve: (value: any) => void;
16
+ reject: (error: Error) => void;
17
+ timeout: NodeJS.Timeout;
18
+ }>();
19
+
20
+ declare $config: NapCatWsClientConfig;
21
+
22
+ constructor(adapter: NapCatAdapter, config: NapCatWsClientConfig) {
23
+ super(adapter, config);
24
+ }
25
+
26
+ async $connect(): Promise<void> {
27
+ return new Promise((resolve, reject) => {
28
+ const headers: Record<string, string> = {};
29
+ let url = this.$config.url;
30
+ if (this.$config.access_token) {
31
+ headers['Authorization'] = `Bearer ${this.$config.access_token}`;
32
+ const u = new URL(url);
33
+ u.searchParams.set('access_token', this.$config.access_token);
34
+ url = u.toString();
35
+ }
36
+ this.ws = new WebSocket(url, { headers });
37
+
38
+ this.ws.on('open', () => {
39
+ this.$connected = true;
40
+ if (!this.$config.access_token) this.logger.warn(`[${this.$id}] missing 'access_token', connection is not secure`);
41
+ this.logger.info(`${this.$id} connected (WS forward: ${this.$config.url})`);
42
+ this.startHeartbeat();
43
+ resolve();
44
+ });
45
+
46
+ this.ws.on('message', (data) => {
47
+ try {
48
+ this.handleWsMessage(JSON.parse(data.toString()));
49
+ } catch (error) {
50
+ this.emit('error', error);
51
+ }
52
+ });
53
+
54
+ this.ws.on('close', (code, reason) => {
55
+ this.$connected = false;
56
+ const reasonStr = reason?.toString?.() || '';
57
+ const codeHint = code === 1005 ? ' [no status]' : code === 1006 ? ' [abnormal]' : '';
58
+ this.logger.warn(`${this.$id} disconnected (code=${code}${codeHint}${reasonStr ? `, reason=${reasonStr}` : ''}), reconnecting in ${this.$config.reconnect_interval || 5000}ms`);
59
+ reject({ code, reason });
60
+ this.scheduleReconnect();
61
+ });
62
+
63
+ this.ws.on('error', (error) => {
64
+ this.logger.warn(`${this.$id} WS error: ${error instanceof Error ? error.message : String(error)}`);
65
+ reject(error);
66
+ });
67
+ });
68
+ }
69
+
70
+ async $disconnect(): Promise<void> {
71
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
72
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
73
+ for (const [, req] of this.pendingRequests) { clearTimeout(req.timeout); req.reject(new Error('Connection closed')); }
74
+ this.pendingRequests.clear();
75
+ if (this.ws) { this.ws.close(); this.ws = undefined; }
76
+ this.$connected = false;
77
+ }
78
+
79
+ async callApi<T = any>(action: string, params: Record<string, any> = {}): Promise<T> {
80
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error('WebSocket is not connected');
81
+ const echo = `req_${++this.requestId}`;
82
+ return new Promise((resolve, reject) => {
83
+ const timeout = setTimeout(() => { this.pendingRequests.delete(echo); reject(new Error(`API call timeout: ${action}`)); }, 30000);
84
+ this.pendingRequests.set(echo, { resolve, reject, timeout });
85
+ this.ws!.send(JSON.stringify({ action, params, echo }));
86
+ });
87
+ }
88
+
89
+ private handleWsMessage(message: any): void {
90
+ if (message.echo && this.pendingRequests.has(message.echo)) {
91
+ const req = this.pendingRequests.get(message.echo)!;
92
+ this.pendingRequests.delete(message.echo);
93
+ clearTimeout(req.timeout);
94
+ const resp = message as ApiResponse;
95
+ if (resp.status === 'ok') return req.resolve(resp.data);
96
+ return req.reject(new Error(`API error [${resp.retcode}]: ${resp.message || resp.wording || 'unknown'}`));
97
+ }
98
+ this.dispatchEvent(message);
99
+ }
100
+
101
+ private startHeartbeat(): void {
102
+ const interval = this.$config.heartbeat_interval || 30000;
103
+ this.heartbeatTimer = setInterval(() => {
104
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.ping();
105
+ }, interval);
106
+ }
107
+
108
+ private scheduleReconnect(): void {
109
+ if (this.reconnectTimer) return;
110
+ const interval = this.$config.reconnect_interval || 5000;
111
+ this.reconnectTimer = setTimeout(async () => {
112
+ this.reconnectTimer = undefined;
113
+ try { await this.$connect(); } catch { this.scheduleReconnect(); }
114
+ }, interval);
115
+ }
116
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * NapCat 反向 WebSocket 连接
3
+ */
4
+ import WebSocket, { WebSocketServer } from 'ws';
5
+ import type { IncomingMessage } from 'http';
6
+ import { NapCatBotBase } from './bot-base.js';
7
+ import type { NapCatWsServerConfig, ApiResponse } from './types.js';
8
+ import type { NapCatAdapter } from './adapter.js';
9
+ import type { Router } from '@zhin.js/http';
10
+
11
+ export class NapCatWsServer extends NapCatBotBase {
12
+ #wss?: WebSocketServer;
13
+ #clientMap = new Map<string, WebSocket>();
14
+ private heartbeatTimer?: NodeJS.Timeout;
15
+ private requestId = 0;
16
+ private pendingRequests = new Map<string, {
17
+ resolve: (value: any) => void;
18
+ reject: (error: Error) => void;
19
+ timeout: NodeJS.Timeout;
20
+ }>();
21
+
22
+ declare $config: NapCatWsServerConfig;
23
+
24
+ constructor(adapter: NapCatAdapter, public router: Router, config: NapCatWsServerConfig) {
25
+ super(adapter, config);
26
+ }
27
+
28
+ async $connect(): Promise<void> {
29
+ if (!this.$config.access_token) this.logger.warn(`[${this.$id}] missing 'access_token', connection is not secure`);
30
+ this.#wss = this.router.ws(this.$config.path, {
31
+ verifyClient: (info: { origin: string; secure: boolean; req: IncomingMessage }) => {
32
+ const authorization = info.req.headers['authorization'] || '';
33
+ if (this.$config.access_token && authorization !== `Bearer ${this.$config.access_token}`) {
34
+ this.logger.error(`[${this.$id}] auth failed`);
35
+ return false;
36
+ }
37
+ return true;
38
+ },
39
+ });
40
+ this.logger.info(`${this.$id} WS server started at path: ${this.$config.path}`);
41
+
42
+ this.#wss.on('connection', (client, req) => {
43
+ this.startHeartbeat();
44
+ this.logger.info(`${this.$id} client connected: ${req.socket.remoteAddress}`);
45
+
46
+ client.on('error', (err) => this.logger.warn(`${this.$id} WS error: ${err instanceof Error ? err.message : String(err)}`));
47
+ client.on('close', (code, reason) => {
48
+ const reasonStr = reason?.toString?.() || '';
49
+ this.logger.warn(`${this.$id} client disconnected (code=${code}${reasonStr ? `, reason=${reasonStr}` : ''})`);
50
+ for (const [key, val] of this.#clientMap) {
51
+ if (val === client) this.#clientMap.delete(key);
52
+ }
53
+ if (this.#clientMap.size === 0) this.$connected = false;
54
+ });
55
+ client.on('message', (data) => {
56
+ try { this.handleWsMessage(client, JSON.parse(data.toString())); } catch (e) { this.emit('error', e); }
57
+ });
58
+ });
59
+ }
60
+
61
+ async $disconnect(): Promise<void> {
62
+ this.#wss?.close();
63
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
64
+ for (const [, req] of this.pendingRequests) { clearTimeout(req.timeout); req.reject(new Error('Connection closed')); }
65
+ this.pendingRequests.clear();
66
+ this.$connected = false;
67
+ }
68
+
69
+ async callApi<T = any>(action: string, params: Record<string, any> = {}): Promise<T> {
70
+ const selfId = this.getFirstSelfId();
71
+ const client = this.#clientMap.get(selfId);
72
+ if (!client || client.readyState !== WebSocket.OPEN) throw new Error('WebSocket is not connected');
73
+ const echo = `req_${++this.requestId}`;
74
+ return new Promise((resolve, reject) => {
75
+ const timeout = setTimeout(() => { this.pendingRequests.delete(echo); reject(new Error(`API call timeout: ${action}`)); }, 30000);
76
+ this.pendingRequests.set(echo, { resolve, reject, timeout });
77
+ client.send(JSON.stringify({ action, params, echo }));
78
+ });
79
+ }
80
+
81
+ private getFirstSelfId(): string {
82
+ const first = this.#clientMap.keys().next().value;
83
+ if (!first) throw new Error('No NapCat client connected to reverse WS');
84
+ return first;
85
+ }
86
+
87
+ private handleWsMessage(client: WebSocket, message: any): void {
88
+ if (message.self_id != null) {
89
+ const selfIdStr = String(message.self_id);
90
+ if (!this.#clientMap.has(selfIdStr) || this.#clientMap.get(selfIdStr) !== client) {
91
+ this.#clientMap.set(selfIdStr, client);
92
+ if (!this.$connected) this.$connected = true;
93
+ }
94
+ }
95
+ if (message.echo && this.pendingRequests.has(message.echo)) {
96
+ const req = this.pendingRequests.get(message.echo)!;
97
+ this.pendingRequests.delete(message.echo);
98
+ clearTimeout(req.timeout);
99
+ const resp = message as ApiResponse;
100
+ if (resp.status === 'ok') return req.resolve(resp.data);
101
+ return req.reject(new Error(`API error [${resp.retcode}]: ${resp.message || resp.wording || 'unknown'}`));
102
+ }
103
+
104
+ if (message.post_type === 'meta_event' && message.sub_type === 'connect') {
105
+ this.#clientMap.set(String(message.self_id), client);
106
+ this.$connected = true;
107
+ this.logger.info(`${this.$id} client ${message.self_id} connected via lifecycle`);
108
+ return;
109
+ }
110
+ this.dispatchEvent(message);
111
+ }
112
+
113
+ private startHeartbeat(): void {
114
+ if (this.heartbeatTimer) return;
115
+ const interval = this.$config.heartbeat_interval || 30000;
116
+ this.heartbeatTimer = setInterval(() => {
117
+ for (const client of this.#wss?.clients || []) {
118
+ if (client.readyState === WebSocket.OPEN) client.ping();
119
+ }
120
+ }, interval);
121
+ }
122
+ }