@zhin.js/adapter-discord 1.1.26 → 1.1.28

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 (52) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/LICENSE +21 -0
  3. package/PERMITS.md +15 -14
  4. package/README.md +7 -2
  5. package/adapters/discord/index.js +3 -0
  6. package/adapters/discord/index.ts +3 -0
  7. package/agents/discord/conversation.js +70 -0
  8. package/agents/discord/conversation.ts +77 -0
  9. package/agents/discord/messages.js +18 -2
  10. package/agents/discord/messages.ts +18 -2
  11. package/agents/discord/skills/discord/SKILL.md +1 -1
  12. package/agents/discord/skills/discord/tools/react/index.js +7 -6
  13. package/agents/discord/skills/discord/tools/react/index.ts +8 -7
  14. package/agents/discord/skills/discord/tools/send_embed/index.js +6 -11
  15. package/agents/discord/skills/discord/tools/send_embed/index.ts +7 -12
  16. package/agents/discord/skills/discord-guild/tools/add_role/index.js +11 -9
  17. package/agents/discord/skills/discord-guild/tools/add_role/index.ts +11 -10
  18. package/agents/discord/skills/discord-guild/tools/create_thread/index.js +5 -5
  19. package/agents/discord/skills/discord-guild/tools/create_thread/index.ts +6 -6
  20. package/agents/discord/skills/discord-guild/tools/forum_post/index.js +7 -3
  21. package/agents/discord/skills/discord-guild/tools/forum_post/index.ts +7 -4
  22. package/agents/discord/skills/discord-guild/tools/list_roles/index.js +7 -8
  23. package/agents/discord/skills/discord-guild/tools/list_roles/index.ts +8 -9
  24. package/agents/discord/skills/discord-guild/tools/remove_role/index.js +11 -9
  25. package/agents/discord/skills/discord-guild/tools/remove_role/index.ts +11 -10
  26. package/lib/channel-visibility.d.ts +9 -0
  27. package/lib/channel-visibility.js +44 -0
  28. package/lib/endpoint.d.ts +1 -0
  29. package/lib/endpoint.js +76 -12
  30. package/lib/gateway.d.ts +1 -0
  31. package/lib/gateway.js +21 -8
  32. package/lib/index.d.ts +1 -1
  33. package/lib/index.js +1 -1
  34. package/lib/platform-permit.d.ts +4 -11
  35. package/lib/platform-permit.js +7 -40
  36. package/lib/protocol.d.ts +18 -0
  37. package/lib/protocol.js +32 -3
  38. package/lib/sender-standing.d.ts +18 -0
  39. package/lib/sender-standing.js +91 -0
  40. package/lib/webhook.d.ts +2 -6
  41. package/lib/webhook.js +23 -55
  42. package/package.json +23 -25
  43. package/plugin.js +0 -6
  44. package/schema.json +0 -56
  45. package/src/channel-visibility.ts +44 -0
  46. package/src/endpoint.ts +77 -11
  47. package/src/gateway.ts +22 -5
  48. package/src/index.ts +1 -6
  49. package/src/platform-permit.ts +5 -52
  50. package/src/protocol.ts +43 -3
  51. package/src/sender-standing.ts +91 -0
  52. package/src/webhook.ts +21 -58
package/lib/webhook.d.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import { type Endpoint } from 'zhin.js/adapter';
2
- /**
3
- * Discord interactions HTTP: signature → parse → admit.
4
- */
5
- import type { IncomingMessage, ServerResponse } from 'node:http';
6
- import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
2
+ import { type HttpHost, type HttpRouteRegistration } from '@zhin.js/host-http';
7
3
  import { type DiscordInboundMessage, type DiscordButtonInbound, type ResolvedDiscordInteractionsConfig } from './protocol.js';
