@zhin.js/adapter-discord 6.0.6 → 7.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,43 @@
1
1
  # @zhin.js/adapter-discord
2
2
 
3
+ ## 7.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [c8f4d45]
8
+ - @zhin.js/plugin-runtime@1.1.2
9
+ - @zhin.js/host-http@1.0.5
10
+ - @zhin.js/adapter@1.1.4
11
+ - @zhin.js/agent@1.1.1
12
+ - @zhin.js/command@1.0.6
13
+ - @zhin.js/core@1.5.1
14
+ - zhin.js@6.0.1
15
+
16
+ ## 7.0.0
17
+
18
+ ### Patch Changes
19
+
20
+ - 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
21
+
22
+ 全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
23
+
24
+ - **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
25
+ - **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
26
+ - **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
27
+ - **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
28
+ - **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
29
+
30
+ 迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
31
+
32
+ - Updated dependencies [7c1e63a]
33
+ - Updated dependencies [4fbff5d]
34
+ - Updated dependencies [5b94d9c]
35
+ - @zhin.js/command@1.0.5
36
+ - @zhin.js/adapter@1.1.3
37
+ - @zhin.js/core@1.5.0
38
+ - @zhin.js/agent@1.1.0
39
+ - zhin.js@6.0.0
40
+
3
41
  ## 6.0.6
4
42
 
5
43
  ### Patch Changes
