@zhin.js/adapter-kook 1.1.4 → 1.1.6
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 +49 -0
- package/README.md +30 -1
- package/adapters/kook/index.js +4 -4
- package/adapters/kook/index.ts +4 -4
- package/lib/api-proxy.d.ts +6 -0
- package/lib/api-proxy.js +18 -0
- package/lib/endpoint.d.ts +8 -5
- package/lib/endpoint.js +68 -26
- package/lib/outbound.d.ts +7 -0
- package/lib/outbound.js +141 -0
- package/lib/protocol.d.ts +16 -0
- package/lib/protocol.js +32 -1
- package/lib/stream-proxy.d.ts +7 -0
- package/lib/stream-proxy.js +18 -0
- package/lib/webhook.d.ts +2 -1
- package/lib/webhook.js +14 -5
- package/lib/ws.d.ts +24 -3
- package/lib/ws.js +162 -10
- package/package.json +17 -17
- package/schema.json +36 -0
- package/src/api-proxy.ts +21 -0
- package/src/endpoint.ts +67 -24
- package/src/outbound.ts +109 -0
- package/src/protocol.ts +33 -1
- package/src/stream-proxy.ts +20 -0
- package/src/webhook.ts +14 -12
- package/src/ws.ts +147 -13
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 {
|
|
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:
|
|
27
|
+
message: unknown,
|
|
28
|
+
quote?: { message_id: string },
|
|
22
29
|
): Promise<{ msg_id?: string | number }>;
|
|
23
30
|
sendPrivateMsg(
|
|
24
31
|
userId: string,
|
|
25
|
-
message:
|
|
32
|
+
message: unknown,
|
|
33
|
+
quote?: { message_id: string },
|
|
26
34
|
): Promise<{ msg_id?: string | number }>;
|
|
27
|
-
|
|
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:
|
|
99
|
-
||
|
|
114
|
+
authorName: author?.info?.nickname
|
|
115
|
+
|| author?.info?.username
|
|
100
116
|
|| String(msg.author_id),
|
|
101
|
-
authorBot:
|
|
102
|
-
authorRoles:
|
|
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:
|
|
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
|
|
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
|
|
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
|
}
|