@zhin.js/adapter-kook 1.1.3 → 1.1.5

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/src/ws.ts CHANGED
@@ -2,7 +2,12 @@
2
2
  * KOOK WebSocket transport: kook-client wrapper and inbound message normalization.
3
3
  */
4
4
  import path from 'node:path';
5
- import { Client } from 'kook-client';
5
+ import { createKookApiAgent, type KookApiProxy } from './api-proxy.js';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
8
+ import { Client, WebsocketReceiver } from 'kook-client';
9
+ import { createKookStreamSocket } from './stream-proxy.js';
10
+ import { createEndpointLifecycle, type EndpointTransportState } from 'zhin.js/adapter';
6
11
  import {
7
12
  type KookInboundMessage,
8
13
  type ResolvedKookConfig,
@@ -12,19 +17,24 @@ import {
12
17
 
13
18
  /** Minimal client surface used by the endpoint (real kook-client or test mock). */
14
19
  export interface KookClientTransport {
20
+ getTransportState?(): EndpointTransportState | undefined;
15
21
  connect(): Promise<void>;
16
22
  disconnect(): Promise<void>;
17
23
  on(event: string, listener: (...args: unknown[]) => void): void;
18
24
  removeAllListeners(): void;
19
25
  sendChannelMsg(
20
26
  channelId: string,
21
- message: string,
27
+ message: unknown,
28
+ quote?: { message_id: string },
22
29
  ): Promise<{ msg_id?: string | number }>;
23
30
  sendPrivateMsg(
24
31
  userId: string,
25
- message: string,
32
+ message: unknown,
33
+ quote?: { message_id: string },
26
34
  ): Promise<{ msg_id?: string | number }>;
27
- recallMsg?(messageId: string): Promise<boolean>;
35
+ uploadMedia?(data: Buffer): Promise<string>;
36
+ recallChannelMsg?(channelId: string, messageId: string): Promise<boolean>;
37
+ recallPrivateMsg?(userId: string, messageId: string): Promise<boolean>;
28
38
  pickGuild(guildId: string): {
29
39
  kick(userId: string): Promise<boolean>;
30
40
  getRoleList(): Promise<Array<{
@@ -88,6 +98,12 @@ export function normalizeKookMessage(raw: unknown): KookInboundMessage | null {
88
98
  }
89
99
  }
90
100
  const content = textParts.join('') || msg.raw_message || '';
101
+ // kook-client resolves these getters through optional local caches and throws
102
+ // for uncached users/channels. Identity and routing come from the event itself.
103
+ let author: typeof msg.author;
104
+ let channel: typeof msg.channel;
105
+ try { author = msg.author; } catch { /* optional cache enrichment unavailable */ }
106
+ try { channel = msg.channel; } catch { /* optional cache enrichment unavailable */ }
91
107
 
92
108
  return {
93
109
  id: String(msg.message_id),
@@ -95,31 +111,149 @@ export function normalizeKookMessage(raw: unknown): KookInboundMessage | null {
95
111
  channelKind,
96
112
  channelId,
97
113
  authorId: String(msg.author_id),
98
- authorName: msg.author?.info?.nickname
99
- || msg.author?.info?.username
114
+ authorName: author?.info?.nickname
115
+ || author?.info?.username
100
116
  || String(msg.author_id),
101
- authorBot: msg.author?.bot === true || msg.author?.info?.bot === true,
102
- authorRoles: msg.author?.info?.roles ?? msg.author?.roles,
117
+ authorBot: author?.bot === true || author?.info?.bot === true,
118
+ authorRoles: author?.info?.roles ?? author?.roles,
103
119
  timestamp: msg.timestamp ?? Date.now(),
104
- guildId: msg.channel?.info?.guild_id,
120
+ guildId: channel?.info?.guild_id,
105
121
  rawMessage: msg.raw_message,
106
122
  };
107
123
  }
108
124
 
125
+ export class RuntimeKookClient extends Client {
126
+ readonly #lifecycle: ReturnType<typeof createEndpointLifecycle>;
127
+ #initialized = false;
128
+ #connectionAttempt = 0;
129
+ constructor(config: ConstructorParameters<typeof Client>[0], lifecycleOptions: Parameters<typeof createEndpointLifecycle>[0] = { name: 'kook' }, apiProxy?: KookApiProxy) {
130
+ super({ ...config, autoReconnect: false, handleProcessErrors: false } as ConstructorParameters<typeof Client>[0]);
131
+ this.#lifecycle = createEndpointLifecycle(lifecycleOptions);
132
+ if (apiProxy) {
133
+ this.request.defaults.httpsAgent = createKookApiAgent(apiProxy);
134
+ this.request.defaults.proxy = false;
135
+ this.request.defaults.maxRedirects = 0;
136
+ this.request.interceptors.request.use(request => {
137
+ const target = new URL(request.url ?? '', request.baseURL);
138
+ if (target.protocol !== 'https:' || target.hostname !== 'www.kookapp.cn' || (target.port && target.port !== '443')) {
139
+ throw new EndpointDeliveryError('invalid_destination', 'KOOK API proxy rejected destination identity', 'not_sent');
140
+ }
141
+ return request;
142
+ });
143
+ }
144
+ const transforms = this.request.defaults.transformResponse;
145
+ this.request.defaults.transformResponse = [
146
+ ...(Array.isArray(transforms) ? transforms : transforms ? [transforms] : []),
147
+ (data: unknown, _headers: unknown, status?: number) => {
148
+ // Classify HTTP status before the SDK discards it in its error interceptor.
149
+ if (status !== undefined && status >= 400) {
150
+ const rejected = status < 500 && status !== 408;
151
+ throw new EndpointDeliveryError(rejected ? 'platform_rejected' : 'delivery_unconfirmed',
152
+ `KOOK HTTP request failed (status=${status})`, rejected ? 'rejected' : 'unknown');
153
+ }
154
+ return data;
155
+ },
156
+ ];
157
+ this.request.interceptors.response.use(response => {
158
+ // kook-client unwraps HTTP JSON but does not reject nonzero platform codes.
159
+ const body = response as unknown as { code?: unknown };
160
+ if (typeof body.code === 'number' && body.code !== 0) {
161
+ throw new EndpointDeliveryError('platform_rejected', `KOOK rejected API request (code=${body.code})`, 'rejected');
162
+ }
163
+ return response;
164
+ });
165
+ }
166
+ async connect(): Promise<void> {
167
+ await this.#lifecycle.start(async handle => {
168
+ const attempt = ++this.#connectionAttempt;
169
+ const receiver = this.receiver;
170
+ let active = true;
171
+ const disconnected = () => { if (!active) return; active = false; receiver.off('disconnected', disconnected); handle.notifyClosed('KOOK receiver disconnected'); };
172
+ receiver.on('disconnected', disconnected);
173
+ handle.onForceClose(() => { active = false; receiver.off('disconnected', disconnected); if (attempt === this.#connectionAttempt) void receiver.disconnect(); });
174
+ try {
175
+ const resume = receiver instanceof WebsocketReceiver && (receiver as unknown as { canResume(): boolean }).canResume();
176
+ await (receiver as WebsocketReceiver).connect(resume);
177
+ if (!active || attempt !== this.#connectionAttempt) throw new Error('KOOK connection closed before admission');
178
+ if (!resume || !this.#initialized) { await this.init(); this.#initialized = true; }
179
+ }
180
+ catch (error) { active = false; receiver.off('disconnected', disconnected); if (attempt === this.#connectionAttempt) await receiver.disconnect(); throw error; }
181
+ });
182
+ }
183
+ async disconnect(): Promise<void> { await this.#lifecycle.stop(); }
184
+ /** Native FormData avoids kook-client's Buffer-to-text multipart conversion. */
185
+ async uploadMedia(data: Buffer | string): Promise<string> {
186
+ let bytes: Buffer;
187
+ if (Buffer.isBuffer(data)) bytes = data;
188
+ else if (/^https?:\/\//.test(data)) {
189
+ const downloaded = await fetch(data, { signal: AbortSignal.timeout(30_000) });
190
+ if (!downloaded.ok) throw new EndpointDeliveryError('media_download_failed', 'KOOK source download failed', 'rejected');
191
+ bytes = Buffer.from(await downloaded.arrayBuffer());
192
+ } else if (/^data:[^,]*;base64,/.test(data) || data.startsWith('base64://')) {
193
+ bytes = Buffer.from(data.replace(/^data:[^,]*,|^base64:\/\//, ''), 'base64');
194
+ } else bytes = await readFile(data.replace(/^file:\/\//, ''));
195
+ const form = new FormData();
196
+ form.append('file', new Blob([Uint8Array.from(bytes)]), 'image.png');
197
+ const response = await fetch('https://www.kookapp.cn/api/v3/asset/create', {
198
+ method: 'POST', headers: { Authorization: `Bot ${this.config.token}` }, body: form,
199
+ signal: AbortSignal.timeout(30_000),
200
+ });
201
+ const body = await response.json() as { code?: number; data?: { url?: string } };
202
+ if (!response.ok || body.code !== 0) {
203
+ const rejected = response.status >= 400 && response.status < 500 && response.status !== 408 || response.ok && typeof body.code === 'number' && body.code !== 0;
204
+ throw new EndpointDeliveryError(rejected ? 'platform_rejected' : 'delivery_unconfirmed',
205
+ `KOOK upload failed (status=${response.status}, code=${typeof body.code === 'number' ? body.code : 'missing'})`, rejected ? 'rejected' : 'unknown');
206
+ }
207
+ if (typeof body.data?.url !== 'string') throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK upload returned no URL', 'unknown');
208
+ return body.data.url;
209
+ }
210
+
211
+ #preloading = false;
212
+
213
+ getTransportState(): EndpointTransportState | undefined {
214
+ if (!(this.receiver instanceof WebsocketReceiver)) return undefined;
215
+ return this.#lifecycle.state;
216
+
217
+ }
218
+
219
+ async init() {
220
+ this.#preloading = true;
221
+ try { return await super.init(); }
222
+ finally { this.#preloading = false; }
223
+ }
224
+
225
+ async getBlacklist(guildId: string) {
226
+ try { return await super.getBlacklist(guildId); }
227
+ catch (error) {
228
+ // kook-client 1.0.4 discards structured HTTP metadata in its interceptor.
229
+ // Match only its exact blacklist permission-denial prefix during preload.
230
+ if (this.#preloading && error instanceof Error
231
+ && (error.message.startsWith('request "/v3/blacklist/list" error with code(403):')
232
+ || error instanceof EndpointDeliveryError && error.code === 'platform_rejected'
233
+ && error.disposition === 'rejected' && error.message === 'KOOK HTTP request failed (status=403)')) {
234
+ this.logger.warn('启动预加载跳过黑名单:缺少管理权限;消息收发继续。');
235
+ return [];
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+ }
241
+
109
242
  export function defaultCreateClient(config: ResolvedKookWebsocketConfig): KookClientTransport {
110
- return new Client({
243
+ return new RuntimeKookClient({
111
244
  token: config.token,
112
245
  mode: 'websocket',
246
+ socketFactory: (url: string) => createKookStreamSocket(url, config.streamProxy),
113
247
  data_dir: config.data_dir || path.join(process.cwd(), 'data', 'kook'),
114
248
  timeout: config.timeout,
115
249
  max_retry: config.max_retry,
116
250
  ignore: config.ignore,
117
251
  logLevel: config.logLevel,
118
- }) as unknown as KookClientTransport;
252
+ } as ConstructorParameters<typeof RuntimeKookClient>[0], undefined, config.apiProxy) as unknown as KookClientTransport;
119
253
  }
120
254
 
121
255
  export function defaultCreateWebhookClient(config: ResolvedKookWebhookConfig): KookClientTransport {
122
- return new Client({
256
+ return new RuntimeKookClient({
123
257
  token: config.token,
124
258
  mode: 'webhook',
125
259
  data_dir: path.join(process.cwd(), 'data', 'kook'),
@@ -127,5 +261,5 @@ export function defaultCreateWebhookClient(config: ResolvedKookWebhookConfig): K
127
261
  max_retry: 3,
128
262
  ignore: config.ignore,
129
263
  logLevel: config.logLevel,
130
- }) as unknown as KookClientTransport;
264
+ } as ConstructorParameters<typeof RuntimeKookClient>[0], undefined, config.apiProxy) as unknown as KookClientTransport;
131
265
  }