@zhin.js/adapter-kook 1.1.25 → 1.1.27

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 (45) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/LICENSE +21 -0
  3. package/PERMITS.md +20 -14
  4. package/README.md +6 -2
  5. package/adapters/kook/index.js +3 -0
  6. package/adapters/kook/index.ts +3 -0
  7. package/agents/kook/conversation.js +88 -0
  8. package/agents/kook/conversation.ts +99 -0
  9. package/agents/kook/messages.js +16 -0
  10. package/agents/kook/messages.ts +16 -0
  11. package/agents/kook/skills/kook/tools/blacklist/index.js +9 -6
  12. package/agents/kook/skills/kook/tools/blacklist/index.ts +9 -7
  13. package/agents/kook/skills/kook/tools/create_role/index.js +5 -6
  14. package/agents/kook/skills/kook/tools/create_role/index.ts +6 -7
  15. package/agents/kook/skills/kook/tools/delete_role/index.js +5 -6
  16. package/agents/kook/skills/kook/tools/delete_role/index.ts +6 -7
  17. package/agents/kook/skills/kook/tools/grant_role/index.js +9 -7
  18. package/agents/kook/skills/kook/tools/grant_role/index.ts +9 -8
  19. package/agents/kook/skills/kook/tools/list_roles/index.js +5 -7
  20. package/agents/kook/skills/kook/tools/list_roles/index.ts +6 -8
  21. package/agents/kook/skills/kook/tools/revoke_role/index.js +9 -7
  22. package/agents/kook/skills/kook/tools/revoke_role/index.ts +9 -8
  23. package/lib/console-spec.js +148 -13
  24. package/lib/endpoint.d.ts +2 -1
  25. package/lib/endpoint.js +27 -12
  26. package/lib/index.d.ts +1 -1
  27. package/lib/index.js +1 -1
  28. package/lib/platform-permit.d.ts +4 -19
  29. package/lib/platform-permit.js +7 -46
  30. package/lib/protocol.d.ts +1 -5
  31. package/lib/protocol.js +3 -28
  32. package/lib/sender-roles.d.ts +6 -6
  33. package/lib/sender-roles.js +55 -21
  34. package/lib/webhook.d.ts +1 -2
  35. package/lib/webhook.js +13 -33
  36. package/package.json +23 -26
  37. package/plugin.js +0 -6
  38. package/schema.json +0 -56
  39. package/src/console-spec.ts +148 -13
  40. package/src/endpoint.ts +27 -13
  41. package/src/index.ts +1 -7
  42. package/src/platform-permit.ts +5 -70
  43. package/src/protocol.ts +4 -33
  44. package/src/sender-roles.ts +59 -27
  45. package/src/webhook.ts +14 -36
