@zhin.js/adapter-kook 1.1.4 → 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/CHANGELOG.md +34 -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 +14 -14
- 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/endpoint.ts
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError, type ConversationRef } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* KookEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
4
4
|
*/
|
|
5
5
|
import { Client } from 'kook-client';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
Endpoint,
|
|
8
8
|
type EndpointControl,
|
|
9
9
|
type EndpointChannel,
|
|
10
10
|
type EndpointGroup,
|
|
11
11
|
type EndpointManagement,
|
|
12
12
|
type EndpointSendRequest,
|
|
13
|
+
type EndpointTransportState,
|
|
13
14
|
} from 'zhin.js/adapter';
|
|
14
15
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
15
16
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
16
17
|
import type { CapabilityId } from 'zhin.js';
|
|
17
18
|
import {
|
|
18
19
|
formatInboundContent,
|
|
19
|
-
|
|
20
|
+
|
|
20
21
|
isKookBotMentioned,
|
|
21
22
|
kookInboundConversation,
|
|
22
23
|
normalizeKookWebhookEvent,
|
|
24
|
+
normalizeKookButtonEvent,
|
|
23
25
|
senderDisplayName,
|
|
24
26
|
type KookInboundMessage,
|
|
25
27
|
type KookWebhookEventData,
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
type CreateKookClient,
|
|
35
37
|
type KookClientTransport,
|
|
36
38
|
} from './ws.js';
|
|
39
|
+
import { recallKookMessage, sendKookOutbound } from './outbound.js';
|
|
37
40
|
import { receiveKookSideEvent } from './side-event-dispatch.js';
|
|
38
41
|
|
|
39
42
|
export interface KookEndpointOptions {
|
|
@@ -43,6 +46,7 @@ export interface KookEndpointOptions {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export class KookWebsocketEndpoint extends Endpoint<KookClientTransport> {
|
|
49
|
+
#sentConversations = new Map<string, ConversationRef>();
|
|
46
50
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
47
51
|
|
|
48
52
|
readonly #options: KookEndpointOptions;
|
|
@@ -51,7 +55,9 @@ export class KookWebsocketEndpoint extends Endpoint<KookClientTransport> {
|
|
|
51
55
|
#open = false;
|
|
52
56
|
#started = false;
|
|
53
57
|
readonly management: EndpointManagement = createKookEndpointManagement(() => this.#requireClient());
|
|
54
|
-
readonly control: EndpointControl =
|
|
58
|
+
readonly control: EndpointControl = Object.freeze<EndpointControl>({
|
|
59
|
+
recall: (message) => recallKookMessage(this.#requireClient(), message),
|
|
60
|
+
});
|
|
55
61
|
|
|
56
62
|
constructor(options: KookEndpointOptions) {
|
|
57
63
|
super();
|
|
@@ -64,6 +70,11 @@ export class KookWebsocketEndpoint extends Endpoint<KookClientTransport> {
|
|
|
64
70
|
return this.#requireClient();
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
get transportState(): EndpointTransportState | undefined {
|
|
74
|
+
if (!this.#started) return 'stopped';
|
|
75
|
+
return this.#client?.getTransportState?.();
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
async start(): Promise<void> {
|
|
68
79
|
if (this.#started) return;
|
|
69
80
|
this.#started = true;
|
|
@@ -94,6 +105,7 @@ export class KookWebsocketEndpoint extends Endpoint<KookClientTransport> {
|
|
|
94
105
|
|
|
95
106
|
async stop(): Promise<void> {
|
|
96
107
|
this.#open = false;
|
|
108
|
+
this.#sentConversations.clear();
|
|
97
109
|
if (this.#client) {
|
|
98
110
|
try {
|
|
99
111
|
this.#client.removeAllListeners();
|
|
@@ -108,12 +120,8 @@ export class KookWebsocketEndpoint extends Endpoint<KookClientTransport> {
|
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const result = conversation.kind === 'private'
|
|
114
|
-
? await client.sendPrivateMsg(conversation.id, body)
|
|
115
|
-
: await client.sendChannelMsg(conversation.id, body);
|
|
116
|
-
const messageId = result?.msg_id != null ? String(result.msg_id) : '';
|
|
123
|
+
const messageId = await sendKookOutbound(this.#requireClient(), { conversation, payload });
|
|
124
|
+
rememberKookConversation(this.#sentConversations, messageId, conversation);
|
|
117
125
|
this.#logger.debug(formatCompact({
|
|
118
126
|
op: 'kook_send',
|
|
119
127
|
endpoint: this.#options.config.id,
|
|
@@ -155,9 +163,9 @@ export class KookWebsocketEndpoint extends Endpoint<KookClientTransport> {
|
|
|
155
163
|
});
|
|
156
164
|
}
|
|
157
165
|
|
|
158
|
-
async recallMessage(messageId: string): Promise<void> {
|
|
159
|
-
if (!
|
|
160
|
-
await this.#requireClient()
|
|
166
|
+
async recallMessage(messageId: string, conversation?: ConversationRef): Promise<void> {
|
|
167
|
+
if (!conversation) throw new EndpointDeliveryError('unsupported_operation', 'KOOK recall requires conversation routing', 'rejected');
|
|
168
|
+
await recallKookMessage(this.#requireClient(), { id: messageId, conversation });
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
#bindClient(client: KookClientTransport): void {
|
|
@@ -168,11 +176,23 @@ export class KookWebsocketEndpoint extends Endpoint<KookClientTransport> {
|
|
|
168
176
|
});
|
|
169
177
|
const receiver = (client as { receiver?: { on(event: string, listener: (...args: unknown[]) => void): void } }).receiver;
|
|
170
178
|
receiver?.on('event', (raw) => {
|
|
179
|
+
if (this.#client !== client || !this.#open) return;
|
|
171
180
|
this.#emitPlatformEvent('event', raw);
|
|
181
|
+
if (normalizeKookButtonEvent(raw)) {
|
|
182
|
+
void this.admitButton(raw).catch(error => this.#logger.warn(formatCompact({ op: 'kook_button_failed', error: error instanceof Error ? error.message : 'dispatch failed' })));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
172
185
|
receiveKookSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, raw, this.#logger);
|
|
173
186
|
});
|
|
174
187
|
}
|
|
175
188
|
|
|
189
|
+
async admitButton(raw: unknown): Promise<void> {
|
|
190
|
+
if (!this.#open) throw new Error('KOOK endpoint is closed');
|
|
191
|
+
const event = normalizeKookButtonEvent(raw);
|
|
192
|
+
if (!event) throw new Error('Invalid KOOK button event');
|
|
193
|
+
await this.emitAccepted('message.receive', kookButtonIngress(String(this.#options.id), this.#options.config.id, event, this.#sentConversations.get(event.sourceMessageId)));
|
|
194
|
+
}
|
|
195
|
+
|
|
176
196
|
#emitPlatformEvent(name: string, event: unknown): void {
|
|
177
197
|
void this.emitPlatform(name, event).catch((error) => {
|
|
178
198
|
this.#logger.warn(formatCompact({
|
|
@@ -197,6 +217,7 @@ export interface KookWebhookEndpointOptions {
|
|
|
197
217
|
}
|
|
198
218
|
|
|
199
219
|
export class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
220
|
+
#sentConversations = new Map<string, ConversationRef>();
|
|
200
221
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
201
222
|
|
|
202
223
|
readonly #options: KookWebhookEndpointOptions;
|
|
@@ -207,7 +228,9 @@ export class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
|
207
228
|
#open = false;
|
|
208
229
|
#started = false;
|
|
209
230
|
readonly management: EndpointManagement = createKookEndpointManagement(() => this.#requireClient());
|
|
210
|
-
readonly control: EndpointControl =
|
|
231
|
+
readonly control: EndpointControl = Object.freeze<EndpointControl>({
|
|
232
|
+
recall: (message) => recallKookMessage(this.#requireClient(), message),
|
|
233
|
+
});
|
|
211
234
|
|
|
212
235
|
constructor(options: KookWebhookEndpointOptions) {
|
|
213
236
|
super();
|
|
@@ -264,6 +287,7 @@ export class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
|
264
287
|
|
|
265
288
|
async stop(): Promise<void> {
|
|
266
289
|
this.#open = false;
|
|
290
|
+
this.#sentConversations.clear();
|
|
267
291
|
for (const release of this.#routeReleases.splice(0)) release();
|
|
268
292
|
this.#processedSn.clear();
|
|
269
293
|
if (this.#client) {
|
|
@@ -280,12 +304,8 @@ export class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
|
280
304
|
}
|
|
281
305
|
|
|
282
306
|
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
const result = conversation.kind === 'private'
|
|
286
|
-
? await client.sendPrivateMsg(conversation.id, body)
|
|
287
|
-
: await client.sendChannelMsg(conversation.id, body);
|
|
288
|
-
const messageId = result?.msg_id != null ? String(result.msg_id) : '';
|
|
307
|
+
const messageId = await sendKookOutbound(this.#requireClient(), { conversation, payload });
|
|
308
|
+
rememberKookConversation(this.#sentConversations, messageId, conversation);
|
|
289
309
|
this.#logger.debug(formatCompact({
|
|
290
310
|
op: 'kook_send',
|
|
291
311
|
endpoint: this.#options.config.id,
|
|
@@ -295,7 +315,13 @@ export class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
|
295
315
|
return messageId;
|
|
296
316
|
}
|
|
297
317
|
|
|
298
|
-
admitEvent(event: KookWebhookEventData): void {
|
|
318
|
+
async admitEvent(event: KookWebhookEventData): Promise<void> {
|
|
319
|
+
if (normalizeKookButtonEvent(event)) {
|
|
320
|
+
if (!this.#open) throw new Error('KOOK endpoint is closed');
|
|
321
|
+
const button = normalizeKookButtonEvent(event)!;
|
|
322
|
+
await this.emitAccepted('message.receive', kookButtonIngress(String(this.#options.id), this.#options.config.id, button, this.#sentConversations.get(button.sourceMessageId)));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
299
325
|
void this.emitPlatform(String(event.type ?? 'event'), event).catch((error) => {
|
|
300
326
|
this.#logger.warn(formatCompact({
|
|
301
327
|
op: 'kook_platform_event_failed',
|
|
@@ -348,9 +374,9 @@ export class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
|
348
374
|
});
|
|
349
375
|
}
|
|
350
376
|
|
|
351
|
-
async recallMessage(messageId: string): Promise<void> {
|
|
352
|
-
if (!
|
|
353
|
-
await this.#requireClient()
|
|
377
|
+
async recallMessage(messageId: string, conversation?: ConversationRef): Promise<void> {
|
|
378
|
+
if (!conversation) throw new EndpointDeliveryError('unsupported_operation', 'KOOK recall requires conversation routing', 'rejected');
|
|
379
|
+
await recallKookMessage(this.#requireClient(), { id: messageId, conversation });
|
|
354
380
|
}
|
|
355
381
|
|
|
356
382
|
checkAndRememberSn(sn: number): boolean {
|
|
@@ -363,6 +389,8 @@ export class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
|
363
389
|
return true;
|
|
364
390
|
}
|
|
365
391
|
|
|
392
|
+
forgetSn(sn: number): void { this.#processedSn.delete(sn); }
|
|
393
|
+
|
|
366
394
|
#requireClient(): KookClientTransport {
|
|
367
395
|
if (!this.#client) throw new Error('KOOK client not initialized');
|
|
368
396
|
return this.#client;
|
|
@@ -380,6 +408,21 @@ function toGroupId(id: string): number {
|
|
|
380
408
|
}
|
|
381
409
|
|
|
382
410
|
/** KOOK 文字频道标记:HTTP API type=1(2=语音),kook-client 消息侧用 'GROUP'。 */
|
|
411
|
+
function rememberKookConversation(cache: Map<string, ConversationRef>, messageId: string, conversation: ConversationRef): void {
|
|
412
|
+
cache.set(messageId, conversation);
|
|
413
|
+
while (cache.size > 2048) cache.delete(cache.keys().next().value!);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function kookButtonIngress(endpointKey: string, endpointId: string, event: NonNullable<ReturnType<typeof normalizeKookButtonEvent>>, sentConversation?: ConversationRef) {
|
|
417
|
+
const conversation: ConversationRef = { endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
418
|
+
kind: sentConversation?.kind ?? event.channelKind, id: sentConversation?.id ?? event.channelId,
|
|
419
|
+
...(sentConversation?.parent ? { parent: sentConversation.parent } : {}) };
|
|
420
|
+
return { conversation, message: { conversation, id: event.id }, content: `[action: ${event.payload}]`,
|
|
421
|
+
segments: [{ type: 'action', data: { id: event.id, payload: event.payload, sourceMessageId: event.sourceMessageId } }],
|
|
422
|
+
sender: { id: event.userId }, endpointId,
|
|
423
|
+
metadata: Object.freeze({ eventType: 'message_btn_click', payload: event.payload, sourceMessageId: event.sourceMessageId, timestamp: event.timestamp }) };
|
|
424
|
+
}
|
|
425
|
+
|
|
383
426
|
function isKookTextChannelType(type: string | number | undefined): boolean {
|
|
384
427
|
return type === 1 || type === '1' || type === 'GROUP';
|
|
385
428
|
}
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { EndpointDeliveryError, isMediaRef, type MessageRef } from '@zhin.js/im-contract';
|
|
3
|
+
import type { EndpointSendRequest } from 'zhin.js/adapter';
|
|
4
|
+
import { formatOutboundKmarkdown } from './protocol.js';
|
|
5
|
+
import type { KookClientTransport } from './ws.js';
|
|
6
|
+
|
|
7
|
+
/** Native cards preserve mixed text/images; SDK's ordinary segments stop at first media. */
|
|
8
|
+
export async function sendKookOutbound(client: KookClientTransport, { conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
9
|
+
try {
|
|
10
|
+
const items = Array.isArray(payload) ? payload : payload && typeof payload === 'object' ? [payload] : [payload];
|
|
11
|
+
let quote: { message_id: string } | undefined;
|
|
12
|
+
const modules: unknown[] = [];
|
|
13
|
+
let hasImage = false;
|
|
14
|
+
let hasCard = false;
|
|
15
|
+
for (const item of items) {
|
|
16
|
+
if (item && typeof item === 'object' && item.type === 'reply') {
|
|
17
|
+
if (typeof item.data?.message_id === 'string' && item.data.message_id.trim()) quote = { message_id: item.data.message_id };
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (item && typeof item === 'object' && item.type === 'keyboard') {
|
|
21
|
+
hasCard = true;
|
|
22
|
+
const rows = item.data?.rows;
|
|
23
|
+
if (!Array.isArray(rows) || !rows.length) throw new EndpointDeliveryError('invalid_payload', 'KOOK keyboard requires button rows', 'not_sent');
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
if (!Array.isArray(row) || !row.length || row.length > 4) throw new EndpointDeliveryError('invalid_payload', 'KOOK button rows require 1-4 buttons', 'not_sent');
|
|
26
|
+
const elements = row.map(button => {
|
|
27
|
+
if (!button || typeof button !== 'object' || typeof button.label !== 'string' || !button.label.trim() || typeof button.payload !== 'string' || !button.payload) throw new EndpointDeliveryError('invalid_payload', 'KOOK buttons require label and payload', 'not_sent');
|
|
28
|
+
if (button.disabled || button.mode === 'command') throw new EndpointDeliveryError('unsupported_operation', 'KOOK disabled/command buttons are not implemented', 'not_sent');
|
|
29
|
+
return { type: 'button', theme: button.style === 'danger' ? 'danger' : button.style === 'primary' ? 'primary' : 'secondary',
|
|
30
|
+
value: button.payload, click: 'return-val', text: { type: 'plain-text', content: button.label } };
|
|
31
|
+
});
|
|
32
|
+
modules.push({ type: 'action-group', elements });
|
|
33
|
+
}
|
|
34
|
+
} else if (item && typeof item === 'object' && item.type === 'share') {
|
|
35
|
+
hasCard = true;
|
|
36
|
+
const data = item.data ?? {};
|
|
37
|
+
let url: URL;
|
|
38
|
+
try { url = new URL(data.url); } catch { throw new EndpointDeliveryError('invalid_payload', 'KOOK share requires an HTTP(S) URL', 'not_sent'); }
|
|
39
|
+
if (!['http:', 'https:'].includes(url.protocol) || typeof data.title !== 'string' || !data.title.trim()) throw new EndpointDeliveryError('invalid_payload', 'KOOK share requires URL and title', 'not_sent');
|
|
40
|
+
if (['image', 'audio', 'artist', 'duration', 'config'].some(key => data[key] !== undefined)) throw new EndpointDeliveryError('unsupported_operation', 'KOOK share media/app metadata is not implemented', 'not_sent');
|
|
41
|
+
modules.push({ type: 'section', mode: 'left', text: { type: 'plain-text', content: data.title } });
|
|
42
|
+
for (const value of [data.description, data.content]) if (typeof value === 'string' && value) modules.push({ type: 'section', mode: 'left', text: { type: 'plain-text', content: value } });
|
|
43
|
+
modules.push({ type: 'action-group', elements: [{ type: 'button', theme: 'secondary', click: 'link', value: url.href, text: { type: 'plain-text', content: '打开链接' } }] });
|
|
44
|
+
} else if (item && typeof item === 'object' && item.type === 'image') {
|
|
45
|
+
hasImage = true;
|
|
46
|
+
const media = item.data?.media;
|
|
47
|
+
if (!isMediaRef(media)) throw new EndpointDeliveryError('unsupported_media', 'KOOK image requires canonical media', 'rejected');
|
|
48
|
+
let url: string;
|
|
49
|
+
if (media.kind === 'file') url = media.value;
|
|
50
|
+
else {
|
|
51
|
+
if (!client.uploadMedia) throw new EndpointDeliveryError('unsupported_media', 'KOOK transport cannot upload images', 'rejected');
|
|
52
|
+
let bytes: Buffer;
|
|
53
|
+
if (media.kind === 'base64') bytes = Buffer.from(media.value.replace(/^data:[^,]*,/, ''), 'base64');
|
|
54
|
+
else if (media.kind === 'path') bytes = await readFile(media.value);
|
|
55
|
+
else {
|
|
56
|
+
const response = await fetch(media.value, { signal: AbortSignal.timeout(30_000) });
|
|
57
|
+
if (!response.ok) throw new EndpointDeliveryError('media_download_failed', 'KOOK media download failed', 'rejected');
|
|
58
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
59
|
+
}
|
|
60
|
+
url = await client.uploadMedia(bytes);
|
|
61
|
+
}
|
|
62
|
+
if (typeof url !== 'string' || !/^https:\/\//.test(url)) throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK upload returned no media URL', 'unknown');
|
|
63
|
+
modules.push({ type: 'container', elements: [{ type: 'image', src: url, alt: String(item.data?.alt ?? 'image') }] });
|
|
64
|
+
} else {
|
|
65
|
+
if (item && typeof item === 'object' && ['file', 'audio', 'video'].includes(item.type)
|
|
66
|
+
&& isMediaRef(item.data?.media) && !['url', 'file'].includes(item.data.media.kind)) {
|
|
67
|
+
throw new EndpointDeliveryError('unsupported_media', 'KOOK binary uploads currently support images only', 'rejected');
|
|
68
|
+
}
|
|
69
|
+
const text = formatOutboundKmarkdown([item]);
|
|
70
|
+
if (text) modules.push({ type: 'section', mode: 'left', text: { type: 'kmarkdown', content: text } });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const body = hasImage || hasCard ? { __isCard: true, type: 'card', theme: 'secondary', size: 'lg', modules } : formatOutboundKmarkdown(payload);
|
|
74
|
+
const result = conversation.kind === 'private'
|
|
75
|
+
? await client.sendPrivateMsg(conversation.id, body, quote)
|
|
76
|
+
: await client.sendChannelMsg(conversation.id, body, quote);
|
|
77
|
+
const id = result?.msg_id != null ? String(result.msg_id) : '';
|
|
78
|
+
if (!id.trim()) throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK did not return a real message ID', 'unknown');
|
|
79
|
+
return id;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof EndpointDeliveryError) throw error;
|
|
82
|
+
// Installed SDK discards HTTP metadata; its exact numerical rejection prefix is safe to recover.
|
|
83
|
+
const code = error instanceof Error ? /error with code\((\d+)\):/.exec(error.message)?.[1] : undefined;
|
|
84
|
+
if (code && Number(code) !== 408 && !(Number(code) >= 500 && Number(code) < 600)) throw new EndpointDeliveryError('platform_rejected', `KOOK request rejected (code=${code})`, 'rejected');
|
|
85
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK outbound outcome is unknown', 'unknown');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
/** Deletion requires the canonical conversation: KOOK has separate channel/private APIs. */
|
|
91
|
+
export async function recallKookMessage(client: KookClientTransport, message: MessageRef): Promise<void> {
|
|
92
|
+
if (!message.id.trim()) throw new EndpointDeliveryError('invalid_message_id', 'KOOK recall requires a message ID', 'rejected');
|
|
93
|
+
try {
|
|
94
|
+
let deleted: boolean;
|
|
95
|
+
if (message.conversation.kind === 'private') {
|
|
96
|
+
if (!client.recallPrivateMsg) throw new EndpointDeliveryError('unsupported_operation', 'KOOK transport cannot delete private messages', 'rejected');
|
|
97
|
+
deleted = await client.recallPrivateMsg(message.conversation.id, message.id);
|
|
98
|
+
} else {
|
|
99
|
+
if (!client.recallChannelMsg) throw new EndpointDeliveryError('unsupported_operation', 'KOOK transport cannot delete channel messages', 'rejected');
|
|
100
|
+
deleted = await client.recallChannelMsg(message.conversation.id, message.id);
|
|
101
|
+
}
|
|
102
|
+
if (deleted !== true) throw new EndpointDeliveryError('platform_rejected', 'KOOK did not confirm message deletion', 'rejected');
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof EndpointDeliveryError) throw error;
|
|
105
|
+
const code = error instanceof Error ? /error with code\((\d+)\):/.exec(error.message)?.[1] : undefined;
|
|
106
|
+
if (code && Number(code) !== 408 && !(Number(code) >= 500 && Number(code) < 600)) throw new EndpointDeliveryError('platform_rejected', `KOOK deletion rejected (code=${code})`, 'rejected');
|
|
107
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK deletion outcome is unknown', 'unknown');
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { createDecipheriv, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { inflateSync } from 'node:zlib';
|
|
5
5
|
import type { IncomingMessage } from 'node:http';
|
|
6
|
-
import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
6
|
+
import { EndpointDeliveryError, isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
7
7
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
8
|
|
|
9
9
|
const logger = getLogger('kook');
|
|
@@ -27,6 +27,8 @@ export enum KookPermission {
|
|
|
27
27
|
|
|
28
28
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
29
29
|
export interface KookEndpointConfig {
|
|
30
|
+
readonly apiProxy?: import('./api-proxy.js').KookApiProxy;
|
|
31
|
+
readonly streamProxy?: import('./stream-proxy.js').KookStreamProxy;
|
|
30
32
|
readonly id: string;
|
|
31
33
|
readonly token: string;
|
|
32
34
|
/** Default `websocket`. `webhook` requires httpHostToken + verify_token. */
|
|
@@ -42,6 +44,8 @@ export interface KookEndpointConfig {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface ResolvedKookWebsocketConfig {
|
|
47
|
+
readonly apiProxy?: import('./api-proxy.js').KookApiProxy;
|
|
48
|
+
readonly streamProxy?: import('./stream-proxy.js').KookStreamProxy;
|
|
45
49
|
readonly context: 'kook';
|
|
46
50
|
readonly connection: 'websocket';
|
|
47
51
|
readonly id: string;
|
|
@@ -54,6 +58,7 @@ export interface ResolvedKookWebsocketConfig {
|
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
export interface ResolvedKookWebhookConfig {
|
|
61
|
+
readonly apiProxy?: import('./api-proxy.js').KookApiProxy;
|
|
57
62
|
readonly context: 'kook';
|
|
58
63
|
readonly connection: 'webhook';
|
|
59
64
|
readonly id: string;
|
|
@@ -84,6 +89,7 @@ export interface KookWebhookEventData {
|
|
|
84
89
|
readonly author_id?: string;
|
|
85
90
|
readonly target_id?: string;
|
|
86
91
|
readonly chat_code?: string;
|
|
92
|
+
readonly value?: string;
|
|
87
93
|
readonly emoji?: { readonly id?: string; readonly name?: string };
|
|
88
94
|
};
|
|
89
95
|
readonly guild_id?: string;
|
|
@@ -125,6 +131,9 @@ export interface KookWireSegment {
|
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
export function resolveKookConfig(config: KookEndpointConfig): ResolvedKookConfig {
|
|
134
|
+
if (config.apiProxy && (!Number.isInteger(config.apiProxy.port) || config.apiProxy.port < 1 || config.apiProxy.port > 65535)) throw new TypeError('Invalid KOOK apiProxy port');
|
|
135
|
+
if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
|
|
136
|
+
|| typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName))) throw new TypeError('Invalid KOOK streamProxy port/serverName');
|
|
128
137
|
const id = requiredEndpointField(config.id, 'id');
|
|
129
138
|
const token = requiredEndpointField(config.token, 'token');
|
|
130
139
|
const connection = config.connection ?? 'websocket';
|
|
@@ -135,6 +144,7 @@ export function resolveKookConfig(config: KookEndpointConfig): ResolvedKookConfi
|
|
|
135
144
|
return {
|
|
136
145
|
context: 'kook',
|
|
137
146
|
connection: 'webhook',
|
|
147
|
+
...(config.apiProxy ? { apiProxy: config.apiProxy } : {}),
|
|
138
148
|
id,
|
|
139
149
|
token,
|
|
140
150
|
webhookPath: normalizeWebhookPath(
|
|
@@ -150,6 +160,8 @@ export function resolveKookConfig(config: KookEndpointConfig): ResolvedKookConfi
|
|
|
150
160
|
return {
|
|
151
161
|
context: 'kook',
|
|
152
162
|
connection: 'websocket',
|
|
163
|
+
...(config.apiProxy ? { apiProxy: config.apiProxy } : {}),
|
|
164
|
+
...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
|
|
153
165
|
id,
|
|
154
166
|
token,
|
|
155
167
|
data_dir: config.data_dir,
|
|
@@ -262,6 +274,9 @@ export function formatOutboundKmarkdown(payload: unknown): string {
|
|
|
262
274
|
return String(data.text ?? data.content ?? '');
|
|
263
275
|
case 'markdown':
|
|
264
276
|
return String(data.content ?? data.text ?? '');
|
|
277
|
+
case 'share':
|
|
278
|
+
case 'keyboard':
|
|
279
|
+
throw new EndpointDeliveryError('unsupported_operation', 'KOOK share cards and native keyboards are not implemented', 'not_sent');
|
|
265
280
|
case 'at': {
|
|
266
281
|
const id = String(data.user_id ?? data.qq ?? data.id ?? '');
|
|
267
282
|
return id === 'all' ? '(met)all(met)' : `(met)${id}(met)`;
|
|
@@ -441,3 +456,20 @@ export function normalizeKookWebhookEvent(
|
|
|
441
456
|
guildId: event.extra?.guild_id,
|
|
442
457
|
};
|
|
443
458
|
}
|
|
459
|
+
|
|
460
|
+
/** Native Card return-val events; routing and actor must come from the click event. */
|
|
461
|
+
export function normalizeKookButtonEvent(raw: unknown): { id: string; sourceMessageId: string; payload: string; userId: string; channelKind: 'private' | 'channel'; channelId: string; timestamp: number } | null {
|
|
462
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
463
|
+
const event = raw as KookWebhookEventData;
|
|
464
|
+
if (event.extra?.type !== 'message_btn_click') return null;
|
|
465
|
+
const body = event.extra.body;
|
|
466
|
+
if (!body || typeof body.value !== 'string' || !body.value || !body.user_id || !body.msg_id) return null;
|
|
467
|
+
// Button system notifications can use PERSON even for a channel card.
|
|
468
|
+
// body.target_id identifies the original channel; envelope target_id is the recipient.
|
|
469
|
+
const bodyTarget = body.channel_id || body.target_id;
|
|
470
|
+
const channelKind = (bodyTarget && String(bodyTarget) !== String(body.user_id)) || event.channel_type === 'GROUP' ? 'channel' : 'private';
|
|
471
|
+
const channelId = channelKind === 'private' ? String(body.user_id) : String(bodyTarget || event.target_id || '');
|
|
472
|
+
if (!channelId) return null;
|
|
473
|
+
const timestamp = event.msg_timestamp ?? Date.now();
|
|
474
|
+
return { id: String(event.msg_id || `button:${body.msg_id}:${timestamp}`), sourceMessageId: String(body.msg_id), payload: body.value, userId: String(body.user_id), channelKind, channelId, timestamp };
|
|
475
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import tls from 'node:tls';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { getLogger } from '@zhin.js/logger';
|
|
4
|
+
|
|
5
|
+
export interface KookStreamProxy { readonly port: number; readonly serverName: string }
|
|
6
|
+
|
|
7
|
+
/** Route TCP only. WSS HTTP Host, path, ticket, TLS SNI and peer validation stay original. */
|
|
8
|
+
export function createKookStreamSocket(url: string, proxy?: KookStreamProxy): WebSocket {
|
|
9
|
+
if (!proxy) return new WebSocket(url);
|
|
10
|
+
const target = new URL(url);
|
|
11
|
+
if (target.protocol !== 'wss:' || target.hostname !== proxy.serverName || (target.port && target.port !== '443')) {
|
|
12
|
+
// The hostname is safe diagnostics; never print URL/query/ticket or SDK errors.
|
|
13
|
+
getLogger('kook').warn(`Stream proxy rejected gateway hostname: ${target.hostname}`);
|
|
14
|
+
throw new Error('KOOK Stream proxy gateway identity changed; update the fixed upstream before retrying');
|
|
15
|
+
}
|
|
16
|
+
return new WebSocket(url, {
|
|
17
|
+
createConnection: (options: import('node:http').ClientRequestArgs) => tls.connect({ ...options as tls.ConnectionOptions, path: undefined, host: '127.0.0.1', port: proxy.port,
|
|
18
|
+
servername: target.hostname, rejectUnauthorized: true }),
|
|
19
|
+
});
|
|
20
|
+
}
|
package/src/webhook.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
|
6
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
7
|
import {
|
|
8
8
|
isKookWebhookChallenge,
|
|
9
|
-
normalizeKookWebhookEvent,
|
|
10
9
|
parseKookWebhookBody,
|
|
11
10
|
readRequestBody,
|
|
12
11
|
verifyKookWebhookToken,
|
|
@@ -15,7 +14,6 @@ import {
|
|
|
15
14
|
type KookWebhookFrame,
|
|
16
15
|
type ResolvedKookWebhookConfig,
|
|
17
16
|
} from './protocol.js';
|
|
18
|
-
import { receiveKookSideEvent } from './side-event-dispatch.js';
|
|
19
17
|
|
|
20
18
|
const logger = getLogger('kook');
|
|
21
19
|
|
|
@@ -24,8 +22,9 @@ export interface KookWebhookHandler {
|
|
|
24
22
|
readonly isOpen: boolean;
|
|
25
23
|
readonly selfId?: string;
|
|
26
24
|
admit(msg: KookInboundMessage): void;
|
|
27
|
-
admitEvent(event: KookWebhookEventData): void;
|
|
25
|
+
admitEvent(event: KookWebhookEventData): Promise<void> | void;
|
|
28
26
|
checkAndRememberSn(sn: number): boolean;
|
|
27
|
+
forgetSn?(sn: number): void;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
export function registerKookWebhookRoutes(
|
|
@@ -85,6 +84,11 @@ export async function handleKookWebhookRequest(
|
|
|
85
84
|
return;
|
|
86
85
|
}
|
|
87
86
|
|
|
87
|
+
if (!handler.isOpen) {
|
|
88
|
+
response.writeHead(503, { 'Content-Type': 'application/json' });
|
|
89
|
+
response.end(JSON.stringify({ message: 'Endpoint unavailable' }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
88
92
|
if (frame.sn != null) {
|
|
89
93
|
if (!handler.checkAndRememberSn(frame.sn)) {
|
|
90
94
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -93,8 +97,13 @@ export async function handleKookWebhookRequest(
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
try {
|
|
101
|
+
await handler.admitEvent(event);
|
|
102
|
+
} catch {
|
|
103
|
+
if (frame.sn != null) handler.forgetSn?.(frame.sn);
|
|
104
|
+
response.writeHead(503, { 'Content-Type': 'application/json' });
|
|
105
|
+
response.end(JSON.stringify({ message: 'Admission unavailable' }));
|
|
106
|
+
return;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -105,10 +114,3 @@ export async function handleKookWebhookRequest(
|
|
|
105
114
|
response.end(JSON.stringify({ message: 'OK' }));
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
|
-
|
|
109
|
-
function handleKookWebhookEvent(
|
|
110
|
-
event: KookWebhookEventData,
|
|
111
|
-
handler: KookWebhookHandler,
|
|
112
|
-
): void {
|
|
113
|
-
handler.admitEvent(event);
|
|
114
|
-
}
|