@zhin.js/adapter-discord 7.0.12 → 7.0.14

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,54 @@
1
1
  # @zhin.js/adapter-discord
2
2
 
3
+ ## 7.0.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 5969c5b: Remove legacy compound-string message targets and Endpoint control probing. Endpoint control and outbound Host operations now carry structured `MessageRef` identities. Endpoint send has one exact result contract (a platform message id), which IM Runtime projects into `DeliveryReceipt.message`; arbitrary result guessing is removed.
8
+ - 9a64283: Add canonical scoped Discord message resolution with structured attachment media references.
9
+ - 974772e: Replace the user-facing `Prompt` vocabulary with the `UserInteraction` authoring surface for input, confirmation, and selection. Commands and handlers now expose `interaction`; IM Runtime exposes `createInteraction`; schema-driven endpoint collection is named `SchemaInteraction`. The old prompt-named interaction types and properties are removed rather than aliased. User interactions render through one canonical Markdown and keyboard/list presentation module shared by commands and Agent `ask_user` turns.
10
+
11
+ Extract the transport-neutral interaction contract into `@zhin.js/interaction`. A discriminated `ask()` API supports text, number, confirmation, single-select, multi-select, and typed lists with structured `title`, `description`, and `tip` content. Typed `sequence()` interactions return one result object keyed by step id, render progress, and retry invalid replies without leaking invalid values to callers.
12
+
13
+ Preserve AI Markdown and card command actions through outbound publishing. QQ delivers Markdown with native command buttons; KOOK, Discord, Telegram, DingTalk, and Lark/Feishu now declare and encode their native Markdown dialects while retaining each adapter's interaction policy. Correct QQ callback button action encoding and button style mapping.
14
+
15
+ - 5969c5b: Add SideEventGateway so adapters forward notice/request/system into HandlerIndex. HandlerContext now exposes only generation-safe capabilities and prompt ports; live Endpoint escape hatches are removed.
16
+ - Updated dependencies [5969c5b]
17
+ - Updated dependencies [d336a3f]
18
+ - Updated dependencies [0c82a7e]
19
+ - Updated dependencies [b9217e4]
20
+ - Updated dependencies [5969c5b]
21
+ - Updated dependencies [5969c5b]
22
+ - Updated dependencies [974772e]
23
+ - Updated dependencies [5969c5b]
24
+ - Updated dependencies [5969c5b]
25
+ - Updated dependencies [2f786bd]
26
+ - Updated dependencies [63d89f9]
27
+ - Updated dependencies [71c7cdd]
28
+ - Updated dependencies [3cca0ea]
29
+ - Updated dependencies [1312ca0]
30
+ - Updated dependencies [985fa22]
31
+ - Updated dependencies [04b861d]
32
+ - Updated dependencies [a23d544]
33
+ - Updated dependencies [8cddabf]
34
+ - Updated dependencies [dbe5081]
35
+ - @zhin.js/im-contract@1.0.4
36
+ - @zhin.js/core@1.5.12
37
+ - @zhin.js/adapter@1.1.11
38
+ - @zhin.js/agent@1.1.14
39
+ - @zhin.js/host-http@1.0.11
40
+ - @zhin.js/command@1.0.15
41
+ - zhin.js@6.0.12
42
+ - @zhin.js/permission@1.0.3
43
+
44
+ ## 7.0.13
45
+
46
+ ### Patch Changes
47
+
48
+ - @zhin.js/core@1.5.11
49
+ - @zhin.js/agent@1.1.13
50
+ - zhin.js@6.0.11
51
+
3
52
  ## 7.0.12
4
53
 
5
54
  ### Patch Changes
package/README.md CHANGED
@@ -7,7 +7,7 @@ Zhin.js Discord 适配器(Plugin Runtime),默认通过 **Gateway WebSocket
7
7
  - Gateway WebSocket 入站(默认;无需公网 HTTPS / host)
8
8
  - 解析 text / mention / attachment / embed / sticker / button
9
9
  - 支持私聊、群组与服务器频道
10
- - 出站 `send({ conversation, payload })` → Discord channel message(text / media / embed / keyboard)
10
+ - 出站 `send({ conversation, payload })` → Discord channel message(Markdown content / media / embed / keyboard)
11
11
  - 约定式 `defineAdapter` / `definePlugin`(无需 `usePlugin`)
12
12
  - Interactions HTTP webhook 延期(需 `httpHostToken`);配置 `connection: interactions` 会明确报错
13
13
 
@@ -3,7 +3,7 @@
3
3
  * Convention entry: discover `adapters/discord.ts` → defineAdapter.
4
4
  */
5
5
  import { defineAdapter } from 'zhin.js/adapter';
6
- import { messageGatewayToken } from '@zhin.js/core/runtime';
6
+ import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
7
7
  import { httpHostToken } from '@zhin.js/host-http';
8
8
  import { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, } from "../lib/endpoint.js";
9
9
  import { resolveDiscordConfig, } from "../lib/protocol.js";
