@zhin.js/adapter-discord 5.0.3 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # @zhin.js/adapter-discord
2
2
 
3
+ ## 6.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
8
+
9
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
10
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
11
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
12
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
13
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
14
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
15
+
16
+ - 74b035c: endpoint 管理命令扩展至 18/20 适配器:kook / discord / github(private_key 支持内联文件路径)/ icqq(bindFlow 登记式 add + `icqq login` 引导)/ dingtalk / lark / line / satori / wechat-mp / wecom / weixin-ilink 接入 `endpoint list/add/remove`(字段对齐各自 schema,凭据写 `.env`)。email(smtp/imap 嵌套对象)与 sandbox(无凭据)暂不接。
17
+ - 09d4f25: Console 社交读取面(management 语义端口)多平台落地:napcat/onebot11/onebot12/milky(好友+群+群成员,OneBot 标准动作);discord/kook/satori(guild+频道+成员,分页聚合,id 保精度留字符串);slack(workspace 成员+public channels+conversations.members);line(群/room 成员分页+profile 回退);wechat-mp(followers openid);weixin-ilink(context_token 对端推导);lark(chats+members 全分页)。`EndpointFriend.user_id`/`EndpointGroup.group_id` 放宽为 `number | string`(雪花 id 不丢精度)。telegram/wecom/dingtalk/github/email/sandbox 注明平台无列表面暂不接。
18
+ - 6cb6152: 统一消息元素通道(UNI-Channel)落地:
19
+
20
+ - **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
21
+ - **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
22
+ - **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
23
+ - **首批迁移**:icqq 全保真出入站(CQ ↔ canonical,quote→reply 段);milky/telegram/discord 入站媒体段恢复(附件/贴纸/callback action);napcat/onebot11/onebot12 出站 canonical→OneBot 数组段;wechat-mp/wecom `/cgi-bin/media/upload` 与 lark `/im/v1/images` 上传通路(base64/URL 图片不再静默丢图,失败降级文本)。
24
+
25
+ - Updated dependencies [cdf64e7]
26
+ - Updated dependencies [2d0a159]
27
+ - Updated dependencies [5691aba]
28
+ - Updated dependencies [078e3f7]
29
+ - Updated dependencies [50497a5]
30
+ - Updated dependencies [9c997b2]
31
+ - Updated dependencies [09d4f25]
32
+ - Updated dependencies [43485a9]
33
+ - Updated dependencies [f0ec5ab]
34
+ - Updated dependencies [3e925d0]
35
+ - Updated dependencies [fa66c4c]
36
+ - Updated dependencies [fa66c4c]
37
+ - Updated dependencies [6cb6152]
38
+ - @zhin.js/command@1.0.3
39
+ - @zhin.js/agent@1.0.6
40
+ - @zhin.js/plugin-runtime@1.1.1
41
+ - @zhin.js/host-http@1.0.3
42
+ - zhin.js@5.0.1
43
+ - @zhin.js/adapter@1.1.1
44
+ - @zhin.js/core@1.4.1
45
+
46
+ ## 6.0.0
47
+
48
+ ### Patch Changes
49
+
50
+ - 7db69c1: 命令前缀改为适配器配置项:`MessageDispatcher` 不再硬编码 `/`,默认按消息所属适配器实例 config 的 `commandPrefix` 解析(默认 `''` 无前缀,任意文本按命令匹配),`endpoints[i].commandPrefix` 逐项覆盖;`ImRuntime({ commandPrefix })` 仍可设全局静态前缀。全部 20 个平台适配器 schema 新增 `commandPrefix` 属性。
51
+
52
+ BREAKING(行为变化):未配置时命令不再需要 `/` 前缀——原 `/zt` 写法不再命中,直接发 `zt` 即可;需要斜杠风格的适配器请在配置里显式设 `commandPrefix: '/'`。
53
+
54
+ - 713445c: 适配器配置格式定稿(不兼容旧格式):`plugins.<adapter>` 顶层仅共享字段 + `commandPrefix`,`endpoints[i]` 携带 endpoint 级字段(`name` + 凭据,各 schema 已类型化),`endpoints` 为必填(icqq 另需顶层 `master`);icqq 新增 `trusted` 列表(顶层/逐项均可)。scaffold-wizard 全部字段式与自定义 configure() 产出改为新格式,examples(full-bot / qq-games-bot)与 20 个适配器 README 同步迁移。
55
+ - Updated dependencies [7db69c1]
56
+ - Updated dependencies [e5c84ed]
57
+ - Updated dependencies [3ea84a0]
58
+ - Updated dependencies [1ddcd70]
59
+ - Updated dependencies [ac9da66]
60
+ - @zhin.js/core@1.4.0
61
+ - @zhin.js/adapter@1.1.0
62
+ - @zhin.js/plugin-runtime@1.1.0
63
+ - @zhin.js/agent@1.0.5
64
+ - @zhin.js/host-http@1.0.2
65
+ - zhin.js@5.0.0
66
+
3
67
  ## 5.0.3
4
68
 
5
69
  ### Patch Changes
package/README.md CHANGED
@@ -42,7 +42,7 @@ pnpm add @zhin.js/adapter-discord discord.js
42
42
  | **Gateway(默认)** | 本地/生产均可;discord.js 连接 Gateway,无需公网 HTTPS |
43
43
  | **host-http** | Gateway **不需要**;Interactions webhook 延期至下一棒 |
44
44
 
45
- 必填字段:`token`。
45
+ 必填字段(`endpoints[i]`):`name`、`token`。
46
46
 
47
47
  ## 最小配置
48
48
 
@@ -50,9 +50,10 @@ pnpm add @zhin.js/adapter-discord discord.js
50
50
  # zhin.config.yml(Plugin Runtime)
51
51
  plugins:
52
52
  discord:
53
- name: my-discord-bot
54
- token: ${DISCORD_BOT_TOKEN}
55
53
  # connection: gateway # 默认
54
+ endpoints:
55
+ - name: my-discord-bot
56
+ token: ${DISCORD_BOT_TOKEN}
56
57
  ```
57
58
 
58
59
  根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-discord`(`instanceKey: discord`)。
