@zhin.js/adapter-kook 1.0.83 → 1.1.0

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +813 -16
  2. package/README.md +75 -374
  3. package/adapters/kook.js +41 -0
  4. package/adapters/kook.ts +56 -0
  5. package/agent/PERMITS.md +23 -0
  6. package/agent/tools/blacklist.ts +31 -0
  7. package/agent/tools/create_role.ts +23 -0
  8. package/agent/tools/delete_role.ts +19 -0
  9. package/agent/tools/grant_role.ts +20 -0
  10. package/agent/tools/list_roles.ts +25 -0
  11. package/agent/tools/revoke_role.ts +20 -0
  12. package/commands/endpoint/add/[id].js +3 -0
  13. package/commands/endpoint/add/[id].ts +3 -0
  14. package/commands/endpoint/list.js +3 -0
  15. package/commands/endpoint/list.ts +3 -0
  16. package/commands/endpoint/remove/[id].js +3 -0
  17. package/commands/endpoint/remove/[id].ts +3 -0
  18. package/lib/client.d.ts +11 -0
  19. package/lib/client.js +2 -0
  20. package/lib/endpoint.d.ts +59 -0
  21. package/lib/endpoint.js +374 -0
  22. package/lib/index.d.ts +6 -18
  23. package/lib/index.js +6 -342
  24. package/lib/kook-endpoint-commands.d.ts +1 -0
  25. package/lib/kook-endpoint-commands.js +18 -0
  26. package/lib/kook-runtime-state.d.ts +1 -0
  27. package/lib/kook-runtime-state.js +6 -0
  28. package/lib/platform-permit.d.ts +22 -0
  29. package/lib/platform-permit.js +48 -0
  30. package/lib/protocol.d.ts +144 -0
  31. package/lib/protocol.js +313 -0
  32. package/lib/side-event-dispatch.d.ts +3 -0
  33. package/lib/side-event-dispatch.js +49 -0
  34. package/lib/webhook.d.ts +16 -0
  35. package/lib/webhook.js +71 -0
  36. package/lib/ws.d.ts +56 -0
  37. package/lib/ws.js +63 -0
  38. package/package.json +60 -28
  39. package/plugin.js +19 -0
  40. package/schema.json +128 -0
  41. package/src/client.ts +15 -0
  42. package/src/endpoint.ts +433 -0
  43. package/src/index.ts +45 -322
  44. package/src/kook-endpoint-commands.ts +19 -0
  45. package/src/kook-runtime-state.ts +7 -0
  46. package/src/platform-permit.ts +75 -0
  47. package/src/protocol.ts +462 -0
  48. package/src/side-event-dispatch.ts +54 -0
  49. package/src/webhook.ts +114 -0
  50. package/src/ws.ts +131 -0
  51. package/client/Dashboard.tsx +0 -255
  52. package/client/index.tsx +0 -11
  53. package/client/tsconfig.json +0 -7
  54. package/client/utils/api.ts +0 -17
  55. package/dist/index.js +0 -31
  56. package/lib/adapter.d.ts +0 -26
  57. package/lib/adapter.d.ts.map +0 -1
  58. package/lib/adapter.js +0 -61
  59. package/lib/adapter.js.map +0 -1
  60. package/lib/bot.d.ts +0 -122
  61. package/lib/bot.d.ts.map +0 -1
  62. package/lib/bot.js +0 -608
  63. package/lib/bot.js.map +0 -1
  64. package/lib/index.d.ts.map +0 -1
  65. package/lib/index.js.map +0 -1
  66. package/lib/types.d.ts +0 -32
  67. package/lib/types.d.ts.map +0 -1
  68. package/lib/types.js +0 -8
  69. package/lib/types.js.map +0 -1
  70. package/plugin.yml +0 -3
  71. package/src/adapter.ts +0 -71
  72. package/src/bot.ts +0 -693
  73. package/src/types.ts +0 -36
  74. /package/{skills/kook/SKILL.md → agent/skills/kook.md} +0 -0
