@zhin.js/adapter-kook 5.0.1 → 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.
Files changed (97) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +58 -451
  3. package/adapters/kook.ts +46 -0
  4. package/agent/tools/blacklist.ts +33 -0
  5. package/agent/tools/create_role.ts +25 -0
  6. package/agent/tools/delete_role.ts +21 -0
  7. package/agent/tools/grant_role.ts +22 -0
  8. package/agent/tools/list_roles.ts +27 -0
  9. package/agent/tools/revoke_role.ts +22 -0
  10. package/lib/endpoint.d.ts +80 -151
  11. package/lib/endpoint.js +260 -736
  12. package/lib/index.d.ts +6 -18
  13. package/lib/index.js +6 -350
  14. package/lib/kook-agent-deps.d.ts +29 -0
  15. package/lib/kook-agent-deps.js +30 -0
  16. package/lib/platform-permit.d.ts +12 -3
  17. package/lib/platform-permit.js +5 -3
  18. package/lib/protocol.d.ts +147 -0
  19. package/lib/protocol.js +279 -0
  20. package/lib/webhook.d.ts +15 -0
  21. package/lib/webhook.js +76 -0
  22. package/lib/ws.d.ts +42 -0
  23. package/lib/ws.js +63 -0
  24. package/package.json +47 -33
  25. package/plugin.ts +13 -0
  26. package/schema.json +55 -0
  27. package/src/endpoint.ts +287 -866
  28. package/src/index.ts +55 -331
  29. package/src/kook-agent-deps.ts +55 -0
  30. package/src/platform-permit.ts +13 -4
  31. package/src/protocol.ts +429 -0
  32. package/src/webhook.ts +116 -0
  33. package/src/ws.ts +120 -0
  34. package/client/Dashboard.tsx +0 -255
  35. package/client/index.tsx +0 -11
  36. package/client/tsconfig.json +0 -7
  37. package/client/utils/api.ts +0 -30
  38. package/dist/index.js +0 -31
  39. package/lib/adapter.d.ts +0 -30
  40. package/lib/adapter.d.ts.map +0 -1
  41. package/lib/adapter.js +0 -67
  42. package/lib/adapter.js.map +0 -1
  43. package/lib/endpoint.d.ts.map +0 -1
  44. package/lib/endpoint.js.map +0 -1
  45. package/lib/index.d.ts.map +0 -1
  46. package/lib/index.js.map +0 -1
  47. package/lib/kook-asset-upload.d.ts +0 -12
  48. package/lib/kook-asset-upload.d.ts.map +0 -1
  49. package/lib/kook-asset-upload.js +0 -46
  50. package/lib/kook-asset-upload.js.map +0 -1
  51. package/lib/kook-inbound.d.ts +0 -6
  52. package/lib/kook-inbound.d.ts.map +0 -1
  53. package/lib/kook-inbound.js +0 -22
  54. package/lib/kook-inbound.js.map +0 -1
  55. package/lib/kook-msg-route.d.ts +0 -34
  56. package/lib/kook-msg-route.d.ts.map +0 -1
  57. package/lib/kook-msg-route.js +0 -106
  58. package/lib/kook-msg-route.js.map +0 -1
  59. package/lib/kook-side-events.d.ts +0 -46
  60. package/lib/kook-side-events.d.ts.map +0 -1
  61. package/lib/kook-side-events.js +0 -189
  62. package/lib/kook-side-events.js.map +0 -1
  63. package/lib/outbound-keyboard.d.ts +0 -13
  64. package/lib/outbound-keyboard.d.ts.map +0 -1
  65. package/lib/outbound-keyboard.js +0 -63
  66. package/lib/outbound-keyboard.js.map +0 -1
  67. package/lib/outbound-media.d.ts +0 -10
  68. package/lib/outbound-media.d.ts.map +0 -1
  69. package/lib/outbound-media.js +0 -42
  70. package/lib/outbound-media.js.map +0 -1
  71. package/lib/outbound-sendable.d.ts +0 -12
  72. package/lib/outbound-sendable.d.ts.map +0 -1
  73. package/lib/outbound-sendable.js +0 -64
  74. package/lib/outbound-sendable.js.map +0 -1
  75. package/lib/platform-permit.d.ts.map +0 -1
  76. package/lib/platform-permit.js.map +0 -1
  77. package/lib/segment-mapper.d.ts +0 -2
  78. package/lib/segment-mapper.d.ts.map +0 -1
  79. package/lib/segment-mapper.js +0 -2
  80. package/lib/segment-mapper.js.map +0 -1
  81. package/lib/types.d.ts +0 -54
  82. package/lib/types.d.ts.map +0 -1
  83. package/lib/types.js +0 -8
  84. package/lib/types.js.map +0 -1
  85. package/plugin.yml +0 -3
  86. package/src/adapter.ts +0 -76
  87. package/src/kook-asset-upload.ts +0 -55
  88. package/src/kook-inbound.ts +0 -22
  89. package/src/kook-msg-route.ts +0 -122
  90. package/src/kook-side-events.ts +0 -250
  91. package/src/outbound-keyboard.ts +0 -78
  92. package/src/outbound-media.ts +0 -67
  93. package/src/outbound-sendable.ts +0 -73
  94. package/src/segment-mapper.ts +0 -1
  95. package/src/types.ts +0 -59
  96. /package/{skills/kook → agent}/PERMITS.md +0 -0
  97. /package/{skills/kook/SKILL.md → agent/skills/kook.md} +0 -0
package/src/endpoint.ts CHANGED
@@ -1,940 +1,361 @@
1
1
  /**
2
- * KOOK Endpoint 实现
2
+ * KookEndpoint lifecycle, outbound, admit, OpenAPI helpers for agent tools.
3
3
  */
