@zhin.js/adapter-discord 6.0.5 → 7.0.0
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 +44 -0
- package/adapters/discord.js +2 -1
- package/adapters/discord.ts +2 -1
- package/lib/discord-endpoint-commands.js +1 -1
- package/lib/discord-runtime-state.js +1 -1
- package/lib/endpoint.d.ts +7 -9
- package/lib/endpoint.js +58 -5
- package/lib/gateway.d.ts +1 -0
- package/lib/gateway.js +19 -1
- package/lib/protocol.d.ts +1 -0
- package/lib/protocol.js +31 -5
- package/package.json +11 -10
- package/plugin.js +1 -1
- package/src/discord-endpoint-commands.ts +1 -1
- package/src/discord-runtime-state.ts +1 -1
- package/src/endpoint.ts +64 -5
- package/src/gateway.ts +19 -1
- package/src/protocol.ts +32 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# @zhin.js/adapter-discord
|
|
2
2
|
|
|
3
|
+
## 7.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
|
|
8
|
+
|
|
9
|
+
全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
|
|
10
|
+
|
|
11
|
+
- **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
|
|
12
|
+
- **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
|
|
13
|
+
- **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
|
|
14
|
+
- **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
|
|
15
|
+
- **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
|
|
16
|
+
|
|
17
|
+
迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [7c1e63a]
|
|
20
|
+
- Updated dependencies [4fbff5d]
|
|
21
|
+
- Updated dependencies [5b94d9c]
|
|
22
|
+
- @zhin.js/command@1.0.5
|
|
23
|
+
- @zhin.js/adapter@1.1.3
|
|
24
|
+
- @zhin.js/core@1.5.0
|
|
25
|
+
- @zhin.js/agent@1.1.0
|
|
26
|
+
- zhin.js@6.0.0
|
|
27
|
+
|
|
28
|
+
## 6.0.6
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- d52f3c5: fix: QQ 适配器群聊误识别为私聊、game-kit 启动崩溃、全面补齐 recallMessage
|
|
33
|
+
|
|
34
|
+
- fix(cli): `resolveChannelType` 增加 `metadata.channelKind` 检查,修复 QQ/Discord/KOOK 群聊消息被误分类为私聊
|
|
35
|
+
- fix(game-kit): `createHostGameDb` 改用延迟代理模型,避免数据库启动前解析模型导致崩溃
|
|
36
|
+
- feat(qq): 实现 `recallMessage`,解析复合 messageId 路由到对应 SDK 撤回方法;`normalizeQqMessage` 增加 `message_type` 缺失时的回退检测
|
|
37
|
+
- feat(slack): 实现 `recallMessage`,利用已有 compound ref 和 `chat.delete` API
|
|
38
|
+
- feat(onebot11): 两个 endpoint 类实现 `recallMessage`(`delete_msg`)
|
|
39
|
+
- feat(onebot12): 两个 endpoint 类实现 `recallMessage`(`delete_message`)
|
|
40
|
+
- feat(napcat): 三个 endpoint 类实现 `recallMessage`(`delete_msg`)
|
|
41
|
+
- feat(discord): 两个 endpoint 类实现 `recallMessage`;`send()` 改为返回 `channelId:snowflake` 复合 ID
|
|
42
|
+
- feat(telegram): 实现 `recallMessage`(`deleteMessage`);`send()` 改为返回 `chatId:messageId` 复合 ID
|
|
43
|
+
- feat(kook): 两个 endpoint 类实现 `recallMessage`,利用 kook-client `recallMsg` API
|
|
44
|
+
- feat(lark): 实现 `recallMessage`(`DELETE /im/v1/messages/{id}`)
|
|
45
|
+
- feat(wecom): 实现 `recallMessage`(`POST /cgi-bin/message/recall`)
|
|
46
|
+
|
|
3
47
|
## 6.0.5
|
|
4
48
|
|
|
5
49
|
### Patch Changes
|
package/adapters/discord.js
CHANGED
|
@@ -11,6 +11,7 @@ import { discordRuntimeStateToken } from "../lib/discord-runtime-state.js";
|
|
|
11
11
|
export { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, } from "../lib/endpoint.js";
|
|
12
12
|
export default defineAdapter({
|
|
13
13
|
capabilities: ['inbound', 'outbound'],
|
|
14
|
+
operations: ['recall'],
|
|
14
15
|
// 媒体 url 直发或由 AttachmentBuilder 物化本地文件上传;message components 原生按钮承载交互段。
|
|
15
16
|
segments: {
|
|
16
17
|
outboundMedia: ['url', 'upload'],
|
|
@@ -19,7 +20,7 @@ export default defineAdapter({
|
|
|
19
20
|
create(context) {
|
|
20
21
|
const config = resolveDiscordConfig(context.config);
|
|
21
22
|
const gateway = context.use(messageGatewayToken);
|
|
22
|
-
// 注册到插件运行时状态(discord
|
|
23
|
+
// 注册到插件运行时状态(discord.endpoint list 的"运行中"数据源)
|
|
23
24
|
context.use(discordRuntimeStateToken).endpoints.set(config.name, {
|
|
24
25
|
name: config.name,
|
|
25
26
|
mode: config.connection,
|
package/adapters/discord.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type {
|
|
|
27
27
|
|
|
28
28
|
export default defineAdapter<DiscordAdapterConfig>({
|
|
29
29
|
capabilities: ['inbound', 'outbound'],
|
|
30
|
+
operations: ['recall'],
|
|
30
31
|
// 媒体 url 直发或由 AttachmentBuilder 物化本地文件上传;message components 原生按钮承载交互段。
|
|
31
32
|
segments: {
|
|
32
33
|
outboundMedia: ['url', 'upload'],
|
|
@@ -35,7 +36,7 @@ export default defineAdapter<DiscordAdapterConfig>({
|
|
|
35
36
|
create(context) {
|
|
36
37
|
const config = resolveDiscordConfig(context.config);
|
|
37
38
|
const gateway = context.use(messageGatewayToken);
|
|
38
|
-
// 注册到插件运行时状态(discord
|
|
39
|
+
// 注册到插件运行时状态(discord.endpoint list 的"运行中"数据源)
|
|
39
40
|
context.use(discordRuntimeStateToken).endpoints.set(config.name, {
|
|
40
41
|
name: config.name,
|
|
41
42
|
mode: config.connection,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `discord
|
|
2
|
+
* `discord.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
3
|
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
4
|
* 注:applicationId / publicKey 为顶层共享字段(slash commands / interactions 用),
|
|
5
5
|
* endpoints[i] 仅需 name + token(见 schema.json),故 add 只录入 token。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Discord 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
-
* 由 plugin.ts setup() provide,adapter create 与 `discord
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `discord.endpoint` 命令共享(同一 owner generation)。
|
|
4
4
|
*/
|
|
5
5
|
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
6
|
export const discordRuntimeStateToken = defineEndpointRuntimeStateToken('discord');
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
1
|
+
import type { EndpointControl, EndpointInstance, EndpointManagement, EndpointSendRequest } 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';
|
|
@@ -14,15 +14,14 @@ export interface DiscordEndpointOptions {
|
|
|
14
14
|
export declare class DiscordGatewayEndpoint implements EndpointInstance {
|
|
15
15
|
#private;
|
|
16
16
|
readonly management: EndpointManagement;
|
|
17
|
+
readonly control: EndpointControl;
|
|
17
18
|
constructor(options: DiscordEndpointOptions);
|
|
18
19
|
start(): Promise<void>;
|
|
19
20
|
open(): void;
|
|
20
21
|
close(): void;
|
|
21
22
|
stop(): Promise<void>;
|
|
22
|
-
send({ target, payload }:
|
|
23
|
-
|
|
24
|
-
readonly payload: unknown;
|
|
25
|
-
}): Promise<string>;
|
|
23
|
+
send({ target, conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
24
|
+
recallMessage(messageId: string): Promise<void>;
|
|
26
25
|
/** Test / internal: admit a message when open. */
|
|
27
26
|
admit(msg: DiscordInboundMessage): void;
|
|
28
27
|
/** Test / internal: admit a button interaction when open. */
|
|
@@ -57,6 +56,7 @@ export interface DiscordInteractionsEndpointOptions {
|
|
|
57
56
|
}
|
|
58
57
|
export declare class DiscordInteractionsEndpoint implements EndpointInstance {
|
|
59
58
|
#private;
|
|
59
|
+
readonly control: EndpointControl;
|
|
60
60
|
constructor(options: DiscordInteractionsEndpointOptions);
|
|
61
61
|
get isOpen(): boolean;
|
|
62
62
|
get config(): ResolvedDiscordInteractionsConfig;
|
|
@@ -64,10 +64,8 @@ export declare class DiscordInteractionsEndpoint implements EndpointInstance {
|
|
|
64
64
|
open(): void;
|
|
65
65
|
close(): void;
|
|
66
66
|
stop(): Promise<void>;
|
|
67
|
-
send({ target, payload }:
|
|
68
|
-
|
|
69
|
-
readonly payload: unknown;
|
|
70
|
-
}): Promise<string>;
|
|
67
|
+
send({ target, conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
68
|
+
recallMessage(messageId: string): Promise<void>;
|
|
71
69
|
admit(msg: DiscordInboundMessage): void;
|
|
72
70
|
}
|
|
73
71
|
/**
|
package/lib/endpoint.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
|
|
3
3
|
*/
|
|
4
4
|
import { ChannelType } from 'discord.js';
|
|
5
|
+
import { formatLegacyMessageReference, nativeConversationId, parseLegacyMessageReference, } from '@zhin.js/im-contract';
|
|
5
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
6
7
|
import { registerDiscordAgentEndpoint } from './discord-agent-deps.js';
|
|
7
8
|
import { connectDiscordGatewayClient, defaultCreateClient, DEFAULT_INTENTS, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
|
|
@@ -22,6 +23,19 @@ export class DiscordGatewayEndpoint {
|
|
|
22
23
|
getClient: () => this.#requireClient(),
|
|
23
24
|
getMembers: (guildId) => this.getMembers(guildId),
|
|
24
25
|
});
|
|
26
|
+
control = Object.freeze({
|
|
27
|
+
recall: (messageId) => this.recallMessage(messageId),
|
|
28
|
+
addReaction: async (messageId, emoji, hint) => {
|
|
29
|
+
const reference = parseLegacyMessageReference(messageId);
|
|
30
|
+
const channelId = hint?.channelId
|
|
31
|
+
?? (reference ? nativeConversationId(reference.target) : undefined);
|
|
32
|
+
const nativeMessageId = reference?.messageId ?? messageId;
|
|
33
|
+
if (!channelId || !nativeMessageId)
|
|
34
|
+
return null;
|
|
35
|
+
await this.addReaction(channelId, nativeMessageId, emoji);
|
|
36
|
+
return emoji;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
25
39
|
constructor(options) {
|
|
26
40
|
this.#options = options;
|
|
27
41
|
this.#createClient = options.createClient ?? defaultCreateClient;
|
|
@@ -76,9 +90,11 @@ export class DiscordGatewayEndpoint {
|
|
|
76
90
|
this.#started = false;
|
|
77
91
|
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
78
92
|
}
|
|
79
|
-
async send({ target, payload }) {
|
|
93
|
+
async send({ target, conversation, payload }) {
|
|
80
94
|
const body = formatOutboundBody(payload);
|
|
81
|
-
const
|
|
95
|
+
const channelId = nativeConversationId(target, conversation);
|
|
96
|
+
const snowflake = await this.#sendBody(channelId, body);
|
|
97
|
+
const messageId = formatLegacyMessageReference({ target, messageId: snowflake });
|
|
82
98
|
logger.debug(formatCompact({
|
|
83
99
|
op: 'discord_send',
|
|
84
100
|
endpoint: this.#options.config.name,
|
|
@@ -87,6 +103,20 @@ export class DiscordGatewayEndpoint {
|
|
|
87
103
|
}));
|
|
88
104
|
return messageId;
|
|
89
105
|
}
|
|
106
|
+
async recallMessage(messageId) {
|
|
107
|
+
if (!messageId)
|
|
108
|
+
return;
|
|
109
|
+
const reference = parseLegacyMessageReference(messageId);
|
|
110
|
+
if (!reference)
|
|
111
|
+
return;
|
|
112
|
+
const channelId = nativeConversationId(reference.target);
|
|
113
|
+
const snowflake = reference.messageId;
|
|
114
|
+
const channel = await this.#requireClient().channels.fetch(channelId);
|
|
115
|
+
if (!channel?.isTextBased() || !channel.messages)
|
|
116
|
+
return;
|
|
117
|
+
const msg = await channel.messages.fetch(snowflake);
|
|
118
|
+
await msg.delete();
|
|
119
|
+
}
|
|
90
120
|
/** Test / internal: admit a message when open. */
|
|
91
121
|
admit(msg) {
|
|
92
122
|
if (!this.#open)
|
|
@@ -281,6 +311,9 @@ export class DiscordInteractionsEndpoint {
|
|
|
281
311
|
#routeReleases = [];
|
|
282
312
|
#open = false;
|
|
283
313
|
#started = false;
|
|
314
|
+
control = Object.freeze({
|
|
315
|
+
recall: (messageId) => this.recallMessage(messageId),
|
|
316
|
+
});
|
|
284
317
|
constructor(options) {
|
|
285
318
|
this.#options = options;
|
|
286
319
|
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
@@ -316,9 +349,10 @@ export class DiscordInteractionsEndpoint {
|
|
|
316
349
|
this.#started = false;
|
|
317
350
|
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
318
351
|
}
|
|
319
|
-
async send({ target, payload }) {
|
|
352
|
+
async send({ target, conversation, payload }) {
|
|
320
353
|
const body = formatOutboundBody(payload);
|
|
321
|
-
const
|
|
354
|
+
const channelId = nativeConversationId(target, conversation);
|
|
355
|
+
const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
|
|
322
356
|
method: 'POST',
|
|
323
357
|
headers: {
|
|
324
358
|
Authorization: `Bot ${this.#options.config.token}`,
|
|
@@ -332,7 +366,26 @@ export class DiscordInteractionsEndpoint {
|
|
|
332
366
|
throw new Error(`Discord send failed (${response.status}): ${text.slice(0, 200)}`);
|
|
333
367
|
}
|
|
334
368
|
const data = JSON.parse(text);
|
|
335
|
-
|
|
369
|
+
const snowflake = data.id ?? '';
|
|
370
|
+
return snowflake ? formatLegacyMessageReference({ target, messageId: snowflake }) : '';
|
|
371
|
+
}
|
|
372
|
+
async recallMessage(messageId) {
|
|
373
|
+
if (!messageId)
|
|
374
|
+
return;
|
|
375
|
+
const reference = parseLegacyMessageReference(messageId);
|
|
376
|
+
if (!reference)
|
|
377
|
+
return;
|
|
378
|
+
const channelId = nativeConversationId(reference.target);
|
|
379
|
+
const snowflake = reference.messageId;
|
|
380
|
+
const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages/${snowflake}`, {
|
|
381
|
+
method: 'DELETE',
|
|
382
|
+
headers: { Authorization: `Bot ${this.#options.config.token}` },
|
|
383
|
+
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
384
|
+
});
|
|
385
|
+
if (!response.ok && response.status !== 404) {
|
|
386
|
+
const text = await response.text();
|
|
387
|
+
throw new Error(`Discord recall failed (${response.status}): ${text.slice(0, 200)}`);
|
|
388
|
+
}
|
|
336
389
|
}
|
|
337
390
|
admit(msg) {
|
|
338
391
|
if (!this.#open)
|
package/lib/gateway.d.ts
CHANGED
package/lib/gateway.js
CHANGED
|
@@ -111,7 +111,12 @@ export async function toMessageCreateOptions(body) {
|
|
|
111
111
|
if (body.files?.length) {
|
|
112
112
|
const files = [];
|
|
113
113
|
for (const file of body.files) {
|
|
114
|
-
if (file.
|
|
114
|
+
if (file.base64) {
|
|
115
|
+
files.push(new AttachmentBuilder(decodeBase64(file.base64), {
|
|
116
|
+
name: file.name || 'attachment',
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
else if (file.file && await fileExists(file.file)) {
|
|
115
120
|
files.push(new AttachmentBuilder(createReadStream(file.file), {
|
|
116
121
|
name: file.name || path.basename(file.file),
|
|
117
122
|
}));
|
|
@@ -119,6 +124,13 @@ export async function toMessageCreateOptions(body) {
|
|
|
119
124
|
else if (file.url) {
|
|
120
125
|
files.push(new AttachmentBuilder(file.url, { name: file.name || 'attachment' }));
|
|
121
126
|
}
|
|
127
|
+
else {
|
|
128
|
+
logger.warn(formatCompact({
|
|
129
|
+
op: 'discord_outbound_media_dropped',
|
|
130
|
+
reason: 'missing_source',
|
|
131
|
+
name: file.name,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
122
134
|
}
|
|
123
135
|
if (files.length)
|
|
124
136
|
options.files = files;
|
|
@@ -176,6 +188,12 @@ async function fileExists(filePath) {
|
|
|
176
188
|
return false;
|
|
177
189
|
}
|
|
178
190
|
}
|
|
191
|
+
/** base64:// / data:*;base64, 前缀归一,Buffer.from 只接受纯 base64。 */
|
|
192
|
+
function decodeBase64(value) {
|
|
193
|
+
const stripped = value.startsWith('base64://') ? value.slice('base64://'.length) : value;
|
|
194
|
+
const comma = stripped.startsWith('data:') ? stripped.indexOf(',') : -1;
|
|
195
|
+
return Buffer.from(comma >= 0 ? stripped.slice(comma + 1) : stripped, 'base64');
|
|
196
|
+
}
|
|
179
197
|
export async function connectDiscordGatewayClient(client, config, handlers) {
|
|
180
198
|
return new Promise((resolve, reject) => {
|
|
181
199
|
let settled = false;
|
package/lib/protocol.d.ts
CHANGED
package/lib/protocol.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
4
|
*/
|
|
5
5
|
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
6
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
7
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
|
+
const logger = getLogger('discord');
|
|
6
9
|
export function resolveDiscordConfig(config = {}) {
|
|
7
10
|
const entry = config.endpoints?.find((item) => item.context === 'discord' || !item.context);
|
|
8
11
|
const token = (typeof config.token === 'string' && config.token)
|
|
@@ -204,12 +207,35 @@ export function formatOutboundBody(payload) {
|
|
|
204
207
|
case 'audio':
|
|
205
208
|
case 'video':
|
|
206
209
|
case 'file': {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
// canonical MediaRef 唯一媒体来源(中央 normalizeOutboundPayload 已保证形状)
|
|
211
|
+
const media = data.media;
|
|
212
|
+
if (!isMediaRef(media)) {
|
|
213
|
+
logger.warn(formatCompact({
|
|
214
|
+
op: 'discord_outbound_media_dropped',
|
|
215
|
+
type: item.type,
|
|
216
|
+
reason: 'missing_media_ref',
|
|
217
|
+
}));
|
|
218
|
+
break;
|
|
210
219
|
}
|
|
211
|
-
|
|
212
|
-
|
|
220
|
+
const name = media.file_name
|
|
221
|
+
?? (typeof data.name === 'string' && data.name ? data.name : item.type);
|
|
222
|
+
if (media.kind === 'url') {
|
|
223
|
+
files.push({ name, url: media.value });
|
|
224
|
+
}
|
|
225
|
+
else if (media.kind === 'base64') {
|
|
226
|
+
files.push({ name, base64: media.value });
|
|
227
|
+
}
|
|
228
|
+
else if (media.kind === 'path') {
|
|
229
|
+
files.push({ name, file: media.value });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// kind=file:Discord 无平台不透明文件引用,无法投递
|
|
233
|
+
logger.warn(formatCompact({
|
|
234
|
+
op: 'discord_outbound_media_dropped',
|
|
235
|
+
type: item.type,
|
|
236
|
+
reason: 'unsupported_media_kind',
|
|
237
|
+
kind: media.kind,
|
|
238
|
+
}));
|
|
213
239
|
}
|
|
214
240
|
break;
|
|
215
241
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-discord",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Zhin.js Discord adapter for Plugin Runtime (Gateway WebSocket)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -33,20 +33,21 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"discord.js": "^14.27.0",
|
|
36
|
-
"@zhin.js/adapter": "1.1.
|
|
37
|
-
"@zhin.js/command": "1.0.
|
|
38
|
-
"@zhin.js/core": "1.
|
|
36
|
+
"@zhin.js/adapter": "1.1.3",
|
|
37
|
+
"@zhin.js/command": "1.0.5",
|
|
38
|
+
"@zhin.js/core": "1.5.0",
|
|
39
39
|
"@zhin.js/host-http": "1.0.4",
|
|
40
|
+
"@zhin.js/im-contract": "1.0.0",
|
|
40
41
|
"@zhin.js/logger": "1.0.75",
|
|
41
42
|
"@zhin.js/plugin-runtime": "1.1.1"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
45
|
"zod": "^4.0.0",
|
|
45
|
-
"@zhin.js/adapter": "1.1.
|
|
46
|
-
"@zhin.js/agent": "1.0
|
|
47
|
-
"@zhin.js/core": "1.
|
|
46
|
+
"@zhin.js/adapter": "1.1.3",
|
|
47
|
+
"@zhin.js/agent": "1.1.0",
|
|
48
|
+
"@zhin.js/core": "1.5.0",
|
|
48
49
|
"@zhin.js/plugin-runtime": "1.1.1",
|
|
49
|
-
"zhin.js": "
|
|
50
|
+
"zhin.js": "6.0.0"
|
|
50
51
|
},
|
|
51
52
|
"peerDependenciesMeta": {
|
|
52
53
|
"zhin.js": {
|
|
@@ -64,9 +65,9 @@
|
|
|
64
65
|
"typescript": "^6.0.3",
|
|
65
66
|
"vitest": "^4.1.10",
|
|
66
67
|
"zod": "^4.4.3",
|
|
67
|
-
"@zhin.js/agent": "1.0
|
|
68
|
+
"@zhin.js/agent": "1.1.0",
|
|
68
69
|
"@zhin.js/host-http": "1.0.4",
|
|
69
|
-
"zhin.js": "
|
|
70
|
+
"zhin.js": "6.0.0"
|
|
70
71
|
},
|
|
71
72
|
"files": [
|
|
72
73
|
"adapters",
|
package/plugin.js
CHANGED
|
@@ -9,7 +9,7 @@ export default definePlugin({
|
|
|
9
9
|
displayName: 'Discord Gateway Adapter',
|
|
10
10
|
},
|
|
11
11
|
setup(context) {
|
|
12
|
-
// 运行中 endpoint 注册表(discord
|
|
12
|
+
// 运行中 endpoint 注册表(discord.endpoint list 的"运行中"数据源)
|
|
13
13
|
context.resources.provide(discordRuntimeStateToken, createEndpointRuntimeState());
|
|
14
14
|
// 平台权限门禁:guild_owner / moderate_members 等(agent 工具 platformPermit)
|
|
15
15
|
return registerDiscordPlatformPermitChecker();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `discord
|
|
2
|
+
* `discord.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
3
|
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
4
|
* 注:applicationId / publicKey 为顶层共享字段(slash commands / interactions 用),
|
|
5
5
|
* endpoints[i] 仅需 name + token(见 schema.json),故 add 只录入 token。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Discord 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
-
* 由 plugin.ts setup() provide,adapter create 与 `discord
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `discord.endpoint` 命令共享(同一 owner generation)。
|
|
4
4
|
*/
|
|
5
5
|
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
6
|
|
package/src/endpoint.ts
CHANGED
|
@@ -4,12 +4,19 @@
|
|
|
4
4
|
import { ChannelType } from 'discord.js';
|
|
5
5
|
import type {
|
|
6
6
|
EndpointChannel,
|
|
7
|
+
EndpointControl,
|
|
7
8
|
EndpointGroup,
|
|
8
9
|
EndpointInstance,
|
|
9
10
|
EndpointManagement,
|
|
11
|
+
EndpointSendRequest,
|
|
10
12
|
} from '@zhin.js/adapter';
|
|
11
13
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
12
14
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
15
|
+
import {
|
|
16
|
+
formatLegacyMessageReference,
|
|
17
|
+
nativeConversationId,
|
|
18
|
+
parseLegacyMessageReference,
|
|
19
|
+
} from '@zhin.js/im-contract';
|
|
13
20
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
14
21
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
15
22
|
import { registerDiscordAgentEndpoint } from './discord-agent-deps.js';
|
|
@@ -65,6 +72,22 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
|
|
|
65
72
|
getClient: () => this.#requireClient(),
|
|
66
73
|
getMembers: (guildId) => this.getMembers(guildId),
|
|
67
74
|
});
|
|
75
|
+
readonly control: EndpointControl = Object.freeze({
|
|
76
|
+
recall: (messageId: string) => this.recallMessage(messageId),
|
|
77
|
+
addReaction: async (
|
|
78
|
+
messageId: string,
|
|
79
|
+
emoji: string,
|
|
80
|
+
hint?: { readonly sceneType?: string; readonly channelId?: string },
|
|
81
|
+
) => {
|
|
82
|
+
const reference = parseLegacyMessageReference(messageId);
|
|
83
|
+
const channelId = hint?.channelId
|
|
84
|
+
?? (reference ? nativeConversationId(reference.target) : undefined);
|
|
85
|
+
const nativeMessageId = reference?.messageId ?? messageId;
|
|
86
|
+
if (!channelId || !nativeMessageId) return null;
|
|
87
|
+
await this.addReaction(channelId, nativeMessageId, emoji);
|
|
88
|
+
return emoji;
|
|
89
|
+
},
|
|
90
|
+
});
|
|
68
91
|
|
|
69
92
|
constructor(options: DiscordEndpointOptions) {
|
|
70
93
|
this.#options = options;
|
|
@@ -122,9 +145,11 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
|
|
|
122
145
|
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
123
146
|
}
|
|
124
147
|
|
|
125
|
-
async send({ target, payload }:
|
|
148
|
+
async send({ target, conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
126
149
|
const body = formatOutboundBody(payload);
|
|
127
|
-
const
|
|
150
|
+
const channelId = nativeConversationId(target, conversation);
|
|
151
|
+
const snowflake = await this.#sendBody(channelId, body);
|
|
152
|
+
const messageId = formatLegacyMessageReference({ target, messageId: snowflake });
|
|
128
153
|
logger.debug(formatCompact({
|
|
129
154
|
op: 'discord_send',
|
|
130
155
|
endpoint: this.#options.config.name,
|
|
@@ -134,6 +159,18 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
|
|
|
134
159
|
return messageId;
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
163
|
+
if (!messageId) return;
|
|
164
|
+
const reference = parseLegacyMessageReference(messageId);
|
|
165
|
+
if (!reference) return;
|
|
166
|
+
const channelId = nativeConversationId(reference.target);
|
|
167
|
+
const snowflake = reference.messageId;
|
|
168
|
+
const channel = await this.#requireClient().channels.fetch(channelId);
|
|
169
|
+
if (!channel?.isTextBased() || !channel.messages) return;
|
|
170
|
+
const msg = await channel.messages.fetch(snowflake);
|
|
171
|
+
await msg.delete();
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
/** Test / internal: admit a message when open. */
|
|
138
175
|
admit(msg: DiscordInboundMessage): void {
|
|
139
176
|
if (!this.#open) return;
|
|
@@ -384,6 +421,9 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
|
|
|
384
421
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
385
422
|
#open = false;
|
|
386
423
|
#started = false;
|
|
424
|
+
readonly control: EndpointControl = Object.freeze({
|
|
425
|
+
recall: (messageId: string) => this.recallMessage(messageId),
|
|
426
|
+
});
|
|
387
427
|
|
|
388
428
|
constructor(options: DiscordInteractionsEndpointOptions) {
|
|
389
429
|
this.#options = options;
|
|
@@ -425,9 +465,10 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
|
|
|
425
465
|
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
426
466
|
}
|
|
427
467
|
|
|
428
|
-
async send({ target, payload }:
|
|
468
|
+
async send({ target, conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
429
469
|
const body = formatOutboundBody(payload);
|
|
430
|
-
const
|
|
470
|
+
const channelId = nativeConversationId(target, conversation);
|
|
471
|
+
const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
|
|
431
472
|
method: 'POST',
|
|
432
473
|
headers: {
|
|
433
474
|
Authorization: `Bot ${this.#options.config.token}`,
|
|
@@ -441,7 +482,25 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
|
|
|
441
482
|
throw new Error(`Discord send failed (${response.status}): ${text.slice(0, 200)}`);
|
|
442
483
|
}
|
|
443
484
|
const data = JSON.parse(text) as { id?: string };
|
|
444
|
-
|
|
485
|
+
const snowflake = data.id ?? '';
|
|
486
|
+
return snowflake ? formatLegacyMessageReference({ target, messageId: snowflake }) : '';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
490
|
+
if (!messageId) return;
|
|
491
|
+
const reference = parseLegacyMessageReference(messageId);
|
|
492
|
+
if (!reference) return;
|
|
493
|
+
const channelId = nativeConversationId(reference.target);
|
|
494
|
+
const snowflake = reference.messageId;
|
|
495
|
+
const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages/${snowflake}`, {
|
|
496
|
+
method: 'DELETE',
|
|
497
|
+
headers: { Authorization: `Bot ${this.#options.config.token}` },
|
|
498
|
+
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
499
|
+
});
|
|
500
|
+
if (!response.ok && response.status !== 404) {
|
|
501
|
+
const text = await response.text();
|
|
502
|
+
throw new Error(`Discord recall failed (${response.status}): ${text.slice(0, 200)}`);
|
|
503
|
+
}
|
|
445
504
|
}
|
|
446
505
|
|
|
447
506
|
admit(msg: DiscordInboundMessage): void {
|
package/src/gateway.ts
CHANGED
|
@@ -55,6 +55,7 @@ export interface DiscordClientTransport {
|
|
|
55
55
|
send?(options: MessageCreateOptions): Promise<{ id: string }>;
|
|
56
56
|
messages?: {
|
|
57
57
|
fetch(id: string): Promise<{
|
|
58
|
+
delete(): Promise<unknown>;
|
|
58
59
|
react(emoji: string): Promise<unknown>;
|
|
59
60
|
reactions: {
|
|
60
61
|
resolve(emoji: unknown): { users: { remove(userId: string): Promise<unknown> } } | null;
|
|
@@ -197,12 +198,22 @@ export async function toMessageCreateOptions(body: DiscordOutboundBody): Promise
|
|
|
197
198
|
if (body.files?.length) {
|
|
198
199
|
const files: AttachmentBuilder[] = [];
|
|
199
200
|
for (const file of body.files) {
|
|
200
|
-
if (file.
|
|
201
|
+
if (file.base64) {
|
|
202
|
+
files.push(new AttachmentBuilder(decodeBase64(file.base64), {
|
|
203
|
+
name: file.name || 'attachment',
|
|
204
|
+
}));
|
|
205
|
+
} else if (file.file && await fileExists(file.file)) {
|
|
201
206
|
files.push(new AttachmentBuilder(createReadStream(file.file), {
|
|
202
207
|
name: file.name || path.basename(file.file),
|
|
203
208
|
}));
|
|
204
209
|
} else if (file.url) {
|
|
205
210
|
files.push(new AttachmentBuilder(file.url, { name: file.name || 'attachment' }));
|
|
211
|
+
} else {
|
|
212
|
+
logger.warn(formatCompact({
|
|
213
|
+
op: 'discord_outbound_media_dropped',
|
|
214
|
+
reason: 'missing_source',
|
|
215
|
+
name: file.name,
|
|
216
|
+
}));
|
|
206
217
|
}
|
|
207
218
|
}
|
|
208
219
|
if (files.length) options.files = files;
|
|
@@ -266,6 +277,13 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|
|
266
277
|
}
|
|
267
278
|
}
|
|
268
279
|
|
|
280
|
+
/** base64:// / data:*;base64, 前缀归一,Buffer.from 只接受纯 base64。 */
|
|
281
|
+
function decodeBase64(value: string): Buffer {
|
|
282
|
+
const stripped = value.startsWith('base64://') ? value.slice('base64://'.length) : value;
|
|
283
|
+
const comma = stripped.startsWith('data:') ? stripped.indexOf(',') : -1;
|
|
284
|
+
return Buffer.from(comma >= 0 ? stripped.slice(comma + 1) : stripped, 'base64');
|
|
285
|
+
}
|
|
286
|
+
|
|
269
287
|
export interface DiscordGatewayConnectHandlers {
|
|
270
288
|
onMessage(msg: DiscordInboundMessage): void;
|
|
271
289
|
onButton(interaction: DiscordButtonInbound): void;
|
package/src/protocol.ts
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
7
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
7
8
|
import type { Segment } from '@zhin.js/core/runtime';
|
|
9
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
|
+
|
|
11
|
+
const logger = getLogger('discord');
|
|
8
12
|
|
|
9
13
|
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
10
14
|
export interface DiscordAdapterConfig {
|
|
@@ -131,6 +135,7 @@ export interface DiscordOutboundBody {
|
|
|
131
135
|
name: string;
|
|
132
136
|
url?: string;
|
|
133
137
|
file?: string;
|
|
138
|
+
base64?: string;
|
|
134
139
|
}>;
|
|
135
140
|
readonly components?: ReadonlyArray<DiscordOutboundActionRow>;
|
|
136
141
|
}
|
|
@@ -314,7 +319,7 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
314
319
|
|
|
315
320
|
let content = '';
|
|
316
321
|
const embeds: Record<string, unknown>[] = [];
|
|
317
|
-
const files: Array<{ name: string; url?: string; file?: string }> = [];
|
|
322
|
+
const files: Array<{ name: string; url?: string; file?: string; base64?: string }> = [];
|
|
318
323
|
let components: DiscordOutboundBody['components'];
|
|
319
324
|
|
|
320
325
|
for (const item of segments) {
|
|
@@ -345,11 +350,32 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
345
350
|
case 'audio':
|
|
346
351
|
case 'video':
|
|
347
352
|
case 'file': {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
+
// canonical MediaRef 唯一媒体来源(中央 normalizeOutboundPayload 已保证形状)
|
|
354
|
+
const media = data.media;
|
|
355
|
+
if (!isMediaRef(media)) {
|
|
356
|
+
logger.warn(formatCompact({
|
|
357
|
+
op: 'discord_outbound_media_dropped',
|
|
358
|
+
type: item.type,
|
|
359
|
+
reason: 'missing_media_ref',
|
|
360
|
+
}));
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
const name = media.file_name
|
|
364
|
+
?? (typeof data.name === 'string' && data.name ? data.name : item.type);
|
|
365
|
+
if (media.kind === 'url') {
|
|
366
|
+
files.push({ name, url: media.value });
|
|
367
|
+
} else if (media.kind === 'base64') {
|
|
368
|
+
files.push({ name, base64: media.value });
|
|
369
|
+
} else if (media.kind === 'path') {
|
|
370
|
+
files.push({ name, file: media.value });
|
|
371
|
+
} else {
|
|
372
|
+
// kind=file:Discord 无平台不透明文件引用,无法投递
|
|
373
|
+
logger.warn(formatCompact({
|
|
374
|
+
op: 'discord_outbound_media_dropped',
|
|
375
|
+
type: item.type,
|
|
376
|
+
reason: 'unsupported_media_kind',
|
|
377
|
+
kind: media.kind,
|
|
378
|
+
}));
|
|
353
379
|
}
|
|
354
380
|
break;
|
|
355
381
|
}
|