@@ -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 endpoint list 的"运行中"数据源)
23
+ // 注册到插件运行时状态(discord.endpoint list 的"运行中"数据源)
23
24
  context.use(discordRuntimeStateToken).endpoints.set(config.name, {
24
25
  name: config.name,
25
26
  mode: config.connection,
@@ -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 endpoint list 的"运行中"数据源)
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 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
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 endpoint` 命令共享(同一 owner generation)。
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,13 @@ 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
- readonly target: string;
24
- readonly payload: unknown;
25
- }): Promise<string>;
23
+ send({ target, conversation, payload }: EndpointSendRequest): Promise<string>;
26
24
  recallMessage(messageId: string): Promise<void>;
27
25
  /** Test / internal: admit a message when open. */
28
26
  admit(msg: DiscordInboundMessage): void;
@@ -58,6 +56,7 @@ export interface DiscordInteractionsEndpointOptions {
58
56
  }
59
57
  export declare class DiscordInteractionsEndpoint implements EndpointInstance {
60
58
  #private;
59
+ readonly control: EndpointControl;
61
60
  constructor(options: DiscordInteractionsEndpointOptions);
62
61
  get isOpen(): boolean;
63
62
  get config(): ResolvedDiscordInteractionsConfig;
@@ -65,10 +64,7 @@ export declare class DiscordInteractionsEndpoint implements EndpointInstance {
65
64
  open(): void;
66
65
  close(): void;
67
66
  stop(): Promise<void>;
68
- send({ target, payload }: {
69
- readonly target: string;
70
- readonly payload: unknown;
71
- }): Promise<string>;
67
+ send({ target, conversation, payload }: EndpointSendRequest): Promise<string>;
72
68
  recallMessage(messageId: string): Promise<void>;
73
69
  admit(msg: DiscordInboundMessage): void;
74
70
  }
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,10 +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 snowflake = await this.#sendBody(target, body);
82
- const messageId = `${target}:${snowflake}`;
95
+ const channelId = nativeConversationId(target, conversation);
96
+ const snowflake = await this.#sendBody(channelId, body);
97
+ const messageId = formatLegacyMessageReference({ target, messageId: snowflake });
83
98
  logger.debug(formatCompact({
84
99
  op: 'discord_send',
85
100
  endpoint: this.#options.config.name,
@@ -91,11 +106,11 @@ export class DiscordGatewayEndpoint {
91
106
  async recallMessage(messageId) {
92
107
  if (!messageId)
93
108
  return;
94
- const colon = messageId.indexOf(':');
95
- if (colon <= 0)
109
+ const reference = parseLegacyMessageReference(messageId);
110
+ if (!reference)
96
111
  return;
97
- const channelId = messageId.slice(0, colon);
98
- const snowflake = messageId.slice(colon + 1);
112
+ const channelId = nativeConversationId(reference.target);
113
+ const snowflake = reference.messageId;
99
114
  const channel = await this.#requireClient().channels.fetch(channelId);
100
115
  if (!channel?.isTextBased() || !channel.messages)
101
116
  return;
@@ -296,6 +311,9 @@ export class DiscordInteractionsEndpoint {
296
311
  #routeReleases = [];
297
312
  #open = false;
298
313
  #started = false;
314
+ control = Object.freeze({
315
+ recall: (messageId) => this.recallMessage(messageId),
316
+ });
299
317
  constructor(options) {
300
318
  this.#options = options;
301
319
  this.#fetch = options.fetch ?? globalThis.fetch;
@@ -331,9 +349,10 @@ export class DiscordInteractionsEndpoint {
331
349
  this.#started = false;
332
350
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
333
351
  }
334
- async send({ target, payload }) {
352
+ async send({ target, conversation, payload }) {
335
353
  const body = formatOutboundBody(payload);
336
- const response = await this.#fetch(`${DISCORD_API}/channels/${target}/messages`, {
354
+ const channelId = nativeConversationId(target, conversation);
355
+ const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
337
356
  method: 'POST',
338
357
  headers: {
339
358
  Authorization: `Bot ${this.#options.config.token}`,
@@ -348,16 +367,16 @@ export class DiscordInteractionsEndpoint {
348
367
  }
349
368
  const data = JSON.parse(text);
350
369
  const snowflake = data.id ?? '';
351
- return snowflake ? `${target}:${snowflake}` : '';
370
+ return snowflake ? formatLegacyMessageReference({ target, messageId: snowflake }) : '';
352
371
  }
353
372
  async recallMessage(messageId) {
354
373
  if (!messageId)
355
374
  return;
356
- const colon = messageId.indexOf(':');
357
- if (colon <= 0)
375
+ const reference = parseLegacyMessageReference(messageId);
376
+ if (!reference)
358
377
  return;
359
- const channelId = messageId.slice(0, colon);
360
- const snowflake = messageId.slice(colon + 1);
378
+ const channelId = nativeConversationId(reference.target);
379
+ const snowflake = reference.messageId;
361
380
  const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages/${snowflake}`, {
362
381
  method: 'DELETE',
363
382
  headers: { Authorization: `Bot ${this.#options.config.token}` },
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.file && await fileExists(file.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
@@ -116,6 +116,7 @@ export interface DiscordOutboundBody {
116
116
  name: string;
117
117
  url?: string;
118
118
  file?: string;
119
+ base64?: string;
119
120
  }>;
120
121
  readonly components?: ReadonlyArray<DiscordOutboundActionRow>;
121
122
  }
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
- const name = String(data.name || data.filename || item.type);
208
- if (typeof data.file === 'string' && data.file) {
209
- files.push({ name, file: data.file });
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
- else if (typeof data.url === 'string' && data.url) {
212
- files.push({ name, url: data.url });
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": "6.0.6",
3
+ "version": "7.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,20 +33,21 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "discord.js": "^14.27.0",
36
- "@zhin.js/adapter": "1.1.2",
37
- "@zhin.js/command": "1.0.4",
38
- "@zhin.js/core": "1.4.3",
39
- "@zhin.js/host-http": "1.0.4",
36
+ "@zhin.js/adapter": "1.1.4",
37
+ "@zhin.js/command": "1.0.6",
38
+ "@zhin.js/core": "1.5.1",
39
+ "@zhin.js/host-http": "1.0.5",
40
+ "@zhin.js/im-contract": "1.0.0",
40
41
  "@zhin.js/logger": "1.0.75",
41
- "@zhin.js/plugin-runtime": "1.1.1"
42
+ "@zhin.js/plugin-runtime": "1.1.2"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "zod": "^4.0.0",
45
- "@zhin.js/adapter": "1.1.2",
46
- "@zhin.js/agent": "1.0.10",
47
- "@zhin.js/core": "1.4.3",
48
- "@zhin.js/plugin-runtime": "1.1.1",
49
- "zhin.js": "5.0.3"
46
+ "@zhin.js/adapter": "1.1.4",
47
+ "@zhin.js/agent": "1.1.1",
48
+ "@zhin.js/core": "1.5.1",
49
+ "@zhin.js/plugin-runtime": "1.1.2",
50
+ "zhin.js": "6.0.1"
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.10",
68
- "@zhin.js/host-http": "1.0.4",
69
- "zhin.js": "5.0.3"
68
+ "@zhin.js/agent": "1.1.1",
69
+ "@zhin.js/host-http": "1.0.5",
70
+ "zhin.js": "6.0.1"
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 endpoint list 的"运行中"数据源)
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 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
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 endpoint` 命令共享(同一 owner generation)。
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,10 +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 }: { readonly target: string; readonly payload: unknown }): Promise<string> {
148
+ async send({ target, conversation, payload }: EndpointSendRequest): Promise<string> {
126
149
  const body = formatOutboundBody(payload);
127
- const snowflake = await this.#sendBody(target, body);
128
- const messageId = `${target}:${snowflake}`;
150
+ const channelId = nativeConversationId(target, conversation);
151
+ const snowflake = await this.#sendBody(channelId, body);
152
+ const messageId = formatLegacyMessageReference({ target, messageId: snowflake });
129
153
  logger.debug(formatCompact({
130
154
  op: 'discord_send',
131
155
  endpoint: this.#options.config.name,
@@ -137,10 +161,10 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
137
161
 
138
162
  async recallMessage(messageId: string): Promise<void> {
139
163
  if (!messageId) return;
140
- const colon = messageId.indexOf(':');
141
- if (colon <= 0) return;
142
- const channelId = messageId.slice(0, colon);
143
- const snowflake = messageId.slice(colon + 1);
164
+ const reference = parseLegacyMessageReference(messageId);
165
+ if (!reference) return;
166
+ const channelId = nativeConversationId(reference.target);
167
+ const snowflake = reference.messageId;
144
168
  const channel = await this.#requireClient().channels.fetch(channelId);
145
169
  if (!channel?.isTextBased() || !channel.messages) return;
146
170
  const msg = await channel.messages.fetch(snowflake);
@@ -397,6 +421,9 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
397
421
  #routeReleases: HttpRouteRegistration[] = [];
398
422
  #open = false;
399
423
  #started = false;
424
+ readonly control: EndpointControl = Object.freeze({
425
+ recall: (messageId: string) => this.recallMessage(messageId),
426
+ });
400
427
 
401
428
  constructor(options: DiscordInteractionsEndpointOptions) {
402
429
  this.#options = options;
@@ -438,9 +465,10 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
438
465
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
439
466
  }
440
467
 
441
- async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
468
+ async send({ target, conversation, payload }: EndpointSendRequest): Promise<string> {
442
469
  const body = formatOutboundBody(payload);
443
- const response = await this.#fetch(`${DISCORD_API}/channels/${target}/messages`, {
470
+ const channelId = nativeConversationId(target, conversation);
471
+ const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
444
472
  method: 'POST',
445
473
  headers: {
446
474
  Authorization: `Bot ${this.#options.config.token}`,
@@ -455,15 +483,15 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
455
483
  }
456
484
  const data = JSON.parse(text) as { id?: string };
457
485
  const snowflake = data.id ?? '';
458
- return snowflake ? `${target}:${snowflake}` : '';
486
+ return snowflake ? formatLegacyMessageReference({ target, messageId: snowflake }) : '';
459
487
  }
460
488
 
461
489
  async recallMessage(messageId: string): Promise<void> {
462
490
  if (!messageId) return;
463
- const colon = messageId.indexOf(':');
464
- if (colon <= 0) return;
465
- const channelId = messageId.slice(0, colon);
466
- const snowflake = messageId.slice(colon + 1);
491
+ const reference = parseLegacyMessageReference(messageId);
492
+ if (!reference) return;
493
+ const channelId = nativeConversationId(reference.target);
494
+ const snowflake = reference.messageId;
467
495
  const response = await this.#fetch(`${DISCORD_API}/channels/${channelId}/messages/${snowflake}`, {
468
496
  method: 'DELETE',
469
497
  headers: { Authorization: `Bot ${this.#options.config.token}` },
package/src/gateway.ts CHANGED
@@ -198,12 +198,22 @@ export async function toMessageCreateOptions(body: DiscordOutboundBody): Promise
198
198
  if (body.files?.length) {
199
199
  const files: AttachmentBuilder[] = [];
200
200
  for (const file of body.files) {
201
- if (file.file && await fileExists(file.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)) {
202
206
  files.push(new AttachmentBuilder(createReadStream(file.file), {
203
207
  name: file.name || path.basename(file.file),
204
208
  }));
205
209
  } else if (file.url) {
206
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
+ }));
207
217
  }
208
218
  }
209
219
  if (files.length) options.files = files;
@@ -267,6 +277,13 @@ async function fileExists(filePath: string): Promise<boolean> {
267
277
  }
268
278
  }
269
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
+
270
287
  export interface DiscordGatewayConnectHandlers {
271
288
  onMessage(msg: DiscordInboundMessage): void;
272
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
- const name = String(data.name || data.filename || item.type);
349
- if (typeof data.file === 'string' && data.file) {
350
- files.push({ name, file: data.file });
351
- } else if (typeof data.url === 'string' && data.url) {
352
- files.push({ name, url: data.url });
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
  }