4
+ import { Client } from 'kook-client';
5
+ import type { EndpointInstance } from '@zhin.js/adapter';
6
+ import type { MessageGateway } from '@zhin.js/core/runtime';
7
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import { registerKookAgentEndpoint } from './kook-agent-deps.js';
4
11
  import {
5
- Client,
6
- PrivateMessageEvent,
7
- ChannelMessageEvent,
8
- MessageSegment,
9
- User,
10
- Guild,
11
- GuildMember,
12
- parseGroupId,
13
- } from "kook-client";
14
- import { EventEmitter } from "node:events";
15
- import path from "path";
12
+ formatInboundContent,
13
+ formatInboundTarget,
14
+ formatOutboundKmarkdown,
15
+ isKookBotMentioned,
16
+ parseSendTarget,
17
+ senderDisplayName,
18
+ type KookInboundMessage,
19
+ type ResolvedKookWebhookConfig,
20
+ type ResolvedKookWebsocketConfig,
21
+ } from './protocol.js';
22
+ import { registerKookWebhookRoutes } from './webhook.js';
16
23
  import {
17
- Endpoint,
18
- Message,
19
- SendOptions,
20
- SendContent,
21
- MessageElement,
22
- segment,
23
- expandInteractiveSegmentsInContent,
24
- MessageType,} from 'zhin.js';
25
- import type { KookEndpointConfig, KookSenderInfo, KookRawMessage } from "./types.js";
26
- import { KookPermission } from "./types.js";
27
- import type { KookAdapter } from "./adapter.js";
28
- import { InboundMessageDeduper } from "./kook-inbound.js";
29
- import { normalizeKookSenderForPermit } from "./platform-permit.js";
30
- import {
31
- enrichKookGatewayForPlugins,
32
- formatKookNotice,
33
- formatKookNoticeLog,
34
- formatKookButtonClickMessage,
35
- isKookButtonClickEvent,
36
- isKookNoticeGatewayEvent,
37
- resolveKookSideEventDedupeKey,
38
- type KookGatewayEvent,
39
- } from "./kook-side-events.js";
40
- import {
41
- encodeKookMsgRef,
42
- encodeKookReactionId,
43
- isKookApiGoneResult,
44
- isKookApiSuccess,
45
- isKookMsgGoneError,
46
- shouldStopDeleteAfterResponse,
47
- kookDeleteApiPath,
48
- kookReactionApiPath,
49
- parseKookMsgRef,
50
- parseKookReactionId,
51
- plainKookMsgId,
52
- resolveKookRoutes,
53
- routeFromSceneType,
54
- routeFromSendType,
55
- type KookMsgRoute,
56
- } from "./kook-msg-route.js";
57
- import { materializeOutboundMedia } from "./outbound-media.js";
58
- import { uploadKookAsset } from "./kook-asset-upload.js";
59
- import { convertToKookSendable } from "./outbound-sendable.js";
60
- import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
61
-
62
- export class KookEndpoint extends Client implements Endpoint<KookEndpointConfig, KookRawMessage> {
63
- $connected: boolean = false;
64
- /** KOOK 平台 user_id,用于 @ 触发匹配(resolveEndpointAtIds) */
65
- $platformUserId?: string;
66
- adapter: KookAdapter;
67
- private readonly inboundDeduper = new InboundMessageDeduper();
68
- private readonly onGatewayEvent = (raw: KookGatewayEvent) => {
69
- this.handleGatewayEvent(raw);
70
- };
71
- /** kook-client 构造函数注入的 process 监听器,disconnect 时需移除 */
72
- private readonly _kookProcessListeners: Array<{ event: string; fn: (...args: any[]) => void }> = [];
73
-
74
- get $id(): string {
75
- return this.$config.name;
76
- }
77
-
78
- get pluginLogger() {
79
- return this.adapter.plugin.logger;
80
- }
81
-
82
- constructor(adapter: KookAdapter, public $config: KookEndpointConfig) {
83
- // 记录构造前 process 上的 uncaughtException 监听器数量
84
- const beforeCount = process.listenerCount('uncaughtException');
85
- super({
86
- token: $config.token,
87
- mode: "websocket", // KOOK 默认使用 WebSocket 模式
88
- data_dir: $config.data_dir || path.join(process.cwd(), "data", "kook"),
89
- timeout: $config.timeout || 10000,
90
- max_retry: $config.max_retry || 3,
91
- ignore: $config.ignore || "bot",
92
- logLevel: $config.logLevel || "info",
93
- });
94
- // 捕获 kook-client 构造函数注入的 process 监听器
95
- const afterListeners = process.listeners('uncaughtException');
96
- for (let i = beforeCount; i < afterListeners.length; i++) {
97
- this._kookProcessListeners.push({ event: 'uncaughtException', fn: afterListeners[i] });
98
- }
99
- this.adapter = adapter;
100
- this.setupEventListeners();
101
- this.hookGatewayReceiver();
102
- }
24
+ defaultCreateClient,
25
+ defaultCreateWebhookClient,
26
+ normalizeKookMessage,
27
+ type CreateKookClient,
28
+ type KookClientTransport,
29
+ } from './ws.js';
30
+
31
+ const logger = getLogger('kook');
32
+
33
+ export interface KookEndpointOptions {
34
+ readonly id: CapabilityId;
35
+ readonly gateway: MessageGateway;
36
+ readonly config: ResolvedKookWebsocketConfig;
37
+ readonly createClient?: CreateKookClient;
38
+ }
103
39
 