@@ -16,10 +16,12 @@ export default defineAdapter({
16
16
  segments: {
17
17
  outboundMedia: ['url', 'upload'],
18
18
  interactive: 'native',
19
+ markdown: 'native',
19
20
  },
20
21
  create(context) {
21
22
  const config = resolveDiscordConfig(context.config);
22
23
  const gateway = context.use(messageGatewayToken);
24
+ const sideEvents = context.use(sideEventGatewayToken);
23
25
  // 注册到插件运行时状态(discord.endpoint list 的"运行中"数据源)
24
26
  context.use(discordRuntimeStateToken).endpoints.set(config.id, {
25
27
  id: config.id,
@@ -29,6 +31,7 @@ export default defineAdapter({
29
31
  return new DiscordInteractionsEndpoint({
30
32
  id: context.id,
31
33
  gateway,
34
+ sideEvents,
32
35
  http: context.use(httpHostToken),
33
36
  config,
34
37
  });
@@ -36,6 +39,7 @@ export default defineAdapter({
36
39
  return new DiscordGatewayEndpoint({
37
40
  id: context.id,
38
41
  gateway,
42
+ sideEvents,
39
43
  config,
40
44
  });
41
45
  },
@@ -2,7 +2,7 @@
2
2
  * Convention entry: discover `adapters/discord.ts` → defineAdapter.
3
3
  */
4
4
  import { defineAdapter } from 'zhin.js/adapter';
5
- import { messageGatewayToken } from '@zhin.js/core/runtime';
5
+ import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
6
6
  import { httpHostToken } from '@zhin.js/host-http';
7
7
  import {
8
8
  DiscordGatewayEndpoint,
@@ -32,10 +32,12 @@ export default defineAdapter<DiscordAdapterConfig>({
32
32
  segments: {
33
33
  outboundMedia: ['url', 'upload'],
34
34
  interactive: 'native',
35
+ markdown: 'native',
35
36
  },
36
37
  create(context) {
37
38
  const config = resolveDiscordConfig(context.config);
38
39
  const gateway = context.use(messageGatewayToken);
40
+ const sideEvents = context.use(sideEventGatewayToken);
39
41
  // 注册到插件运行时状态(discord.endpoint list 的"运行中"数据源)
40
42
  context.use(discordRuntimeStateToken).endpoints.set(config.id, {
41
43
  id: config.id,
@@ -45,6 +47,7 @@ export default defineAdapter<DiscordAdapterConfig>({
45
47
  return new DiscordInteractionsEndpoint({
46
48
  id: context.id,
47
49
  gateway,
50
+ sideEvents,
48
51
  http: context.use(httpHostToken),
49
52
  config,
50
53
  });
@@ -52,6 +55,7 @@ export default defineAdapter<DiscordAdapterConfig>({
52
55
  return new DiscordGatewayEndpoint({
53
56
  id: context.id,
54
57
  gateway,
58
+ sideEvents,
55
59
  config,
56
60
  });
57
61
  },
package/lib/endpoint.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { EndpointControl, EndpointInstance, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
2
- import type { MessageGateway } from '@zhin.js/core/runtime';
1
+ import type { EndpointContentPort, EndpointControl, EndpointInstance, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
2
+ import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import { type MessageRef } from '@zhin.js/im-contract';
5
5
  import type { CapabilityId } from 'zhin.js';
@@ -9,13 +9,16 @@ export type { CreateDiscordClient, DiscordClientTransport, } from './gateway.js'
9
9
  export interface DiscordEndpointOptions {
10
10
  readonly id: CapabilityId;
11
11
  readonly gateway: MessageGateway;
12
+ readonly sideEvents?: SideEventGateway;
12
13
  readonly config: ResolvedDiscordGatewayConfig;
13
14
  readonly createClient?: CreateDiscordClient;
15
+ readonly fetch?: typeof globalThis.fetch;
14
16
  }
15
17
  export declare class DiscordGatewayEndpoint implements EndpointInstance {
16
18
  #private;
17
19
  readonly management: EndpointManagement;
18
20
  readonly control: EndpointControl;
21
+ readonly content: EndpointContentPort;
19
22
  constructor(options: DiscordEndpointOptions);
20
23
  start(): Promise<void>;
21
24
  open(): void;
@@ -51,6 +54,7 @@ export declare class DiscordGatewayEndpoint implements EndpointInstance {
51
54
  export interface DiscordInteractionsEndpointOptions {
52
55
  readonly id: CapabilityId;
53
56
  readonly gateway: MessageGateway;
57
+ readonly sideEvents?: SideEventGateway;
54
58
  readonly http: HttpHost;
55
59
  readonly config: ResolvedDiscordInteractionsConfig;
56
60
  readonly fetch?: typeof globalThis.fetch;
@@ -58,6 +62,7 @@ export interface DiscordInteractionsEndpointOptions {
58
62
  export declare class DiscordInteractionsEndpoint implements EndpointInstance {
59
63
  #private;
60
64
  readonly control: EndpointControl;
65
+ readonly content: EndpointContentPort;
61
66
  constructor(options: DiscordInteractionsEndpointOptions);
62
67
  get isOpen(): boolean;
63
68
  get config(): ResolvedDiscordInteractionsConfig;
package/lib/endpoint.js CHANGED
@@ -7,6 +7,7 @@ import { registerDiscordAgentEndpoint } from './discord-agent-deps.js';
7
7
  import { connectDiscordGatewayClient, defaultCreateClient, DEFAULT_INTENTS, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
8
8
  import { discordInboundConversation, formatButtonContent, formatButtonSegments, formatInboundContent, formatInboundSegments, formatOutboundBody, senderDisplayName, } from './protocol.js';
9
9
  import { registerDiscordInteractionRoutes } from './webhook.js';
10
+ import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
10
11
  const DISCORD_API = 'https://discord.com/api/v10';
11
12
  /** 出站 HTTP 调用统一 30s 超时。 */
12
13
  const OUTBOUND_TIMEOUT_MS = 30_000;
@@ -14,6 +15,7 @@ export class DiscordGatewayEndpoint {
14
15
  #logger;
15
16
  #options;
16
17
  #createClient;
18
+ #fetch;
17
19
  #client = null;
18
20
  #open = false;
19
21
  #started = false;
@@ -32,10 +34,14 @@ export class DiscordGatewayEndpoint {
32
34
  return emoji;
33
35
  },
34
36
  });
37
+ content = Object.freeze({
38
+ resolve: (reference, context) => resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
39
+ });
35
40
  constructor(options) {
36
41
  this.#logger = getAdapterLogger('discord', options.config.id);
37
42
  this.#options = options;
38
43
  this.#createClient = options.createClient ?? defaultCreateClient;
44
+ this.#fetch = options.fetch ?? globalThis.fetch;
39
45
  }
40
46
  async start() {
41
47
  if (this.#started)
@@ -50,6 +56,12 @@ export class DiscordGatewayEndpoint {
50
56
  await connectDiscordGatewayClient(this.#client, this.#options.config, {
51
57
  onMessage: (msg) => this.admit(msg),
52
58
  onButton: (interaction) => this.admitButton(interaction),
59
+ onGuildMemberAdd: (member) => {
60
+ receiveDiscordGuildMemberSideEvent(this.#options.sideEvents, this.#options.config.id, 'member_increase', member, this.#logger);
61
+ },
62
+ onGuildMemberRemove: (member) => {
63
+ receiveDiscordGuildMemberSideEvent(this.#options.sideEvents, this.#options.config.id, 'member_decrease', member, this.#logger);
64
+ },
53
65
  });
54
66
  this.#logger.info(formatCompact({
55
67
  op: 'connect',
@@ -309,6 +321,9 @@ export class DiscordInteractionsEndpoint {
309
321
  control = Object.freeze({
310
322
  recall: (message) => this.recallMessage(message),
311
323
  });
324
+ content = Object.freeze({
325
+ resolve: (reference, context) => resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
326
+ });
312
327
  constructor(options) {
313
328
  this.#logger = getAdapterLogger('discord', options.config.id);
314
329
  this.#options = options;
@@ -408,6 +423,55 @@ export class DiscordInteractionsEndpoint {
408
423
  });
409
424
  }
410
425
  }
426
+ async function resolveDiscordContent(fetch, token, reference, context) {
427
+ if (reference.kind === 'forward') {
428
+ return Object.freeze({ status: 'unsupported', code: 'discord_merged_forward_unavailable' });
429
+ }
430
+ if (reference.kind === 'media') {
431
+ return reference.media.kind === 'file'
432
+ ? Object.freeze({ status: 'unsupported', code: 'discord_opaque_media_unavailable' })
433
+ : Object.freeze({ status: 'resolved', reference, value: reference.media });
434
+ }
435
+ try {
436
+ context.signal.throwIfAborted();
437
+ const response = await fetch(`${DISCORD_API}/channels/${reference.message.conversation.id}/messages/${reference.message.id}`, { headers: { Authorization: `Bot ${token}` }, signal: context.signal });
438
+ if (response.status === 404)
439
+ return Object.freeze({ status: 'not_found', code: 'discord_message_not_found' });
440
+ if (response.status === 403)
441
+ return Object.freeze({ status: 'forbidden', code: 'discord_message_forbidden' });
442
+ if (!response.ok)
443
+ return Object.freeze({ status: 'failed', code: 'discord_message_fetch_failed' });
444
+ const row = await response.json();
445
+ const author = row.author;
446
+ const attachments = Array.isArray(row.attachments) ? row.attachments : [];
447
+ const segments = [];
448
+ if (typeof row.content === 'string' && row.content.trim())
449
+ segments.push({ type: 'text', data: { text: row.content } });
450
+ for (const item of attachments.slice(0, context.maxEntries)) {
451
+ const attachment = item;
452
+ if (typeof attachment.url !== 'string')
453
+ continue;
454
+ const mime = typeof attachment.content_type === 'string' ? attachment.content_type : undefined;
455
+ const type = mime?.startsWith('image/') ? 'image' : mime?.startsWith('audio/') ? 'audio' : mime?.startsWith('video/') ? 'video' : 'file';
456
+ segments.push({ type, data: { media: { kind: 'url', value: attachment.url, ...(mime ? { mime_type: mime } : {}), ...(attachment.filename ? { file_name: String(attachment.filename) } : {}), ...(typeof attachment.size === 'number' ? { size: attachment.size } : {}) } } });
457
+ }
458
+ return Object.freeze({
459
+ status: 'resolved',
460
+ reference,
461
+ value: Object.freeze({
462
+ ref: reference.message,
463
+ ...(author?.id ? { actor: Object.freeze({ id: String(author.id), ...(author.global_name || author.username ? { displayName: String(author.global_name ?? author.username) } : {}) }) } : {}),
464
+ segments: Object.freeze(segments),
465
+ timestamp: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : Date.now(),
466
+ }),
467
+ });
468
+ }
469
+ catch (error) {
470
+ if (context.signal.aborted)
471
+ return Object.freeze({ status: 'expired', code: 'turn_aborted' });
472
+ return Object.freeze({ status: 'failed', code: 'discord_content_resolution_failed', message: error instanceof Error ? error.message : String(error) });
473
+ }
474
+ }
411
475
  /**
412
476
  * Discord snowflake 是 64 位整数的字符串形式,超出 Number.MAX_SAFE_INTEGER,
413
477
  * Number() 转换会丢精度。Console 社交面只把 group_id 当 JSON 值透传、
package/lib/gateway.d.ts CHANGED
@@ -129,5 +129,15 @@ export declare function toMessageCreateOptions(body: DiscordOutboundBody): Promi
129
129
  export interface DiscordGatewayConnectHandlers {
130
130
  onMessage(msg: DiscordInboundMessage): void;
131
131
  onButton(interaction: DiscordButtonInbound): void;
132
+ onGuildMemberAdd?(member: {
133
+ guildId: string;
134
+ userId: string;
135
+ userName?: string;
136
+ }): void;
137
+ onGuildMemberRemove?(member: {
138
+ guildId: string;
139
+ userId: string;
140
+ userName?: string;
141
+ }): void;
132
142
  }
133
143
  export declare function connectDiscordGatewayClient(client: DiscordClientTransport, config: ResolvedDiscordGatewayConfig, handlers: DiscordGatewayConnectHandlers): Promise<void>;
package/lib/gateway.js CHANGED
@@ -224,6 +224,30 @@ export async function connectDiscordGatewayClient(client, config, handlers) {
224
224
  sourceMessageId: interaction.message?.id,
225
225
  });
226
226
  });
227
+ client.on('guildMemberAdd', (raw) => {
228
+ const member = raw;
229
+ const guildId = member.guild?.id;
230
+ const userId = member.user?.id;
231
+ if (!guildId || !userId)
232
+ return;
233
+ handlers.onGuildMemberAdd?.({
234
+ guildId,
235
+ userId,
236
+ userName: member.user?.username || member.user?.displayName,
237
+ });
238
+ });
239
+ client.on('guildMemberRemove', (raw) => {
240
+ const member = raw;
241
+ const guildId = member.guild?.id;
242
+ const userId = member.user?.id;
243
+ if (!guildId || !userId)
244
+ return;
245
+ handlers.onGuildMemberRemove?.({
246
+ guildId,
247
+ userId,
248
+ userName: member.user?.username || member.user?.displayName,
249
+ });
250
+ });
227
251
  client.once('clientReady', () => {
228
252
  void (async () => {
229
253
  try {
package/lib/protocol.d.ts CHANGED
@@ -138,7 +138,7 @@ export declare function senderDisplayName(msg: DiscordInboundMessage): string;
138
138
  export declare function formatInboundContent(msg: DiscordInboundMessage): string;
139
139
  export declare function formatButtonContent(interaction: DiscordButtonInbound): string;
140
140
  /**
141
- * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
141
+ * 入站消息 → canonical Segment[];引用与附件不重复编码进文本视图。
142
142
  * 附件 url 是 Discord CDN 真实 http(s) 地址,MediaRef kind=url;
143
143
  * embeds / stickers 仅留在纯文本视图,不进段(最小侵入)。
144
144
  */
package/lib/protocol.js CHANGED
@@ -80,21 +80,8 @@ export function senderDisplayName(msg) {
80
80
  /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
81
81
  export function formatInboundContent(msg) {
82
82
  const parts = [];
83
- if (msg.replyToId)
84
- parts.push(`[reply:${msg.replyToId}]`);
85
83
  if (msg.content?.trim())
86
84
  parts.push(msg.content.trim());
87
- for (const attachment of msg.attachments ?? []) {
88
- const kind = attachment.contentType?.startsWith('image/')
89
- ? 'image'
90
- : attachment.contentType?.startsWith('audio/')
91
- ? 'audio'
92
- : attachment.contentType?.startsWith('video/')
93
- ? 'video'
94
- : 'file';
95
- const name = attachment.name || attachment.url || 'attachment';
96
- parts.push(`[${kind}: ${name}]`);
97
- }
98
85
  for (const title of msg.embedTitles ?? []) {
99
86
  parts.push(`[embed: ${title}]`);
100
87
  }
@@ -102,7 +89,7 @@ export function formatInboundContent(msg) {
102
89
  parts.push(`[sticker: ${name}]`);
103
90
  }
104
91
  const text = parts.join('\n').trim();
105
- return text || '(Empty message)';
92
+ return text;
106
93
  }
107
94
  export function formatButtonContent(interaction) {
108
95
  return `[action: ${interaction.customId}]`;
@@ -117,7 +104,7 @@ function attachmentMediaKind(contentType) {
117
104
  return 'file';
118
105
  }
119
106
  /**
120
- * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
107
+ * 入站消息 → canonical Segment[];引用与附件不重复编码进文本视图。
121
108
  * 附件 url 是 Discord CDN 真实 http(s) 地址,MediaRef kind=url;
122
109
  * embeds / stickers 仅留在纯文本视图,不进段(最小侵入)。
123
110
  */
@@ -204,6 +191,9 @@ export function formatOutboundBody(payload) {
204
191
  case 'text':
205
192
  content += String(data.text ?? data.content ?? '');
206
193
  break;
194
+ case 'markdown':
195
+ content += String(data.content ?? data.text ?? '');
196
+ break;
207
197
  case 'at':
208
198
  content += `<@${String(data.id ?? '')}>`;
209
199
  break;
@@ -0,0 +1,8 @@
1
+ import type { SideEventGateway } from '@zhin.js/core/runtime';
2
+ import { type getAdapterLogger } from '@zhin.js/logger';
3
+ export interface DiscordGuildMemberSideEvent {
4
+ readonly guildId: string;
5
+ readonly userId: string;
6
+ readonly userName?: string;
7
+ }
8
+ export declare function receiveDiscordGuildMemberSideEvent(sideEvents: SideEventGateway | undefined, configId: string, kind: 'member_increase' | 'member_decrease', event: DiscordGuildMemberSideEvent, logger: ReturnType<typeof getAdapterLogger>): void;
@@ -0,0 +1,24 @@
1
+ import { buildNotice, senderFromId } from '@zhin.js/core';
2
+ import { formatCompact } from '@zhin.js/logger';
3
+ export function receiveDiscordGuildMemberSideEvent(sideEvents, configId, kind, event, logger) {
4
+ if (!sideEvents)
5
+ return;
6
+ void sideEvents.receiveNotice(buildNotice(event, {
7
+ $id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
8
+ $adapter: 'discord',
9
+ $endpoint: configId,
10
+ $type: 'notice',
11
+ $scene_id: event.guildId,
12
+ $scene_type: 'group',
13
+ $sub_type: kind,
14
+ $actor: senderFromId(event.userId, event.userName),
15
+ $timestamp: Date.now(),
16
+ })).catch((err) => {
17
+ logger.warn(formatCompact({
18
+ op: 'discord_side_event_failed',
19
+ endpoint: configId,
20
+ event: kind,
21
+ error: err instanceof Error ? err.message : String(err),
22
+ }));
23
+ });
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-discord",
3
- "version": "7.0.12",
3
+ "version": "7.0.14",
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,20 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "discord.js": "^14.27.0",
36
- "@zhin.js/adapter": "1.1.10",
37
- "@zhin.js/core": "1.5.10",
38
- "@zhin.js/host-http": "1.0.10",
39
- "@zhin.js/im-contract": "1.0.3",
36
+ "@zhin.js/adapter": "1.1.11",
37
+ "@zhin.js/core": "1.5.12",
38
+ "@zhin.js/host-http": "1.0.11",
39
+ "@zhin.js/im-contract": "1.0.4",
40
40
  "@zhin.js/logger": "1.0.76",
41
- "@zhin.js/permission": "1.0.2"
41
+ "@zhin.js/permission": "1.0.3"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "zod": "^4.0.0",
45
- "@zhin.js/adapter": "1.1.10",
46
- "@zhin.js/agent": "1.1.12",
47
- "@zhin.js/command": "1.0.14",
48
- "@zhin.js/core": "1.5.10",
49
- "zhin.js": "6.0.10"
45
+ "@zhin.js/adapter": "1.1.11",
46
+ "@zhin.js/agent": "1.1.14",
47
+ "@zhin.js/command": "1.0.15",
48
+ "@zhin.js/core": "1.5.12",
49
+ "zhin.js": "6.0.12"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@zhin.js/agent": {
@@ -67,9 +67,9 @@
67
67
  "typescript": "^6.0.3",
68
68
  "vitest": "^4.1.10",
69
69
  "zod": "^4.4.3",
70
- "@zhin.js/agent": "1.1.12",
71
- "@zhin.js/host-http": "1.0.10",
72
- "zhin.js": "6.0.10"
70
+ "@zhin.js/agent": "1.1.14",
71
+ "@zhin.js/host-http": "1.0.11",
72
+ "zhin.js": "6.0.12"
73
73
  },
74
74
  "files": [
75
75
  "adapters",
package/src/endpoint.ts CHANGED
@@ -4,15 +4,19 @@
4
4
  import { ChannelType } from 'discord.js';
5
5
  import type {
6
6
  EndpointChannel,
7
+ EndpointContentPort,
8
+ EndpointContentResolveContext,
7
9
  EndpointControl,
8
10
  EndpointGroup,
9
11
  EndpointInstance,
10
12
  EndpointManagement,
11
13
  EndpointSendRequest,
12
14
  } from 'zhin.js/adapter';
13
- import type { MessageGateway } from '@zhin.js/core/runtime';
15
+ import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
14
16
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
15
17
  import {
18
+ type ConversationReference,
19
+ type ConversationResolution,
16
20
  type MessageRef,
17
21
  } from '@zhin.js/im-contract';
18
22
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
@@ -42,6 +46,7 @@ import {
42
46
  type ResolvedDiscordInteractionsConfig,
43
47
  } from './protocol.js';
44
48
  import { registerDiscordInteractionRoutes } from './webhook.js';
49
+ import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
45
50
 
46
51
  const DISCORD_API = 'https://discord.com/api/v10';
47
52
  /** 出站 HTTP 调用统一 30s 超时。 */
@@ -54,8 +59,10 @@ export type {
54
59
  export interface DiscordEndpointOptions {
55
60
  readonly id: CapabilityId;
56
61
  readonly gateway: MessageGateway;
62
+ readonly sideEvents?: SideEventGateway;
57
63
  readonly config: ResolvedDiscordGatewayConfig;
58
64
  readonly createClient?: CreateDiscordClient;
65
+ readonly fetch?: typeof globalThis.fetch;
59
66
  }
60
67
 
61
68
  export class DiscordGatewayEndpoint implements EndpointInstance {
@@ -63,6 +70,7 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
63
70
 
64
71
  readonly #options: DiscordEndpointOptions;
65
72
  readonly #createClient: CreateDiscordClient;
73
+ readonly #fetch: typeof globalThis.fetch;
66
74
  #client: DiscordClientTransport | null = null;
67
75
  #open = false;
68
76
  #started = false;
@@ -84,11 +92,16 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
84
92
  return emoji;
85
93
  },
86
94
  });
95
+ readonly content: EndpointContentPort = Object.freeze({
96
+ resolve: (reference: ConversationReference, context: EndpointContentResolveContext) =>
97
+ resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
98
+ });
87
99
 
88
100
  constructor(options: DiscordEndpointOptions) {
89
101
  this.#logger = getAdapterLogger('discord', options.config.id);
90
102
  this.#options = options;
91
103
  this.#createClient = options.createClient ?? defaultCreateClient;
104
+ this.#fetch = options.fetch ?? globalThis.fetch;
92
105
  }
93
106
 
94
107
  async start(): Promise<void> {
@@ -103,6 +116,24 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
103
116
  await connectDiscordGatewayClient(this.#client, this.#options.config, {
104
117
  onMessage: (msg) => this.admit(msg),
105
118
  onButton: (interaction) => this.admitButton(interaction),
119
+ onGuildMemberAdd: (member) => {
120
+ receiveDiscordGuildMemberSideEvent(
121
+ this.#options.sideEvents,
122
+ this.#options.config.id,
123
+ 'member_increase',
124
+ member,
125
+ this.#logger,
126
+ );
127
+ },
128
+ onGuildMemberRemove: (member) => {
129
+ receiveDiscordGuildMemberSideEvent(
130
+ this.#options.sideEvents,
131
+ this.#options.config.id,
132
+ 'member_decrease',
133
+ member,
134
+ this.#logger,
135
+ );
136
+ },
106
137
  });
107
138
  this.#logger.info(formatCompact({
108
139
  op: 'connect',
@@ -405,6 +436,7 @@ export class DiscordGatewayEndpoint implements EndpointInstance {
405
436
  export interface DiscordInteractionsEndpointOptions {
406
437
  readonly id: CapabilityId;
407
438
  readonly gateway: MessageGateway;
439
+ readonly sideEvents?: SideEventGateway;
408
440
  readonly http: HttpHost;
409
441
  readonly config: ResolvedDiscordInteractionsConfig;
410
442
  readonly fetch?: typeof globalThis.fetch;
@@ -421,6 +453,10 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
421
453
  readonly control: EndpointControl = Object.freeze({
422
454
  recall: (message: MessageRef) => this.recallMessage(message),
423
455
  });
456
+ readonly content: EndpointContentPort = Object.freeze({
457
+ resolve: (reference: ConversationReference, context: EndpointContentResolveContext) =>
458
+ resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
459
+ });
424
460
 
425
461
  constructor(options: DiscordInteractionsEndpointOptions) {
426
462
  this.#logger = getAdapterLogger('discord', options.config.id);
@@ -527,6 +563,57 @@ export class DiscordInteractionsEndpoint implements EndpointInstance {
527
563
  }
528
564
  }
529
565
 
566
+ async function resolveDiscordContent(
567
+ fetch: typeof globalThis.fetch,
568
+ token: string,
569
+ reference: ConversationReference,
570
+ context: EndpointContentResolveContext,
571
+ ): Promise<ConversationResolution> {
572
+ if (reference.kind === 'forward') {
573
+ return Object.freeze({ status: 'unsupported', code: 'discord_merged_forward_unavailable' });
574
+ }
575
+ if (reference.kind === 'media') {
576
+ return reference.media.kind === 'file'
577
+ ? Object.freeze({ status: 'unsupported', code: 'discord_opaque_media_unavailable' })
578
+ : Object.freeze({ status: 'resolved', reference, value: reference.media });
579
+ }
580
+ try {
581
+ context.signal.throwIfAborted();
582
+ const response = await fetch(
583
+ `${DISCORD_API}/channels/${reference.message.conversation.id}/messages/${reference.message.id}`,
584
+ { headers: { Authorization: `Bot ${token}` }, signal: context.signal },
585
+ );
586
+ if (response.status === 404) return Object.freeze({ status: 'not_found', code: 'discord_message_not_found' });
587
+ if (response.status === 403) return Object.freeze({ status: 'forbidden', code: 'discord_message_forbidden' });
588
+ if (!response.ok) return Object.freeze({ status: 'failed', code: 'discord_message_fetch_failed' });
589
+ const row = await response.json() as Record<string, unknown>;
590
+ const author = row.author as Record<string, unknown> | undefined;
591
+ const attachments = Array.isArray(row.attachments) ? row.attachments : [];
592
+ const segments: import('@zhin.js/im-contract').Segment[] = [];
593
+ if (typeof row.content === 'string' && row.content.trim()) segments.push({ type: 'text', data: { text: row.content } });
594
+ for (const item of attachments.slice(0, context.maxEntries)) {
595
+ const attachment = item as Record<string, unknown>;
596
+ if (typeof attachment.url !== 'string') continue;
597
+ const mime = typeof attachment.content_type === 'string' ? attachment.content_type : undefined;
598
+ const type = mime?.startsWith('image/') ? 'image' : mime?.startsWith('audio/') ? 'audio' : mime?.startsWith('video/') ? 'video' : 'file';
599
+ segments.push({ type, data: { media: { kind: 'url', value: attachment.url, ...(mime ? { mime_type: mime } : {}), ...(attachment.filename ? { file_name: String(attachment.filename) } : {}), ...(typeof attachment.size === 'number' ? { size: attachment.size } : {}) } } });
600
+ }
601
+ return Object.freeze({
602
+ status: 'resolved',
603
+ reference,
604
+ value: Object.freeze({
605
+ ref: reference.message,
606
+ ...(author?.id ? { actor: Object.freeze({ id: String(author.id), ...(author.global_name || author.username ? { displayName: String(author.global_name ?? author.username) } : {}) }) } : {}),
607
+ segments: Object.freeze(segments),
608
+ timestamp: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : Date.now(),
609
+ }),
610
+ });
611
+ } catch (error) {
612
+ if (context.signal.aborted) return Object.freeze({ status: 'expired', code: 'turn_aborted' });
613
+ return Object.freeze({ status: 'failed', code: 'discord_content_resolution_failed', message: error instanceof Error ? error.message : String(error) });
614
+ }
615
+ }
616
+
530
617
  /**
531
618
  * Discord snowflake 是 64 位整数的字符串形式,超出 Number.MAX_SAFE_INTEGER,
532
619
  * Number() 转换会丢精度。Console 社交面只把 group_id 当 JSON 值透传、
package/src/gateway.ts CHANGED
@@ -287,6 +287,8 @@ function decodeBase64(value: string): Buffer {
287
287
  export interface DiscordGatewayConnectHandlers {
288
288
  onMessage(msg: DiscordInboundMessage): void;
289
289
  onButton(interaction: DiscordButtonInbound): void;
290
+ onGuildMemberAdd?(member: { guildId: string; userId: string; userName?: string }): void;
291
+ onGuildMemberRemove?(member: { guildId: string; userId: string; userName?: string }): void;
290
292
  }
291
293
 
292
294
  export async function connectDiscordGatewayClient(
@@ -331,6 +333,36 @@ export async function connectDiscordGatewayClient(
331
333
  });
332
334
  });
333
335
 
336
+ client.on('guildMemberAdd', (raw) => {
337
+ const member = raw as {
338
+ guild?: { id?: string };
339
+ user?: { id?: string; username?: string; displayName?: string };
340
+ };
341
+ const guildId = member.guild?.id;
342
+ const userId = member.user?.id;
343
+ if (!guildId || !userId) return;
344
+ handlers.onGuildMemberAdd?.({
345
+ guildId,
346
+ userId,
347
+ userName: member.user?.username || member.user?.displayName,
348
+ });
349
+ });
350
+
351
+ client.on('guildMemberRemove', (raw) => {
352
+ const member = raw as {
353
+ guild?: { id?: string };
354
+ user?: { id?: string; username?: string; displayName?: string };
355
+ };
356
+ const guildId = member.guild?.id;
357
+ const userId = member.user?.id;
358
+ if (!guildId || !userId) return;
359
+ handlers.onGuildMemberRemove?.({
360
+ guildId,
361
+ userId,
362
+ userName: member.user?.username || member.user?.displayName,
363
+ });
364
+ });
365
+
334
366
  client.once('clientReady', () => {
335
367
  void (async () => {
336
368
  try {
package/src/protocol.ts CHANGED
@@ -230,19 +230,7 @@ export function senderDisplayName(msg: DiscordInboundMessage): string {
230
230
  /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
231
231
  export function formatInboundContent(msg: DiscordInboundMessage): string {
232
232
  const parts: string[] = [];
233
- if (msg.replyToId) parts.push(`[reply:${msg.replyToId}]`);
234
233
  if (msg.content?.trim()) parts.push(msg.content.trim());
235
- for (const attachment of msg.attachments ?? []) {
236
- const kind = attachment.contentType?.startsWith('image/')
237
- ? 'image'
238
- : attachment.contentType?.startsWith('audio/')
239
- ? 'audio'
240
- : attachment.contentType?.startsWith('video/')
241
- ? 'video'
242
- : 'file';
243
- const name = attachment.name || attachment.url || 'attachment';
244
- parts.push(`[${kind}: ${name}]`);
245
- }
246
234
  for (const title of msg.embedTitles ?? []) {
247
235
  parts.push(`[embed: ${title}]`);
248
236
  }
@@ -250,7 +238,7 @@ export function formatInboundContent(msg: DiscordInboundMessage): string {
250
238
  parts.push(`[sticker: ${name}]`);
251
239
  }
252
240
  const text = parts.join('\n').trim();
253
- return text || '(Empty message)';
241
+ return text;
254
242
  }
255
243
 
256
244
  export function formatButtonContent(interaction: DiscordButtonInbound): string {
@@ -265,7 +253,7 @@ function attachmentMediaKind(contentType: string | undefined): 'image' | 'audio'
265
253
  }
266
254
 
267
255
  /**
268
- * 入站消息 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
256
+ * 入站消息 → canonical Segment[];引用与附件不重复编码进文本视图。
269
257
  * 附件 url 是 Discord CDN 真实 http(s) 地址,MediaRef kind=url;
270
258
  * embeds / stickers 仅留在纯文本视图,不进段(最小侵入)。
271
259
  */
@@ -356,6 +344,9 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
356
344
  case 'text':
357
345
  content += String(data.text ?? data.content ?? '');
358
346
  break;
347
+ case 'markdown':
348
+ content += String(data.content ?? data.text ?? '');
349
+ break;
359
350
  case 'at':
360
351
  content += `<@${String(data.id ?? '')}>`;
361
352
  break;
@@ -0,0 +1,37 @@
1
+ import { buildNotice, senderFromId } from '@zhin.js/core';
2
+ import type { SideEventGateway } from '@zhin.js/core/runtime';
3
+ import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
4
+
5
+ export interface DiscordGuildMemberSideEvent {
6
+ readonly guildId: string;
7
+ readonly userId: string;
8
+ readonly userName?: string;
9
+ }
10
+
11
+ export function receiveDiscordGuildMemberSideEvent(
12
+ sideEvents: SideEventGateway | undefined,
13
+ configId: string,
14
+ kind: 'member_increase' | 'member_decrease',
15
+ event: DiscordGuildMemberSideEvent,
16
+ logger: ReturnType<typeof getAdapterLogger>,
17
+ ): void {
18
+ if (!sideEvents) return;
19
+ void sideEvents.receiveNotice(buildNotice(event, {
20
+ $id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
21
+ $adapter: 'discord' as never,
22
+ $endpoint: configId,
23
+ $type: 'notice',
24
+ $scene_id: event.guildId,
25
+ $scene_type: 'group',
26
+ $sub_type: kind,
27
+ $actor: senderFromId(event.userId, event.userName),
28
+ $timestamp: Date.now(),
29
+ })).catch((err) => {
30
+ logger.warn(formatCompact({
31
+ op: 'discord_side_event_failed',
32
+ endpoint: configId,
33
+ event: kind,
34
+ error: err instanceof Error ? err.message : String(err),
35
+ }));
36
+ });
37
+ }