@zhin.js/adapter-discord 6.0.0 → 6.0.2
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 +61 -0
- package/adapters/discord.js +41 -0
- package/adapters/discord.ts +11 -0
- package/commands/endpoint/add/[name:string].js +3 -0
- package/commands/endpoint/add/[name:string].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].js +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/discord-endpoint-commands.d.ts +1 -0
- package/lib/discord-endpoint-commands.js +18 -0
- package/lib/discord-runtime-state.d.ts +1 -0
- package/lib/discord-runtime-state.js +6 -0
- package/lib/endpoint.d.ts +11 -2
- package/lib/endpoint.js +64 -4
- package/lib/gateway.d.ts +10 -0
- package/lib/gateway.js +24 -3
- package/lib/platform-permit.js +1 -1
- package/lib/protocol.d.ts +16 -1
- package/lib/protocol.js +69 -1
- package/package.json +21 -15
- package/plugin.js +17 -0
- package/src/discord-endpoint-commands.ts +19 -0
- package/src/discord-runtime-state.ts +7 -0
- package/src/endpoint.ts +72 -4
- package/src/gateway.ts +36 -3
- package/src/platform-permit.ts +1 -1
- package/src/protocol.ts +69 -0
- package/plugin.ts +0 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# @zhin.js/adapter-discord
|
|
2
2
|
|
|
3
|
+
## 6.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d5cd4aa: Publish Plugin Runtime entry points and convention modules as JavaScript so
|
|
8
|
+
installed npm plugins load on Node without TypeScript stripping. Workspace
|
|
9
|
+
development continues to prefer TypeScript sources for local HMR.
|
|
10
|
+
|
|
11
|
+
Remove the unconsumed legacy game hub APIs from game-kit; game navigation and
|
|
12
|
+
records now use ordinary convention commands owned by the game hub plugin.
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [d5cd4aa]
|
|
15
|
+
- @zhin.js/command@1.0.4
|
|
16
|
+
- zhin.js@5.0.2
|
|
17
|
+
- @zhin.js/adapter@1.1.2
|
|
18
|
+
- @zhin.js/core@1.4.2
|
|
19
|
+
- @zhin.js/agent@1.0.7
|
|
20
|
+
|
|
21
|
+
## 6.0.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
|
|
26
|
+
|
|
27
|
+
- **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
|
|
28
|
+
- **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
|
|
29
|
+
- **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
|
|
30
|
+
- **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
|
|
31
|
+
- **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
|
|
32
|
+
- **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
|
|
33
|
+
|
|
34
|
+
- 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(无凭据)暂不接。
|
|
35
|
+
- 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 注明平台无列表面暂不接。
|
|
36
|
+
- 6cb6152: 统一消息元素通道(UNI-Channel)落地:
|
|
37
|
+
|
|
38
|
+
- **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
|
|
39
|
+
- **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
|
|
40
|
+
- **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
|
|
41
|
+
- **首批迁移**: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 图片不再静默丢图,失败降级文本)。
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [cdf64e7]
|
|
44
|
+
- Updated dependencies [2d0a159]
|
|
45
|
+
- Updated dependencies [5691aba]
|
|
46
|
+
- Updated dependencies [078e3f7]
|
|
47
|
+
- Updated dependencies [50497a5]
|
|
48
|
+
- Updated dependencies [9c997b2]
|
|
49
|
+
- Updated dependencies [09d4f25]
|
|
50
|
+
- Updated dependencies [43485a9]
|
|
51
|
+
- Updated dependencies [f0ec5ab]
|
|
52
|
+
- Updated dependencies [3e925d0]
|
|
53
|
+
- Updated dependencies [fa66c4c]
|
|
54
|
+
- Updated dependencies [fa66c4c]
|
|
55
|
+
- Updated dependencies [6cb6152]
|
|
56
|
+
- @zhin.js/command@1.0.3
|
|
57
|
+
- @zhin.js/agent@1.0.6
|
|
58
|
+
- @zhin.js/plugin-runtime@1.1.1
|
|
59
|
+
- @zhin.js/host-http@1.0.3
|
|
60
|
+
- zhin.js@5.0.1
|
|
61
|
+
- @zhin.js/adapter@1.1.1
|
|
62
|
+
- @zhin.js/core@1.4.1
|
|
63
|
+
|
|
3
64
|
## 6.0.0
|
|
4
65
|
|
|
5
66
|
### Patch Changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* Convention entry: discover `adapters/discord.ts` → defineAdapter.
|
|
4
|
+
*/
|
|
5
|
+
import { defineAdapter } from '@zhin.js/adapter';
|
|
6
|
+
import { messageGatewayToken } from '@zhin.js/core/runtime';
|
|
7
|
+
import { httpHostToken } from '@zhin.js/host-http';
|
|
8
|
+
import { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, } from "../lib/endpoint.js";
|
|
9
|
+
import { resolveDiscordConfig, } from "../lib/protocol.js";
|
|
10
|
+
import { discordRuntimeStateToken } from "../lib/discord-runtime-state.js";
|
|
11
|
+
export { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, } from "../lib/endpoint.js";
|
|
12
|
+
export default defineAdapter({
|
|
13
|
+
capabilities: ['inbound', 'outbound'],
|
|
14
|
+
// 媒体 url 直发或由 AttachmentBuilder 物化本地文件上传;message components 原生按钮承载交互段。
|
|
15
|
+
segments: {
|
|
16
|
+
outboundMedia: ['url', 'upload'],
|
|
17
|
+
interactive: 'native',
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
const config = resolveDiscordConfig(context.config);
|
|
21
|
+
const gateway = context.use(messageGatewayToken);
|
|
22
|
+
// 注册到插件运行时状态(discord endpoint list 的"运行中"数据源)
|
|
23
|
+
context.use(discordRuntimeStateToken).endpoints.set(config.name, {
|
|
24
|
+
name: config.name,
|
|
25
|
+
mode: config.connection,
|
|
26
|
+
});
|
|
27
|
+
if (config.connection === 'interactions') {
|
|
28
|
+
return new DiscordInteractionsEndpoint({
|
|
29
|
+
id: context.id,
|
|
30
|
+
gateway,
|
|
31
|
+
http: context.use(httpHostToken),
|
|
32
|
+
config,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return new DiscordGatewayEndpoint({
|
|
36
|
+
id: context.id,
|
|
37
|
+
gateway,
|
|
38
|
+
config,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
package/adapters/discord.ts
CHANGED
|
@@ -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 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/lib/platform-permit.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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": "6.0.
|
|
3
|
+
"version": "6.0.2",
|
|
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.1.
|
|
37
|
-
"@zhin.js/
|
|
38
|
-
"@zhin.js/
|
|
36
|
+
"@zhin.js/adapter": "1.1.2",
|
|
37
|
+
"@zhin.js/command": "1.0.4",
|
|
38
|
+
"@zhin.js/core": "1.4.2",
|
|
39
|
+
"@zhin.js/host-http": "1.0.3",
|
|
39
40
|
"@zhin.js/logger": "1.0.75",
|
|
40
|
-
"@zhin.js/plugin-runtime": "1.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.1.
|
|
45
|
-
"@zhin.js/agent": "1.0.
|
|
46
|
-
"@zhin.js/core": "1.4.
|
|
47
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
48
|
-
"zhin.js": "5.0.
|
|
45
|
+
"@zhin.js/adapter": "1.1.2",
|
|
46
|
+
"@zhin.js/agent": "1.0.7",
|
|
47
|
+
"@zhin.js/core": "1.4.2",
|
|
48
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
49
|
+
"zhin.js": "5.0.2"
|
|
49
50
|
},
|
|
50
51
|
"peerDependenciesMeta": {
|
|
51
52
|
"zhin.js": {
|
|
@@ -63,13 +64,14 @@
|
|
|
63
64
|
"typescript": "^6.0.3",
|
|
64
65
|
"vitest": "^4.1.10",
|
|
65
66
|
"zod": "^4.4.3",
|
|
66
|
-
"@zhin.js/agent": "1.0.
|
|
67
|
-
"@zhin.js/host-http": "1.0.
|
|
68
|
-
"zhin.js": "5.0.
|
|
67
|
+
"@zhin.js/agent": "1.0.7",
|
|
68
|
+
"@zhin.js/host-http": "1.0.3",
|
|
69
|
+
"zhin.js": "5.0.2"
|
|
69
70
|
},
|
|
70
71
|
"files": [
|
|
71
72
|
"adapters",
|
|
72
|
-
"
|
|
73
|
+
"commands",
|
|
74
|
+
"plugin.js",
|
|
73
75
|
"schema.json",
|
|
74
76
|
"src",
|
|
75
77
|
"lib",
|
|
@@ -87,13 +89,17 @@
|
|
|
87
89
|
"zhin": {
|
|
88
90
|
"protocol": 1,
|
|
89
91
|
"type": "plugin",
|
|
90
|
-
"entry": "./plugin.
|
|
92
|
+
"entry": "./plugin.js",
|
|
91
93
|
"engine": "^1.0.0",
|
|
92
94
|
"runtime": "trusted",
|
|
93
95
|
"features": [
|
|
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.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
3
|
+
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
4
|
+
import { registerDiscordPlatformPermitChecker } from "./lib/platform-permit.js";
|
|
5
|
+
import { discordRuntimeStateToken } from "./lib/discord-runtime-state.js";
|
|
6
|
+
export default definePlugin({
|
|
7
|
+
name: 'discord',
|
|
8
|
+
metadata: {
|
|
9
|
+
displayName: 'Discord Gateway Adapter',
|
|
10
|
+
},
|
|
11
|
+
setup(context) {
|
|
12
|
+
// 运行中 endpoint 注册表(discord endpoint list 的"运行中"数据源)
|
|
13
|
+
context.resources.provide(discordRuntimeStateToken, createEndpointRuntimeState());
|
|
14
|
+
// 平台权限门禁:guild_owner / moderate_members 等(agent 工具 platformPermit)
|
|
15
|
+
return registerDiscordPlatformPermitChecker();
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -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 {
|
|
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
|
|
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<{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/platform-permit.ts
CHANGED
|
@@ -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'),
|
package/plugin.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
2
|
-
import { registerDiscordPlatformPermitChecker } from './src/platform-permit.js';
|
|
3
|
-
|
|
4
|
-
export default definePlugin({
|
|
5
|
-
name: 'discord',
|
|
6
|
-
metadata: {
|
|
7
|
-
displayName: 'Discord Gateway Adapter',
|
|
8
|
-
},
|
|
9
|
-
setup() {
|
|
10
|
-
// 平台权限门禁:guild_owner / moderate_members 等(agent 工具 platformPermit)
|
|
11
|
-
return registerDiscordPlatformPermitChecker();
|
|
12
|
-
},
|
|
13
|
-
});
|