104
- /**
105
- * kook-client Receiver transform 之前拦截原始 gateway 事件(SDK 未实现 notice transform)
106
- */
107
- private hookGatewayReceiver(): void {
108
- const receiver = this.receiver as import("node:events").EventEmitter;
109
- receiver.prependListener("event", this.onGatewayEvent);
40
+ export class KookWebsocketEndpoint implements EndpointInstance {
41
+ readonly #options: KookEndpointOptions;
42
+ readonly #createClient: CreateKookClient;
43
+ #client: KookClientTransport | null = null;
44
+ #open = false;
45
+ #started = false;
46
+ #unregisterAgent?: () => void;
47
+
48
+ constructor(options: KookEndpointOptions) {
49
+ this.#options = options;
50
+ this.#createClient = options.createClient ?? (defaultCreateClient as CreateKookClient);
110
51
  }
111
52
 
112
- private handleGatewayEvent(raw: KookGatewayEvent): void {
53
+ async start(): Promise<void> {
54
+ if (this.#started) return;
55
+ this.#started = true;
113
56
  try {
114
- const enriched = enrichKookGatewayForPlugins(raw);
115
- EventEmitter.prototype.emit.call(this.adapter, "kook.gateway", enriched);
116
-
117
- if (isKookButtonClickEvent(raw)) {
118
- const dedupeKey = resolveKookSideEventDedupeKey(raw, "gateway");
119
- if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
120
- const message = formatKookButtonClickMessage(raw, this.$id);
121
- this.adapter.emit("message.receive", message);
122
- this.pluginLogger.debug(
123
- `KOOK 按钮点击 ${message.$channel.type}(${message.$channel.id}): ${message.$raw}`,
124
- );
125
- return;
126
- }
127
-
128
- if (!isKookNoticeGatewayEvent(raw)) return;
129
-
130
- const dedupeKey = resolveKookSideEventDedupeKey(raw, "notice");
131
- if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
132
-
133
- const notice = formatKookNotice(raw, this.$config.name);
134
- this.emitSideEvent("notice.receive", notice);
135
- this.pluginLogger.info(formatKookNoticeLog(notice));
57
+ this.#unregisterAgent = registerKookAgentEndpoint(this.#options.config.name, this);
58
+ this.#client = this.#createClient(this.#options.config);
59
+ this.#bindClient(this.#client);
60
+ await this.#client.connect();
61
+ logger.info(formatCompact({
62
+ op: 'connect',
63
+ endpoint: this.#options.config.name,
64
+ mode: 'websocket',
65
+ self_id: this.#client.self_id != null ? String(this.#client.self_id) : undefined,
66
+ }));
136
67
  } catch (error) {
137
- this.pluginLogger.error("处理 KOOK gateway 事件失败:", error);
68
+ await this.stop();
69
+ logger.error('Failed to connect KOOK websocket:', error);
70
+ throw error;
138
71
  }
139
72
  }
140
73
 
141
- private emitSideEvent(
142
- event: "notice.receive" | "request.receive",
143
- payload: unknown,
144
- ): void {
145
- this.adapter.emit(event, payload as never);
74
+ open(): void {
75
+ this.#open = true;
146
76
  }
147
77
 
148
- /**
149
- * 设置事件监听器
150
- */
151
- private setupEventListeners(): void {
152
- // 监听消息事件
153
- this.on("message", (msg: KookRawMessage) => {
154
- try {
155
- const message = this.$formatMessage(msg);
156
- const atSegs = message.$content.filter((s) => s.type === 'at');
157
- if (atSegs.length > 0) {
158
- this.pluginLogger.debug(
159
- `KOOK @解析: self_id=${this.$platformUserId ?? (this as { self_id?: string }).self_id ?? '?'}`
160
- + ` at=${JSON.stringify(atSegs.map((s) => s.data))}`
161
- + ` preview=${segment.raw(message.$content)}`,
162
- );
163
- }
164
- this.pluginLogger.debug(`KOOK 格式化消息: $content=${JSON.stringify(message.$content)}, $raw=${message.$raw}`);
165
- this.adapter.emit("message.receive", message);
166
-
167
- // 根据消息类型触发特定事件
168
- const eventMap: Record<MessageType, string> = {
169
- private: "message.private.receive",
170
- group: "message.group.receive",
171
- channel: "message.channel.receive",
172
- };
173
-
174
- const specificEvent = eventMap[msg.message_type];
175
- if (specificEvent) {
176
- this.adapter.emit(specificEvent as any, message);
177
- }
178
- } catch (error) {
179
- this.pluginLogger.error(`处理 KOOK 消息失败:`, error);
180
- }
181
- });
182
-
183
- // 监听连接事件
184
- this.on("connect" as any, () => {
185
- this.$connected = true;
186
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 已连接`);
187
- });
188
-
189
- // 监听断开事件
190
- this.on("disconnect" as any, () => {
191
- this.$connected = false;
192
- this.pluginLogger.warn(`KOOK Endpoint ${this.$id} 已断开`);
193
- });
194
-
195
- // 监听错误事件
196
- this.on("error" as any, (error: Error) => {
197
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 错误:`, error);
198
- });
78
+ close(): void {
79
+ this.#open = false;
199
80
  }
200
81
 
201
- /**
202
- * KOOK 消息转换为标准消息格式
203
- */
204
- $formatMessage(msg: KookRawMessage): Message<KookRawMessage> {
205
- const channelId = msg.message_type === "channel"
206
- ? (msg as ChannelMessageEvent).channel_id
207
- : msg.author_id;
208
-
209
- // 获取发送者的权限信息
210
- const senderInfo = this.getSenderInfo(msg);
211
-
212
- // 频道消息需要获取 guild_id
213
- let guildId: string | undefined;
214
- if (msg.message_type === "channel") {
215
- const channelMsg = msg as ChannelMessageEvent;
216
- // 尝试从 channel 获取 guild_id
82
+ async stop(): Promise<void> {
83
+ this.#open = false;
84
+ this.#unregisterAgent?.();
85
+ this.#unregisterAgent = undefined;
86
+ if (this.#client) {
217
87
  try {
218
- const channel = channelMsg.channel;
219
- guildId = (channel?.info as any)?.guild_id;
88
+ this.#client.removeAllListeners();
89
+ await this.#client.disconnect();
220
90
  } catch {
221
- // 如果获取失败,尝试从缓存中查找
91
+ /* ignore */
222
92
  }
93
+ this.#client = null;
223
94
  }
224
-
225
- const message: Message<KookRawMessage> = Message.from(msg, {
226
- $id: msg.message_id.toString(),
227
- $adapter: "kook" as const,
228
- $endpoint: this.$id,
229
-
230
- $sender: senderInfo,
231
-
232
- $channel: {
233
- id: channelId.toString(),
234
- type: msg.message_type === "channel" ? "channel" : "private",
235
- // 频道消息包含服务器ID
236
- ...(guildId ? { guild_id: guildId } : {}),
237
- },
238
-
239
- $content: toCanonicalSegments(this.parseMessageContent(msg.message)),
240
- $raw: msg.raw_message,
241
- $timestamp: msg.timestamp,
242
-
243
- $recall: async () => {
244
- await this.$recallMessage(message.$id, {
245
- route: msg.message_type === 'channel' ? 'channel' : 'direct',
246
- });
247
- },
248
-
249
- $reply: async (content: SendContent, quote?: string | boolean): Promise<string> => {
250
- const elements = Array.isArray(content) ? content : [content];
251
- const finalContent: MessageElement[] = [];
252
-
253
- if (quote) {
254
- finalContent.push({
255
- type: "reply",
256
- data: {
257
- id: typeof quote === "boolean" ? message.$id : quote,
258
- },
259
- });
260
- }
261
-
262
- finalContent.push(...elements.map(el =>
263
- typeof el === 'string' ? { type: 'text' as const, data: { text: el } } : el
264
- ));
265
-
266
- return await this.adapter.sendMessage({
267
- ...message.$channel,
268
- context: "kook",
269
- endpoint: this.$id,
270
- content: finalContent,
271
- });
272
- },
95
+ this.#started = false;
96
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
97
+ }
98
+
99
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
100
+ const body = formatOutboundKmarkdown(payload);
101
+ const parsed = parseSendTarget(target);
102
+ const client = this.#requireClient();
103
+ const result = parsed.kind === 'private'
104
+ ? await client.sendPrivateMsg(parsed.id, body)
105
+ : await client.sendChannelMsg(parsed.id, body);
106
+ const messageId = result?.msg_id != null ? String(result.msg_id) : '';
107
+ logger.debug(formatCompact({
108
+ op: 'kook_send',
109
+ endpoint: this.#options.config.name,
110
+ target,
111
+ messageId,
112
+ }));
113
+ return messageId;
114
+ }
115
+
116
+ /** Test / internal: admit a message when open. */
117
+ admit(msg: KookInboundMessage): void {
118
+ if (!this.#open) return;
119
+ if (msg.authorBot) return;
120
+ const target = formatInboundTarget(msg);
121
+ const selfId = this.#client?.self_id != null ? String(this.#client.self_id) : undefined;
122
+ void this.#options.gateway.receive({
123
+ adapter: this.#options.id,
124
+ target,
125
+ content: formatInboundContent(msg),
126
+ sender: senderDisplayName(msg),
127
+ id: msg.id,
128
+ metadata: Object.freeze({
129
+ endpoint: this.#options.config.name,
130
+ channelKind: msg.channelKind,
131
+ userId: msg.authorId,
132
+ guildId: msg.guildId,
133
+ roles: msg.authorRoles,
134
+ ...(isKookBotMentioned(msg, selfId) ? { mentioned: true } : {}),
135
+ }),
136
+ }).catch((err) => {
137
+ logger.warn(formatCompact({
138
+ op: 'kook_gateway_receive_failed',
139
+ target,
140
+ error: err instanceof Error ? err.message : String(err),
141
+ }));
273
142
  });
274
-
275
- return message;
276
- }
277
-
278
- /**
279
- * 获取发送者的详细权限信息
280
- */
281
- private getSenderInfo(msg: KookRawMessage): KookSenderInfo {
282
- const authorInfo = msg.author?.info;
283
- const senderInfo: KookSenderInfo = {
284
- id: msg.author_id.toString(),
285
- name: authorInfo?.nickname || authorInfo?.username || "未知用户",
286
- };
287
-
288
- // 频道消息才有权限信息
289
- if (msg.message_type === "channel") {
290
- const channelMsg = msg as ChannelMessageEvent;
291
-
292
- // 从 author.info 中获取权限信息(如果 kook-client 提供)
293
- if (authorInfo) {
294
- // 尝试获取角色列表
295
- senderInfo.roles = (authorInfo as any).roles || [];
296
-
297
- // 尝试获取 guild_id 并检查是否为服务器主人
298
- try {
299
- const channel = channelMsg.channel;
300
- const guildId = (channel?.info as any)?.guild_id;
301
- if (guildId) {
302
- const guildInfo = this.guilds?.get(guildId);
303
- if (guildInfo) {
304
- senderInfo.isGuildOwner = guildInfo.user_id === msg.author_id;
305
- }
306
- }
307
- } catch {
308
- // 忽略获取失败
309
- }
310
-
311
- // 根据 permission 字段判断(如果有)
312
- const permission = (authorInfo as any).permission as KookPermission | undefined;
313
- if (permission !== undefined) {
314
- senderInfo.permission = permission;
315
- senderInfo.isAdmin = permission === KookPermission.Admin ||
316
- permission === KookPermission.Owner ||
317
- permission === KookPermission.ChannelAdmin;
318
- senderInfo.isGuildOwner = senderInfo.isGuildOwner || permission === KookPermission.Owner;
319
- }
320
- }
321
- }
322
-
323
- const normalized = normalizeKookSenderForPermit(senderInfo, msg.message_type === "channel");
324
- senderInfo.role = normalized.role;
325
- senderInfo.permissions = normalized.permissions;
326
- return senderInfo;
327
143
  }
328
144
 
329
- // ==================== 频道管理 API ====================
330
-
331
- /**
332
- * 踢出用户
333
- * @param guildId 服务器ID
334
- * @param userId 用户ID
335
- */
336
- async kickUser(guildId: string, userId: string): Promise<boolean> {
337
- try {
338
- const guild = this.pickGuild(guildId);
339
- const result = await guild.kick(userId);
340
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 踢出用户 ${userId} 从服务器 ${guildId}`);
341
- return result;
342
- } catch (error) {
343
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 踢出用户失败:`, error);
344
- throw error;
345
- }
346
- }
145
+ // ── Agent tool surface ──────────────────────────────────────────────
347
146
 
348
- /**
349
- * 将用户加入黑名单
350
- * @param guildId 服务器ID
351
- * @param userId 用户ID
352
- * @param remark 备注
353
- * @param delMsgDays 删除消息天数(0-7)
354
- */
355
- async addToBlacklist(guildId: string, userId: string, remark?: string, delMsgDays?: number): Promise<boolean> {
356
- try {
357
- const member = this.pickGuildMember(guildId, userId);
358
- const result = await member.addToBlackList(remark, delMsgDays);
359
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 将用户 ${userId} 加入黑名单(服务器 ${guildId})`);
360
- return result;
361
- } catch (error) {
362
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 加入黑名单失败:`, error);
363
- throw error;
364
- }
147
+ async getRoleList(guildId: string) {
148
+ return this.#requireClient().pickGuild(guildId).getRoleList();
365
149
  }
366
150
 
367
- /**
368
- * 将用户从黑名单移除
369
- * @param guildId 服务器ID
370
- * @param userId 用户ID
371
- */
372
- async removeFromBlacklist(guildId: string, userId: string): Promise<boolean> {
373
- try {
374
- const member = this.pickGuildMember(guildId, userId);
375
- const result = await member.removeFromBlackList();
376
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 将用户 ${userId} 从黑名单移除(服务器 ${guildId})`);
377
- return result;
378
- } catch (error) {
379
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 移除黑名单失败:`, error);
380
- throw error;
381
- }
151
+ async createRole(guildId: string, name: string) {
152
+ return this.#requireClient().pickGuild(guildId).createRole(name);
382
153
  }
383
154
 
384
- /**
385
- * 给用户授予角色
386
- * @param guildId 服务器ID
387
- * @param userId 用户ID
388
- * @param roleId 角色ID
389
- */
390
- async grantRole(guildId: string, userId: string, roleId: string): Promise<boolean> {
391
- try {
392
- const member = this.pickGuildMember(guildId, userId);
393
- const result = await member.grant(roleId);
394
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 授予用户 ${userId} 角色 ${roleId}(服务器 ${guildId})`);
395
- return result;
396
- } catch (error) {
397
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 授予角色失败:`, error);
398
- throw error;
399
- }
155
+ async deleteRole(guildId: string, roleId: string) {
156
+ return this.#requireClient().pickGuild(guildId).deleteRole(roleId);
400
157
  }
401
158
 
402
- /**
403
- * 撤销用户角色
404
- * @param guildId 服务器ID
405
- * @param userId 用户ID
406
- * @param roleId 角色ID
407
- */
408
- async revokeRole(guildId: string, userId: string, roleId: string): Promise<boolean> {
409
- try {
410
- const member = this.pickGuildMember(guildId, userId);
411
- const result = await member.revoke(roleId);
412
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 撤销用户 ${userId} 角色 ${roleId}(服务器 ${guildId})`);
413
- return result;
414
- } catch (error) {
415
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 撤销角色失败:`, error);
416
- throw error;
417
- }
159
+ async grantRole(guildId: string, userId: string, roleId: string) {
160
+ return this.#requireClient().pickGuildMember(guildId, userId).grant(roleId);
418
161
  }
419
162
 
420
- /**
421
- * 设置用户昵称
422
- * @param guildId 服务器ID
423
- * @param userId 用户ID
424
- * @param nickname 新昵称
425
- */
426
- async setNickname(guildId: string, userId: string, nickname: string): Promise<boolean> {
427
- try {
428
- const member = this.pickGuildMember(guildId, userId);
429
- const result = await member.setNickname(nickname);
430
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 设置用户 ${userId} 昵称为 "${nickname}"(服务器 ${guildId})`);
431
- return result;
432
- } catch (error) {
433
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 设置昵称失败:`, error);
434
- throw error;
435
- }
163
+ async revokeRole(guildId: string, userId: string, roleId: string) {
164
+ return this.#requireClient().pickGuildMember(guildId, userId).revoke(roleId);
436
165
  }
437
166
 
438
- /**
439
- * 获取服务器角色列表
440
- * @param guildId 服务器ID
441
- */
442
- async getRoleList(guildId: string): Promise<Guild.Role[]> {
443
- try {
444
- const guild = this.pickGuild(guildId);
445
- return await guild.getRoleList();
446
- } catch (error) {
447
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 获取角色列表失败:`, error);
448
- throw error;
449
- }
167
+ async addToBlacklist(guildId: string, userId: string, remark?: string) {
168
+ return this.#requireClient().pickGuildMember(guildId, userId).addToBlackList(remark);
450
169
  }
451
170
 
452
- /**
453
- * 创建角色
454
- * @param guildId 服务器ID
455
- * @param name 角色名称
456
- */
457
- async createRole(guildId: string, name: string): Promise<Guild.Role> {
458
- try {
459
- const guild = this.pickGuild(guildId);
460
- const role = await guild.createRole(name);
461
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 创建角色 "${name}"(服务器 ${guildId})`);
462
- return role;
463
- } catch (error) {
464
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 创建角色失败:`, error);
465
- throw error;
466
- }
171
+ async removeFromBlacklist(guildId: string, userId: string) {
172
+ return this.#requireClient().pickGuildMember(guildId, userId).removeFromBlackList();
467
173
  }
468
174
 
469
- /**
470
- * 删除角色
471
- * @param guildId 服务器ID
472
- * @param roleId 角色ID
473
- */
474
- async deleteRole(guildId: string, roleId: string): Promise<boolean> {
475
- try {
476
- const guild = this.pickGuild(guildId);
477
- const result = await guild.deleteRole(roleId);
478
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 删除角色 ${roleId}(服务器 ${guildId})`);
479
- return result;
480
- } catch (error) {
481
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 删除角色失败:`, error);
482
- throw error;
483
- }
175
+ #bindClient(client: KookClientTransport): void {
176
+ client.on('message', (raw) => {
177
+ const msg = normalizeKookMessage(raw);
178
+ if (msg) this.admit(msg);
179
+ });
484
180
  }
485
181
 
486
- /**
487
- * 获取服务器成员列表
488
- * @param guildId 服务器ID
489
- * @param channelId 可选的频道ID
490
- */
491
- async getGuildMembers(guildId: string, channelId?: string): Promise<User.Info[]> {
492
- try {
493
- return await this.getGuildUserList(guildId, channelId);
494
- } catch (error) {
495
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 获取成员列表失败:`, error);
496
- throw error;
497
- }
182
+ #requireClient(): KookClientTransport {
183
+ if (!this.#client) throw new Error('KOOK client not connected');
184
+ return this.#client;
498
185
  }
499
- private buildAtElement(userId: string | number): MessageElement {
500
- const id = String(userId);
501
- return { type: 'at', data: { id, user_id: id, qq: id } };
502
- }
503
-
504
- private resolveAtUserId(data: Record<string, unknown>): string {
505
- const raw = data.user_id ?? data.qq ?? data.id;
506
- return raw == null ? '' : String(raw);
507
- }
508
-
509
- private parseMarkdown(content: string): MessageElement[] {
510
- const elements: MessageElement[] = [];
511
-
512
- // KMarkdown 图片格式: ![alt](url)
513
- const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
514
- // KMarkdown @提及格式: (met)userId(met) 或 @用户名
515
- const mentionRegex = /\(met\)(\d+)\(met\)|@([^\s]+)/g;
516
- // KMarkdown 表情格式: (emj)表情名(emj)[表情ID]
517
- const emojiRegex = /\(emj\)([^(]+)\(emj\)\[([^\]]+)\]/g;
518
- // KMarkdown 频道格式: (chn)channelId(chn)
519
- const channelRegex = /\(chn\)(\d+)\(chn\)/g;
520
-
521
- let lastIndex = 0;
522
- const matches: Array<{ index: number; length: number; element: MessageElement }> = [];
523
-
524
- // 解析图片
525
- let match: RegExpExecArray | null;
526
- while ((match = imageRegex.exec(content)) !== null) {
527
- matches.push({
528
- index: match.index,
529
- length: match[0].length,
530
- element: { type: "image", data: { url: match[2], alt: match[1] } }
531
- });
532
- }
533
-
534
- // 解析 @提及
535
- while ((match = mentionRegex.exec(content)) !== null) {
536
- const userId = match[1] || match[2];
537
- matches.push({
538
- index: match.index,
539
- length: match[0].length,
540
- element: this.buildAtElement(userId),
541
- });
542
- }
543
-
544
- // 解析表情
545
- while ((match = emojiRegex.exec(content)) !== null) {
546
- matches.push({
547
- index: match.index,
548
- length: match[0].length,
549
- element: { type: "face", data: { id: match[2], name: match[1] } }
550
- });
551
- }
552
-
553
- // 解析频道引用
554
- while ((match = channelRegex.exec(content)) !== null) {
555
- matches.push({
556
- index: match.index,
557
- length: match[0].length,
558
- element: { type: "text", data: { text: `#频道:${match[1]}` } }
559
- });
560
- }
186
+ }
561
187
 
562
- // 按位置排序
563
- matches.sort((a, b) => a.index - b.index);
564
-
565
- // 组装消息段
566
- for (const match of matches) {
567
- // 添加之前的文本
568
- if (match.index > lastIndex) {
569
- const text = content.slice(lastIndex, match.index);
570
- if (text) {
571
- elements.push({ type: "text", data: { text } });
572
- }
573
- }
188
+ export interface KookWebhookEndpointOptions {
189
+ readonly id: CapabilityId;
190
+ readonly gateway: MessageGateway;
191
+ readonly http: HttpHost;
192
+ readonly config: ResolvedKookWebhookConfig;
193
+ readonly createClient?: CreateKookClient;
194
+ }
574
195
 
575
- // 添加特殊元素
576
- elements.push(match.element);
577
- lastIndex = match.index + match.length;
578
- }
196
+ export class KookWebhookEndpoint implements EndpointInstance {
197
+ readonly #options: KookWebhookEndpointOptions;
198
+ readonly #createClient: CreateKookClient;
199
+ #client: KookClientTransport | null = null;
200
+ #routeReleases: HttpRouteRegistration[] = [];
201
+ #processedSn = new Set<number>();
202
+ #open = false;
203
+ #started = false;
204
+ #unregisterAgent?: () => void;
579
205
 
580
- // 添加剩余文本
581
- if (lastIndex < content.length) {
582
- const text = content.slice(lastIndex);
583
- if (text) {
584
- elements.push({ type: "text", data: { text } });
585
- }
586
- }
206
+ constructor(options: KookWebhookEndpointOptions) {
207
+ this.#options = options;
208
+ this.#createClient = options.createClient ?? (defaultCreateWebhookClient as CreateKookClient);
209
+ }
587
210
 
588
- // 如果没有解析到任何特殊元素,返回纯文本
589
- if (elements.length === 0) {
590
- elements.push({ type: "text", data: { text: content } });
591
- }
211
+ /** Used by webhook handler. */
212
+ get isOpen(): boolean {
213
+ return this.#open;
214
+ }
592
215
 
593
- return elements;
594
- }
595
- /**
596
- * 将 kook-client 的 MessageSegment[] 转换为 Zhin 的 MessageElement[]
597
- */
598
- private parseMessageContent(segments: MessageSegment[]): MessageElement[] {
599
- const elements: MessageElement[] = [];
600
-
601
- for (const segment of segments) {
602
- switch (segment.type) {
603
- case "markdown":
604
- // 检查是否包含特殊语法,如果是纯文本则直接转换
605
- if (this.hasKMarkdownSyntax(segment.text)) {
606
- elements.push(...this.parseMarkdown(segment.text));
607
- } else {
608
- elements.push({ type: "text", data: { text: segment.text } });
609
- }
610
- break;
611
-
612
- case "text":
613
- elements.push({ type: "text", data: { text: segment.text } });
614
- break;
615
-
616
- case "at":
617
- elements.push(this.buildAtElement(segment.user_id));
618
- break;
619
-
620
- case "image":
621
- elements.push({
622
- type: "image",
623
- data: { url: segment.url, alt: segment.title || "图片" }
624
- });
625
- break;
626
-
627
- case "video":
628
- elements.push({ type: "video", data: { url: segment.url } });
629
- break;
630
-
631
- case "audio":
632
- elements.push({ type: "audio", data: { url: segment.url } });
633
- break;
634
-
635
- case "file":
636
- elements.push({
637
- type: "file",
638
- data: { url: segment.url, name: segment.name }
639
- });
640
- break;
641
-
642
- case "reply":
643
- elements.push({ type: "reply", data: { id: segment.id } });
644
- break;
645
-
646
- case "card":
647
- // Card 消息暂不支持,转为提示文本
648
- elements.push({ type: "text", data: { text: "[卡片消息]" } });
649
- break;
650
-
651
- default:
652
- this.pluginLogger.warn(`未知的 KOOK 消息段类型: ${(segment as any).type}`);
653
- break;
654
- }
655
- }
656
-
657
- return elements.length > 0 ? elements : [{ type: "text", data: { text: "" } }];
216
+ get config(): ResolvedKookWebhookConfig {
217
+ return this.#options.config;
658
218
  }
659
-
660
- /**
661
- * 检查文本是否包含 KMarkdown 特殊语法
662
- */
663
- private hasKMarkdownSyntax(text: string): boolean {
664
- return /!\[.*?\]\(.*?\)|\(met\)|\(emj\)|\(chn\)|@\S+/.test(text);
219
+
220
+ get selfId(): string | undefined {
221
+ return this.#client?.self_id != null ? String(this.#client.self_id) : undefined;
665
222
  }
666
223
 
667
- /**
668
- * 连接到 KOOK
669
- */
670
- async $connect(): Promise<void> {
224
+ async start(): Promise<void> {
225
+ if (this.#started) return;
226
+ this.#started = true;
671
227
  try {
672
- await this.connect();
673
- this.$connected = true;
674
- const selfId = (this as { self_id?: string | number }).self_id;
675
- if (selfId != null) {
676
- this.$platformUserId = String(selfId);
677
- }
678
- this.pluginLogger.info(
679
- `KOOK Endpoint ${this.$id} 连接成功`
680
- + (this.$platformUserId ? ` (platform_user_id=${this.$platformUserId})` : ''),
681
- );
228
+ this.#unregisterAgent = registerKookAgentEndpoint(this.#options.config.name, this);
229
+ this.#client = this.#createClient(this.#options.config);
230
+ await (this.#client as Client).init();
231
+ this.#routeReleases.push(...registerKookWebhookRoutes(this.#options.http, this));
232
+ logger.info(formatCompact({
233
+ op: 'connect',
234
+ endpoint: this.#options.config.name,
235
+ mode: 'webhook',
236
+ path: this.#options.config.webhookPath,
237
+ self_id: this.#client.self_id != null ? String(this.#client.self_id) : undefined,
238
+ }));
682
239
  } catch (error) {
683
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 连接失败:`, error);
240
+ await this.stop();
241
+ logger.error('Failed to connect KOOK webhook:', error);
684
242
  throw error;
685
243
  }
686
244
  }
687
245
 
688
- /**
689
- * 断开连接
690
- */
691
- async $disconnect(): Promise<void> {
692
- try {
693
- const receiver = this.receiver as import("node:events").EventEmitter;
694
- receiver.off("event", this.onGatewayEvent);
695
- this.inboundDeduper.clear();
696
- (this as unknown as import('node:events').EventEmitter).removeAllListeners();
697
- // 移除 kook-client 构造函数注入的 process 监听器
698
- for (const { event, fn } of this._kookProcessListeners) {
699
- process.removeListener(event, fn);
700
- }
701
- this._kookProcessListeners.length = 0;
702
- await this.disconnect();
703
- this.$connected = false;
704
- this.pluginLogger.info(`KOOK Endpoint ${this.$id} 已断开连接`);
705
- } catch (error) {
706
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 断开连接失败:`, error);
707
- throw error;
708
- }
246
+ open(): void {
247
+ this.#open = true;
709
248
  }
710
249
 
711
- /**
712
- * 上传媒体(覆盖 kook-client:其 FormData.append 不接受 Node Buffer)
713
- */
714
- override async uploadMedia(data: string | Buffer): Promise<string> {
715
- return uploadKookAsset(this.request, data);
250
+ close(): void {
251
+ this.#open = false;
716
252
  }
717
253
 
718
- /**
719
- * 发送消息
720
- */
721
- async $sendMessage(options: SendOptions): Promise<string> {
722
- try {
723
- const { id, type, content } = options;
724
-
725
- const expanded = expandInteractiveSegmentsInContent(content);
726
- const canonical = toCanonicalSegments(Array.isArray(expanded) ? expanded : [expanded]);
727
- const wire = fromCanonicalSegments(canonical);
728
- const elements = await materializeOutboundMedia(this, wire);
729
- const kookContent = convertToKookSendable(
730
- elements,
731
- (els) => this.convertToKookFormat(els),
732
- );
733
-
734
- // 根据消息类型发送
735
- let result: any;
736
- if (type === "private") {
737
- result = await (this as any).sendPrivateMsg(id, kookContent);
738
- } else {
739
- result = await (this as any).sendChannelMsg(id, kookContent);
254
+ async stop(): Promise<void> {
255
+ this.#open = false;
256
+ for (const release of this.#routeReleases.splice(0)) release();
257
+ this.#unregisterAgent?.();
258
+ this.#unregisterAgent = undefined;
259
+ this.#processedSn.clear();
260
+ if (this.#client) {
261
+ try {
262
+ this.#client.removeAllListeners();
263
+ await this.#client.disconnect();
264
+ } catch {
265
+ /* ignore */
740
266
  }
741
-
742
- const route = routeFromSendType(type);
743
- return encodeKookMsgRef(route, String(result?.msg_id ?? ''));
744
- } catch (error) {
745
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 发送消息失败:`, error);
746
- throw error;
267
+ this.#client = null;
747
268
  }
269
+ this.#started = false;
270
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
271
+ }
272
+
273
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
274
+ const body = formatOutboundKmarkdown(payload);
275
+ const parsed = parseSendTarget(target);
276
+ const client = this.#requireClient();
277
+ const result = parsed.kind === 'private'
278
+ ? await client.sendPrivateMsg(parsed.id, body)
279
+ : await client.sendChannelMsg(parsed.id, body);
280
+ const messageId = result?.msg_id != null ? String(result.msg_id) : '';
281
+ logger.debug(formatCompact({
282
+ op: 'kook_send',
283
+ endpoint: this.#options.config.name,
284
+ target,
285
+ messageId,
286
+ }));
287
+ return messageId;
288
+ }
289
+
290
+ /** Test / internal: admit a message when open. */
291
+ admit(msg: KookInboundMessage): void {
292
+ if (!this.#open) return;
293
+ if (msg.authorBot) return;
294
+ const target = formatInboundTarget(msg);
295
+ const selfId = this.#client?.self_id != null ? String(this.#client.self_id) : undefined;
296
+ void this.#options.gateway.receive({
297
+ adapter: this.#options.id,
298
+ target,
299
+ content: formatInboundContent(msg),
300
+ sender: senderDisplayName(msg),
301
+ id: msg.id,
302
+ metadata: Object.freeze({
303
+ endpoint: this.#options.config.name,
304
+ channelKind: msg.channelKind,
305
+ userId: msg.authorId,
306
+ guildId: msg.guildId,
307
+ roles: msg.authorRoles,
308
+ ...(isKookBotMentioned(msg, selfId) ? { mentioned: true } : {}),
309
+ }),
310
+ }).catch((err) => {
311
+ logger.warn(formatCompact({
312
+ op: 'kook_gateway_receive_failed',
313
+ target,
314
+ error: err instanceof Error ? err.message : String(err),
315
+ }));
316
+ });
748
317
  }
749
318
 
750
- /**
751
- * 撤回消息。支持 `kook:channel:msgId` 出站 ref,或入站 plain msgId + route/sceneType 提示。
752
- */
753
- async $recallMessage(
754
- messageIdOrRef: string,
755
- hint?: { route?: KookMsgRoute; sceneType?: 'private' | 'group' | 'channel' },
756
- ): Promise<void> {
757
- const { route: encodedRoute, msgId } = parseKookMsgRef(messageIdOrRef);
758
- const routeHint = encodedRoute ?? hint?.route ?? routeFromSceneType(hint?.sceneType);
759
- try {
760
- await this.deleteKookMsg(msgId, routeHint);
761
- this.pluginLogger.debug(
762
- `KOOK Endpoint ${this.$id} 撤回消息 (${routeHint ?? 'auto'}): ${msgId}`,
763
- );
764
- } catch (error) {
765
- this.pluginLogger.error(`KOOK Endpoint ${this.$id} 撤回消息失败:`, error);
766
- throw error;
319
+ checkAndRememberSn(sn: number): boolean {
320
+ if (this.#processedSn.has(sn)) return false;
321
+ this.#processedSn.add(sn);
322
+ if (this.#processedSn.size > 1024) {
323
+ const first = this.#processedSn.values().next().value;
324
+ if (first != null) this.#processedSn.delete(first);
767
325
  }
326
+ return true;
327
+ }
328
+
329
+ async getRoleList(guildId: string) {
330
+ return this.#requireClient().pickGuild(guildId).getRoleList();
768
331
  }
769
332
 
770
- private async deleteKookMsg(msgId: string, routeHint?: KookMsgRoute): Promise<void> {
771
- const routes = resolveKookRoutes('delete', routeHint);
772
- let lastError: unknown;
333
+ async createRole(guildId: string, name: string) {
334
+ return this.#requireClient().pickGuild(guildId).createRole(name);
335
+ }
773
336
 
774
- for (const route of routes) {
775
- try {
776
- const result = await this.request.post(
777
- kookDeleteApiPath(route),
778
- { msg_id: msgId },
779
- ) as { code?: number };
780
- if (shouldStopDeleteAfterResponse(result, routes.length)) return;
781
- } catch (err) {
782
- if (isKookMsgGoneError(err)) return;
783
- lastError = err;
784
- }
785
- }
337
+ async deleteRole(guildId: string, roleId: string) {
338
+ return this.#requireClient().pickGuild(guildId).deleteRole(roleId);
339
+ }
786
340
 
787
- return;
788
- }
789
-
790
- /**
791
- * 为消息添加表情回应(TypingIndicator reaction 模式)
792
- * @returns reactionId(含 channel/direct 路由),供 $removeReaction 使用
793
- */
794
- async $addReaction(
795
- messageId: string,
796
- emoji: string,
797
- hint?: { sceneType?: 'private' | 'group' | 'channel' },
798
- ): Promise<string> {
799
- const msgId = plainKookMsgId(messageId);
800
- const route = await this.mutateMsgReaction(
801
- msgId,
802
- emoji,
803
- 'add',
804
- routeFromSceneType(hint?.sceneType),
805
- );
806
- return encodeKookReactionId(route, msgId, emoji);
807
- }
808
-
809
- /** 移除本 Endpoint 在消息上的表情回应 */
810
- async $removeReaction(messageId: string, reactionId: string): Promise<void> {
811
- const { route, emoji } = parseKookReactionId(reactionId);
812
- await this.mutateMsgReaction(plainKookMsgId(messageId), emoji, 'delete', route);
813
- }
814
-
815
- private async mutateMsgReaction(
816
- msgId: string,
817
- emoji: string,
818
- action: 'add' | 'delete',
819
- routeHint?: KookMsgRoute,
820
- ): Promise<KookMsgRoute> {
821
- const routes = resolveKookRoutes(action, routeHint);
822
- const body = { msg_id: msgId, emoji };
823
- let lastError: unknown;
824
-
825
- for (const route of routes) {
826
- try {
827
- const result = await this.request.post(
828
- kookReactionApiPath(route, action),
829
- body,
830
- ) as { code?: number };
831
- if (isKookApiSuccess(result)) {
832
- this.pluginLogger.debug(
833
- `KOOK Endpoint ${this.$id} ${action} reaction (${route}) on ${msgId}`,
834
- );
835
- return route;
836
- }
837
- if (action === 'delete' && shouldStopDeleteAfterResponse(result, routes.length)) {
838
- const label = isKookApiGoneResult(result) ? 'already gone' : 'done';
839
- this.pluginLogger.debug(
840
- `KOOK Endpoint ${this.$id} delete reaction ${label} (${route}) on ${msgId}`,
841
- );
842
- return route;
843
- }
844
- } catch (err) {
845
- if (action === 'delete' && isKookMsgGoneError(err)) {
846
- this.pluginLogger.debug(
847
- `KOOK Endpoint ${this.$id} delete reaction already gone (${route}) on ${msgId}`,
848
- );
849
- return route;
850
- }
851
- lastError = err;
852
- }
853
- }
341
+ async grantRole(guildId: string, userId: string, roleId: string) {
342
+ return this.#requireClient().pickGuildMember(guildId, userId).grant(roleId);
343
+ }
854
344
 
855
- if (action === 'delete') {
856
- this.pluginLogger.debug(
857
- `KOOK Endpoint ${this.$id} delete reaction noop on ${msgId}`,
858
- );
859
- return routeHint ?? 'channel';
860
- }
345
+ async revokeRole(guildId: string, userId: string, roleId: string) {
346
+ return this.#requireClient().pickGuildMember(guildId, userId).revoke(roleId);
347
+ }
861
348
 
862
- throw lastError instanceof Error
863
- ? lastError
864
- : new Error(`KOOK ${action} reaction failed (msg_id=${msgId})`);
349
+ async addToBlacklist(guildId: string, userId: string, remark?: string) {
350
+ return this.#requireClient().pickGuildMember(guildId, userId).addToBlackList(remark);
865
351
  }
866
352
 
867
- /**
868
- * 将消息段转换为 KOOK KMarkdown 格式
869
- * 支持:文本、图片、@提及、表情、引用等
870
- */
871
- private convertToKookFormat(content: MessageElement[]): string {
872
- return content
873
- .map((el) => {
874
- switch (el.type) {
875
- case "text":
876
- // 纯文本,转义特殊字符
877
- return el.data.text.replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&');
878
-
879
- case "image":
880
- // 图片:![alt](url)
881
- return `![${el.data.alt || '图片'}](${el.data.url || el.data.file})`;
882
-
883
- case "at": {
884
- const atId = this.resolveAtUserId(el.data as Record<string, unknown>);
885
- if (atId === 'all') {
886
- return '(met)all(met)';
887
- }
888
- return `(met)${atId}(met)`;
889
- }
890
-
891
- case "face":
892
- // 表情:(emj)表情名(emj)[表情ID]
893
- return `(emj)${el.data.name || 'emoji'}(emj)[${el.data.id}]`;
894
-
895
- case "reply":
896
- // 引用消息(KOOK 使用 quote 参数,不在消息内容中)
897
- return "";
898
-
899
- case "video":
900
- // 视频:使用链接形式
901
- return `[视频](${el.data.url || el.data.file})`;
902
-
903
- case "audio":
904
- // 音频:使用链接形式
905
- return `[音频](${el.data.url || el.data.file})`;
906
-
907
- case "file":
908
- // 文件:使用链接形式
909
- return `[文件: ${el.data.name || '未命名'}](${el.data.url || el.data.file})`;
910
-
911
- case "link":
912
- // 链接:[文本](url)
913
- return `[${el.data.text || el.data.url}](${el.data.url})`;
914
-
915
- case "bold":
916
- // 粗体:**文本**
917
- return `**${el.data.text}**`;
918
-
919
- case "italic":
920
- // 斜体:*文本*
921
- return `*${el.data.text}*`;
922
-
923
- case "code":
924
- // 行内代码:`代码`
925
- return `\`${el.data.text}\``;
926
-
927
- case "code_block":
928
- // 代码块:```语言\n代码\n```
929
- return `\`\`\`${el.data.language || ''}\n${el.data.text}\n\`\`\``;
930
-
931
- default:
932
- // 未知类型,尝试转换为文本
933
- this.pluginLogger.warn(`未知的消息段类型: ${el.type}`);
934
- return el.data.text || JSON.stringify(el.data);
935
- }
936
- })
937
- .filter(Boolean)
938
- .join("");
353
+ async removeFromBlacklist(guildId: string, userId: string) {
354
+ return this.#requireClient().pickGuildMember(guildId, userId).removeFromBlackList();
355
+ }
356
+
357
+ #requireClient(): KookClientTransport {
358
+ if (!this.#client) throw new Error('KOOK client not initialized');
359
+ return this.#client;
939
360
  }
940
361
  }