@zhin.js/adapter-kook 5.0.2 → 5.0.3
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 +41 -0
- package/README.md +58 -452
- package/adapters/kook.ts +46 -0
- package/agent/tools/blacklist.ts +2 -2
- package/agent/tools/create_role.ts +2 -2
- package/agent/tools/delete_role.ts +2 -2
- package/agent/tools/grant_role.ts +2 -2
- package/agent/tools/list_roles.ts +2 -2
- package/agent/tools/revoke_role.ts +2 -2
- package/lib/endpoint.d.ts +84 -0
- package/lib/endpoint.js +299 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +6 -0
- package/lib/kook-agent-deps.d.ts +29 -0
- package/lib/kook-agent-deps.js +30 -0
- package/lib/platform-permit.d.ts +23 -0
- package/lib/{src/platform-permit.js → platform-permit.js} +2 -3
- package/lib/protocol.d.ts +147 -0
- package/lib/protocol.js +279 -0
- package/lib/webhook.d.ts +15 -0
- package/lib/webhook.js +76 -0
- package/lib/ws.d.ts +42 -0
- package/lib/ws.js +63 -0
- package/package.json +44 -40
- package/plugin.ts +13 -0
- package/schema.json +55 -0
- package/src/endpoint.ts +287 -879
- package/src/index.ts +55 -177
- package/src/kook-agent-deps.ts +44 -11
- package/src/platform-permit.ts +13 -3
- package/src/protocol.ts +429 -0
- package/src/webhook.ts +116 -0
- package/src/ws.ts +120 -0
- package/client/Dashboard.tsx +0 -255
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -30
- package/dist/index.js +0 -31
- package/lib/agent/tools/blacklist.js +0 -33
- package/lib/agent/tools/blacklist.js.map +0 -1
- package/lib/agent/tools/create_role.js +0 -25
- package/lib/agent/tools/create_role.js.map +0 -1
- package/lib/agent/tools/delete_role.js +0 -21
- package/lib/agent/tools/delete_role.js.map +0 -1
- package/lib/agent/tools/grant_role.js +0 -22
- package/lib/agent/tools/grant_role.js.map +0 -1
- package/lib/agent/tools/list_roles.js +0 -27
- package/lib/agent/tools/list_roles.js.map +0 -1
- package/lib/agent/tools/revoke_role.js +0 -22
- package/lib/agent/tools/revoke_role.js.map +0 -1
- package/lib/src/adapter.js +0 -67
- package/lib/src/adapter.js.map +0 -1
- package/lib/src/endpoint.js +0 -796
- package/lib/src/endpoint.js.map +0 -1
- package/lib/src/index.js +0 -197
- package/lib/src/index.js.map +0 -1
- package/lib/src/kook-agent-deps.js +0 -10
- package/lib/src/kook-agent-deps.js.map +0 -1
- package/lib/src/kook-asset-upload.js +0 -46
- package/lib/src/kook-asset-upload.js.map +0 -1
- package/lib/src/kook-inbound.js +0 -22
- package/lib/src/kook-inbound.js.map +0 -1
- package/lib/src/kook-msg-route.js +0 -106
- package/lib/src/kook-msg-route.js.map +0 -1
- package/lib/src/kook-side-events.js +0 -166
- package/lib/src/kook-side-events.js.map +0 -1
- package/lib/src/outbound-keyboard.js +0 -66
- package/lib/src/outbound-keyboard.js.map +0 -1
- package/lib/src/outbound-media.js +0 -45
- package/lib/src/outbound-media.js.map +0 -1
- package/lib/src/outbound-sendable.js +0 -64
- package/lib/src/outbound-sendable.js.map +0 -1
- package/lib/src/platform-permit.js.map +0 -1
- package/lib/src/segment-mapper.js +0 -2
- package/lib/src/segment-mapper.js.map +0 -1
- package/lib/src/types.js +0 -8
- package/lib/src/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -75
- package/src/kook-asset-upload.ts +0 -55
- package/src/kook-inbound.ts +0 -22
- package/src/kook-msg-route.ts +0 -122
- package/src/kook-side-events.ts +0 -230
- package/src/outbound-keyboard.ts +0 -77
- package/src/outbound-media.ts +0 -60
- package/src/outbound-sendable.ts +0 -73
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -58
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
import type { IncomingMessage } from 'node:http';
|
|
6
|
+
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'mark' | 'off';
|
|
7
|
+
export declare enum KookPermission {
|
|
8
|
+
Normal = 1,
|
|
9
|
+
Admin = 2,
|
|
10
|
+
Owner = 4,
|
|
11
|
+
ChannelAdmin = 5
|
|
12
|
+
}
|
|
13
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
14
|
+
export interface KookAdapterConfig {
|
|
15
|
+
readonly name?: string;
|
|
16
|
+
readonly token?: string;
|
|
17
|
+
/** Default `websocket`. `webhook` requires httpHostToken + verify_token. */
|
|
18
|
+
readonly connection?: 'websocket' | 'webhook';
|
|
19
|
+
readonly webhookPath?: string;
|
|
20
|
+
readonly verify_token?: string;
|
|
21
|
+
readonly encrypt_key?: string;
|
|
22
|
+
readonly data_dir?: string;
|
|
23
|
+
readonly timeout?: number;
|
|
24
|
+
readonly max_retry?: number;
|
|
25
|
+
readonly ignore?: 'bot' | 'self';
|
|
26
|
+
readonly logLevel?: LogLevel;
|
|
27
|
+
/** Transitional: legacy root `endpoints[]` with `context: kook`. */
|
|
28
|
+
readonly endpoints?: ReadonlyArray<{
|
|
29
|
+
readonly context?: string;
|
|
30
|
+
readonly name?: string;
|
|
31
|
+
readonly token?: string;
|
|
32
|
+
readonly connection?: 'websocket' | 'webhook';
|
|
33
|
+
readonly webhookPath?: string;
|
|
34
|
+
readonly verify_token?: string;
|
|
35
|
+
readonly encrypt_key?: string;
|
|
36
|
+
readonly data_dir?: string;
|
|
37
|
+
readonly timeout?: number;
|
|
38
|
+
readonly max_retry?: number;
|
|
39
|
+
readonly ignore?: 'bot' | 'self';
|
|
40
|
+
readonly logLevel?: LogLevel;
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
export interface ResolvedKookWebsocketConfig {
|
|
44
|
+
readonly context: 'kook';
|
|
45
|
+
readonly connection: 'websocket';
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly token: string;
|
|
48
|
+
readonly data_dir?: string;
|
|
49
|
+
readonly timeout: number;
|
|
50
|
+
readonly max_retry: number;
|
|
51
|
+
readonly ignore: 'bot' | 'self';
|
|
52
|
+
readonly logLevel: LogLevel;
|
|
53
|
+
}
|
|
54
|
+
export interface ResolvedKookWebhookConfig {
|
|
55
|
+
readonly context: 'kook';
|
|
56
|
+
readonly connection: 'webhook';
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly token: string;
|
|
59
|
+
readonly webhookPath: string;
|
|
60
|
+
readonly verifyToken: string;
|
|
61
|
+
readonly encryptKey?: string;
|
|
62
|
+
readonly ignore: 'bot' | 'self';
|
|
63
|
+
readonly logLevel: LogLevel;
|
|
64
|
+
}
|
|
65
|
+
export interface KookWebhookEventData {
|
|
66
|
+
readonly channel_type?: string;
|
|
67
|
+
readonly type?: number;
|
|
68
|
+
readonly target_id?: string;
|
|
69
|
+
readonly author_id?: string;
|
|
70
|
+
readonly content?: string;
|
|
71
|
+
readonly msg_id?: string;
|
|
72
|
+
readonly msg_timestamp?: number;
|
|
73
|
+
readonly verify_token?: string;
|
|
74
|
+
readonly challenge?: string;
|
|
75
|
+
readonly extra?: {
|
|
76
|
+
readonly guild_id?: string;
|
|
77
|
+
readonly author?: {
|
|
78
|
+
readonly id?: string;
|
|
79
|
+
readonly username?: string;
|
|
80
|
+
readonly bot?: boolean;
|
|
81
|
+
readonly roles?: number[];
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export interface KookWebhookFrame {
|
|
86
|
+
readonly s?: number;
|
|
87
|
+
readonly sn?: number;
|
|
88
|
+
readonly d?: KookWebhookEventData;
|
|
89
|
+
readonly encrypt?: string;
|
|
90
|
+
}
|
|
91
|
+
export type ResolvedKookConfig = ResolvedKookWebsocketConfig | ResolvedKookWebhookConfig;
|
|
92
|
+
export interface KookInboundMessage {
|
|
93
|
+
readonly id: string;
|
|
94
|
+
readonly content: string;
|
|
95
|
+
readonly channelKind: 'private' | 'channel';
|
|
96
|
+
readonly channelId: string;
|
|
97
|
+
readonly authorId: string;
|
|
98
|
+
readonly authorName: string;
|
|
99
|
+
readonly authorBot?: boolean;
|
|
100
|
+
readonly authorRoles?: number[];
|
|
101
|
+
readonly timestamp: number;
|
|
102
|
+
readonly guildId?: string;
|
|
103
|
+
readonly rawMessage?: string;
|
|
104
|
+
}
|
|
105
|
+
export interface KookWireSegment {
|
|
106
|
+
readonly type: string;
|
|
107
|
+
readonly data?: Record<string, unknown>;
|
|
108
|
+
}
|
|
109
|
+
export interface ParsedSendTarget {
|
|
110
|
+
readonly kind: 'private' | 'channel';
|
|
111
|
+
readonly id: string;
|
|
112
|
+
}
|
|
113
|
+
export declare function resolveKookConfig(config?: KookAdapterConfig): ResolvedKookConfig;
|
|
114
|
+
/**
|
|
115
|
+
* Gateway reply target:`private:uid` / `channel:cid`,便于 send() 还原 API。
|
|
116
|
+
*/
|
|
117
|
+
export declare function formatInboundTarget(msg: KookInboundMessage): string;
|
|
118
|
+
export declare function parseSendTarget(target: string): ParsedSendTarget;
|
|
119
|
+
export declare function senderDisplayName(msg: KookInboundMessage): string;
|
|
120
|
+
/** Build inbound text for MessageGateway.receive */
|
|
121
|
+
export declare function formatInboundContent(msg: KookInboundMessage): string;
|
|
122
|
+
/**
|
|
123
|
+
* KOOK @ 用户标记为 `(met)<userId>(met)`;content 含 `(met)<botId>(met)` 时视为 @ 机器人。
|
|
124
|
+
* botId 取 client 缓存的 self_id(/me 类接口),拿不到则不标注。
|
|
125
|
+
*/
|
|
126
|
+
export declare function isKookBotMentioned(msg: KookInboundMessage, selfId?: string): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Wire-encode an already-rendered outbound payload into KOOK KMarkdown.
|
|
129
|
+
* Segment canonicalization is intentionally not done here.
|
|
130
|
+
*/
|
|
131
|
+
export declare function formatOutboundKmarkdown(payload: unknown): string;
|
|
132
|
+
export declare function normalizeWebhookPath(path: string): string;
|
|
133
|
+
export declare function readTextBody(request: IncomingMessage, options?: {
|
|
134
|
+
readonly limit?: number;
|
|
135
|
+
}): Promise<string>;
|
|
136
|
+
export declare function readRequestBody(request: IncomingMessage, options?: {
|
|
137
|
+
readonly limit?: number;
|
|
138
|
+
}): Promise<Buffer>;
|
|
139
|
+
export declare function padKookEncryptKey(key: string): Buffer;
|
|
140
|
+
export declare function decryptKookWebhookPayload(encrypted: string, encryptKey: string): Buffer;
|
|
141
|
+
export declare function parseKookWebhookBody(rawBody: Buffer, encryptKey?: string): KookWebhookFrame;
|
|
142
|
+
export declare function isKookWebhookChallenge(event: KookWebhookEventData): boolean;
|
|
143
|
+
export declare function verifyKookWebhookToken(expected: string, actual?: string): boolean;
|
|
144
|
+
export declare function normalizeKookWebhookEvent(event: KookWebhookEventData, options?: {
|
|
145
|
+
readonly ignore?: 'bot' | 'self';
|
|
146
|
+
readonly selfId?: string;
|
|
147
|
+
}): KookInboundMessage | null;
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
import { createDecipheriv, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { inflateSync } from 'node:zlib';
|
|
7
|
+
import { pickCredential } from '@zhin.js/adapter';
|
|
8
|
+
export var KookPermission;
|
|
9
|
+
(function (KookPermission) {
|
|
10
|
+
KookPermission[KookPermission["Normal"] = 1] = "Normal";
|
|
11
|
+
KookPermission[KookPermission["Admin"] = 2] = "Admin";
|
|
12
|
+
KookPermission[KookPermission["Owner"] = 4] = "Owner";
|
|
13
|
+
KookPermission[KookPermission["ChannelAdmin"] = 5] = "ChannelAdmin";
|
|
14
|
+
})(KookPermission || (KookPermission = {}));
|
|
15
|
+
export function resolveKookConfig(config = {}) {
|
|
16
|
+
const entry = config.endpoints?.find((item) => item.context === 'kook' || !item.context);
|
|
17
|
+
const token = pickCredential(config.token, entry?.token, process.env.KOOK_TOKEN, process.env.KOOK_BOT_TOKEN);
|
|
18
|
+
if (!token) {
|
|
19
|
+
throw new TypeError('KOOK adapter requires token (plugins.<key>.token or endpoints with context: kook)');
|
|
20
|
+
}
|
|
21
|
+
const name = (typeof config.name === 'string' && config.name)
|
|
22
|
+
|| (typeof entry?.name === 'string' && entry.name)
|
|
23
|
+
|| process.env.KOOK_BOT_NAME
|
|
24
|
+
|| 'kook-bot';
|
|
25
|
+
const connection = config.connection
|
|
26
|
+
?? entry?.connection
|
|
27
|
+
?? 'websocket';
|
|
28
|
+
if (connection === 'webhook') {
|
|
29
|
+
const verifyToken = (typeof config.verify_token === 'string' && config.verify_token)
|
|
30
|
+
|| (typeof entry?.verify_token === 'string' && entry.verify_token)
|
|
31
|
+
|| process.env.KOOK_VERIFY_TOKEN
|
|
32
|
+
|| '';
|
|
33
|
+
if (!verifyToken) {
|
|
34
|
+
throw new TypeError('KOOK webhook mode requires verify_token (plugins.<key>.verify_token or KOOK_VERIFY_TOKEN)');
|
|
35
|
+
}
|
|
36
|
+
const encryptKey = config.encrypt_key ?? entry?.encrypt_key ?? process.env.KOOK_ENCRYPT_KEY;
|
|
37
|
+
return {
|
|
38
|
+
context: 'kook',
|
|
39
|
+
connection: 'webhook',
|
|
40
|
+
name,
|
|
41
|
+
token,
|
|
42
|
+
webhookPath: normalizeWebhookPath(config.webhookPath ?? entry?.webhookPath ?? process.env.KOOK_WEBHOOK_PATH ?? '/kook/webhook'),
|
|
43
|
+
verifyToken,
|
|
44
|
+
encryptKey: typeof encryptKey === 'string' && encryptKey ? encryptKey : undefined,
|
|
45
|
+
ignore: config.ignore ?? entry?.ignore ?? 'bot',
|
|
46
|
+
logLevel: config.logLevel ?? entry?.logLevel ?? 'info',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
context: 'kook',
|
|
51
|
+
connection: 'websocket',
|
|
52
|
+
name,
|
|
53
|
+
token,
|
|
54
|
+
data_dir: config.data_dir ?? entry?.data_dir,
|
|
55
|
+
timeout: config.timeout ?? entry?.timeout ?? 10_000,
|
|
56
|
+
max_retry: config.max_retry ?? entry?.max_retry ?? 3,
|
|
57
|
+
ignore: config.ignore ?? entry?.ignore ?? 'bot',
|
|
58
|
+
logLevel: config.logLevel ?? entry?.logLevel ?? 'info',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Gateway reply target:`private:uid` / `channel:cid`,便于 send() 还原 API。
|
|
63
|
+
*/
|
|
64
|
+
export function formatInboundTarget(msg) {
|
|
65
|
+
return `${msg.channelKind}:${msg.channelId}`;
|
|
66
|
+
}
|
|
67
|
+
export function parseSendTarget(target) {
|
|
68
|
+
const sep = target.indexOf(':');
|
|
69
|
+
if (sep <= 0) {
|
|
70
|
+
return { kind: 'channel', id: target };
|
|
71
|
+
}
|
|
72
|
+
const head = target.slice(0, sep);
|
|
73
|
+
const rest = target.slice(sep + 1);
|
|
74
|
+
if (head === 'private')
|
|
75
|
+
return { kind: 'private', id: rest };
|
|
76
|
+
if (head === 'channel' || head === 'group')
|
|
77
|
+
return { kind: 'channel', id: rest };
|
|
78
|
+
return { kind: 'channel', id: target };
|
|
79
|
+
}
|
|
80
|
+
export function senderDisplayName(msg) {
|
|
81
|
+
return msg.authorName || msg.authorId;
|
|
82
|
+
}
|
|
83
|
+
/** Build inbound text for MessageGateway.receive */
|
|
84
|
+
export function formatInboundContent(msg) {
|
|
85
|
+
const text = (msg.content || msg.rawMessage || '').trim();
|
|
86
|
+
return text || '(Empty message)';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* KOOK @ 用户标记为 `(met)<userId>(met)`;content 含 `(met)<botId>(met)` 时视为 @ 机器人。
|
|
90
|
+
* botId 取 client 缓存的 self_id(/me 类接口),拿不到则不标注。
|
|
91
|
+
*/
|
|
92
|
+
export function isKookBotMentioned(msg, selfId) {
|
|
93
|
+
if (!selfId)
|
|
94
|
+
return false;
|
|
95
|
+
const marker = `(met)${selfId}(met)`;
|
|
96
|
+
return (msg.content || '').includes(marker) || (msg.rawMessage || '').includes(marker);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Wire-encode an already-rendered outbound payload into KOOK KMarkdown.
|
|
100
|
+
* Segment canonicalization is intentionally not done here.
|
|
101
|
+
*/
|
|
102
|
+
export function formatOutboundKmarkdown(payload) {
|
|
103
|
+
if (typeof payload === 'string')
|
|
104
|
+
return payload;
|
|
105
|
+
const segments = Array.isArray(payload)
|
|
106
|
+
? payload
|
|
107
|
+
: payload && typeof payload === 'object' && 'type' in payload
|
|
108
|
+
? [payload]
|
|
109
|
+
: [];
|
|
110
|
+
if (segments.length === 0) {
|
|
111
|
+
return payload == null
|
|
112
|
+
? ''
|
|
113
|
+
: typeof payload === 'object'
|
|
114
|
+
? JSON.stringify(payload)
|
|
115
|
+
: String(payload);
|
|
116
|
+
}
|
|
117
|
+
return segments
|
|
118
|
+
.map((item) => {
|
|
119
|
+
if (typeof item === 'string')
|
|
120
|
+
return item;
|
|
121
|
+
const data = item.data ?? {};
|
|
122
|
+
switch (item.type) {
|
|
123
|
+
case 'text':
|
|
124
|
+
return String(data.text ?? data.content ?? '');
|
|
125
|
+
case 'at': {
|
|
126
|
+
const id = String(data.user_id ?? data.qq ?? data.id ?? '');
|
|
127
|
+
return id === 'all' ? '(met)all(met)' : `(met)${id}(met)`;
|
|
128
|
+
}
|
|
129
|
+
case 'image':
|
|
130
|
+
return `})`;
|
|
131
|
+
case 'face':
|
|
132
|
+
return `(emj)${String(data.name || 'emoji')}(emj)[${String(data.id ?? '')}]`;
|
|
133
|
+
case 'link':
|
|
134
|
+
return `[${String(data.text || data.url || '')}](${String(data.url || '')})`;
|
|
135
|
+
case 'video':
|
|
136
|
+
return `[视频](${String(data.url || data.file || '')})`;
|
|
137
|
+
case 'audio':
|
|
138
|
+
return `[音频](${String(data.url || data.file || '')})`;
|
|
139
|
+
case 'file':
|
|
140
|
+
return `[文件: ${String(data.name || '未命名')}](${String(data.url || data.file || '')})`;
|
|
141
|
+
case 'bold':
|
|
142
|
+
return `**${String(data.text ?? '')}**`;
|
|
143
|
+
case 'italic':
|
|
144
|
+
return `*${String(data.text ?? '')}*`;
|
|
145
|
+
case 'code':
|
|
146
|
+
return `\`${String(data.text ?? '')}\``;
|
|
147
|
+
case 'code_block':
|
|
148
|
+
return `\`\`\`${String(data.language || '')}\n${String(data.text ?? '')}\n\`\`\``;
|
|
149
|
+
case 'reply':
|
|
150
|
+
return '';
|
|
151
|
+
default:
|
|
152
|
+
return data.text != null ? String(data.text) : '';
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join('');
|
|
157
|
+
}
|
|
158
|
+
export function normalizeWebhookPath(path) {
|
|
159
|
+
const trimmed = path.trim() || '/kook/webhook';
|
|
160
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
161
|
+
}
|
|
162
|
+
export async function readTextBody(request, options = {}) {
|
|
163
|
+
const limit = options.limit ?? 1_048_576;
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let size = 0;
|
|
166
|
+
for await (const chunk of request) {
|
|
167
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
168
|
+
size += buffer.length;
|
|
169
|
+
if (size > limit) {
|
|
170
|
+
request.destroy();
|
|
171
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
172
|
+
}
|
|
173
|
+
chunks.push(buffer);
|
|
174
|
+
}
|
|
175
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
176
|
+
}
|
|
177
|
+
export async function readRequestBody(request, options = {}) {
|
|
178
|
+
const limit = options.limit ?? 1_048_576;
|
|
179
|
+
const chunks = [];
|
|
180
|
+
let size = 0;
|
|
181
|
+
for await (const chunk of request) {
|
|
182
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
183
|
+
size += buffer.length;
|
|
184
|
+
if (size > limit) {
|
|
185
|
+
request.destroy();
|
|
186
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
187
|
+
}
|
|
188
|
+
chunks.push(buffer);
|
|
189
|
+
}
|
|
190
|
+
return Buffer.concat(chunks);
|
|
191
|
+
}
|
|
192
|
+
export function padKookEncryptKey(key) {
|
|
193
|
+
const buf = Buffer.alloc(32, 0);
|
|
194
|
+
Buffer.from(key, 'utf8').copy(buf, 0, 0, Math.min(32, Buffer.byteLength(key, 'utf8')));
|
|
195
|
+
return buf;
|
|
196
|
+
}
|
|
197
|
+
export function decryptKookWebhookPayload(encrypted, encryptKey) {
|
|
198
|
+
const decoded = Buffer.from(encrypted, 'base64');
|
|
199
|
+
const iv = decoded.subarray(0, 16);
|
|
200
|
+
const ciphertext = decoded.subarray(16);
|
|
201
|
+
const decipher = createDecipheriv('aes-256-cbc', padKookEncryptKey(encryptKey), iv);
|
|
202
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
203
|
+
}
|
|
204
|
+
export function parseKookWebhookBody(rawBody, encryptKey) {
|
|
205
|
+
let text = rawBody.toString('utf8').trim();
|
|
206
|
+
if (!text.startsWith('{')) {
|
|
207
|
+
text = inflateSync(rawBody).toString('utf8');
|
|
208
|
+
}
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(text);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new TypeError('Invalid KOOK webhook JSON body');
|
|
215
|
+
}
|
|
216
|
+
if (parsed.encrypt) {
|
|
217
|
+
if (!encryptKey) {
|
|
218
|
+
throw new TypeError('KOOK webhook payload is encrypted but encrypt_key is not configured');
|
|
219
|
+
}
|
|
220
|
+
const decrypted = decryptKookWebhookPayload(parsed.encrypt, encryptKey);
|
|
221
|
+
// 在字节层面判断:明文 JSON 以 '{' 开头,否则按 zlib 压缩数据处理。
|
|
222
|
+
// 不能先 toString('utf8') 再转回 Buffer —— 压缩二进制会被替换字符损坏。
|
|
223
|
+
let offset = 0;
|
|
224
|
+
while (offset < decrypted.length
|
|
225
|
+
&& (decrypted[offset] === 0x20 || decrypted[offset] === 0x09
|
|
226
|
+
|| decrypted[offset] === 0x0a || decrypted[offset] === 0x0d)) {
|
|
227
|
+
offset += 1;
|
|
228
|
+
}
|
|
229
|
+
const decryptedText = decrypted[offset] === 0x7b // '{'
|
|
230
|
+
? decrypted.toString('utf8')
|
|
231
|
+
: inflateSync(decrypted).toString('utf8');
|
|
232
|
+
parsed = JSON.parse(decryptedText);
|
|
233
|
+
}
|
|
234
|
+
return parsed;
|
|
235
|
+
}
|
|
236
|
+
export function isKookWebhookChallenge(event) {
|
|
237
|
+
return event.type === 255 && event.channel_type === 'WEBHOOK_CHALLENGE';
|
|
238
|
+
}
|
|
239
|
+
export function verifyKookWebhookToken(expected, actual) {
|
|
240
|
+
if (!actual)
|
|
241
|
+
return false;
|
|
242
|
+
const left = Buffer.from(expected, 'utf8');
|
|
243
|
+
const right = Buffer.from(actual, 'utf8');
|
|
244
|
+
if (left.length !== right.length)
|
|
245
|
+
return false;
|
|
246
|
+
return timingSafeEqual(left, right);
|
|
247
|
+
}
|
|
248
|
+
export function normalizeKookWebhookEvent(event, options = {}) {
|
|
249
|
+
if (isKookWebhookChallenge(event))
|
|
250
|
+
return null;
|
|
251
|
+
if (event.msg_id == null || event.author_id == null)
|
|
252
|
+
return null;
|
|
253
|
+
const authorBot = event.extra?.author?.bot === true;
|
|
254
|
+
if (authorBot && options.ignore === 'bot')
|
|
255
|
+
return null;
|
|
256
|
+
if (options.selfId && String(event.author_id) === options.selfId && options.ignore === 'self') {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const channelKind = event.channel_type === 'GROUP' ? 'channel' : 'private';
|
|
260
|
+
const channelId = channelKind === 'channel'
|
|
261
|
+
? String(event.target_id ?? '')
|
|
262
|
+
: String(event.author_id);
|
|
263
|
+
if (!channelId)
|
|
264
|
+
return null;
|
|
265
|
+
return {
|
|
266
|
+
id: String(event.msg_id),
|
|
267
|
+
content: event.content ?? '',
|
|
268
|
+
channelKind,
|
|
269
|
+
channelId,
|
|
270
|
+
authorId: String(event.author_id),
|
|
271
|
+
authorName: event.extra?.author?.username
|
|
272
|
+
|| event.extra?.author?.id
|
|
273
|
+
|| String(event.author_id),
|
|
274
|
+
authorBot,
|
|
275
|
+
authorRoles: event.extra?.author?.roles,
|
|
276
|
+
timestamp: event.msg_timestamp ?? Date.now(),
|
|
277
|
+
guildId: event.extra?.guild_id,
|
|
278
|
+
};
|
|
279
|
+
}
|
package/lib/webhook.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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 { type KookInboundMessage, type ResolvedKookWebhookConfig } from './protocol.js';
|
|
7
|
+
export interface KookWebhookHandler {
|
|
8
|
+
readonly config: ResolvedKookWebhookConfig;
|
|
9
|
+
readonly isOpen: boolean;
|
|
10
|
+
readonly selfId?: string;
|
|
11
|
+
admit(msg: KookInboundMessage): void;
|
|
12
|
+
checkAndRememberSn(sn: number): boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function registerKookWebhookRoutes(http: HttpHost, handler: KookWebhookHandler): HttpRouteRegistration[];
|
|
15
|
+
export declare function handleKookWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: KookWebhookHandler): Promise<void>;
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import { isKookWebhookChallenge, normalizeKookWebhookEvent, parseKookWebhookBody, readRequestBody, verifyKookWebhookToken, } from './protocol.js';
|
|
3
|
+
const logger = getLogger('kook');
|
|
4
|
+
export function registerKookWebhookRoutes(http, handler) {
|
|
5
|
+
const path = handler.config.webhookPath;
|
|
6
|
+
return [
|
|
7
|
+
http.route('POST', path, async (request, response) => {
|
|
8
|
+
await handleKookWebhookRequest(request, response, handler);
|
|
9
|
+
}, { summary: 'KOOK webhook callback', tags: ['kook'] }),
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
export async function handleKookWebhookRequest(request, response, handler) {
|
|
13
|
+
try {
|
|
14
|
+
const rawBody = await readRequestBody(request);
|
|
15
|
+
let frame;
|
|
16
|
+
try {
|
|
17
|
+
frame = parseKookWebhookBody(rawBody, handler.config.encryptKey);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
logger.warn(formatCompact({
|
|
21
|
+
op: 'kook_webhook',
|
|
22
|
+
ok: false,
|
|
23
|
+
error: error instanceof Error ? error.message : String(error),
|
|
24
|
+
}));
|
|
25
|
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
26
|
+
response.end(JSON.stringify({ message: 'Invalid webhook payload' }));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const event = frame.d;
|
|
30
|
+
if (!event || typeof event !== 'object') {
|
|
31
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
32
|
+
response.end(JSON.stringify({ message: 'OK' }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (isKookWebhookChallenge(event)) {
|
|
36
|
+
if (!verifyKookWebhookToken(handler.config.verifyToken, event.verify_token)) {
|
|
37
|
+
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
38
|
+
response.end(JSON.stringify({ message: 'Invalid verify_token' }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
42
|
+
response.end(JSON.stringify({ challenge: event.challenge ?? '' }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!verifyKookWebhookToken(handler.config.verifyToken, event.verify_token)) {
|
|
46
|
+
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
47
|
+
response.end(JSON.stringify({ message: 'Invalid verify_token' }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (frame.sn != null) {
|
|
51
|
+
if (!handler.checkAndRememberSn(frame.sn)) {
|
|
52
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
53
|
+
response.end(JSON.stringify({ message: 'OK' }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (handler.isOpen) {
|
|
58
|
+
handleKookWebhookEvent(event, handler);
|
|
59
|
+
}
|
|
60
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
61
|
+
response.end(JSON.stringify({ message: 'OK' }));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
logger.error('KOOK webhook error:', error);
|
|
65
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
66
|
+
response.end(JSON.stringify({ message: 'OK' }));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function handleKookWebhookEvent(event, handler) {
|
|
70
|
+
const msg = normalizeKookWebhookEvent(event, {
|
|
71
|
+
ignore: handler.config.ignore,
|
|
72
|
+
selfId: handler.selfId,
|
|
73
|
+
});
|
|
74
|
+
if (msg)
|
|
75
|
+
handler.admit(msg);
|
|
76
|
+
}
|
package/lib/ws.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type KookInboundMessage, type ResolvedKookConfig, type ResolvedKookWebhookConfig, type ResolvedKookWebsocketConfig } from './protocol.js';
|
|
2
|
+
/** Minimal client surface used by the endpoint (real kook-client or test mock). */
|
|
3
|
+
export interface KookClientTransport {
|
|
4
|
+
connect(): Promise<void>;
|
|
5
|
+
disconnect(): Promise<void>;
|
|
6
|
+
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
7
|
+
removeAllListeners(): void;
|
|
8
|
+
sendChannelMsg(channelId: string, message: string): Promise<{
|
|
9
|
+
msg_id?: string | number;
|
|
10
|
+
}>;
|
|
11
|
+
sendPrivateMsg(userId: string, message: string): Promise<{
|
|
12
|
+
msg_id?: string | number;
|
|
13
|
+
}>;
|
|
14
|
+
pickGuild(guildId: string): {
|
|
15
|
+
kick(userId: string): Promise<boolean>;
|
|
16
|
+
getRoleList(): Promise<Array<{
|
|
17
|
+
role_id: string | number;
|
|
18
|
+
name: string;
|
|
19
|
+
color?: number;
|
|
20
|
+
position?: number;
|
|
21
|
+
permissions?: unknown;
|
|
22
|
+
}>>;
|
|
23
|
+
createRole(name: string): Promise<{
|
|
24
|
+
role_id: string | number;
|
|
25
|
+
name: string;
|
|
26
|
+
}>;
|
|
27
|
+
deleteRole(roleId: string): Promise<boolean>;
|
|
28
|
+
};
|
|
29
|
+
pickGuildMember(guildId: string, userId: string): {
|
|
30
|
+
addToBlackList(remark?: string, delMsgDays?: number): Promise<boolean>;
|
|
31
|
+
removeFromBlackList(): Promise<boolean>;
|
|
32
|
+
grant(roleId: string): Promise<boolean>;
|
|
33
|
+
revoke(roleId: string): Promise<boolean>;
|
|
34
|
+
setNickname(nickname: string): Promise<boolean>;
|
|
35
|
+
};
|
|
36
|
+
getGuildUserList?(guildId: string, channelId?: string): Promise<unknown[]>;
|
|
37
|
+
self_id?: string | number;
|
|
38
|
+
}
|
|
39
|
+
export type CreateKookClient = (config: ResolvedKookConfig) => KookClientTransport;
|
|
40
|
+
export declare function normalizeKookMessage(raw: unknown): KookInboundMessage | null;
|
|
41
|
+
export declare function defaultCreateClient(config: ResolvedKookWebsocketConfig): KookClientTransport;
|
|
42
|
+
export declare function defaultCreateWebhookClient(config: ResolvedKookWebhookConfig): KookClientTransport;
|
package/lib/ws.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK WebSocket transport: kook-client wrapper and inbound message normalization.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Client } from 'kook-client';
|
|
6
|
+
export function normalizeKookMessage(raw) {
|
|
7
|
+
if (!raw || typeof raw !== 'object')
|
|
8
|
+
return null;
|
|
9
|
+
const msg = raw;
|
|
10
|
+
if (msg.message_id == null || msg.author_id == null)
|
|
11
|
+
return null;
|
|
12
|
+
const channelKind = msg.message_type === 'channel' ? 'channel' : 'private';
|
|
13
|
+
const channelId = channelKind === 'channel'
|
|
14
|
+
? String(msg.channel_id ?? '')
|
|
15
|
+
: String(msg.author_id);
|
|
16
|
+
if (!channelId)
|
|
17
|
+
return null;
|
|
18
|
+
const textParts = [];
|
|
19
|
+
for (const seg of msg.message ?? []) {
|
|
20
|
+
if (seg.type === 'text' || seg.type === 'markdown') {
|
|
21
|
+
if (seg.text)
|
|
22
|
+
textParts.push(seg.text);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const content = textParts.join('') || msg.raw_message || '';
|
|
26
|
+
return {
|
|
27
|
+
id: String(msg.message_id),
|
|
28
|
+
content,
|
|
29
|
+
channelKind,
|
|
30
|
+
channelId,
|
|
31
|
+
authorId: String(msg.author_id),
|
|
32
|
+
authorName: msg.author?.info?.nickname
|
|
33
|
+
|| msg.author?.info?.username
|
|
34
|
+
|| String(msg.author_id),
|
|
35
|
+
authorBot: msg.author?.bot === true || msg.author?.info?.bot === true,
|
|
36
|
+
authorRoles: msg.author?.info?.roles ?? msg.author?.roles,
|
|
37
|
+
timestamp: msg.timestamp ?? Date.now(),
|
|
38
|
+
guildId: msg.channel?.info?.guild_id,
|
|
39
|
+
rawMessage: msg.raw_message,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function defaultCreateClient(config) {
|
|
43
|
+
return new Client({
|
|
44
|
+
token: config.token,
|
|
45
|
+
mode: 'websocket',
|
|
46
|
+
data_dir: config.data_dir || path.join(process.cwd(), 'data', 'kook'),
|
|
47
|
+
timeout: config.timeout,
|
|
48
|
+
max_retry: config.max_retry,
|
|
49
|
+
ignore: config.ignore,
|
|
50
|
+
logLevel: config.logLevel,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function defaultCreateWebhookClient(config) {
|
|
54
|
+
return new Client({
|
|
55
|
+
token: config.token,
|
|
56
|
+
mode: 'webhook',
|
|
57
|
+
data_dir: path.join(process.cwd(), 'data', 'kook'),
|
|
58
|
+
timeout: 10_000,
|
|
59
|
+
max_retry: 3,
|
|
60
|
+
ignore: config.ignore,
|
|
61
|
+
logLevel: config.logLevel,
|
|
62
|
+
});
|
|
63
|
+
}
|