@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/src/endpoint.ts CHANGED
@@ -1,25 +1,27 @@
1
- import { Endpoint } from 'zhin.js/adapter';
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
- createRecallEndpointControl,
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
- formatOutboundKmarkdown,
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 = createRecallEndpointControl((id) => this.recallMessage(id));
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 body = formatOutboundKmarkdown(payload);
112
- const client = this.#requireClient();
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 (!messageId) return;
160
- await this.#requireClient().recallMsg?.(messageId);
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 = createRecallEndpointControl((id) => this.recallMessage(id));
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 body = formatOutboundKmarkdown(payload);
284
- const client = this.#requireClient();
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 (!messageId) return;
353
- await this.#requireClient().recallMsg?.(messageId);
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
  }
@@ -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
- if (handler.isOpen) {
97
- handleKookWebhookEvent(event, handler);
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
- }