@@ -0,0 +1,462 @@
1
+ /**
2
+ * KOOK protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+
6
+ import { createDecipheriv, timingSafeEqual } from 'node:crypto';
7
+ import { inflateSync } from 'node:zlib';
8
+ import type { IncomingMessage } from 'node:http';
9
+ import { pickCredential } from 'zhin.js/adapter';
10
+ import { isMediaRef } from '@zhin.js/core';
11
+ import type { ConversationRef } from '@zhin.js/im-contract';
12
+ import { formatCompact, getLogger } from '@zhin.js/logger';
13
+
14
+ const logger = getLogger('kook');
15
+
16
+ export type LogLevel =
17
+ | 'trace'
18
+ | 'debug'
19
+ | 'info'
20
+ | 'warn'
21
+ | 'error'
22
+ | 'fatal'
23
+ | 'mark'
24
+ | 'off';
25
+
26
+ export enum KookPermission {
27
+ Normal = 1,
28
+ Admin = 2,
29
+ Owner = 4,
30
+ ChannelAdmin = 5,
31
+ }
32
+
33
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
34
+ export interface KookAdapterConfig {
35
+ readonly id?: string;
36
+ readonly token?: string;
37
+ /** Default `websocket`. `webhook` requires httpHostToken + verify_token. */
38
+ readonly connection?: 'websocket' | 'webhook';
39
+ readonly webhookPath?: string;
40
+ readonly verify_token?: string;
41
+ readonly encrypt_key?: string;
42
+ readonly data_dir?: string;
43
+ readonly timeout?: number;
44
+ readonly max_retry?: number;
45
+ readonly ignore?: 'bot' | 'self';
46
+ readonly logLevel?: LogLevel;
47
+ /** Transitional: legacy root `endpoints[]` with `context: kook`. */
48
+ readonly endpoints?: ReadonlyArray<{
49
+ readonly context?: string;
50
+ readonly id?: string;
51
+ readonly token?: string;
52
+ readonly connection?: 'websocket' | 'webhook';
53
+ readonly webhookPath?: string;
54
+ readonly verify_token?: string;
55
+ readonly encrypt_key?: string;
56
+ readonly data_dir?: string;
57
+ readonly timeout?: number;
58
+ readonly max_retry?: number;
59
+ readonly ignore?: 'bot' | 'self';
60
+ readonly logLevel?: LogLevel;
61
+ }>;
62
+ }
63
+
64
+ export interface ResolvedKookWebsocketConfig {
65
+ readonly context: 'kook';
66
+ readonly connection: 'websocket';
67
+ readonly id: string;
68
+ readonly token: string;
69
+ readonly data_dir?: string;
70
+ readonly timeout: number;
71
+ readonly max_retry: number;
72
+ readonly ignore: 'bot' | 'self';
73
+ readonly logLevel: LogLevel;
74
+ }
75
+
76
+ export interface ResolvedKookWebhookConfig {
77
+ readonly context: 'kook';
78
+ readonly connection: 'webhook';
79
+ readonly id: string;
80
+ readonly token: string;
81
+ readonly webhookPath: string;
82
+ readonly verifyToken: string;
83
+ readonly encryptKey?: string;
84
+ readonly ignore: 'bot' | 'self';
85
+ readonly logLevel: LogLevel;
86
+ }
87
+
88
+ export interface KookWebhookEventData {
89
+ readonly channel_type?: string;
90
+ readonly type?: number;
91
+ readonly target_id?: string;
92
+ readonly author_id?: string;
93
+ readonly content?: string;
94
+ readonly msg_id?: string;
95
+ readonly msg_timestamp?: number;
96
+ readonly verify_token?: string;
97
+ readonly challenge?: string;
98
+ readonly extra?: {
99
+ readonly guild_id?: string;
100
+ readonly author?: {
101
+ readonly id?: string;
102
+ readonly username?: string;
103
+ readonly bot?: boolean;
104
+ readonly roles?: number[];
105
+ };
106
+ };
107
+ }
108
+
109
+ export interface KookWebhookFrame {
110
+ readonly s?: number;
111
+ readonly sn?: number;
112
+ readonly d?: KookWebhookEventData;
113
+ readonly encrypt?: string;
114
+ }
115
+
116
+ export type ResolvedKookConfig = ResolvedKookWebsocketConfig | ResolvedKookWebhookConfig;
117
+
118
+ export interface KookInboundMessage {
119
+ readonly id: string;
120
+ readonly content: string;
121
+ readonly channelKind: 'private' | 'channel';
122
+ readonly channelId: string;
123
+ readonly authorId: string;
124
+ readonly authorName: string;
125
+ readonly authorBot?: boolean;
126
+ readonly authorRoles?: number[];
127
+ readonly timestamp: number;
128
+ readonly guildId?: string;
129
+ readonly rawMessage?: string;
130
+ }
131
+
132
+ export interface KookWireSegment {
133
+ readonly type: string;
134
+ readonly data?: Record<string, unknown>;
135
+ }
136
+
137
+ export function resolveKookConfig(config: KookAdapterConfig = {}): ResolvedKookConfig {
138
+ const entry = config.endpoints?.find((item) => item.context === 'kook' || !item.context);
139
+ const token = pickCredential(config.token, entry?.token, process.env.KOOK_TOKEN, process.env.KOOK_BOT_TOKEN);
140
+ if (!token) {
141
+ throw new TypeError(
142
+ 'KOOK adapter requires token (plugins.<key>.token or endpoints with context: kook)',
143
+ );
144
+ }
145
+ const id = (typeof config.id === 'string' && config.id)
146
+ || (typeof entry?.id === 'string' && entry.id)
147
+ || process.env.KOOK_BOT_NAME
148
+ || 'kook-bot';
149
+ const connection = config.connection
150
+ ?? entry?.connection
151
+ ?? 'websocket';
152
+
153
+ if (connection === 'webhook') {
154
+ const verifyToken = (typeof config.verify_token === 'string' && config.verify_token)
155
+ || (typeof entry?.verify_token === 'string' && entry.verify_token)
156
+ || process.env.KOOK_VERIFY_TOKEN
157
+ || '';
158
+ if (!verifyToken) {
159
+ throw new TypeError(
160
+ 'KOOK webhook mode requires verify_token (plugins.<key>.verify_token or KOOK_VERIFY_TOKEN)',
161
+ );
162
+ }
163
+ const encryptKey = config.encrypt_key ?? entry?.encrypt_key ?? process.env.KOOK_ENCRYPT_KEY;
164
+ return {
165
+ context: 'kook',
166
+ connection: 'webhook',
167
+ id,
168
+ token,
169
+ webhookPath: normalizeWebhookPath(
170
+ config.webhookPath ?? entry?.webhookPath ?? process.env.KOOK_WEBHOOK_PATH ?? '/kook/webhook',
171
+ ),
172
+ verifyToken,
173
+ encryptKey: typeof encryptKey === 'string' && encryptKey ? encryptKey : undefined,
174
+ ignore: config.ignore ?? entry?.ignore ?? 'bot',
175
+ logLevel: config.logLevel ?? entry?.logLevel ?? 'info',
176
+ };
177
+ }
178
+
179
+ return {
180
+ context: 'kook',
181
+ connection: 'websocket',
182
+ id,
183
+ token,
184
+ data_dir: config.data_dir ?? entry?.data_dir,
185
+ timeout: config.timeout ?? entry?.timeout ?? 10_000,
186
+ max_retry: config.max_retry ?? entry?.max_retry ?? 3,
187
+ ignore: config.ignore ?? entry?.ignore ?? 'bot',
188
+ logLevel: config.logLevel ?? entry?.logLevel ?? 'info',
189
+ };
190
+ }
191
+
192
+ /**
193
+ * 入站归一化 → ConversationRef:频道消息的所属 guild 进 `parent`(guild 容器
194
+ * 映射为 channel 容器);私聊(PERSON)无容器,直接 kind 'private'。
195
+ */
196
+ export function kookInboundConversation(endpointKey: string, msg: KookInboundMessage): ConversationRef {
197
+ return {
198
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
199
+ kind: msg.channelKind,
200
+ id: msg.channelId,
201
+ ...(msg.guildId && msg.channelKind === 'channel'
202
+ ? { parent: { kind: 'channel' as const, id: msg.guildId } }
203
+ : {}),
204
+ };
205
+ }
206
+
207
+ export function senderDisplayName(msg: KookInboundMessage): string {
208
+ return msg.authorName || msg.authorId;
209
+ }
210
+
211
+ /** Build inbound text for OutboundMessageService.receive */
212
+ export function formatInboundContent(msg: KookInboundMessage): string {
213
+ const text = (msg.content || msg.rawMessage || '').trim();
214
+ return text || '(Empty message)';
215
+ }
216
+
217
+ /**
218
+ * KOOK @ 用户标记为 `(met)<userId>(met)`;content 含 `(met)<botId>(met)` 时视为 @ 机器人。
219
+ * botId 取 client 缓存的 self_id(/me 类接口),拿不到则不标注。
220
+ */
221
+ export function isKookBotMentioned(msg: KookInboundMessage, selfId?: string): boolean {
222
+ if (!selfId) return false;
223
+ const marker = `(met)${selfId}(met)`;
224
+ return (msg.content || '').includes(marker) || (msg.rawMessage || '').includes(marker);
225
+ }
226
+
227
+ /**
228
+ * 解析 image/audio/video/file 段的投递 URL(canonical MediaRef 唯一来源)。
229
+ * KOOK KMarkdown 媒体只能消费远程 URL;base64 / path / file 引用无法经
230
+ * KMarkdown 投递,warn 后丢弃(返回 undefined)。
231
+ */
232
+ function resolveOutboundMediaUrl(seg: KookWireSegment): string | undefined {
233
+ const media = (seg.data ?? {}).media;
234
+ if (!isMediaRef(media)) {
235
+ logger.warn(formatCompact({
236
+ op: 'kook_outbound_media_dropped',
237
+ type: seg.type,
238
+ reason: 'missing_media_ref',
239
+ }));
240
+ return undefined;
241
+ }
242
+ if (media.kind !== 'url') {
243
+ logger.warn(formatCompact({
244
+ op: 'kook_outbound_media_dropped',
245
+ type: seg.type,
246
+ reason: 'unsupported_media_kind',
247
+ kind: media.kind,
248
+ }));
249
+ return undefined;
250
+ }
251
+ return media.value;
252
+ }
253
+
254
+ /**
255
+ * Wire-encode an already-rendered outbound payload into KOOK KMarkdown.
256
+ * Segment canonicalization is intentionally not done here.
257
+ */
258
+ export function formatOutboundKmarkdown(payload: unknown): string {
259
+ if (typeof payload === 'string') return payload;
260
+
261
+ const segments: Array<string | KookWireSegment> = Array.isArray(payload)
262
+ ? payload as Array<string | KookWireSegment>
263
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
264
+ ? [payload as KookWireSegment]
265
+ : [];
266
+
267
+ if (segments.length === 0) {
268
+ return payload == null
269
+ ? ''
270
+ : typeof payload === 'object'
271
+ ? JSON.stringify(payload)
272
+ : String(payload);
273
+ }
274
+
275
+ return segments
276
+ .map((item) => {
277
+ if (typeof item === 'string') return item;
278
+ const data = item.data ?? {};
279
+ switch (item.type) {
280
+ case 'text':
281
+ return String(data.text ?? data.content ?? '');
282
+ case 'markdown':
283
+ return String(data.content ?? data.text ?? '');
284
+ case 'at': {
285
+ const id = String(data.user_id ?? data.qq ?? data.id ?? '');
286
+ return id === 'all' ? '(met)all(met)' : `(met)${id}(met)`;
287
+ }
288
+ case 'image': {
289
+ const url = resolveOutboundMediaUrl(item);
290
+ return url ? `![${String(data.alt || '图片')}](${url})` : '';
291
+ }
292
+ case 'face':
293
+ return `(emj)${String(data.name || 'emoji')}(emj)[${String(data.id ?? '')}]`;
294
+ case 'link':
295
+ return `[${String(data.text || data.url || '')}](${String(data.url || '')})`;
296
+ case 'video': {
297
+ const url = resolveOutboundMediaUrl(item);
298
+ return url ? `[视频](${url})` : '';
299
+ }
300
+ case 'audio': {
301
+ const url = resolveOutboundMediaUrl(item);
302
+ return url ? `[音频](${url})` : '';
303
+ }
304
+ case 'file': {
305
+ const url = resolveOutboundMediaUrl(item);
306
+ return url ? `[文件: ${String(data.name || '未命名')}](${url})` : '';
307
+ }
308
+ case 'bold':
309
+ return `**${String(data.text ?? '')}**`;
310
+ case 'italic':
311
+ return `*${String(data.text ?? '')}*`;
312
+ case 'code':
313
+ return `\`${String(data.text ?? '')}\``;
314
+ case 'code_block':
315
+ return `\`\`\`${String(data.language || '')}\n${String(data.text ?? '')}\n\`\`\``;
316
+ case 'reply':
317
+ return '';
318
+ default:
319
+ return data.text != null ? String(data.text) : '';
320
+ }
321
+ })
322
+ .filter(Boolean)
323
+ .join('');
324
+ }
325
+
326
+ export function normalizeWebhookPath(path: string): string {
327
+ const trimmed = path.trim() || '/kook/webhook';
328
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
329
+ }
330
+
331
+ export async function readTextBody(
332
+ request: IncomingMessage,
333
+ options: { readonly limit?: number } = {},
334
+ ): Promise<string> {
335
+ const limit = options.limit ?? 1_048_576;
336
+ const chunks: Buffer[] = [];
337
+ let size = 0;
338
+ for await (const chunk of request) {
339
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
340
+ size += buffer.length;
341
+ if (size > limit) {
342
+ request.destroy();
343
+ throw new Error(`Request body exceeds ${limit} bytes`);
344
+ }
345
+ chunks.push(buffer);
346
+ }
347
+ return Buffer.concat(chunks).toString('utf8');
348
+ }
349
+
350
+ export async function readRequestBody(
351
+ request: IncomingMessage,
352
+ options: { readonly limit?: number } = {},
353
+ ): Promise<Buffer> {
354
+ const limit = options.limit ?? 1_048_576;
355
+ const chunks: Buffer[] = [];
356
+ let size = 0;
357
+ for await (const chunk of request) {
358
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
359
+ size += buffer.length;
360
+ if (size > limit) {
361
+ request.destroy();
362
+ throw new Error(`Request body exceeds ${limit} bytes`);
363
+ }
364
+ chunks.push(buffer);
365
+ }
366
+ return Buffer.concat(chunks);
367
+ }
368
+
369
+ export function padKookEncryptKey(key: string): Buffer {
370
+ const buf = Buffer.alloc(32, 0);
371
+ Buffer.from(key, 'utf8').copy(buf, 0, 0, Math.min(32, Buffer.byteLength(key, 'utf8')));
372
+ return buf;
373
+ }
374
+
375
+ export function decryptKookWebhookPayload(encrypted: string, encryptKey: string): Buffer {
376
+ const decoded = Buffer.from(encrypted, 'base64');
377
+ const iv = decoded.subarray(0, 16);
378
+ const ciphertext = decoded.subarray(16);
379
+ const decipher = createDecipheriv('aes-256-cbc', padKookEncryptKey(encryptKey), iv);
380
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
381
+ }
382
+
383
+ export function parseKookWebhookBody(rawBody: Buffer, encryptKey?: string): KookWebhookFrame {
384
+ let text = rawBody.toString('utf8').trim();
385
+ if (!text.startsWith('{')) {
386
+ text = inflateSync(rawBody).toString('utf8');
387
+ }
388
+ let parsed: KookWebhookFrame;
389
+ try {
390
+ parsed = JSON.parse(text) as KookWebhookFrame;
391
+ } catch {
392
+ throw new TypeError('Invalid KOOK webhook JSON body');
393
+ }
394
+ if (parsed.encrypt) {
395
+ if (!encryptKey) {
396
+ throw new TypeError('KOOK webhook payload is encrypted but encrypt_key is not configured');
397
+ }
398
+ const decrypted = decryptKookWebhookPayload(parsed.encrypt, encryptKey);
399
+ // 在字节层面判断:明文 JSON 以 '{' 开头,否则按 zlib 压缩数据处理。
400
+ // 不能先 toString('utf8') 再转回 Buffer —— 压缩二进制会被替换字符损坏。
401
+ let offset = 0;
402
+ while (
403
+ offset < decrypted.length
404
+ && (decrypted[offset] === 0x20 || decrypted[offset] === 0x09
405
+ || decrypted[offset] === 0x0a || decrypted[offset] === 0x0d)
406
+ ) {
407
+ offset += 1;
408
+ }
409
+ const decryptedText = decrypted[offset] === 0x7b // '{'
410
+ ? decrypted.toString('utf8')
411
+ : inflateSync(decrypted).toString('utf8');
412
+ parsed = JSON.parse(decryptedText) as KookWebhookFrame;
413
+ }
414
+ return parsed;
415
+ }
416
+
417
+ export function isKookWebhookChallenge(event: KookWebhookEventData): boolean {
418
+ return event.type === 255 && event.channel_type === 'WEBHOOK_CHALLENGE';
419
+ }
420
+
421
+ export function verifyKookWebhookToken(expected: string, actual?: string): boolean {
422
+ if (!actual) return false;
423
+ const left = Buffer.from(expected, 'utf8');
424
+ const right = Buffer.from(actual, 'utf8');
425
+ if (left.length !== right.length) return false;
426
+ return timingSafeEqual(left, right);
427
+ }
428
+
429
+ export function normalizeKookWebhookEvent(
430
+ event: KookWebhookEventData,
431
+ options: { readonly ignore?: 'bot' | 'self'; readonly selfId?: string } = {},
432
+ ): KookInboundMessage | null {
433
+ if (isKookWebhookChallenge(event)) return null;
434
+ if (event.msg_id == null || event.author_id == null) return null;
435
+
436
+ const authorBot = event.extra?.author?.bot === true;
437
+ if (authorBot && options.ignore === 'bot') return null;
438
+ if (options.selfId && String(event.author_id) === options.selfId && options.ignore === 'self') {
439
+ return null;
440
+ }
441
+
442
+ const channelKind = event.channel_type === 'GROUP' ? 'channel' : 'private';
443
+ const channelId = channelKind === 'channel'
444
+ ? String(event.target_id ?? '')
445
+ : String(event.author_id);
446
+ if (!channelId) return null;
447
+
448
+ return {
449
+ id: String(event.msg_id),
450
+ content: event.content ?? '',
451
+ channelKind,
452
+ channelId,
453
+ authorId: String(event.author_id),
454
+ authorName: event.extra?.author?.username
455
+ || event.extra?.author?.id
456
+ || String(event.author_id),
457
+ authorBot,
458
+ authorRoles: event.extra?.author?.roles,
459
+ timestamp: event.msg_timestamp ?? Date.now(),
460
+ guildId: event.extra?.guild_id,
461
+ };
462
+ }
@@ -0,0 +1,54 @@
1
+ import { buildNotice, mapNoticeParts, senderFromId, KOOK_NOTICE_PARTS_MAP } from '@zhin.js/core';
2
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
3
+ import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
4
+ import type { KookWebhookEventData } from './protocol.js';
5
+
6
+ function extractKookNoticeType(raw: unknown): string | null {
7
+ if (!raw || typeof raw !== 'object') return null;
8
+ const event = raw as KookWebhookEventData & { extra?: { type?: string } };
9
+ const extraType = event.extra?.type;
10
+ if (typeof extraType === 'string' && extraType) return extraType;
11
+ if (event.channel_type === 'NOTICE' && event.type != null) {
12
+ return String(event.type);
13
+ }
14
+ return null;
15
+ }
16
+
17
+ export function receiveKookSideEvent(
18
+ emit: EndpointEventEmitter,
19
+ configId: string,
20
+ raw: unknown,
21
+ logger: ReturnType<typeof getAdapterLogger>,
22
+ ): boolean {
23
+ const noticeType = extractKookNoticeType(raw);
24
+ if (!noticeType) return false;
25
+ if (!Object.prototype.hasOwnProperty.call(KOOK_NOTICE_PARTS_MAP, noticeType)) return false;
26
+ const event = raw as KookWebhookEventData;
27
+ const parts = mapNoticeParts('kook', noticeType);
28
+ const guildId = event.extra?.guild_id;
29
+ const userId = event.author_id ?? event.extra?.author?.id;
30
+ const sceneId = guildId != null
31
+ ? String(guildId)
32
+ : userId != null
33
+ ? String(userId)
34
+ : configId;
35
+ void emit('notice.receive', buildNotice(event, {
36
+ $id: `kook:${noticeType}:${event.msg_timestamp ?? Date.now()}:${sceneId}:${String(userId ?? '')}`,
37
+ $adapter: 'kook' as never,
38
+ $endpoint: configId,
39
+ $type: 'notice',
40
+ $scene_id: sceneId,
41
+ $scene_type: parts.scene_type,
42
+ $sub_type: parts.sub_type,
43
+ $actor: senderFromId(userId, event.extra?.author?.username),
44
+ $timestamp: event.msg_timestamp ?? Date.now(),
45
+ })).catch((err) => {
46
+ logger.warn(formatCompact({
47
+ op: 'kook_side_event_failed',
48
+ endpoint: configId,
49
+ event: noticeType,
50
+ error: err instanceof Error ? err.message : String(err),
51
+ }));
52
+ });
53
+ return true;
54
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * KOOK webhook HTTP: verify → parse → admit.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import {
8
+ isKookWebhookChallenge,
9
+ normalizeKookWebhookEvent,
10
+ parseKookWebhookBody,
11
+ readRequestBody,
12
+ verifyKookWebhookToken,
13
+ type KookInboundMessage,
14
+ type KookWebhookEventData,
15
+ type KookWebhookFrame,
16
+ type ResolvedKookWebhookConfig,
17
+ } from './protocol.js';
18
+ import { receiveKookSideEvent } from './side-event-dispatch.js';
19
+
20
+ const logger = getLogger('kook');
21
+
22
+ export interface KookWebhookHandler {
23
+ readonly config: ResolvedKookWebhookConfig;
24
+ readonly isOpen: boolean;
25
+ readonly selfId?: string;
26
+ admit(msg: KookInboundMessage): void;
27
+ admitEvent(event: KookWebhookEventData): void;
28
+ checkAndRememberSn(sn: number): boolean;
29
+ }
30
+
31
+ export function registerKookWebhookRoutes(
32
+ http: HttpHost,
33
+ handler: KookWebhookHandler,
34
+ ): HttpRouteRegistration[] {
35
+ const path = handler.config.webhookPath;
36
+ return [
37
+ http.route('POST', path, async (request, response) => {
38
+ await handleKookWebhookRequest(request, response, handler);
39
+ }, { summary: 'KOOK webhook callback', tags: ['kook'] }),
40
+ ];
41
+ }
42
+
43
+ export async function handleKookWebhookRequest(
44
+ request: IncomingMessage,
45
+ response: ServerResponse,
46
+ handler: KookWebhookHandler,
47
+ ): Promise<void> {
48
+ try {
49
+ const rawBody = await readRequestBody(request);
50
+ let frame: KookWebhookFrame;
51
+ try {
52
+ frame = parseKookWebhookBody(rawBody, handler.config.encryptKey);
53
+ } catch (error) {
54
+ logger.warn(formatCompact({
55
+ op: 'kook_webhook',
56
+ ok: false,
57
+ error: error instanceof Error ? error.message : String(error),
58
+ }));
59
+ response.writeHead(400, { 'Content-Type': 'application/json' });
60
+ response.end(JSON.stringify({ message: 'Invalid webhook payload' }));
61
+ return;
62
+ }
63
+
64
+ const event = frame.d;
65
+ if (!event || typeof event !== 'object') {
66
+ response.writeHead(200, { 'Content-Type': 'application/json' });
67
+ response.end(JSON.stringify({ message: 'OK' }));
68
+ return;
69
+ }
70
+
71
+ if (isKookWebhookChallenge(event)) {
72
+ if (!verifyKookWebhookToken(handler.config.verifyToken, event.verify_token)) {
73
+ response.writeHead(403, { 'Content-Type': 'application/json' });
74
+ response.end(JSON.stringify({ message: 'Invalid verify_token' }));
75
+ return;
76
+ }
77
+ response.writeHead(200, { 'Content-Type': 'application/json' });
78
+ response.end(JSON.stringify({ challenge: event.challenge ?? '' }));
79
+ return;
80
+ }
81
+
82
+ if (!verifyKookWebhookToken(handler.config.verifyToken, event.verify_token)) {
83
+ response.writeHead(403, { 'Content-Type': 'application/json' });
84
+ response.end(JSON.stringify({ message: 'Invalid verify_token' }));
85
+ return;
86
+ }
87
+
88
+ if (frame.sn != null) {
89
+ if (!handler.checkAndRememberSn(frame.sn)) {
90
+ response.writeHead(200, { 'Content-Type': 'application/json' });
91
+ response.end(JSON.stringify({ message: 'OK' }));
92
+ return;
93
+ }
94
+ }
95
+
96
+ if (handler.isOpen) {
97
+ handleKookWebhookEvent(event, handler);
98
+ }
99
+
100
+ response.writeHead(200, { 'Content-Type': 'application/json' });
101
+ response.end(JSON.stringify({ message: 'OK' }));
102
+ } catch (error) {
103
+ logger.error('KOOK webhook error:', error);
104
+ response.writeHead(200, { 'Content-Type': 'application/json' });
105
+ response.end(JSON.stringify({ message: 'OK' }));
106
+ }
107
+ }
108
+
109
+ function handleKookWebhookEvent(
110
+ event: KookWebhookEventData,
111
+ handler: KookWebhookHandler,
112
+ ): void {
113
+ handler.admitEvent(event);
114
+ }