@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/lib/protocol.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** KOOK protocol helpers. Canonicalization is owned by Core. */
2
2
  import { createDecipheriv, timingSafeEqual } from 'node:crypto';
3
3
  import { inflateSync } from 'node:zlib';
4
- import { isMediaRef } from '@zhin.js/im-contract';
4
+ import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
5
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
6
6
  const logger = getLogger('kook');
7
7
  export var KookPermission;
@@ -12,6 +12,11 @@ export var KookPermission;
12
12
  KookPermission[KookPermission["ChannelAdmin"] = 5] = "ChannelAdmin";
13
13
  })(KookPermission || (KookPermission = {}));
14
14
  export function resolveKookConfig(config) {
15
+ if (config.apiProxy && (!Number.isInteger(config.apiProxy.port) || config.apiProxy.port < 1 || config.apiProxy.port > 65535))
16
+ throw new TypeError('Invalid KOOK apiProxy port');
17
+ if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
18
+ || typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName)))
19
+ throw new TypeError('Invalid KOOK streamProxy port/serverName');
15
20
  const id = requiredEndpointField(config.id, 'id');
16
21
  const token = requiredEndpointField(config.token, 'token');
17
22
  const connection = config.connection ?? 'websocket';
@@ -21,6 +26,7 @@ export function resolveKookConfig(config) {
21
26
  return {
22
27
  context: 'kook',
23
28
  connection: 'webhook',
29
+ ...(config.apiProxy ? { apiProxy: config.apiProxy } : {}),
24
30
  id,
25
31
  token,
26
32
  webhookPath: normalizeWebhookPath(config.webhookPath ?? '/kook/webhook'),
@@ -33,6 +39,8 @@ export function resolveKookConfig(config) {
33
39
  return {
34
40
  context: 'kook',
35
41
  connection: 'websocket',
42
+ ...(config.apiProxy ? { apiProxy: config.apiProxy } : {}),
43
+ ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
36
44
  id,
37
45
  token,
38
46
  data_dir: config.data_dir,
@@ -135,6 +143,9 @@ export function formatOutboundKmarkdown(payload) {
135
143
  return String(data.text ?? data.content ?? '');
136
144
  case 'markdown':
137
145
  return String(data.content ?? data.text ?? '');
146
+ case 'share':
147
+ case 'keyboard':
148
+ throw new EndpointDeliveryError('unsupported_operation', 'KOOK share cards and native keyboards are not implemented', 'not_sent');
138
149
  case 'at': {
139
150
  const id = String(data.user_id ?? data.qq ?? data.id ?? '');
140
151
  return id === 'all' ? '(met)all(met)' : `(met)${id}(met)`;
@@ -298,3 +309,23 @@ export function normalizeKookWebhookEvent(event, options = {}) {
298
309
  guildId: event.extra?.guild_id,
299
310
  };
300
311
  }
312
+ /** Native Card return-val events; routing and actor must come from the click event. */
313
+ export function normalizeKookButtonEvent(raw) {
314
+ if (!raw || typeof raw !== 'object')
315
+ return null;
316
+ const event = raw;
317
+ if (event.extra?.type !== 'message_btn_click')
318
+ return null;
319
+ const body = event.extra.body;
320
+ if (!body || typeof body.value !== 'string' || !body.value || !body.user_id || !body.msg_id)
321
+ return null;
322
+ // Button system notifications can use PERSON even for a channel card.
323
+ // body.target_id identifies the original channel; envelope target_id is the recipient.
324
+ const bodyTarget = body.channel_id || body.target_id;
325
+ const channelKind = (bodyTarget && String(bodyTarget) !== String(body.user_id)) || event.channel_type === 'GROUP' ? 'channel' : 'private';
326
+ const channelId = channelKind === 'private' ? String(body.user_id) : String(bodyTarget || event.target_id || '');
327
+ if (!channelId)
328
+ return null;
329
+ const timestamp = event.msg_timestamp ?? Date.now();
330
+ 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 };
331
+ }
@@ -0,0 +1,7 @@
1
+ import WebSocket from 'ws';
2
+ export interface KookStreamProxy {
3
+ readonly port: number;
4
+ readonly serverName: string;
5
+ }
6
+ /** Route TCP only. WSS HTTP Host, path, ticket, TLS SNI and peer validation stay original. */
7
+ export declare function createKookStreamSocket(url: string, proxy?: KookStreamProxy): WebSocket;
@@ -0,0 +1,18 @@
1
+ import tls from 'node:tls';
2
+ import WebSocket from 'ws';
3
+ import { getLogger } from '@zhin.js/logger';
4
+ /** Route TCP only. WSS HTTP Host, path, ticket, TLS SNI and peer validation stay original. */
5
+ export function createKookStreamSocket(url, proxy) {
6
+ if (!proxy)
7
+ return new WebSocket(url);
8
+ const target = new URL(url);
9
+ if (target.protocol !== 'wss:' || target.hostname !== proxy.serverName || (target.port && target.port !== '443')) {
10
+ // The hostname is safe diagnostics; never print URL/query/ticket or SDK errors.
11
+ getLogger('kook').warn(`Stream proxy rejected gateway hostname: ${target.hostname}`);
12
+ throw new Error('KOOK Stream proxy gateway identity changed; update the fixed upstream before retrying');
13
+ }
14
+ return new WebSocket(url, {
15
+ createConnection: (options) => tls.connect({ ...options, path: undefined, host: '127.0.0.1', port: proxy.port,
16
+ servername: target.hostname, rejectUnauthorized: true }),
17
+ });
18
+ }
package/lib/webhook.d.ts CHANGED
@@ -9,8 +9,9 @@ export interface KookWebhookHandler {
9
9
  readonly isOpen: boolean;
10
10
  readonly selfId?: string;
11
11
  admit(msg: KookInboundMessage): void;
12
- admitEvent(event: KookWebhookEventData): void;
12
+ admitEvent(event: KookWebhookEventData): Promise<void> | void;
13
13
  checkAndRememberSn(sn: number): boolean;
14
+ forgetSn?(sn: number): void;
14
15
  }
15
16
  export declare function registerKookWebhookRoutes(http: HttpHost, handler: KookWebhookHandler): HttpRouteRegistration[];
16
17
  export declare function handleKookWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: KookWebhookHandler): Promise<void>;
package/lib/webhook.js CHANGED
@@ -47,6 +47,11 @@ export async function handleKookWebhookRequest(request, response, handler) {
47
47
  response.end(JSON.stringify({ message: 'Invalid verify_token' }));
48
48
  return;
49
49
  }
50
+ if (!handler.isOpen) {
51
+ response.writeHead(503, { 'Content-Type': 'application/json' });
52
+ response.end(JSON.stringify({ message: 'Endpoint unavailable' }));
53
+ return;
54
+ }
50
55
  if (frame.sn != null) {
51
56
  if (!handler.checkAndRememberSn(frame.sn)) {
52
57
  response.writeHead(200, { 'Content-Type': 'application/json' });
@@ -54,8 +59,15 @@ export async function handleKookWebhookRequest(request, response, handler) {
54
59
  return;
55
60
  }
56
61
  }
57
- if (handler.isOpen) {
58
- handleKookWebhookEvent(event, handler);
62
+ try {
63
+ await handler.admitEvent(event);
64
+ }
65
+ catch {
66
+ if (frame.sn != null)
67
+ handler.forgetSn?.(frame.sn);
68
+ response.writeHead(503, { 'Content-Type': 'application/json' });
69
+ response.end(JSON.stringify({ message: 'Admission unavailable' }));
70
+ return;
59
71
  }
60
72
  response.writeHead(200, { 'Content-Type': 'application/json' });
61
73
  response.end(JSON.stringify({ message: 'OK' }));
@@ -66,6 +78,3 @@ export async function handleKookWebhookRequest(request, response, handler) {
66
78
  response.end(JSON.stringify({ message: 'OK' }));
67
79
  }
68
80
  }
69
- function handleKookWebhookEvent(event, handler) {
70
- handler.admitEvent(event);
71
- }
package/lib/ws.d.ts CHANGED
@@ -1,17 +1,27 @@
1
+ import { type KookApiProxy } from './api-proxy.js';
2
+ import { Client } from 'kook-client';
3
+ import { createEndpointLifecycle, type EndpointTransportState } from 'zhin.js/adapter';
1
4
  import { type KookInboundMessage, type ResolvedKookConfig, type ResolvedKookWebhookConfig, type ResolvedKookWebsocketConfig } from './protocol.js';
2
5
  /** Minimal client surface used by the endpoint (real kook-client or test mock). */
3
6
  export interface KookClientTransport {
7
+ getTransportState?(): EndpointTransportState | undefined;
4
8
  connect(): Promise<void>;
5
9
  disconnect(): Promise<void>;
6
10
  on(event: string, listener: (...args: unknown[]) => void): void;
7
11
  removeAllListeners(): void;
8
- sendChannelMsg(channelId: string, message: string): Promise<{
12
+ sendChannelMsg(channelId: string, message: unknown, quote?: {
13
+ message_id: string;
14
+ }): Promise<{
9
15
  msg_id?: string | number;
10
16
  }>;
11
- sendPrivateMsg(userId: string, message: string): Promise<{
17
+ sendPrivateMsg(userId: string, message: unknown, quote?: {
18
+ message_id: string;
19
+ }): Promise<{
12
20
  msg_id?: string | number;
13
21
  }>;
14
- recallMsg?(messageId: string): Promise<boolean>;
22
+ uploadMedia?(data: Buffer): Promise<string>;
23
+ recallChannelMsg?(channelId: string, messageId: string): Promise<boolean>;
24
+ recallPrivateMsg?(userId: string, messageId: string): Promise<boolean>;
15
25
  pickGuild(guildId: string): {
16
26
  kick(userId: string): Promise<boolean>;
17
27
  getRoleList(): Promise<Array<{
@@ -52,5 +62,16 @@ export interface KookClientTransport {
52
62
  }
53
63
  export type CreateKookClient = (config: ResolvedKookConfig) => KookClientTransport;
54
64
  export declare function normalizeKookMessage(raw: unknown): KookInboundMessage | null;
65
+ export declare class RuntimeKookClient extends Client {
66
+ #private;
67
+ constructor(config: ConstructorParameters<typeof Client>[0], lifecycleOptions?: Parameters<typeof createEndpointLifecycle>[0], apiProxy?: KookApiProxy);
68
+ connect(): Promise<void>;
69
+ disconnect(): Promise<void>;
70
+ /** Native FormData avoids kook-client's Buffer-to-text multipart conversion. */
71
+ uploadMedia(data: Buffer | string): Promise<string>;
72
+ getTransportState(): EndpointTransportState | undefined;
73
+ init(): Promise<void>;
74
+ getBlacklist(guildId: string): Promise<import("kook-client").Guild.BlackInfo[]>;
75
+ }
55
76
  export declare function defaultCreateClient(config: ResolvedKookWebsocketConfig): KookClientTransport;
56
77
  export declare function defaultCreateWebhookClient(config: ResolvedKookWebhookConfig): KookClientTransport;
package/lib/ws.js 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 } 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 } from 'zhin.js/adapter';
6
11
  export function normalizeKookMessage(raw) {
7
12
  if (!raw || typeof raw !== 'object')
8
13
  return null;
@@ -23,35 +28,182 @@ export function normalizeKookMessage(raw) {
23
28
  }
24
29
  }
25
30
  const content = textParts.join('') || msg.raw_message || '';
31
+ // kook-client resolves these getters through optional local caches and throws
32
+ // for uncached users/channels. Identity and routing come from the event itself.
33
+ let author;
34
+ let channel;
35
+ try {
36
+ author = msg.author;
37
+ }
38
+ catch { /* optional cache enrichment unavailable */ }
39
+ try {
40
+ channel = msg.channel;
41
+ }
42
+ catch { /* optional cache enrichment unavailable */ }
26
43
  return {
27
44
  id: String(msg.message_id),
28
45
  content,
29
46
  channelKind,
30
47
  channelId,
31
48
  authorId: String(msg.author_id),
32
- authorName: msg.author?.info?.nickname
33
- || msg.author?.info?.username
49
+ authorName: author?.info?.nickname
50
+ || author?.info?.username
34
51
  || String(msg.author_id),
35
- authorBot: msg.author?.bot === true || msg.author?.info?.bot === true,
36
- authorRoles: msg.author?.info?.roles ?? msg.author?.roles,
52
+ authorBot: author?.bot === true || author?.info?.bot === true,
53
+ authorRoles: author?.info?.roles ?? author?.roles,
37
54
  timestamp: msg.timestamp ?? Date.now(),
38
- guildId: msg.channel?.info?.guild_id,
55
+ guildId: channel?.info?.guild_id,
39
56
  rawMessage: msg.raw_message,
40
57
  };
41
58
  }
59
+ export class RuntimeKookClient extends Client {
60
+ #lifecycle;
61
+ #initialized = false;
62
+ #connectionAttempt = 0;
63
+ constructor(config, lifecycleOptions = { name: 'kook' }, apiProxy) {
64
+ super({ ...config, autoReconnect: false, handleProcessErrors: false });
65
+ this.#lifecycle = createEndpointLifecycle(lifecycleOptions);
66
+ if (apiProxy) {
67
+ this.request.defaults.httpsAgent = createKookApiAgent(apiProxy);
68
+ this.request.defaults.proxy = false;
69
+ this.request.defaults.maxRedirects = 0;
70
+ this.request.interceptors.request.use(request => {
71
+ const target = new URL(request.url ?? '', request.baseURL);
72
+ if (target.protocol !== 'https:' || target.hostname !== 'www.kookapp.cn' || (target.port && target.port !== '443')) {
73
+ throw new EndpointDeliveryError('invalid_destination', 'KOOK API proxy rejected destination identity', 'not_sent');
74
+ }
75
+ return request;
76
+ });
77
+ }
78
+ const transforms = this.request.defaults.transformResponse;
79
+ this.request.defaults.transformResponse = [
80
+ ...(Array.isArray(transforms) ? transforms : transforms ? [transforms] : []),
81
+ (data, _headers, status) => {
82
+ // Classify HTTP status before the SDK discards it in its error interceptor.
83
+ if (status !== undefined && status >= 400) {
84
+ const rejected = status < 500 && status !== 408;
85
+ throw new EndpointDeliveryError(rejected ? 'platform_rejected' : 'delivery_unconfirmed', `KOOK HTTP request failed (status=${status})`, rejected ? 'rejected' : 'unknown');
86
+ }
87
+ return data;
88
+ },
89
+ ];
90
+ this.request.interceptors.response.use(response => {
91
+ // kook-client unwraps HTTP JSON but does not reject nonzero platform codes.
92
+ const body = response;
93
+ if (typeof body.code === 'number' && body.code !== 0) {
94
+ throw new EndpointDeliveryError('platform_rejected', `KOOK rejected API request (code=${body.code})`, 'rejected');
95
+ }
96
+ return response;
97
+ });
98
+ }
99
+ async connect() {
100
+ await this.#lifecycle.start(async (handle) => {
101
+ const attempt = ++this.#connectionAttempt;
102
+ const receiver = this.receiver;
103
+ let active = true;
104
+ const disconnected = () => { if (!active)
105
+ return; active = false; receiver.off('disconnected', disconnected); handle.notifyClosed('KOOK receiver disconnected'); };
106
+ receiver.on('disconnected', disconnected);
107
+ handle.onForceClose(() => { active = false; receiver.off('disconnected', disconnected); if (attempt === this.#connectionAttempt)
108
+ void receiver.disconnect(); });
109
+ try {
110
+ const resume = receiver instanceof WebsocketReceiver && receiver.canResume();
111
+ await receiver.connect(resume);
112
+ if (!active || attempt !== this.#connectionAttempt)
113
+ throw new Error('KOOK connection closed before admission');
114
+ if (!resume || !this.#initialized) {
115
+ await this.init();
116
+ this.#initialized = true;
117
+ }
118
+ }
119
+ catch (error) {
120
+ active = false;
121
+ receiver.off('disconnected', disconnected);
122
+ if (attempt === this.#connectionAttempt)
123
+ await receiver.disconnect();
124
+ throw error;
125
+ }
126
+ });
127
+ }
128
+ async disconnect() { await this.#lifecycle.stop(); }
129
+ /** Native FormData avoids kook-client's Buffer-to-text multipart conversion. */
130
+ async uploadMedia(data) {
131
+ let bytes;
132
+ if (Buffer.isBuffer(data))
133
+ bytes = data;
134
+ else if (/^https?:\/\//.test(data)) {
135
+ const downloaded = await fetch(data, { signal: AbortSignal.timeout(30_000) });
136
+ if (!downloaded.ok)
137
+ throw new EndpointDeliveryError('media_download_failed', 'KOOK source download failed', 'rejected');
138
+ bytes = Buffer.from(await downloaded.arrayBuffer());
139
+ }
140
+ else if (/^data:[^,]*;base64,/.test(data) || data.startsWith('base64://')) {
141
+ bytes = Buffer.from(data.replace(/^data:[^,]*,|^base64:\/\//, ''), 'base64');
142
+ }
143
+ else
144
+ bytes = await readFile(data.replace(/^file:\/\//, ''));
145
+ const form = new FormData();
146
+ form.append('file', new Blob([Uint8Array.from(bytes)]), 'image.png');
147
+ const response = await fetch('https://www.kookapp.cn/api/v3/asset/create', {
148
+ method: 'POST', headers: { Authorization: `Bot ${this.config.token}` }, body: form,
149
+ signal: AbortSignal.timeout(30_000),
150
+ });
151
+ const body = await response.json();
152
+ if (!response.ok || body.code !== 0) {
153
+ const rejected = response.status >= 400 && response.status < 500 && response.status !== 408 || response.ok && typeof body.code === 'number' && body.code !== 0;
154
+ throw new EndpointDeliveryError(rejected ? 'platform_rejected' : 'delivery_unconfirmed', `KOOK upload failed (status=${response.status}, code=${typeof body.code === 'number' ? body.code : 'missing'})`, rejected ? 'rejected' : 'unknown');
155
+ }
156
+ if (typeof body.data?.url !== 'string')
157
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK upload returned no URL', 'unknown');
158
+ return body.data.url;
159
+ }
160
+ #preloading = false;
161
+ getTransportState() {
162
+ if (!(this.receiver instanceof WebsocketReceiver))
163
+ return undefined;
164
+ return this.#lifecycle.state;
165
+ }
166
+ async init() {
167
+ this.#preloading = true;
168
+ try {
169
+ return await super.init();
170
+ }
171
+ finally {
172
+ this.#preloading = false;
173
+ }
174
+ }
175
+ async getBlacklist(guildId) {
176
+ try {
177
+ return await super.getBlacklist(guildId);
178
+ }
179
+ catch (error) {
180
+ // kook-client 1.0.4 discards structured HTTP metadata in its interceptor.
181
+ // Match only its exact blacklist permission-denial prefix during preload.
182
+ if (this.#preloading && error instanceof Error
183
+ && (error.message.startsWith('request "/v3/blacklist/list" error with code(403):')
184
+ || error instanceof EndpointDeliveryError && error.code === 'platform_rejected'
185
+ && error.disposition === 'rejected' && error.message === 'KOOK HTTP request failed (status=403)')) {
186
+ this.logger.warn('启动预加载跳过黑名单:缺少管理权限;消息收发继续。');
187
+ return [];
188
+ }
189
+ throw error;
190
+ }
191
+ }
192
+ }
42
193
  export function defaultCreateClient(config) {
43
- return new Client({
194
+ return new RuntimeKookClient({
44
195
  token: config.token,
45
196
  mode: 'websocket',
197
+ socketFactory: (url) => createKookStreamSocket(url, config.streamProxy),
46
198
  data_dir: config.data_dir || path.join(process.cwd(), 'data', 'kook'),
47
199
  timeout: config.timeout,
48
200
  max_retry: config.max_retry,
49
201
  ignore: config.ignore,
50
202
  logLevel: config.logLevel,
51
- });
203
+ }, undefined, config.apiProxy);
52
204
  }
53
205
  export function defaultCreateWebhookClient(config) {
54
- return new Client({
206
+ return new RuntimeKookClient({
55
207
  token: config.token,
56
208
  mode: 'webhook',
57
209
  data_dir: path.join(process.cwd(), 'data', 'kook'),
@@ -59,5 +211,5 @@ export function defaultCreateWebhookClient(config) {
59
211
  max_retry: 3,
60
212
  ignore: config.ignore,
61
213
  logLevel: config.logLevel,
62
- });
214
+ }, undefined, config.apiProxy);
63
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-kook",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Zhin.js KOOK adapter for Plugin Runtime (WebSocket gateway or Webhook)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -34,23 +34,23 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "kook-client": "^1.0.4",
37
- "@zhin.js/adapter": "1.1.15",
38
- "@zhin.js/agent-feature": "1.1.2",
39
- "@zhin.js/core": "1.1.39",
37
+ "@zhin.js/adapter": "1.1.16",
38
+ "@zhin.js/agent-feature": "1.1.3",
39
+ "@zhin.js/core": "1.1.40",
40
40
  "@zhin.js/feature-kit": "1.1.2",
41
- "@zhin.js/host-http": "1.1.2",
42
- "@zhin.js/im-contract": "1.1.2",
41
+ "@zhin.js/host-http": "1.1.3",
42
+ "@zhin.js/im-contract": "1.1.3",
43
43
  "@zhin.js/logger": "1.1.1",
44
44
  "@zhin.js/permission": "1.1.3",
45
- "@zhin.js/skill": "1.1.2",
46
- "@zhin.js/tool": "1.1.2"
45
+ "@zhin.js/skill": "1.1.3",
46
+ "@zhin.js/tool": "1.1.3"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "zod": "^4.0.0",
50
- "@zhin.js/adapter": "^1.1.15",
51
- "@zhin.js/command": "^1.1.3",
52
- "@zhin.js/core": "^1.1.39",
53
- "zhin.js": "^1.1.4"
50
+ "@zhin.js/adapter": "^1.1.16",
51
+ "@zhin.js/command": "^1.1.4",
52
+ "@zhin.js/core": "^1.1.40",
53
+ "zhin.js": "^1.1.5"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@zhin.js/command": {
@@ -68,8 +68,8 @@
68
68
  "typescript": "^6.0.3",
69
69
  "vitest": "^4.1.10",
70
70
  "zod": "^4.4.3",
71
- "@zhin.js/host-http": "1.1.2",
72
- "zhin.js": "1.1.4"
71
+ "@zhin.js/host-http": "1.1.3",
72
+ "zhin.js": "1.1.5"
73
73
  },
74
74
  "files": [
75
75
  "adapters",
package/schema.json CHANGED
@@ -151,6 +151,42 @@
151
151
  "id": {
152
152
  "type": "string",
153
153
  "description": "KOOK bot name"
154
+ },
155
+ "streamProxy": {
156
+ "type": "object",
157
+ "additionalProperties": false,
158
+ "properties": {
159
+ "port": {
160
+ "type": "integer",
161
+ "minimum": 1,
162
+ "maximum": 65535
163
+ },
164
+ "serverName": {
165
+ "type": "string",
166
+ "minLength": 1,
167
+ "pattern": "^[^\\s/:@?#]+$"
168
+ }
169
+ },
170
+ "required": [
171
+ "port",
172
+ "serverName"
173
+ ],
174
+ "description": "WSS故障验收固定loopback,保持真实TLS身份"
175
+ },
176
+ "apiProxy": {
177
+ "type": "object",
178
+ "additionalProperties": false,
179
+ "required": [
180
+ "port"
181
+ ],
182
+ "properties": {
183
+ "port": {
184
+ "type": "integer",
185
+ "minimum": 1,
186
+ "maximum": 65535
187
+ }
188
+ },
189
+ "description": "Optional fixed loopback TCP route for www.kookapp.cn JSON API only; verified TLS remains enabled."
154
190
  }
155
191
  },
156
192
  "required": [
@@ -0,0 +1,21 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { Socket } from 'node:net';
4
+
5
+ export interface KookApiProxy { readonly port: number }
6
+
7
+ /** JSON Web API only: keep www.kookapp.cn identity, route TCP to a fixed loopback port. */
8
+ export function createKookApiAgent(proxy: KookApiProxy): Agent {
9
+ const agent = new Agent({ keepAlive: false });
10
+ agent.createConnection = options => {
11
+ const hostname = String(options.hostname ?? options.host ?? '');
12
+ if (hostname !== 'www.kookapp.cn' || Number(options.port ?? 443) !== 443) {
13
+ const rejected = new Socket();
14
+ queueMicrotask(() => rejected.destroy(new Error('KOOK JSON API proxy rejected destination identity')));
15
+ return rejected;
16
+ }
17
+ return tls.connect({ ...options as tls.ConnectionOptions, path: undefined,
18
+ host: '127.0.0.1', port: proxy.port, servername: 'www.kookapp.cn', rejectUnauthorized: true });
19
+ };
20
+ return agent;
21
+ }