@@ -7,19 +7,154 @@ export const kookConsoleSpec = {
7
7
  adapterKey: 'kook',
8
8
  adapterDisplayName: 'KOOK',
9
9
  fields: [
10
- { key: 'token', required: true, env: true, description: 'KOOK bot token' },
11
- { key: 'encrypt_key', env: true, description: 'webhook 加密密钥(connection: webhook)', descriptionI18n: { en: 'Webhook encryption secret' } },
12
- { key: 'verify_token', env: true, description: 'webhook 验证 token(connection: webhook)', descriptionI18n: { en: 'Webhook verification token' } },
13
- { key: 'connection', description: 'websocket or webhook' },
14
- { key: 'webhookPath', description: 'Local webhook route' },
15
- { key: 'data_dir', description: 'SDK data directory' },
16
- { key: 'timeout', type: 'number', description: 'SDK request timeout' },
17
- { key: 'max_retry', type: 'number', description: 'SDK configured retry count' },
18
- { key: 'ignore', description: 'SDK inbound ignore setting: bot or self' },
19
- { key: 'logLevel', description: 'SDK log level' },
20
- { key: 'apiProxy.port', type: 'number', description: 'Configured API loopback proxy port' },
21
- { key: 'streamProxy.port', type: 'number', description: 'Configured Gateway loopback proxy port' },
22
- { key: 'streamProxy.serverName', description: 'Expected Gateway TLS hostname' },
10
+ {
11
+ key: 'token',
12
+ required: true,
13
+ env: true,
14
+ label: 'Bot Token (机器人令牌)',
15
+ labelI18n: { en: 'Bot Token' },
16
+ group: '基础认证',
17
+ groupI18n: { en: 'Basic Auth' },
18
+ placeholder: '1/MTIzNDU=/...',
19
+ description: 'KOOK 开发者中心获取的 Bot Token',
20
+ descriptionI18n: { en: 'KOOK bot token' },
21
+ },
22
+ {
23
+ key: 'connection',
24
+ label: '连接模式',
25
+ labelI18n: { en: 'Connection Mode' },
26
+ group: '连接与回调',
27
+ groupI18n: { en: 'Connection & Webhook' },
28
+ placeholder: 'websocket',
29
+ description: 'websocket(默认长连接)或 webhook',
30
+ descriptionI18n: { en: 'websocket or webhook' },
31
+ options: [
32
+ { value: 'websocket', label: 'WebSocket 长连接(推荐)', labelI18n: { en: 'WebSocket (Recommended)' } },
33
+ { value: 'webhook', label: 'Webhook 回调', labelI18n: { en: 'Webhook' } },
34
+ ],
35
+ },
36
+ {
37
+ key: 'encrypt_key',
38
+ env: true,
39
+ label: 'Webhook Encrypt Key',
40
+ labelI18n: { en: 'Webhook Encrypt Key' },
41
+ group: '连接与回调',
42
+ groupI18n: { en: 'Connection & Webhook' },
43
+ placeholder: 'Webhook 模式下的 Encrypt Key',
44
+ placeholderI18n: { en: 'Encrypt key for webhook mode' },
45
+ description: 'webhook 加密密钥(connection: webhook)',
46
+ descriptionI18n: { en: 'Webhook encryption secret' },
47
+ },
48
+ {
49
+ key: 'verify_token',
50
+ env: true,
51
+ label: 'Webhook Verify Token',
52
+ labelI18n: { en: 'Webhook Verify Token' },
53
+ group: '连接与回调',
54
+ groupI18n: { en: 'Connection & Webhook' },
55
+ placeholder: 'Webhook 模式下的 Verify Token',
56
+ placeholderI18n: { en: 'Verify token for webhook mode' },
57
+ description: 'webhook 验证 token(connection: webhook)',
58
+ descriptionI18n: { en: 'Webhook verification token' },
59
+ },
60
+ {
61
+ key: 'webhookPath',
62
+ label: 'Webhook 本地路由',
63
+ labelI18n: { en: 'Webhook Local Path' },
64
+ group: '连接与回调',
65
+ groupI18n: { en: 'Connection & Webhook' },
66
+ placeholder: '/kook/webhook',
67
+ description: '本地监听的 Webhook 路径',
68
+ descriptionI18n: { en: 'Local webhook route' },
69
+ },
70
+ {
71
+ key: 'ignore',
72
+ label: '消息忽略策略',
73
+ labelI18n: { en: 'Ignore Policy' },
74
+ group: '流式体验与代理',
75
+ groupI18n: { en: 'Streaming & Proxy' },
76
+ placeholder: 'bot',
77
+ description: '忽略消息来源:bot(所有机器人)或 self(仅自身)',
78
+ descriptionI18n: { en: 'SDK inbound ignore setting: bot or self' },
79
+ options: [
80
+ { value: 'bot', label: '忽略所有机器人 (bot)', labelI18n: { en: 'Ignore all bots (bot)' } },
81
+ { value: 'self', label: '仅忽略自身 (self)', labelI18n: { en: 'Ignore self only (self)' } },
82
+ ],
83
+ },
84
+ {
85
+ key: 'timeout',
86
+ type: 'number',
87
+ label: '请求超时 (ms)',
88
+ labelI18n: { en: 'Request Timeout (ms)' },
89
+ group: '流式体验与代理',
90
+ groupI18n: { en: 'Streaming & Proxy' },
91
+ placeholder: '10000',
92
+ description: 'API 请求超时时间(毫秒)',
93
+ descriptionI18n: { en: 'SDK request timeout' },
94
+ },
95
+ {
96
+ key: 'max_retry',
97
+ type: 'number',
98
+ label: '最大重试次数',
99
+ labelI18n: { en: 'Max Retries' },
100
+ group: '流式体验与代理',
101
+ groupI18n: { en: 'Streaming & Proxy' },
102
+ placeholder: '3',
103
+ description: 'SDK 请求失败最大重试次数',
104
+ descriptionI18n: { en: 'SDK configured retry count' },
105
+ },
106
+ {
107
+ key: 'data_dir',
108
+ label: 'SDK 数据目录',
109
+ labelI18n: { en: 'SDK Data Directory' },
110
+ group: '流式体验与代理',
111
+ groupI18n: { en: 'Streaming & Proxy' },
112
+ placeholder: './data/kook',
113
+ description: 'KOOK SDK 本地缓存目录(可选)',
114
+ descriptionI18n: { en: 'SDK data directory' },
115
+ },
116
+ {
117
+ key: 'logLevel',
118
+ label: 'SDK 日志级别',
119
+ labelI18n: { en: 'SDK Log Level' },
120
+ group: '流式体验与代理',
121
+ groupI18n: { en: 'Streaming & Proxy' },
122
+ placeholder: 'info',
123
+ description: 'KOOK SDK 日志级别(info / warn / error / debug)',
124
+ descriptionI18n: { en: 'SDK log level' },
125
+ },
126
+ {
127
+ key: 'apiProxy.port',
128
+ type: 'number',
129
+ label: 'API 代理端口',
130
+ labelI18n: { en: 'API Proxy Port' },
131
+ group: '流式体验与代理',
132
+ groupI18n: { en: 'Streaming & Proxy' },
133
+ placeholder: '例如 7890',
134
+ description: '本地回环 API 代理端口(可选)',
135
+ descriptionI18n: { en: 'Configured API loopback proxy port' },
136
+ },
137
+ {
138
+ key: 'streamProxy.port',
139
+ type: 'number',
140
+ label: '网关代理端口',
141
+ labelI18n: { en: 'Gateway Proxy Port' },
142
+ group: '流式体验与代理',
143
+ groupI18n: { en: 'Streaming & Proxy' },
144
+ placeholder: '例如 7890',
145
+ description: '本地回环 Gateway 代理端口(可选)',
146
+ descriptionI18n: { en: 'Configured Gateway loopback proxy port' },
147
+ },
148
+ {
149
+ key: 'streamProxy.serverName',
150
+ label: '网关 TLS 主机名',
151
+ labelI18n: { en: 'Gateway TLS Server Name' },
152
+ group: '流式体验与代理',
153
+ groupI18n: { en: 'Streaming & Proxy' },
154
+ placeholder: 'gateway.kookapp.cn',
155
+ description: '代理校验的 Gateway TLS 主机名(可选)',
156
+ descriptionI18n: { en: 'Expected Gateway TLS hostname' },
157
+ },
23
158
  ] as readonly AdapterConsoleField[],
24
159
  docsUrl: 'https://zhin.js.org/adapters/kook',
25
160
  readPermission: 'kook:read',
package/src/endpoint.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EndpointDeliveryError, type ConversationRef } from '@zhin.js/im-contract';
1
+ import { EndpointDeliveryError, type ConversationRef, type PlatformStanding, type SelfStandingRequest } from '@zhin.js/im-contract';
2
2
  /**
3
3
  * KookEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
4
4
  */