8
4
  export interface DiscordInteractionsHandler extends Endpoint {
9
5
  readonly config: ResolvedDiscordInteractionsConfig;
@@ -13,4 +9,4 @@ export interface DiscordInteractionsHandler extends Endpoint {
13
9
  admitPlatform(event: Record<string, unknown>): void;
14
10
  }
15
11
  export declare function registerDiscordInteractionRoutes(http: HttpHost, handler: DiscordInteractionsHandler): HttpRouteRegistration[];
16
- export declare function handleDiscordInteractionRequest(request: IncomingMessage, response: ServerResponse, handler: DiscordInteractionsHandler): Promise<void>;
12
+ export declare function handleDiscordInteractionRequest(request: Request, handler: DiscordInteractionsHandler): Promise<Response>;
package/lib/webhook.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { endpointSpec } from 'zhin.js/adapter';
2
+ /**
3
+ * Discord interactions HTTP: signature → parse → admit.
4
+ */
2
5
  import { createMessages } from 'zhin.js/i18n';
6
+ import { readTextBody } from '@zhin.js/host-http';
3
7
  import { getLogger } from '@zhin.js/logger';
4
8
  import { interactionToInboundMessage, verifyDiscordInteractionSignature, } from './protocol.js';
5
9
  const logger = getLogger('discord');
@@ -15,55 +19,44 @@ const INTERACTION_FLAG_EPHEMERAL = 64;
15
19
  export function registerDiscordInteractionRoutes(http, handler) {
16
20
  const path = handler.config.interactionsPath;
17
21
  return [
18
- http.route('POST', path, async (request, response) => {
19
- await handleDiscordInteractionRequest(request, response, handler);
20
- }, { summary: 'Discord interactions callback', tags: ['discord'] }),
22
+ http.route('POST', path, (request) => handleDiscordInteractionRequest(request, handler), { summary: 'Discord interactions callback', tags: ['discord'] }),
21
23
  ];
22
24
  }
23
- export async function handleDiscordInteractionRequest(request, response, handler) {
25
+ export async function handleDiscordInteractionRequest(request, handler) {
24
26
  try {
25
- const signature = headerValue(request.headers['x-signature-ed25519']);
26
- const timestamp = headerValue(request.headers['x-signature-timestamp']);
27
- const rawBody = await readInteractionBody(request);
27
+ const signature = request.headers.get('x-signature-ed25519') ?? '';
28
+ const timestamp = request.headers.get('x-signature-timestamp') ?? '';
29
+ // Oversize bodies (> 1 MiB) fail and answer 500, as before.
30
+ const rawBody = await readTextBody(request);
28
31
  if (!signature || !timestamp) {
29
- response.writeHead(401, { 'Content-Type': 'text/plain' });
30
- response.end('Unauthorized');
31
- return;
32
+ return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
32
33
  }
33
34
  if (!verifyDiscordInteractionSignature(handler.config.publicKey, rawBody, signature, timestamp)) {
34
- response.writeHead(401, { 'Content-Type': 'text/plain' });
35
- response.end('Unauthorized');
36
- return;
35
+ return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
37
36
  }
38
37
  const interaction = JSON.parse(rawBody);
39
38
  if (!interaction || interaction.application_id !== handler.config.applicationId) {
40
- response.writeHead(401, { 'Content-Type': 'text/plain' });
41
- response.end('Unauthorized');
42
- return;
39
+ return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
43
40
  }
44
41
  // A verified protocol challenge is not a business message and keeps its immediate ACK.
45
42
  if (interaction.type === INTERACTION_TYPE_PING) {
46
43
  if (handler.isOpen)
47
44
  void handler[endpointSpec].events.run(() => handler.admitPlatform(interaction))
48
45
  .catch(() => logger.debug('Protocol event dispatch did not complete'));
49
- writeJson(response, 200, { type: INTERACTION_RESPONSE_PONG });
50
- return;
46
+ return writeJson(200, { type: INTERACTION_RESPONSE_PONG });
51
47
  }
52
- await handler[endpointSpec].events.run(async () => {
48
+ return await handler[endpointSpec].events.run(async () => {
53
49
  if (handler.isOpen)
54
50
  handler.admitPlatform(interaction);
55
51
  if (interaction.type === 3) {
56
52
  const data = interaction.data;
57
53
  if (data?.component_type === 2 && typeof data.custom_id === 'string') {
58
54
  if (!handler.isOpen) {
59
- response.writeHead(503);
60
- response.end('Unavailable');
61
- return;
55
+ return new Response('Unavailable', { status: 503 });
62
56
  }
63
57
  const message = interactionToInboundMessage(interaction);
64
- handler.admitButton({ event: interaction, id: message.id, customId: data.custom_id, channelId: message.channelId, channelKind: message.channelKind, userId: message.authorId, userName: message.authorName, ...(message.guildId ? { guildId: message.guildId } : {}), sourceMessageId: interaction.message?.id });
65
- writeJson(response, 200, { type: 6 });
66
- return;
58
+ handler.admitButton({ event: interaction, id: message.id, customId: data.custom_id, channelId: message.channelId, channelKind: message.channelKind, userId: message.authorId, userName: message.authorName, ...(message.guildId ? { guildId: message.guildId } : {}), ...(message.threadParentId ? { threadParentId: message.threadParentId } : {}), sourceMessageId: interaction.message?.id });
59
+ return writeJson(200, { type: 6 });
67
60
  }
68
61
  }
69
62
  if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
@@ -74,47 +67,22 @@ export async function handleDiscordInteractionRequest(request, response, handler
74
67
  const locale = await handler[endpointSpec].resolveLocale(message.authorId);
75
68
  // 即时响应(type 4):defer(type 5) 需要 followup PATCH,未实现会让用户端一直转圈
76
69
  const commandName = String(interaction.data?.name ?? '');
77
- writeJson(response, 200, {
70
+ return writeJson(200, {
78
71
  type: INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE,
79
72
  data: {
80
73
  content: messages(locale, 'processing', { command: commandName }),
81
74
  flags: INTERACTION_FLAG_EPHEMERAL,
82
75
  },
83
76
  });
84
- return;
85
77
  }
86
- response.writeHead(400, { 'Content-Type': 'text/plain' });
87
- response.end('Unsupported interaction type');
78
+ return new Response('Unsupported interaction type', { status: 400, headers: { 'Content-Type': 'text/plain' } });
88
79
  });
89
80
  }
90
81
  catch (error) {
91
82
  logger.error('Discord interactions error:', error);
92
- if (!response.headersSent) {
93
- response.writeHead(500, { 'Content-Type': 'text/plain' });
94
- response.end('Internal Server Error');
95
- }
96
- }
97
- }
98
- function headerValue(value) {
99
- if (Array.isArray(value))
100
- return value[0] ?? '';
101
- return value ?? '';
102
- }
103
- async function readInteractionBody(request) {
104
- const chunks = [];
105
- let size = 0;
106
- for await (const chunk of request) {
107
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
108
- size += buffer.length;
109
- if (size > 1_048_576) {
110
- request.destroy();
111
- throw new Error('Request body exceeds 1MB');
112
- }
113
- chunks.push(buffer);
83
+ return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } });
114
84
  }
115
- return Buffer.concat(chunks).toString('utf8');
116
85
  }
117
- function writeJson(response, status, body) {
118
- response.writeHead(status, { 'Content-Type': 'application/json' });
119
- response.end(JSON.stringify(body));
86
+ function writeJson(status, body) {
87
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
120
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-discord",
3
- "version": "1.1.26",
3
+ "version": "1.1.28",
4
4
  "description": "Zhin.js Discord adapter for Plugin Runtime (Gateway WebSocket)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -35,25 +35,25 @@
35
35
  "@discordjs/ws": "1.2.3",
36
36
  "discord.js": "^14.27.0",
37
37
  "undici": "6.29.1",
38
- "@zhin.js/adapter": "1.1.32",
39
- "@zhin.js/agent-feature": "1.1.16",
40
- "@zhin.js/console-contract": "1.1.5",
41
- "@zhin.js/core": "1.1.60",
42
- "@zhin.js/feature-kit": "1.1.15",
43
- "@zhin.js/host-http": "1.1.17",
44
- "@zhin.js/im-contract": "1.1.13",
45
- "@zhin.js/logger": "1.1.2",
46
- "@zhin.js/page": "1.1.17",
47
- "@zhin.js/permission": "1.1.15",
48
- "@zhin.js/skill": "1.1.16",
49
- "@zhin.js/tool": "1.1.18"
38
+ "zod": "^4.6.7",
39
+ "@zhin.js/adapter": "1.1.33",
40
+ "@zhin.js/agent-feature": "1.1.17",
41
+ "@zhin.js/console-contract": "1.1.6",
42
+ "@zhin.js/core": "1.2.0",
43
+ "@zhin.js/feature-kit": "1.1.16",
44
+ "@zhin.js/host-http": "1.2.0",
45
+ "@zhin.js/im-contract": "1.1.14",
46
+ "@zhin.js/logger": "1.1.3",
47
+ "@zhin.js/page": "1.1.18",
48
+ "@zhin.js/permission": "1.2.0",
49
+ "@zhin.js/skill": "1.1.17",
50
+ "@zhin.js/tool": "1.1.19"
50
51
  },
51
52
  "peerDependencies": {
52
- "zod": "^4.0.0",
53
- "@zhin.js/adapter": "^1.1.32",
54
- "@zhin.js/command": "^1.1.17",
55
- "@zhin.js/core": "^1.1.60",
56
- "zhin.js": "^1.1.25"
53
+ "@zhin.js/adapter": "^1.1.33",
54
+ "@zhin.js/command": "^1.2.0",
55
+ "@zhin.js/core": "^1.2.0",
56
+ "zhin.js": "^1.1.26"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "@zhin.js/command": {
@@ -61,18 +61,16 @@
61
61
  },
62
62
  "zhin.js": {
63
63
  "optional": true
64
- },
65
- "zod": {
66
- "optional": true
67
64
  }
68
65
  },
69
66
  "devDependencies": {
70
67
  "@types/node": "^26.1.2",
68
+ "ajv": "8.18.0",
71
69
  "typescript": "^6.0.3",
72
70
  "vitest": "^4.1.10",
73
- "zod": "^4.4.3",
74
- "@zhin.js/host-http": "1.1.17",
75
- "zhin.js": "1.1.25"
71
+ "ws": "^8.21.1",
72
+ "@zhin.js/host-http": "1.2.0",
73
+ "zhin.js": "1.1.26"
76
74
  },
77
75
  "files": [
78
76
  "adapters",
@@ -131,6 +129,6 @@
131
129
  "scripts": {
132
130
  "build": "tsc",
133
131
  "clean": "rimraf lib",
134
- "test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/discord/tests"
132
+ "test": "vitest run --root ../../.. plugins/adapters/discord/tests"
135
133
  }
136
134
  }
package/plugin.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { definePlugin } from 'zhin.js';
3
- import { permissionHostToken } from '@zhin.js/permission';
4
- import { checkDiscordPlatformPermit } from "./lib/platform-permit.js";
5
3
  import { createDiscordConsoleRpc } from "./lib/console.js";
6
4
  export default definePlugin({
7
5
  name: 'discord',
@@ -10,12 +8,8 @@ export default definePlugin({
10
8
  displayName: 'Discord Gateway Adapter',
11
9
  },
12
10
  setup(context) {
13
- const dispose = context.resources.has(permissionHostToken)
14
- ? context.resources.use(permissionHostToken).registerPlatform('discord', checkDiscordPlatformPermit)
15
- : undefined;
16
11
  return {
17
12
  consoleRpc: createDiscordConsoleRpc(() => context.config.get()),
18
- ...(dispose ? { dispose } : {}),
19
13
  };
20
14
  },
21
15
  });
package/schema.json CHANGED
@@ -71,34 +71,6 @@
71
71
  "default": "/discord/interactions",
72
72
  "description": "POST path on httpHostToken when connection is interactions."
73
73
  },
74
- "master": {
75
- "type": [
76
- "string",
77
- "number"
78
- ],
79
- "description": "框架 master(Discord user snowflake;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖",
80
- "x-i18n": {
81
- "en": {
82
- "description": "Framework masters (Discord user snowflake; AI/tool permissions and endpoint management). endpoints[i].master can override this value."
83
- }
84
- }
85
- },
86
- "trusted": {
87
- "type": "array",
88
- "items": {
89
- "type": [
90
- "string",
91
- "number"
92
- ],
93
- "description": "Trusted Discord user snowflake"
94
- },
95
- "description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加",
96
- "x-i18n": {
97
- "en": {
98
- "description": "Framework trusted users, below master authority. endpoints[i].trusted can add users."
99
- }
100
- }
101
- },
102
74
  "endpoints": {
103
75
  "type": "array",
104
76
  "minItems": 1,
@@ -107,34 +79,6 @@
107
79
  "type": "object",
108
80
  "additionalProperties": false,
109
81
  "properties": {
110
- "master": {
111
- "type": [
112
- "string",
113
- "number"
114
- ],
115
- "description": "本 endpoint 的框架 master(Discord user snowflake);覆盖顶层 master",
116
- "x-i18n": {
117
- "en": {
118
- "description": "Framework masters for this endpoint (Discord user snowflake), overriding the top-level master list."
119
- }
120
- }
121
- },
122
- "trusted": {
123
- "type": "array",
124
- "items": {
125
- "type": [
126
- "string",
127
- "number"
128
- ],
129
- "description": "Trusted Discord user snowflake"
130
- },
131
- "description": "本 endpoint 的 trusted 列表",
132
- "x-i18n": {
133
- "en": {
134
- "description": "Trusted users for this endpoint."
135
- }
136
- }
137
- },
138
82
  "token": {
139
83
  "type": "string",
140
84
  "description": "Discord bot token"
@@ -0,0 +1,44 @@
1
+ import { ChannelFlagsBitField } from 'discord.js';
2
+
3
+ /**
4
+ * Discord 频道混淆(Channel Obfuscation)判定。
5
+ *
6
+ * 自 2026-11-16 起,Gateway 会继续下发机器人缺少 `VIEW_CHANNEL` 的频道,但其元数据被混淆:
7
+ * `name` 变为 `"___hidden___"`,并在 `flags` 上置 `CHANNEL_OBFUSCATED`;HTTP 的
8
+ * `GET /guilds/{guild.id}/channels` 则直接不返回这些频道。
9
+ *
10
+ * 出处:
11
+ * - https://docs.discord.com/developers/resources/channel#channel-object-channel-flags
12
+ * (Channel Flags 表:`CHANNEL_OBFUSCATED` = `1 << 17`)
13
+ * - https://docs.discord.com/developers/resources/channel#channel-object-obfuscated-channels
14
+ * - https://docs.discord.com/developers/change-log (2026-08-12 "Channel Obfuscation for Users and Bots")
15
+ * - discord-api-types `ChannelFlags.ChannelObfuscated`(0.38.54 起,值为 `131072`)。
16
+ * discord.js 14.27.0 把它声明为 dependencies `^0.38.49`,并通过
17
+ * `ChannelFlagsBitField.Flags` 暴露同一枚举。0.38.49–0.38.53 上该枚举是 `undefined`,
18
+ * 因此这里用 `?? (1 << 17)` 兜底,避免过滤悄悄失效。
19
+ *
20
+ * 官方要求以 flag 判定,不要依赖 `name` 等被混淆字段。
21
+ */
22
+ function obfuscatedFlagBit(): number {
23
+ return ChannelFlagsBitField.Flags.ChannelObfuscated ?? (1 << 17);
24
+ }
25
+
26
+ export const DISCORD_CHANNEL_FLAG_OBFUSCATED = obfuscatedFlagBit();
27
+
28
+ type ChannelFlagsLike = number | bigint | { readonly bitfield?: number | bigint } | null | undefined;
29
+
30
+ function readFlagBits(flags: ChannelFlagsLike): number {
31
+ if (flags == null) return 0;
32
+ if (typeof flags === 'number') return flags;
33
+ if (typeof flags === 'bigint') return Number(flags & 0xffffffffn);
34
+ const bitfield = flags.bitfield;
35
+ if (typeof bitfield === 'number') return bitfield;
36
+ if (typeof bitfield === 'bigint') return Number(bitfield & 0xffffffffn);
37
+ return 0;
38
+ }
39
+
40
+ /** 频道是否被 Discord 混淆(机器人无 `VIEW_CHANNEL`)。兼容 discord.js 的 `ChannelFlagsBitField` 与原始 `flags` 数值。 */
41
+ export function isDiscordChannelObfuscated(channel: { readonly flags?: ChannelFlagsLike } | null | undefined): boolean {
42
+ if (!channel) return false;
43
+ return (readFlagBits(channel.flags) & obfuscatedFlagBit()) !== 0;
44
+ }
package/src/endpoint.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createDiscordRestApiAgent } from './rest-api-proxy.js';
2
2
  import { createDiscordSelfProfile, createDiscordRestSelfProfile } from './self-profile.js';
3
3
  import { DiscordRestRequestError } from './rest-request-error.js';
4
+ import { isDiscordChannelObfuscated } from './channel-visibility.js';
4
5
  /**
5
6
  * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
6
7
  */
@@ -27,8 +28,11 @@ import {
27
28
  } from 'zhin.js/adapter';
28
29
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
29
30
  import {
31
+ type ConversationRef,
30
32
  type ConversationReference,
31
33
  type ConversationResolution,
34
+ type PlatformStanding,
35
+ type SelfStandingRequest,
32
36
  } from '@zhin.js/im-contract';
33
37
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
34
38
  import type { CapabilityId } from 'zhin.js';
@@ -36,11 +40,11 @@ import {
36
40
  connectDiscordGatewayClient,
37
41
  discordClientOptions,
38
42
  DEFAULT_INTENTS,
39
- resolveSenderRole,
40
43
  toMessageCreateOptions,
41
44
  type DiscordClientTransport,
42
45
  } from './gateway.js';
43
46
  import {
47
+ discordChannelOf,
44
48
  discordInboundConversation,
45
49
  formatButtonContent,
46
50
  formatButtonSegments,
@@ -56,6 +60,9 @@ import {
56
60
  } from './protocol.js';
57
61
  import { registerDiscordInteractionRoutes } from './webhook.js';
58
62
  import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
63
+ import {
64
+ discordGuildPermissionNamesFromChannelBitfield, discordPermissionNames, discordSenderStanding, discordStandingFromPermissions,
65
+ } from './sender-standing.js';
59
66
 
60
67
  const DISCORD_API = 'https://discord.com/api/v10';
61
68
  /** 出站 HTTP 调用统一 30s 超时。 */
@@ -201,11 +208,11 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
201
208
 
202
209
  async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
203
210
  const body = formatOutboundBody(payload);
204
- const snowflake = await this.#sendBody({ channelId: conversation.id, body, signal });
211
+ const snowflake = await this.#sendBody({ channelId: discordChannelOf(conversation), body, signal });
205
212
  this.#logger.debug(formatCompact({
206
213
  op: 'discord_send',
207
214
  endpoint: this.#options.config.id,
208
- target: conversation.id,
215
+ target: discordChannelOf(conversation),
209
216
  messageId: snowflake,
210
217
  }));
211
218
  return snowflake;
@@ -214,7 +221,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
214
221
  async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
215
222
  if (!message.id) return;
216
223
  const operation = createEndpointOperation({ signal });
217
- const channel = await operation.prepare(() => this.#requireClient().channels.fetch(message.conversation.id));
224
+ const channel = await operation.prepare(() => this.#requireClient().channels.fetch(discordChannelOf(message.conversation)));
218
225
  if (!channel?.isTextBased() || !channel.messages) return;
219
226
  const msg = await operation.prepare(() => channel.messages.fetch(message.id));
220
227
  await operation.run(() => msg.delete());
@@ -225,6 +232,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
225
232
  if (!this.#open) return;
226
233
  if (msg.authorBot) return;
227
234
  const conversation = discordInboundConversation(String(this.#options.id), msg);
235
+ const platform = discordSenderStanding(msg);
228
236
  void this[endpointSpec].events.emit('message.receive', {
229
237
  conversation,
230
238
  event: msg.event,
@@ -235,7 +243,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
235
243
  sender: {
236
244
  id: msg.authorId,
237
245
  name: senderDisplayName(msg) || undefined,
238
- ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)!] } : {}),
246
+ ...(platform ? { platform } : {}),
239
247
  },
240
248
  endpointId: this.#options.config.id,
241
249
  ...(msg.mentionedBot ? { mentioned: true } : {}),
@@ -245,7 +253,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
245
253
  userId: msg.authorId,
246
254
  guildId: msg.guildId,
247
255
  permissions: msg.permissionTokens,
248
- role: resolveSenderRole(msg),
256
+ role: platform?.standing,
249
257
  }),
250
258
  }).catch((err) => {
251
259
  this.#logger.warn(formatCompact({
@@ -284,7 +292,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
284
292
  }
285
293
 
286
294
  async #addReaction({ message, emoji, hint, signal }: EndpointReactionRequest): Promise<string | null> {
287
- const channelId = hint?.channelId ?? message.conversation.id;
295
+ const channelId = hint?.channelId ?? discordChannelOf(message.conversation);
288
296
  if (!channelId || !message.id) return null;
289
297
  const operation = createEndpointOperation({ signal });
290
298
  const channel = await operation.prepare(() => this.#requireClient().channels.fetch(channelId));
@@ -405,6 +413,9 @@ export class DiscordRestClient {
405
413
  }
406
414
  }
407
415
 
416
+ const APP_PERMISSIONS_TTL_MS = 10 * 60_000;
417
+ const APP_PERMISSIONS_MAX = 1_000;
418
+
408
419
  export class DiscordInteractionsEndpoint extends DiscordRestClient implements Endpoint {
409
420
  readonly [endpointSpec]: EndpointSpecification;
410
421
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
@@ -414,6 +425,8 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
414
425
  #routeReleases: HttpRouteRegistration[] = [];
415
426
  #open = false;
416
427
  #started = false;
428
+ /** Latest interaction `app_permissions` per guild (guild-level names), for the Bot's own standing. */
429
+ readonly #appPermissions = new Map<string, { readonly names: readonly string[]; readonly at: number }>();
417
430
  readonly #control: EndpointControl = Object.freeze<EndpointControl>({
418
431
  recall: request => this.#recallMessage(request),
419
432
  });
@@ -430,6 +443,12 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
430
443
  this[endpointSpec] = createEndpointSpecification(this, {
431
444
  start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
432
445
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
446
+ // Interactions mode has no gateway session to read the bot's guild member: its own standing
447
+ // comes from the latest interaction's `app_permissions` in that guild (ADR 0056 §1), reduced
448
+ // to guild-level names like senders; without a recent interaction it stays unknown.
449
+ management: Object.freeze<EndpointManagement>({
450
+ inspectSelfStanding: async ({ conversation }) => this.#selfStandingFromInteractions(conversation),
451
+ }),
433
452
  control: this.#control, content: this.#content,
434
453
  profile: createDiscordRestSelfProfile(this, id => this[endpointSpec].reportSelfUserId(id)),
435
454
  });
@@ -472,18 +491,40 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
472
491
 
473
492
  async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
474
493
  const body = formatOutboundBody(payload);
475
- const data = await this.createMessage({ channelId: conversation.id, body, signal });
494
+ const data = await this.createMessage({ channelId: discordChannelOf(conversation), body, signal });
476
495
  return data.id ?? '';
477
496
  }
478
497
 
479
498
  async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
480
499
  if (!message.id) return;
481
- await this.deleteMessage({ channelId: message.conversation.id, messageId: message.id, signal });
500
+ await this.deleteMessage({ channelId: discordChannelOf(message.conversation), messageId: message.id, signal });
501
+ }
502
+
503
+ #selfStandingFromInteractions(conversation: Pick<ConversationRef, 'kind' | 'parent'>): PlatformStanding | undefined {
504
+ const guildId = conversation.kind === 'channel' && conversation.parent?.kind === 'channel' ? conversation.parent.id : undefined;
505
+ const entry = guildId ? this.#appPermissions.get(guildId) : undefined;
506
+ if (!entry || Date.now() - entry.at > APP_PERMISSIONS_TTL_MS) return undefined;
507
+ // The guild owner is not visible to an interaction; `administrator` still reads as `admin`.
508
+ return { standing: discordStandingFromPermissions(entry.names), ...(entry.names.length ? { permissions: entry.names } : {}) };
509
+ }
510
+
511
+ #recordAppPermissions(msg: DiscordInboundMessage): void {
512
+ const appPermissions = (msg.event as { app_permissions?: unknown } | undefined)?.app_permissions;
513
+ if (!msg.guildId || appPermissions === undefined) return;
514
+ this.#appPermissions.delete(msg.guildId);
515
+ this.#appPermissions.set(msg.guildId, { names: discordGuildPermissionNamesFromChannelBitfield(appPermissions), at: Date.now() });
516
+ while (this.#appPermissions.size > APP_PERMISSIONS_MAX) {
517
+ const oldest = this.#appPermissions.keys().next().value;
518
+ if (oldest === undefined) break;
519
+ this.#appPermissions.delete(oldest);
520
+ }
482
521
  }