@@ -12,6 +12,7 @@ import {
12
12
  resolveDiscordConfig,
13
13
  type DiscordAdapterConfig,
14
14
  } from '../src/protocol.js';
15
+ import { discordRuntimeStateToken } from '../src/discord-runtime-state.js';
15
16
 
16
17
  export {
17
18
  DiscordGatewayEndpoint,
@@ -26,9 +27,19 @@ export type {
26
27
 
27
28
  export default defineAdapter<DiscordAdapterConfig>({
28
29
  capabilities: ['inbound', 'outbound'],
30
+ // 媒体 url 直发或由 AttachmentBuilder 物化本地文件上传;message components 原生按钮承载交互段。
31
+ segments: {
32
+ outboundMedia: ['url', 'upload'],
33
+ interactive: 'native',
34
+ },
29
35
  create(context) {
30
36
  const config = resolveDiscordConfig(context.config);
31
37
  const gateway = context.use(messageGatewayToken);
38
+ // 注册到插件运行时状态(discord endpoint list 的"运行中"数据源)
39
+ context.use(discordRuntimeStateToken).endpoints.set(config.name, {
40
+ name: config.name,
41
+ mode: config.connection,
42
+ });
32
43
  if (config.connection === 'interactions') {
33
44
  return new DiscordInteractionsEndpoint({
34
45
  id: context.id,
@@ -0,0 +1,3 @@
1
+ import { discordEndpointCommands } from '../../../src/discord-endpoint-commands.js';
2
+
3
+ export default discordEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { discordEndpointCommands } from '../../src/discord-endpoint-commands.js';
2
+
3
+ export default discordEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { discordEndpointCommands } from '../../../src/discord-endpoint-commands.js';
2
+
3
+ export default discordEndpointCommands.remove;
@@ -0,0 +1 @@
1
+ export declare const discordEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `discord endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ * 注:applicationId / publicKey 为顶层共享字段(slash commands / interactions 用),
5
+ * endpoints[i] 仅需 name + token(见 schema.json),故 add 只录入 token。
6
+ */
7
+ import { createEndpointCommands } from '@zhin.js/adapter';
8
+ import { defineCommand } from '@zhin.js/command';
9
+ import { discordRuntimeStateToken } from './discord-runtime-state.js';
10
+ export const discordEndpointCommands = createEndpointCommands({
11
+ adapterKey: 'discord',
12
+ adapterDisplayName: 'Discord',
13
+ fields: [
14
+ { key: 'token', required: true, env: true, description: 'Discord bot token' },
15
+ ],
16
+ running: (use) => use(discordRuntimeStateToken).endpoints.values(),
17
+ describeEntry: (entry) => `token: ${String(entry.token)}`,
18
+ }, defineCommand);
@@ -0,0 +1 @@
1
+ export declare const discordRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Discord 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `discord endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+ export const discordRuntimeStateToken = defineEndpointRuntimeStateToken('discord');
package/lib/endpoint.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { EndpointInstance } from '@zhin.js/adapter';
1
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
2
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
5
- import { type CreateDiscordClient } from './gateway.js';
5
+ import { type CreateDiscordClient, type DiscordClientTransport } from './gateway.js';
6
6
  import { type DiscordButtonInbound, type DiscordInboundMessage, type ResolvedDiscordGatewayConfig, type ResolvedDiscordInteractionsConfig } from './protocol.js';
7
7
  export type { CreateDiscordClient, DiscordClientTransport, } from './gateway.js';
8
8
  export interface DiscordEndpointOptions {
@@ -13,6 +13,7 @@ export interface DiscordEndpointOptions {
13
13
  }
14
14
  export declare class DiscordGatewayEndpoint implements EndpointInstance {
15
15
  #private;
16
+ readonly management: EndpointManagement;
16
17
  constructor(options: DiscordEndpointOptions);
17
18
  start(): Promise<void>;
18
19
  open(): void;
@@ -69,3 +70,11 @@ export declare class DiscordInteractionsEndpoint implements EndpointInstance {
69
70
  }): Promise<string>;
70
71
  admit(msg: DiscordInboundMessage): void;
71
72
  }
73
+ /**
74
+ * DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
75
+ * 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
76
+ */
77
+ export declare function createDiscordEndpointManagement(endpoint: {
78
+ getClient(): DiscordClientTransport;
79
+ getMembers(guildId: string): Promise<unknown[]>;
80
+ }): EndpointManagement;
package/lib/endpoint.js CHANGED
@@ -5,9 +5,11 @@ import { ChannelType } from 'discord.js';
5
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
6
6
  import { registerDiscordAgentEndpoint } from './discord-agent-deps.js';
7
7
  import { connectDiscordGatewayClient, defaultCreateClient, DEFAULT_INTENTS, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
8
- import { formatButtonContent, formatInboundContent, formatOutboundBody, senderDisplayName, } from './protocol.js';
8
+ import { formatButtonContent, formatButtonSegments, formatInboundContent, formatInboundSegments, formatOutboundBody, senderDisplayName, } from './protocol.js';
9
9
  import { registerDiscordInteractionRoutes } from './webhook.js';
10
10
  const DISCORD_API = 'https://discord.com/api/v10';
11
+ /** 出站 HTTP 调用统一 30s 超时。 */
12
+ const OUTBOUND_TIMEOUT_MS = 30_000;
11
13
  const logger = getLogger('discord');
12
14
  export class DiscordGatewayEndpoint {
13
15
  #options;
@@ -16,7 +18,10 @@ export class DiscordGatewayEndpoint {
16
18
  #open = false;
17
19
  #started = false;
18
20
  #unregisterAgent;
19
- #messageChannelMap = new Map();
21
+ management = createDiscordEndpointManagement({
22
+ getClient: () => this.#requireClient(),
23
+ getMembers: (guildId) => this.getMembers(guildId),
24
+ });
20
25
  constructor(options) {
21
26
  this.#options = options;
22
27
  this.#createClient = options.createClient ?? defaultCreateClient;
@@ -74,7 +79,6 @@ export class DiscordGatewayEndpoint {
74
79
  async send({ target, payload }) {
75
80
  const body = formatOutboundBody(payload);
76
81
  const messageId = await this.#sendBody(target, body);
77
- this.#messageChannelMap.set(messageId, target);
78
82
  logger.debug(formatCompact({
79
83
  op: 'discord_send',
80
84
  endpoint: this.#options.config.name,
@@ -89,11 +93,11 @@ export class DiscordGatewayEndpoint {
89
93
  return;
90
94
  if (msg.authorBot)
91
95
  return;
92
- this.#messageChannelMap.set(msg.id, msg.channelId);
93
96
  void this.#options.gateway.receive({
94
97
  adapter: this.#options.id,
95
98
  target: msg.channelId,
96
99
  content: formatInboundContent(msg),
100
+ segments: formatInboundSegments(msg),
97
101
  sender: senderDisplayName(msg),
98
102
  id: msg.id,
99
103
  metadata: Object.freeze({
@@ -121,6 +125,7 @@ export class DiscordGatewayEndpoint {
121
125
  adapter: this.#options.id,
122
126
  target: interaction.channelId,
123
127
  content: formatButtonContent(interaction),
128
+ segments: formatButtonSegments(interaction),
124
129
  sender: interaction.userName,
125
130
  id: interaction.id,
126
131
  metadata: Object.freeze({
@@ -320,6 +325,7 @@ export class DiscordInteractionsEndpoint {
320
325
  'Content-Type': 'application/json',
321
326
  },
322
327
  body: JSON.stringify(body),
328
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
323
329
  });
324
330
  const text = await response.text();
325
331
  if (!response.ok) {
@@ -335,6 +341,7 @@ export class DiscordInteractionsEndpoint {
335
341
  adapter: this.#options.id,
336
342
  target: msg.channelId,
337
343
  content: formatInboundContent(msg),
344
+ segments: formatInboundSegments(msg),
338
345
  sender: senderDisplayName(msg),
339
346
  id: msg.id,
340
347
  metadata: Object.freeze({
@@ -353,3 +360,56 @@ export class DiscordInteractionsEndpoint {
353
360
  });
354
361
  }
355
362
  }
363
+ /**
364
+ * Discord snowflake 是 64 位整数的字符串形式,超出 Number.MAX_SAFE_INTEGER,
365
+ * Number() 转换会丢精度。Console 社交面只把 group_id 当 JSON 值透传、
366
+ * 并以字符串回传给 listGroupMembers,因此这里保留原始字符串(仅按契约
367
+ * 类型声明强转),是全链路最不丢信息的方案。
368
+ */
369
+ function toGroupId(id) {
370
+ return id;
371
+ }
372
+ /**
373
+ * DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
374
+ * 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
375
+ */
376
+ export function createDiscordEndpointManagement(endpoint) {
377
+ return Object.freeze({
378
+ async listGroups() {
379
+ const groups = [];
380
+ for (const guild of endpoint.getClient().guilds.cache.values()) {
381
+ if (!guild?.id)
382
+ continue;
383
+ groups.push({
384
+ group_id: toGroupId(String(guild.id)),
385
+ name: String(guild.name ?? guild.id),
386
+ });
387
+ }
388
+ return groups;
389
+ },
390
+ async listChannels() {
391
+ const channels = [];
392
+ for (const guild of endpoint.getClient().guilds.cache.values()) {
393
+ if (!guild?.id)
394
+ continue;
395
+ const guildId = String(guild.id);
396
+ const guildName = String(guild.name ?? guildId);
397
+ for (const channel of guild.channels?.cache?.values() ?? []) {
398
+ if (!channel?.id)
399
+ continue;
400
+ if (channel.type !== ChannelType.GuildText)
401
+ continue;
402
+ channels.push({
403
+ id: String(channel.id),
404
+ name: channel.name ? String(channel.name) : undefined,
405
+ parent: { type: 'guild', id: guildId, name: guildName },
406
+ });
407
+ }
408
+ }
409
+ return channels;
410
+ },
411
+ async listGroupMembers(groupId) {
412
+ return endpoint.getMembers(groupId);
413
+ },
414
+ });
415
+ }
package/lib/gateway.d.ts CHANGED
@@ -106,6 +106,16 @@ export interface DiscordClientTransport {
106
106
  cache: {
107
107
  values(): IterableIterator<{
108
108
  id: string;
109
+ name: string;
110
+ channels: {
111
+ cache: {
112
+ values(): IterableIterator<{
113
+ id: string;
114
+ name?: string;
115
+ type: number;
116
+ }>;
117
+ };
118
+ };
109
119
  }>;
110
120
  };
111
121
  };
package/lib/gateway.js CHANGED
@@ -140,7 +140,7 @@ export async function toMessageCreateOptions(body) {
140
140
  }
141
141
  return options;
142
142
  }
143
- async function registerSlashCommands(config, applicationId) {
143
+ async function registerSlashCommands(config, applicationId, guildIds = []) {
144
144
  if (!config.slashCommands?.length)
145
145
  return;
146
146
  const rest = new REST({ version: '10' }).setToken(config.token);
@@ -149,7 +149,23 @@ async function registerSlashCommands(config, applicationId) {
149
149
  body: config.slashCommands,
150
150
  });
151
151
  logger.info(formatCompact({ op: 'slash_commands', scope: 'global' }));
152
+ return;
153
+ }
154
+ // globalCommands=false 时按 guild 注册,否则 enableSlashCommands 会是静默 no-op
155
+ if (guildIds.length === 0) {
156
+ logger.warn(formatCompact({
157
+ op: 'slash_commands',
158
+ ok: false,
159
+ error: 'enableSlashCommands=true but globalCommands=false and no guilds cached; slash commands not registered',
160
+ }));
161
+ return;
152
162
  }
163
+ for (const guildId of guildIds) {
164
+ await rest.put(Routes.applicationGuildCommands(applicationId, guildId), {
165
+ body: config.slashCommands,
166
+ });
167
+ }
168
+ logger.info(formatCompact({ op: 'slash_commands', scope: 'guild', guilds: guildIds.length }));
153
169
  }
154
170
  async function fileExists(filePath) {
155
171
  try {
@@ -200,7 +216,8 @@ export async function connectDiscordGatewayClient(client, config, handlers) {
200
216
  });
201
217
  }
202
218
  if (config.enableSlashCommands && config.slashCommands?.length && client.user) {
203
- await registerSlashCommands(config, client.user.id);
219
+ const guildIds = [...client.guilds.cache.values()].map((guild) => guild.id);
220
+ await registerSlashCommands(config, client.user.id, guildIds);
204
221
  }
205
222
  if (!settled) {
206
223
  settled = true;
@@ -228,7 +245,11 @@ export async function connectDiscordGatewayClient(client, config, handlers) {
228
245
  client.login(config.token).catch((error) => {
229
246
  if (!settled) {
230
247
  settled = true;
231
- reject(error instanceof Error ? error : new Error(String(error)));
248
+ const raw = error instanceof Error ? error.message : String(error);
249
+ const tokenShapeHint = config.token.split('.').length === 3
250
+ ? ''
251
+ : ';当前 token 不是标准三段式 Bot Token 格式,请到 Discord Developer Portal → Bot → Reset Token 重新获取(勿用 Client Secret)';
252
+ reject(new Error(`Discord 连接失败:请检查 token 是否为 Bot Token 且未过期、Bot 是否已开启所需 Intents${tokenShapeHint}(原始错误:${raw})`, { cause: error instanceof Error ? error : undefined }));
232
253
  }
233
254
  });
234
255
  });
@@ -38,7 +38,7 @@ export function checkDiscordPlatformPermit(perm, message) {
38
38
  case 'guild_owner':
39
39
  return role === 'owner' || has('guild_owner');
40
40
  case 'moderate_members':
41
- return role === 'owner' || has('ADMINISTRATOR') || has('MODERATE_MEMBERS');
41
+ return role === 'owner' || role === 'admin' || has('ADMINISTRATOR') || has('MODERATE_MEMBERS');
42
42
  case 'manage_roles':
43
43
  return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_ROLES');
44
44
  case 'manage_channels':
package/lib/protocol.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Discord Gateway protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
+ import type { Segment } from '@zhin.js/core/runtime';
5
6
  /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
6
7
  export interface DiscordAdapterConfig {
7
8
  readonly name?: string;
@@ -124,12 +125,26 @@ export declare function senderDisplayName(msg: DiscordInboundMessage): string;
124
125
  /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
125
126
  export declare function formatInboundContent(msg: DiscordInboundMessage): string;
126
127
  export declare function formatButtonContent(interaction: DiscordButtonInbound): string;
128
+ /**
129
+ * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
130
+ * 附件 url 是 Discord CDN 真实 http(s) 地址,MediaRef kind=url;
131
+ * embeds / stickers 仅留在纯文本视图,不进段(最小侵入)。
132
+ */
133
+ export declare function formatInboundSegments(msg: DiscordInboundMessage): Segment[];
134
+ /**
135
+ * button interaction → action 段(Wave 1 C interactive 约定:
136
+ * {type:'action', data:{id, payload, sourceMessageId?}}),
137
+ * 与 formatButtonContent / metadata.payload 同源。
138
+ */
139
+ export declare function formatButtonSegments(interaction: DiscordButtonInbound): Segment[];
127
140
  /**
128
141
  * Wire-encode an already-rendered outbound payload into Discord message body.
129
142
  * Segment canonicalization is intentionally not done here.
130
143
  */
131
144
  export declare function formatOutboundBody(payload: unknown): DiscordOutboundBody;
132
145
  export declare function activityTypeCode(type: NonNullable<DiscordAdapterConfig['defaultActivity']>['type']): number;
133
- export declare function verifyDiscordInteractionSignature(publicKeyHex: string, body: string, signature: string, timestamp: string): boolean;
146
+ /** interactions 验签时间戳时效窗口(防重放)。 */
147
+ export declare const DISCORD_SIGNATURE_MAX_AGE_MS: number;
148
+ export declare function verifyDiscordInteractionSignature(publicKeyHex: string, body: string, signature: string, timestamp: string, nowMs?: number): boolean;
134
149
  export declare function formatSlashCommandContent(interaction: Record<string, unknown>): string;
135
150
  export declare function interactionToInboundMessage(interaction: Record<string, unknown>): DiscordInboundMessage;
package/lib/protocol.js CHANGED
@@ -89,6 +89,67 @@ export function formatInboundContent(msg) {
89
89
  export function formatButtonContent(interaction) {
90
90
  return `[action: ${interaction.customId}]`;
91
91
  }
92
+ function attachmentMediaKind(contentType) {
93
+ if (contentType?.startsWith('image/'))
94
+ return 'image';
95
+ if (contentType?.startsWith('audio/'))
96
+ return 'audio';
97
+ if (contentType?.startsWith('video/'))
98
+ return 'video';
99
+ return 'file';
100
+ }
101
+ /**
102
+ * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
103
+ * 附件 url 是 Discord CDN 真实 http(s) 地址,MediaRef kind=url;
104
+ * embeds / stickers 仅留在纯文本视图,不进段(最小侵入)。
105
+ */
106
+ export function formatInboundSegments(msg) {
107
+ const out = [];
108
+ if (msg.replyToId) {
109
+ out.push({ type: 'reply', data: { message_id: msg.replyToId } });
110
+ }
111
+ const text = msg.content?.trim();
112
+ if (text)
113
+ out.push({ type: 'text', data: { text } });
114
+ for (const attachment of msg.attachments ?? []) {
115
+ if (!attachment.url)
116
+ continue;
117
+ const kind = attachmentMediaKind(attachment.contentType);
118
+ out.push({
119
+ type: kind,
120
+ data: {
121
+ media: {
122
+ kind: 'url',
123
+ value: attachment.url,
124
+ ...(attachment.contentType ? { mime_type: attachment.contentType } : {}),
125
+ },
126
+ ...(attachment.name
127
+ ? kind === 'image'
128
+ ? { alt: attachment.name }
129
+ : { name: attachment.name }
130
+ : {}),
131
+ },
132
+ });
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * button interaction → action 段(Wave 1 C interactive 约定:
138
+ * {type:'action', data:{id, payload, sourceMessageId?}}),
139
+ * 与 formatButtonContent / metadata.payload 同源。
140
+ */
141
+ export function formatButtonSegments(interaction) {
142
+ return [{
143
+ type: 'action',
144
+ data: {
145
+ id: interaction.id,
146
+ payload: interaction.customId,
147
+ ...(interaction.sourceMessageId
148
+ ? { sourceMessageId: interaction.sourceMessageId }
149
+ : {}),
150
+ },
151
+ }];
152
+ }
92
153
  /**
93
154
  * Wire-encode an already-rendered outbound payload into Discord message body.
94
155
  * Segment canonicalization is intentionally not done here.
@@ -192,10 +253,17 @@ export function activityTypeCode(type) {
192
253
  };
193
254
  return map[type] ?? 0;
194
255
  }
195
- export function verifyDiscordInteractionSignature(publicKeyHex, body, signature, timestamp) {
256
+ /** interactions 验签时间戳时效窗口(防重放)。 */
257
+ export const DISCORD_SIGNATURE_MAX_AGE_MS = 5 * 60_000;
258
+ export function verifyDiscordInteractionSignature(publicKeyHex, body, signature, timestamp, nowMs = Date.now()) {
196
259
  if (!publicKeyHex || !signature || !timestamp)
197
260
  return false;
198
261
  try {
262
+ // 时效窗口 ±5min,超出直接拒绝(timestamp 为秒)
263
+ const ts = Number(timestamp);
264
+ if (!Number.isFinite(ts) || Math.abs(nowMs - ts * 1000) > DISCORD_SIGNATURE_MAX_AGE_MS) {
265
+ return false;
266
+ }
199
267
  const key = createPublicKey({
200
268
  key: Buffer.concat([
201
269
  Buffer.from('302a300506032b6570032100', 'hex'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-discord",
3
- "version": "5.0.3",
3
+ "version": "6.0.1",
4
4
  "description": "Zhin.js Discord adapter for Plugin Runtime (Gateway WebSocket)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,19 +33,20 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "discord.js": "^14.26.4",
36
- "@zhin.js/adapter": "1.0.1",
37
- "@zhin.js/core": "1.3.5",
38
- "@zhin.js/host-http": "1.0.1",
36
+ "@zhin.js/adapter": "1.1.1",
37
+ "@zhin.js/command": "1.0.3",
38
+ "@zhin.js/core": "1.4.1",
39
+ "@zhin.js/host-http": "1.0.3",
39
40
  "@zhin.js/logger": "1.0.75",
40
- "@zhin.js/plugin-runtime": "1.0.1"
41
+ "@zhin.js/plugin-runtime": "1.1.1"
41
42
  },
42
43
  "peerDependencies": {
43
44
  "zod": "^4.0.0",
44
- "@zhin.js/adapter": "1.0.1",
45
- "@zhin.js/agent": "1.0.4",
46
- "@zhin.js/core": "1.3.5",
47
- "@zhin.js/plugin-runtime": "1.0.1",
48
- "zhin.js": "4.1.3"
45
+ "@zhin.js/adapter": "1.1.1",
46
+ "@zhin.js/agent": "1.0.6",
47
+ "@zhin.js/core": "1.4.1",
48
+ "@zhin.js/plugin-runtime": "1.1.1",
49
+ "zhin.js": "5.0.1"
49
50
  },
50
51
  "peerDependenciesMeta": {
51
52
  "zhin.js": {
@@ -63,12 +64,13 @@
63
64
  "typescript": "^6.0.3",
64
65
  "vitest": "^4.1.10",
65
66
  "zod": "^4.4.3",
66
- "@zhin.js/agent": "1.0.4",
67
- "@zhin.js/host-http": "1.0.1",
68
- "zhin.js": "4.1.3"
67
+ "@zhin.js/agent": "1.0.6",
68
+ "@zhin.js/host-http": "1.0.3",
69
+ "zhin.js": "5.0.1"
69
70
  },
70
71
  "files": [
71
72
  "adapters",
73
+ "commands",
72
74
  "plugin.ts",
73
75
  "schema.json",
74
76
  "src",
@@ -94,6 +96,10 @@
94
96
  {
95
97
  "package": "@zhin.js/adapter",
96
98
  "api": "^1.0.0"
99
+ },
100
+ {
101
+ "package": "@zhin.js/command",
102
+ "api": "^1.0.0"
97
103
  }
98
104
  ],
99
105
  "plugins": []
package/plugin.ts CHANGED
@@ -1,12 +1,16 @@
1
+ import { createEndpointRuntimeState } from '@zhin.js/adapter';
1
2
  import { definePlugin } from '@zhin.js/plugin-runtime';
2
3
  import { registerDiscordPlatformPermitChecker } from './src/platform-permit.js';
4
+ import { discordRuntimeStateToken } from './src/discord-runtime-state.js';
3
5
 
4
6
  export default definePlugin({
5
7
  name: 'discord',
6
8
  metadata: {
7
9
  displayName: 'Discord Gateway Adapter',
8
10
  },
9
- setup() {
11
+ setup(context) {
12
+ // 运行中 endpoint 注册表(discord endpoint list 的"运行中"数据源)
13
+ context.resources.provide(discordRuntimeStateToken, createEndpointRuntimeState());
10
14
  // 平台权限门禁:guild_owner / moderate_members 等(agent 工具 platformPermit)
11
15
  return registerDiscordPlatformPermitChecker();
12
16
  },
package/schema.json CHANGED
@@ -3,22 +3,20 @@
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
5
  "properties": {
6
- "name": {
7
- "type": "string",
8
- "default": "discord-bot"
9
- },
10
- "token": {
11
- "type": "string"
12
- },
13
6
  "connection": {
14
7
  "type": "string",
15
- "enum": ["gateway", "interactions"],
8
+ "enum": [
9
+ "gateway",
10
+ "interactions"
11
+ ],
16
12
  "default": "gateway",
17
13
  "description": "Gateway WebSocket (default). interactions uses httpHostToken POST + Ed25519 verify."
18
14
  },
19
15
  "intents": {
20
16
  "type": "array",
21
- "items": { "type": "number" }
17
+ "items": {
18
+ "type": "number"
19
+ }
22
20
  },
23
21
  "enableSlashCommands": {
24
22
  "type": "boolean",
@@ -32,18 +30,33 @@
32
30
  "type": "object",
33
31
  "additionalProperties": false,
34
32
  "properties": {
35
- "name": { "type": "string" },
33
+ "name": {
34
+ "type": "string"
35
+ },
36
36
  "type": {
37
37
  "type": "string",
38
- "enum": ["PLAYING", "STREAMING", "LISTENING", "WATCHING", "COMPETING"]
38
+ "enum": [
39
+ "PLAYING",
40
+ "STREAMING",
41
+ "LISTENING",
42
+ "WATCHING",
43
+ "COMPETING"
44
+ ]
39
45
  },
40
- "url": { "type": "string" }
46
+ "url": {
47
+ "type": "string"
48
+ }
41
49
  },
42
- "required": ["name", "type"]
50
+ "required": [
51
+ "name",
52
+ "type"
53
+ ]
43
54
  },
44
55
  "slashCommands": {
45
56
  "type": "array",
46
- "items": { "type": "object" }
57
+ "items": {
58
+ "type": "object"
59
+ }
47
60
  },
48
61
  "applicationId": {
49
62
  "type": "string",
@@ -57,7 +70,36 @@
57
70
  "type": "string",
58
71
  "default": "/discord/interactions",
59
72
  "description": "POST path on httpHostToken when connection is interactions."
73
+ },
74
+ "endpoints": {
75
+ "type": "array",
76
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
77
+ "items": {
78
+ "type": "object",
79
+ "additionalProperties": true,
80
+ "properties": {
81
+ "name": {
82
+ "type": "string",
83
+ "description": "Discord bot name"
84
+ },
85
+ "token": {
86
+ "type": "string",
87
+ "description": "Discord bot token"
88
+ }
89
+ },
90
+ "required": [
91
+ "name",
92
+ "token"
93
+ ]
94
+ }
95
+ },
96
+ "commandPrefix": {
97
+ "type": "string",
98
+ "default": "",
99
+ "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
60
100
  }
61
101
  },
62
- "required": ["token"]
102
+ "required": [
103
+ "endpoints"
104
+ ]
63
105
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `discord endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ * 注:applicationId / publicKey 为顶层共享字段(slash commands / interactions 用),
5
+ * endpoints[i] 仅需 name + token(见 schema.json),故 add 只录入 token。
6
+ */
7
+ import { createEndpointCommands } from '@zhin.js/adapter';
8
+ import { defineCommand } from '@zhin.js/command';
9
+ import { discordRuntimeStateToken } from './discord-runtime-state.js';
10
+
11
+ export const discordEndpointCommands = createEndpointCommands({
12
+ adapterKey: 'discord',
13
+ adapterDisplayName: 'Discord',
14
+ fields: [
15
+ { key: 'token', required: true, env: true, description: 'Discord bot token' },
16
+ ],
17
+ running: (use) => use(discordRuntimeStateToken).endpoints.values(),
18
+ describeEntry: (entry) => `token: ${String(entry.token)}`,
19
+ }, defineCommand);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Discord 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `discord endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const discordRuntimeStateToken = defineEndpointRuntimeStateToken('discord');
package/src/endpoint.ts CHANGED
@@ -2,7 +2,12 @@
2
2
  * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
3
3
  */
4
4
  import { ChannelType } from 'discord.js';
5
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type {
6
+ EndpointChannel,
7
+ EndpointGroup,
8
+ EndpointInstance,
9
+ EndpointManagement,
10
+ } from '@zhin.js/adapter';
6
11
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
12
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
13
  import { formatCompact, getLogger } from '@zhin.js/logger';
@@ -19,7 +24,9 @@ import {
19
24
  } from './gateway.js';
20
25
  import {
21
26
  formatButtonContent,
27
+ formatButtonSegments,
22
28
  formatInboundContent,
29
+ formatInboundSegments,
23
30
  formatOutboundBody,
24
31
  senderDisplayName,
25
32
  type DiscordButtonInbound,
@@ -31,6 +38,8 @@ import {
31
38
  import { registerDiscordInteractionRoutes } from './webhook.js';
32
39
 
33
40
  const DISCORD_API = 'https://discord.com/api/v10';
41
+ /** 出站 HTTP 调用统一 30s 超时。 */
42
+ const OUTBOUND_TIMEOUT_MS = 30_000;
34
43
  const logger = getLogger('discord');
35
44
 
36
45
  export type {
@@ -52,7 +61,10 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
52
61
  #open = false;
53
62
  #started = false;
54
63
  #unregisterAgent?: () => void;
55
- readonly #messageChannelMap = new Map<string, string>();
64
+ readonly management: EndpointManagement = createDiscordEndpointManagement({
65
+ getClient: () => this.#requireClient(),
66
+ getMembers: (guildId) => this.getMembers(guildId),
67
+ });
56
68
 
57
69
  constructor(options: DiscordEndpointOptions) {
58
70
  this.#options = options;
@@ -113,7 +125,6 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
113
125
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
114
126
  const body = formatOutboundBody(payload);
115
127
  const messageId = await this.#sendBody(target, body);
116
- this.#messageChannelMap.set(messageId, target);
117
128
  logger.debug(formatCompact({
118
129
  op: 'discord_send',
119
130
  endpoint: this.#options.config.name,
@@ -127,11 +138,11 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
127
138
  admit(msg: DiscordInboundMessage): void {
128
139
  if (!this.#open) return;
129
140
  if (msg.authorBot) return;
130
- this.#messageChannelMap.set(msg.id, msg.channelId);
131
141
  void this.#options.gateway.receive({
132
142
  adapter: this.#options.id,
133
143
  target: msg.channelId,
134
144
  content: formatInboundContent(msg),
145
+ segments: formatInboundSegments(msg),
135
146
  sender: senderDisplayName(msg),
136
147
  id: msg.id,
137
148
  metadata: Object.freeze({
@@ -159,6 +170,7 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
159
170
  adapter: this.#options.id,
160
171
  target: interaction.channelId,
161
172
  content: formatButtonContent(interaction),
173
+ segments: formatButtonSegments(interaction),
162
174
  sender: interaction.userName,
163
175
  id: interaction.id,
164
176
  metadata: Object.freeze({
@@ -422,6 +434,7 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
422
434
  'Content-Type': 'application/json',
423
435
  },
424
436
  body: JSON.stringify(body),
437
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
425
438
  });
426
439
  const text = await response.text();
427
440
  if (!response.ok) {
@@ -437,6 +450,7 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
437
450
  adapter: this.#options.id,
438
451
  target: msg.channelId,
439
452
  content: formatInboundContent(msg),
453
+ segments: formatInboundSegments(msg),
440
454
  sender: senderDisplayName(msg),
441
455
  id: msg.id,
442
456
  metadata: Object.freeze({
@@ -455,3 +469,57 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
455
469
  });
456
470
  }
457
471
  }
472
+
473
+ /**
474
+ * Discord snowflake 是 64 位整数的字符串形式,超出 Number.MAX_SAFE_INTEGER,
475
+ * Number() 转换会丢精度。Console 社交面只把 group_id 当 JSON 值透传、
476
+ * 并以字符串回传给 listGroupMembers,因此这里保留原始字符串(仅按契约
477
+ * 类型声明强转),是全链路最不丢信息的方案。
478
+ */
479
+ function toGroupId(id: string): number {
480
+ return id as unknown as number;
481
+ }
482
+
483
+ /**
484
+ * DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
485
+ * 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
486
+ */
487
+ export function createDiscordEndpointManagement(endpoint: {
488
+ getClient(): DiscordClientTransport;
489
+ getMembers(guildId: string): Promise<unknown[]>;
490
+ }): EndpointManagement {
491
+ return Object.freeze<EndpointManagement>({
492
+ async listGroups(): Promise<readonly EndpointGroup[]> {
493
+ const groups: EndpointGroup[] = [];
494
+ for (const guild of endpoint.getClient().guilds.cache.values()) {
495
+ if (!guild?.id) continue;
496
+ groups.push({
497
+ group_id: toGroupId(String(guild.id)),
498
+ name: String(guild.name ?? guild.id),
499
+ });
500
+ }
501
+ return groups;
502
+ },
503
+ async listChannels(): Promise<readonly EndpointChannel[]> {
504
+ const channels: EndpointChannel[] = [];
505
+ for (const guild of endpoint.getClient().guilds.cache.values()) {
506
+ if (!guild?.id) continue;
507
+ const guildId = String(guild.id);
508
+ const guildName = String(guild.name ?? guildId);
509
+ for (const channel of guild.channels?.cache?.values() ?? []) {
510
+ if (!channel?.id) continue;
511
+ if (channel.type !== ChannelType.GuildText) continue;
512
+ channels.push({
513
+ id: String(channel.id),
514
+ name: channel.name ? String(channel.name) : undefined,
515
+ parent: { type: 'guild', id: guildId, name: guildName },
516
+ });
517
+ }
518
+ }
519
+ return channels;
520
+ },
521
+ async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
522
+ return endpoint.getMembers(groupId);
523
+ },
524
+ });
525
+ }
package/src/gateway.ts CHANGED
@@ -98,7 +98,13 @@ export interface DiscordClientTransport {
98
98
  unban(userId: string, reason?: string): Promise<unknown>;
99
99
  };
100
100
  }>;
101
- cache: { values(): IterableIterator<{ id: string }> };
101
+ cache: { values(): IterableIterator<{
102
+ id: string;
103
+ name: string;
104
+ channels: {
105
+ cache: { values(): IterableIterator<{ id: string; name?: string; type: number }> };
106
+ };
107
+ }> };
102
108
  };
103
109
  }
104
110
 
@@ -223,6 +229,7 @@ export async function toMessageCreateOptions(body: DiscordOutboundBody): Promise
223
229
  async function registerSlashCommands(
224
230
  config: ResolvedDiscordGatewayConfig,
225
231
  applicationId: string,
232
+ guildIds: readonly string[] = [],
226
233
  ): Promise<void> {
227
234
  if (!config.slashCommands?.length) return;
228
235
  const rest = new REST({ version: '10' }).setToken(config.token);
@@ -231,7 +238,23 @@ async function registerSlashCommands(
231
238
  body: config.slashCommands,
232
239
  });
233
240
  logger.info(formatCompact({ op: 'slash_commands', scope: 'global' }));
241
+ return;
242
+ }
243
+ // globalCommands=false 时按 guild 注册,否则 enableSlashCommands 会是静默 no-op
244
+ if (guildIds.length === 0) {
245
+ logger.warn(formatCompact({
246
+ op: 'slash_commands',
247
+ ok: false,
248
+ error: 'enableSlashCommands=true but globalCommands=false and no guilds cached; slash commands not registered',
249
+ }));
250
+ return;
234
251
  }
252
+ for (const guildId of guildIds) {
253
+ await rest.put(Routes.applicationGuildCommands(applicationId, guildId), {
254
+ body: config.slashCommands,
255
+ });
256
+ }
257
+ logger.info(formatCompact({ op: 'slash_commands', scope: 'guild', guilds: guildIds.length }));
235
258
  }
236
259
 
237
260
  async function fileExists(filePath: string): Promise<boolean> {
@@ -300,7 +323,8 @@ export async function connectDiscordGatewayClient(
300
323
  });
301
324
  }
302
325
  if (config.enableSlashCommands && config.slashCommands?.length && client.user) {
303
- await registerSlashCommands(config, client.user.id);
326
+ const guildIds = [...client.guilds.cache.values()].map((guild) => guild.id);
327
+ await registerSlashCommands(config, client.user.id, guildIds);
304
328
  }
305
329
  if (!settled) {
306
330
  settled = true;
@@ -330,7 +354,16 @@ export async function connectDiscordGatewayClient(
330
354
  client.login(config.token).catch((error) => {
331
355
  if (!settled) {
332
356
  settled = true;
333
- reject(error instanceof Error ? error : new Error(String(error)));
357
+ const raw = error instanceof Error ? error.message : String(error);
358
+ const tokenShapeHint = config.token.split('.').length === 3
359
+ ? ''
360
+ : ';当前 token 不是标准三段式 Bot Token 格式,请到 Discord Developer Portal → Bot → Reset Token 重新获取(勿用 Client Secret)';
361
+ reject(
362
+ new Error(
363
+ `Discord 连接失败:请检查 token 是否为 Bot Token 且未过期、Bot 是否已开启所需 Intents${tokenShapeHint}(原始错误:${raw})`,
364
+ { cause: error instanceof Error ? error : undefined },
365
+ ),
366
+ );
334
367
  }
335
368
  });
336
369
  });
@@ -49,7 +49,7 @@ export function checkDiscordPlatformPermit(perm: string, message: Message<any>):
49
49
  case 'guild_owner':
50
50
  return role === 'owner' || has('guild_owner');
51
51
  case 'moderate_members':
52
- return role === 'owner' || has('ADMINISTRATOR') || has('MODERATE_MEMBERS');
52
+ return role === 'owner' || role === 'admin' || has('ADMINISTRATOR') || has('MODERATE_MEMBERS');
53
53
  case 'manage_roles':
54
54
  return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_ROLES');
55
55
  case 'manage_channels':
package/src/protocol.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
7
+ import type { Segment } from '@zhin.js/core/runtime';
7
8
 
8
9
  /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
9
10
  export interface DiscordAdapterConfig {
@@ -227,6 +228,65 @@ export function formatButtonContent(interaction: DiscordButtonInbound): string {
227
228
  return `[action: ${interaction.customId}]`;
228
229
  }
229
230
 
231
+ function attachmentMediaKind(contentType: string | undefined): 'image' | 'audio' | 'video' | 'file' {
232
+ if (contentType?.startsWith('image/')) return 'image';
233
+ if (contentType?.startsWith('audio/')) return 'audio';
234
+ if (contentType?.startsWith('video/')) return 'video';
235
+ return 'file';
236
+ }
237
+
238
+ /**
239
+ * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
240
+ * 附件 url 是 Discord CDN 真实 http(s) 地址,MediaRef kind=url;
241
+ * embeds / stickers 仅留在纯文本视图,不进段(最小侵入)。
242
+ */
243
+ export function formatInboundSegments(msg: DiscordInboundMessage): Segment[] {
244
+ const out: Segment[] = [];
245
+ if (msg.replyToId) {
246
+ out.push({ type: 'reply', data: { message_id: msg.replyToId } });
247
+ }
248
+ const text = msg.content?.trim();
249
+ if (text) out.push({ type: 'text', data: { text } });
250
+ for (const attachment of msg.attachments ?? []) {
251
+ if (!attachment.url) continue;
252
+ const kind = attachmentMediaKind(attachment.contentType);
253
+ out.push({
254
+ type: kind,
255
+ data: {
256
+ media: {
257
+ kind: 'url',
258
+ value: attachment.url,
259
+ ...(attachment.contentType ? { mime_type: attachment.contentType } : {}),
260
+ },
261
+ ...(attachment.name
262
+ ? kind === 'image'
263
+ ? { alt: attachment.name }
264
+ : { name: attachment.name }
265
+ : {}),
266
+ },
267
+ });
268
+ }
269
+ return out;
270
+ }
271
+
272
+ /**
273
+ * button interaction → action 段(Wave 1 C interactive 约定:
274
+ * {type:'action', data:{id, payload, sourceMessageId?}}),
275
+ * 与 formatButtonContent / metadata.payload 同源。
276
+ */
277
+ export function formatButtonSegments(interaction: DiscordButtonInbound): Segment[] {
278
+ return [{
279
+ type: 'action',
280
+ data: {
281
+ id: interaction.id,
282
+ payload: interaction.customId,
283
+ ...(interaction.sourceMessageId
284
+ ? { sourceMessageId: interaction.sourceMessageId }
285
+ : {}),
286
+ },
287
+ }];
288
+ }
289
+
230
290
  /**
231
291
  * Wire-encode an already-rendered outbound payload into Discord message body.
232
292
  * Segment canonicalization is intentionally not done here.
@@ -342,14 +402,23 @@ export function activityTypeCode(
342
402
  return map[type] ?? 0;
343
403
  }
344
404
 
405
+ /** interactions 验签时间戳时效窗口(防重放)。 */
406
+ export const DISCORD_SIGNATURE_MAX_AGE_MS = 5 * 60_000;
407
+
345
408
  export function verifyDiscordInteractionSignature(
346
409
  publicKeyHex: string,
347
410
  body: string,
348
411
  signature: string,
349
412
  timestamp: string,
413
+ nowMs: number = Date.now(),
350
414
  ): boolean {
351
415
  if (!publicKeyHex || !signature || !timestamp) return false;
352
416
  try {
417
+ // 时效窗口 ±5min,超出直接拒绝(timestamp 为秒)
418
+ const ts = Number(timestamp);
419
+ if (!Number.isFinite(ts) || Math.abs(nowMs - ts * 1000) > DISCORD_SIGNATURE_MAX_AGE_MS) {
420
+ return false;
421
+ }
353
422
  const key = createPublicKey({
354
423
  key: Buffer.concat([
355
424
  Buffer.from('302a300506032b6570032100', 'hex'),