@@ -40,7 +40,7 @@ import {
40
40
  type KookClientTransport,
41
41
  } from './ws.js';
42
42
  import { recallKookMessage, sendKookOutbound } from './outbound.js';
43
- import { KookSenderRoleResolver, type KookSenderRoleProjection } from './sender-roles.js';
43
+ import { KookSenderRoleResolver } from './sender-roles.js';
44
44
  import { receiveKookSideEvent } from './side-event-dispatch.js';
45
45
  import { createKookSelfProfile } from './self-profile.js';
46
46
 
@@ -49,12 +49,12 @@ export interface KookEndpointOptions {
49
49
  readonly config: ResolvedKookWebsocketConfig;
50
50
  }
51
51
 
52
- /** 入站 message.receive 载荷:规范角色 owner/admin 进 roles,原始角色 id 进 permissions。 */
52
+ /** 入站 message.receive 载荷:发送者平台身份进 `sender.platform`(ADR 0056 §1)。 */
53
53
  function kookInboundPayload(
54
54
  endpointId: string,
55
55
  conversation: ConversationRef,
56
56
  msg: KookInboundMessage,
57
- projection: KookSenderRoleProjection,
57
+ platform: PlatformStanding | undefined,
58
58
  selfId: string | undefined,
59
59
  ) {
60
60
  return {
@@ -66,8 +66,7 @@ function kookInboundPayload(
66
66
  sender: {
67
67
  id: msg.authorId,
68
68
  name: senderDisplayName(msg) || undefined,
69
- ...(projection.role ? { roles: [projection.role] } : {}),
70
- ...(projection.permissions.length ? { permissions: [...projection.permissions] } : {}),
69
+ ...(platform ? { platform } : {}),
71
70
  },
72
71
  endpointId,
73
72
  ...(isKookBotMentioned(msg, selfId) ? { mentioned: true } : {}),
@@ -76,7 +75,6 @@ function kookInboundPayload(
76
75
  userId: msg.authorId,
77
76
  guildId: msg.guildId,
78
77
  authorRoleIds: msg.authorRoles,
79
- ...(projection.role ? { senderRole: projection.role } : {}),
80
78
  }),
81
79
  };
82
80
  }