483
522
 
484
523
  admit(msg: DiscordInboundMessage): void {
485
524
  if (!this.#open) return;
525
+ this.#recordAppPermissions(msg);
486
526
  const conversation = discordInboundConversation(String(this.#options.id), msg);
527
+ const platform = discordSenderStanding(msg);
487
528
  void this[endpointSpec].events.emit('message.receive', {
488
529
  conversation,
489
530
  event: msg.event,
@@ -494,7 +535,7 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
494
535
  sender: {
495
536
  id: msg.authorId,
496
537
  name: senderDisplayName(msg) || undefined,
497
- ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)!] } : {}),
538
+ ...(platform ? { platform } : {}),
498
539
  },
499
540
  endpointId: this.#options.config.id,
500
541
  metadata: Object.freeze({
@@ -569,7 +610,7 @@ async function resolveDiscordContent(
569
610
  try {
570
611
  context.signal.throwIfAborted();
571
612
  const response = await fetch(
572
- `${DISCORD_API}/channels/${reference.message.conversation.id}/messages/${reference.message.id}`,
613
+ `${DISCORD_API}/channels/${discordChannelOf(reference.message.conversation)}/messages/${reference.message.id}`,
573
614
  { headers: { Authorization: `Bot ${token}` }, signal: context.signal },
574
615
  );
575
616
  if (response.status === 404) return Object.freeze({ status: 'not_found', code: 'discord_message_not_found' });
@@ -606,11 +647,34 @@ async function resolveDiscordContent(
606
647
  /**
607
648
  * DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
608
649
  * 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
650
+ * listChannels 会过滤带 CHANNEL_OBFUSCATED flag 的混淆频道(见 channel-visibility.ts)。
609
651
  */
610
652
  export function createDiscordEndpointManagement(
611
653
  requireClient: () => DiscordClientTransport,
612
654
  ): EndpointManagement {
613
655
  return Object.freeze<EndpointManagement>({
656
+ /**
657
+ * ADR 0056 §1: the Bot's own guild member in the conversation's guild — guild owner → `owner`;
658
+ * `administrator` / `moderate_members` → `admin`; roles and **guild-level** permission names
659
+ * (`GuildMember.permissions`, as for senders) so `bot-permission:*` and `platform-permission:*`
660
+ * read the same scope. Channel overwrites are left to the platform at execution time.
661
+ */
662
+ async inspectSelfStanding({ conversation, signal }: SelfStandingRequest): Promise<PlatformStanding | undefined> {
663
+ if (conversation.kind === 'private') return undefined;
664
+ const operation = createEndpointOperation({ signal });
665
+ const client = requireClient();
666
+ const channel = await operation.run(() => client.channels.fetch(conversation.threadId ?? conversation.id));
667
+ if (!channel || !('guild' in channel) || !client.user) return undefined;
668
+ const guild = channel.guild;
669
+ const me = guild.members.me ?? await operation.run(() => guild.members.fetchMe());
670
+ const permissions = me.permissions;
671
+ const names = permissions ? discordPermissionNames(bit => permissions.has(bit)) : [];
672
+ const roles = [...me.roles.cache.values()]
673
+ .filter(role => role.id !== guild.id)
674
+ .map(role => (role.name ? { id: String(role.id), name: String(role.name) } : { id: String(role.id) }));
675
+ const standing = discordStandingFromPermissions(names, guild.ownerId === client.user.id);
676
+ return { standing, ...(roles.length ? { roles } : {}), ...(names.length ? { permissions: names } : {}) };
677
+ },
614
678
  async listGroups(request: EndpointManagementRequest = {}): Promise<readonly EndpointGroup[]> {
615
679
  return createEndpointOperation(request).run(() => {
616
680
  const groups: EndpointGroup[] = [];
@@ -634,6 +698,8 @@ export function createDiscordEndpointManagement(
634
698
  for (const channel of guild.channels?.cache?.values() ?? []) {
635
699
  if (!channel?.id) continue;
636
700
  if (channel.type !== ChannelType.GuildText) continue;
701
+ // 机器人无 VIEW_CHANNEL 的频道会被 Gateway 混淆下发(HTTP 列表已不返回),默认过滤以保持两条路径一致。
702
+ if (isDiscordChannelObfuscated(channel)) continue;
637
703
  channels.push({
638
704
  id: String(channel.id),
639
705
  name: channel.name ? String(channel.name) : undefined,