@@ -91,7 +89,7 @@ export class KookWebsocketEndpoint extends RuntimeKookClient implements Endpoint
91
89
  #open = false;
92
90
  #executionSignal?: AbortSignal;
93
91
  #started = false;
94
- readonly #management: EndpointManagement = createKookEndpointManagement(() => this.#requireClient());
92
+ readonly #management: EndpointManagement = createKookEndpointManagement(() => this.#requireClient(), this.#roles);
95
93
  readonly #control: EndpointControl = Object.freeze<EndpointControl>({
96
94
  recall: request => recallKookMessage(this.#requireClient(), request),
97
95
  });
@@ -172,10 +170,10 @@ export class KookWebsocketEndpoint extends RuntimeKookClient implements Endpoint
172
170
  if (msg.authorBot) return;
173
171
  const conversation = kookInboundConversation(String(this.#options.id), msg);
174
172
  const selfId = this.self_id != null ? String(this.self_id) : undefined;
175
- return this.#roles.resolve(msg).then((projection) => {
173
+ return this.#roles.resolve(msg).then((platform) => {
176
174
  this.#executionSignal?.throwIfAborted();
177
175
  return this[endpointSpec].events.emit('message.receive',
178
- kookInboundPayload(this.#options.config.id, conversation, msg, projection, selfId));
176
+ kookInboundPayload(this.#options.config.id, conversation, msg, platform, selfId));
179
177
  }).catch((err) => {
180
178
  this.#logger.warn(formatCompact({
181
179
  op: 'kook_gateway_receive_failed',
@@ -255,7 +253,7 @@ export class KookWebhookEndpoint extends RuntimeKookClient implements Endpoint {
255
253
  #open = false;
256
254
  #executionSignal?: AbortSignal;
257
255
  #started = false;
258
- readonly #management: EndpointManagement = createKookEndpointManagement(() => this.#requireClient());
256
+ readonly #management: EndpointManagement = createKookEndpointManagement(() => this.#requireClient(), this.#roles);
259
257
  readonly #control: EndpointControl = Object.freeze<EndpointControl>({
260
258
  recall: request => recallKookMessage(this.#requireClient(), request),
261
259
  });
@@ -374,10 +372,10 @@ export class KookWebhookEndpoint extends RuntimeKookClient implements Endpoint {
374
372
  if (msg.authorBot) return;
375
373
  const conversation = kookInboundConversation(String(this.#options.id), msg);
376
374
  const selfId = this.self_id != null ? String(this.self_id) : undefined;
377
- return this.#roles.resolve(msg).then((projection) => {
375
+ return this.#roles.resolve(msg).then((platform) => {
378
376
  this.#executionSignal?.throwIfAborted();
379
377
  return this[endpointSpec].events.emit('message.receive',
380
- kookInboundPayload(this.#options.config.id, conversation, msg, projection, selfId));
378
+ kookInboundPayload(this.#options.config.id, conversation, msg, platform, selfId));
381
379
  }).catch((err) => {
382
380
  this.#logger.warn(formatCompact({
383
381
  op: 'kook_gateway_receive_failed',
@@ -438,8 +436,24 @@ function isKookTextChannelType(type: string | number | undefined): boolean {
438
436
  */
439
437
  export function createKookEndpointManagement(
440
438
  requireClient: () => KookClientTransport,
439
+ roles: KookSenderRoleResolver = new KookSenderRoleResolver(requireClient),
441
440
  ): EndpointManagement {
442
441
  return Object.freeze<EndpointManagement>({
442
+ /**
443
+ * ADR 0056 §1: the Bot's own standing in the channel's guild (`/v3/user/view` self → its role
444
+ * ids, then the same guild owner / role-permission resolution as senders).
445
+ */
446
+ async inspectSelfStanding({ conversation, signal }: SelfStandingRequest): Promise<PlatformStanding | undefined> {
447
+ const guildId = conversation.kind === 'channel' ? conversation.parent?.id : undefined;
448
+ if (!guildId) return undefined;
449
+ const operation = createEndpointOperation({ signal });
450
+ const client = requireClient();
451
+ const selfId = client.self_id != null ? String(client.self_id) : undefined;
452
+ if (!selfId) return undefined;
453
+ const { data } = await operation.run(() => client.request.get('/v3/user/view', { params: { user_id: selfId, guild_id: guildId } }));
454
+ const held = Array.isArray((data as { roles?: unknown })?.roles) ? (data as { roles: unknown[] }).roles.map(Number).filter(Number.isFinite) : [];
455
+ return roles.resolve({ guildId, authorId: selfId, authorRoles: held });
456
+ },
443
457
  async listGroups(request: EndpointManagementRequest = {}): Promise<readonly EndpointGroup[]> {
444
458
  const operation = createEndpointOperation(request);
445
459
  const guilds = await operation.run(() => requireClient().getGuildList());
package/src/index.ts CHANGED
@@ -36,10 +36,4 @@ export {
36
36
  export { type KookClientEventMap } from './client.js';
37
37
  export { KookAdapter } from './adapter.js';
38
38
 
39
- export {
40
- checkKookPlatformPermit,
41
- kookGroupPermitResolver,
42
- normalizeKookSenderForPermit,
43
- platformPermit,
44
- type KookSenderInfo,
45
- } from './platform-permit.js';
39
+ export { platformPermit } from './platform-permit.js';
@@ -1,75 +1,10 @@
1
1
  /**
2
- * KOOK platform permit — guild / channel 差异化门禁
2
+ * KOOK platform permit helper(ADR 0056 §5)。
3
+ *
4
+ * 平台身份由适配器上报到 `sender.platform`(见 `sender-roles.ts`),permit 由内置评估器
5
+ * (`@zhin.js/permission`)判断;适配器不再导出自有的 permit 解析器或检查函数。
3
6
  */
4
- import type { PermissionSubject } from '@zhin.js/permission';
5
- import { KookPermission } from './protocol.js';
6
-
7
- const ADAPTER = 'kook';
8
7
 
9
8
  export function platformPermit(perm: string): string {
10
- return `platform(${ADAPTER},${perm})`;
11
- }
12
-
13
- const FACTORY_PERM_MAP: Record<string, string> = {
14
- scene_admin: 'guild_admin',
15
- scene_owner: 'guild_owner',
16
- };
17
-
18
- export function kookGroupPermitResolver(logicalPerm: string): string {
19
- return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
9
+ return `platform-permission:${perm}`;
20
10
  }
21
-
22
- export interface KookSenderInfo {
23
- id: string;
24
- name: string;
25
- permission?: KookPermission;
26
- roles?: number[];
27
- isGuildOwner?: boolean;
28
- isAdmin?: boolean;
29
- role?: string;
30
- permissions?: string[];
31
- }
32
-
33
- export function normalizeKookSenderForPermit(
34
- info: KookSenderInfo,
35
- isChannel: boolean,
36
- ): { role?: string; permissions?: string[] } {
37
- const permissions: string[] = [];
38
- if (info.roles?.length) {
39
- permissions.push(...info.roles.map((r) => `role:${r}`));
40
- }
41
-
42
- if (info.isGuildOwner || info.permission === KookPermission.Owner) {
43
- permissions.push('guild_owner', 'guild_admin');
44
- return { role: 'owner', permissions };
45
- }
46
- if (info.permission === KookPermission.ChannelAdmin && isChannel) {
47
- permissions.push('channel_admin', 'guild_admin');
48
- return { role: 'channel_admin', permissions };
49
- }
50
- if (info.isAdmin || info.permission === KookPermission.Admin) {
51
- permissions.push('guild_admin');
52
- return { role: 'admin', permissions };
53
- }
54
- return { role: 'member', permissions };
55
- }
56
-
57
- export function checkKookPlatformPermit(perm: string, subject: PermissionSubject): boolean {
58
- const role = subject.sender?.role?.[0];
59
- const permissions = subject.sender?.permissions ?? [];
60
- const has = (t: string) => permissions.includes(t);
61
-
62
- switch (perm) {
63
- case 'guild_owner':
64
- return role === 'owner' || has('guild_owner');
65
- case 'guild_admin':
66
- return role === 'owner' || role === 'admin' || role === 'channel_admin' || has('guild_admin') || has('channel_admin');
67
- case 'channel_admin':
68
- return role === 'owner' || role === 'admin' || role === 'channel_admin' || has('channel_admin') || has('guild_admin');
69
- case 'manage_roles':
70
- return role === 'owner' || role === 'admin' || has('guild_owner') || has('guild_admin');
71
- default:
72
- return false;
73
- }
74
- }
75
-
package/src/protocol.ts CHANGED
@@ -3,7 +3,7 @@ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
3
3
 
4
4
  import { createDecipheriv, timingSafeEqual } from 'node:crypto';
5
5
  import { inflateSync } from 'node:zlib';
6
- import type { IncomingMessage } from 'node:http';
6
+ import { readBodyBytes } from '@zhin.js/host-http';
7
7
  import { EndpointDeliveryError, isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
9
  import type { Locale } from 'zhin.js/i18n';
@@ -329,42 +329,13 @@ export function normalizeWebhookPath(path: string): string {
329
329
  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
330
330
  }
331
331
 
332
- export async function readTextBody(
333
- request: IncomingMessage,
334
- options: { readonly limit?: number } = {},
335
- ): Promise<string> {
336
- const limit = options.limit ?? 1_048_576;
337
- const chunks: Buffer[] = [];
338
- let size = 0;
339
- for await (const chunk of request) {
340
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
341
- size += buffer.length;
342
- if (size > limit) {
343
- request.destroy();
344
- throw new Error(`Request body exceeds ${limit} bytes`);
345
- }
346
- chunks.push(buffer);
347
- }
348
- return Buffer.concat(chunks).toString('utf8');
349
- }
350
332
 
351
333
  export async function readRequestBody(
352
- request: IncomingMessage,
334
+ request: Request,
353
335
  options: { readonly limit?: number } = {},
354
336
  ): Promise<Buffer> {
355
- const limit = options.limit ?? 1_048_576;
356
- const chunks: Buffer[] = [];
357
- let size = 0;
358
- for await (const chunk of request) {
359
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
360
- size += buffer.length;
361
- if (size > limit) {
362
- request.destroy();
363
- throw new Error(`Request body exceeds ${limit} bytes`);
364
- }
365
- chunks.push(buffer);
366
- }
367
- return Buffer.concat(chunks);
337
+ // Oversize bodies throw (the webhook answers its generic OK), as before.
338
+ return Buffer.from(await readBodyBytes(request, { limit: options.limit ?? 1_048_576 }));
368
339
  }
369
340
 
370
341
  export function padKookEncryptKey(key: string): Buffer {
@@ -1,27 +1,48 @@
1
1
  /**
2
- * KOOK 入站发送者角色投影。
2
+ * KOOK 入站发送者平台身份(ADR 0056 §1)。
3
3
  *
4
4
  * 事件只携带数字角色 id,无法直接判断群主 / 管理员;这里按服务器缓存
5
- * guild.view(群主 id)与 guild-role.list(角色权限位),把发送者投影为
6
- * 跨平台规范角色 owner / admin。原始角色 id 以 `role:<id>` 形式放入 permissions,
7
- * 不进入 `sender.roles`。
5
+ * guild.view(群主 id)与 guild-role.list(角色名与权限位),把发送者归一为
6
+ * `sender.platform`:
7
+ * - `standing`:服务器主 → `owner`;持有带「管理员」权限位的角色 → `admin`;否则 `member`。
8
+ * 查询失败、无法判断时不上报(不猜)。
9
+ * - `roles`:发送者持有的服务器角色(id + 名称)。
10
+ * - `permissions`:「全体成员」角色(role_id 0,每个成员都持有、事件里不列出)与这些角色权限位的并集,
11
+ * 按 KOOK 权限名(snake_case)上报;服务器主与持有「管理员」位的成员拥有全部权限,展开为全部权限名。
12
+ * 机器人自身身份(`inspectSelfStanding`)走同一套规则。
8
13
  */
9
14
  import { TtlLookupCache } from 'zhin.js/adapter';
15
+ import type { PlatformStanding } from '@zhin.js/im-contract';
10
16
  import type { KookClientTransport } from './ws.js';
11
17
 
12
- /** KOOK 角色权限位:0x1 = 管理员。 */
13
- const KOOK_ROLE_ADMIN_BIT = 1;
14
18
  const CACHE_TTL_MS = 60_000;
19
+ /** 「全体成员」:每个成员都持有的基础角色。 */
20
+ const EVERYONE_ROLE_ID = '0';
15
21
 
16
- export interface KookSenderRoleProjection {
17
- /** 规范角色:owner / admin;普通成员为 undefined。 */
18
- readonly role?: 'owner' | 'admin';
19
- readonly permissions: readonly string[];
22
+ /** KOOK 角色权限位(按位序,见 KOOK 开发者文档「权限说明」)。 */
23
+ export const KOOK_ROLE_PERMISSIONS: readonly string[] = Object.freeze([
24
+ 'administrator', 'manage_guild', 'view_audit_log', 'create_invite', 'manage_invite', 'manage_channels',
25
+ 'kick_members', 'ban_members', 'manage_emojis', 'change_nickname', 'manage_roles', 'view_channels',
26
+ 'send_messages', 'manage_messages', 'attach_files', 'connect_voice', 'manage_voice', 'mention_everyone',
27
+ 'add_reaction', 'follow_reaction', 'passive_connect_voice', 'push_to_talk_only', 'free_voice_speak', 'speak',
28
+ 'deafen_members', 'mute_members', 'manage_nicknames', 'play_music',
29
+ ]);
30
+
31
+ /** KOOK 权限位 → 权限名;未知位丢弃。 */
32
+ export function kookPermissionNames(bits: number): string[] {
33
+ if (!Number.isFinite(bits) || bits <= 0) return [];
34
+ return KOOK_ROLE_PERMISSIONS.filter((_, index) => Math.floor(bits / 2 ** index) % 2 === 1);
35
+ }
36
+
37
+ interface GuildRole {
38
+ readonly name?: string;
39
+ readonly permissions: number;
20
40
  }
21
41
 
22
42
  interface GuildSnapshot {
23
43
  readonly ownerId?: string;
24
- readonly adminRoleIds: ReadonlySet<string>;
44
+ /** `undefined` when the role list lookup failed. */
45
+ readonly roles?: ReadonlyMap<string, GuildRole>;
25
46
  }
26
47
 
27
48
  export class KookSenderRoleResolver {
@@ -33,19 +54,28 @@ export class KookSenderRoleResolver {
33
54
  readonly guildId?: string;
34
55
  readonly authorId: string;
35
56
  readonly authorRoles?: readonly number[];
36
- }): Promise<KookSenderRoleProjection> {
37
- const roleIds = (input.authorRoles ?? []).map(String);
38
- const permissions = roleIds.map((id) => `role:${id}`);
39
- if (!input.guildId) return { permissions };
40
-
57
+ }): Promise<PlatformStanding | undefined> {
58
+ if (!input.guildId) return undefined;
59
+ const roleIds = [...new Set((input.authorRoles ?? []).map(String))];
41
60
  const snapshot = await this.#snapshot(input.guildId);
42
- if (snapshot?.ownerId !== undefined && snapshot.ownerId === input.authorId) {
43
- return { role: 'owner', permissions: [...permissions, 'guild_owner', 'guild_admin'] };
44
- }
45
- if (snapshot && roleIds.some((id) => snapshot.adminRoleIds.has(id))) {
46
- return { role: 'admin', permissions: [...permissions, 'guild_admin'] };
47
- }
48
- return { permissions };
61
+ if (!snapshot) return undefined;
62
+ const held = roleIds.filter((id) => id !== EVERYONE_ROLE_ID).map((id) => ({ id, role: snapshot.roles?.get(id) }));
63
+ const everyone = snapshot.roles?.get(EVERYONE_ROLE_ID)?.permissions ?? 0;
64
+ const bits = held.reduce((sum, { role }) => sum | (role?.permissions ?? 0), everyone);
65
+ const roles = held.map(({ id, role }) => (role?.name ? { id, name: role.name } : { id }));
66
+ const isOwner = snapshot.ownerId !== undefined && snapshot.ownerId === input.authorId;
67
+ const isAdmin = kookPermissionNames(bits).includes('administrator');
68
+ // The server owner and administrators hold every permission (KOOK ignores per-bit grants for them).
69
+ const permissions = isOwner || isAdmin ? [...KOOK_ROLE_PERMISSIONS] : kookPermissionNames(bits);
70
+ // Without the owner id or the role list the standing cannot be decided; an admin role still proves admin.
71
+ const standing = isOwner ? 'owner' : isAdmin ? 'admin'
72
+ : snapshot.ownerId !== undefined && snapshot.roles ? 'member' : undefined;
73
+ if (!standing) return undefined;
74
+ return {
75
+ standing,
76
+ ...(roles.length ? { roles } : {}),
77
+ ...(permissions.length ? { permissions } : {}),
78
+ };
49
79
  }
50
80
 
51
81
  async #snapshot(guildId: string): Promise<GuildSnapshot | undefined> {
@@ -58,16 +88,18 @@ export class KookSenderRoleResolver {
58
88
  const client = this.getClient();
59
89
  if (!client) return undefined;
60
90
  let ownerId: string | undefined;
61
- const adminRoleIds = new Set<string>();
91
+ let roles: Map<string, GuildRole> | undefined;
62
92
  try {
63
93
  const info = await client.getGuildInfo?.(guildId);
64
94
  if (info?.user_id != null && String(info.user_id)) ownerId = String(info.user_id);
65
95
  } catch { /* 保守:视为无群主信息 */ }
66
96
  try {
97
+ roles = new Map();
67
98
  for (const role of await client.pickGuild(guildId).getRoleList()) {
68
- if ((Number(role.permissions) & KOOK_ROLE_ADMIN_BIT) !== 0) adminRoleIds.add(String(role.role_id));
99
+ const name = typeof (role as { name?: unknown }).name === 'string' ? (role as { name: string }).name : undefined;
100
+ roles.set(String(role.role_id), { ...(name ? { name } : {}), permissions: Number(role.permissions) || 0 });
69
101
  }
70
- } catch { /* 保守:视为无管理员角色 */ }
71
- return { ...(ownerId ? { ownerId } : {}), adminRoleIds };
102
+ } catch { roles = undefined; /* 保守:视为无角色信息 */ }
103
+ return { ...(ownerId ? { ownerId } : {}), ...(roles ? { roles } : {}) };
72
104
  }
73
105
  }