@zhin.js/adapter-discord 1.1.20 → 1.1.22

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,70 @@
1
1
  # @zhin.js/adapter-discord
2
2
 
3
+ ## 1.1.22
4
+
5
+ ### Patch Changes
6
+
7
+ - 41ff1a5: ADR 0044 C5: every official adapter now declares a capability `profile` (its `segments` moved into `profile.default.messaging.segments`). icqq declares the complete profile (per conversation kind: scene, all 14 actions with real limits, events, feedback, delivery) and implements the seven new management ports (`pokeUser`, `unmuteGroupMember`, `muteAllGroupMembers`, `setGroupCard`, `setGroupTitle`, `setGroupEssence`, `inviteGroupMember`) with fresh bot-role reads. qq official declares its passive reply windows; undeclared capabilities stay unsupported.
8
+ - cbd3f14: ADR 0044 integration and C6 guard. `delivery.spool` now reproduces the pre-0044 media rule (icqq `false`, every other official adapter `true`). icqq narrows `actions.recall.others` unless the Bot is admin or owner, and declares recall for groups. Endpoint kind capabilities merge the Endpoint's `operations` (Discord reactions keep working; Discord declares reaction feedback). Adapters in the `@zhin.js/` scope are trusted: their custom prompt text is not labelled `external_content`. Core no longer keeps a telegram/discord interactive-policy table (adapters declare `segments.interactive`). New `pnpm check:no-platform-branches` blocks platform-name branches in runtime code.
9
+ - 4fe49b5: 支持配置按需生效:分离保存与有效版本,独立变更先应用,账号资料更新保留连接,模型按回合使用确定版本;Console 返回结构化应用结果。监听改为仅显式 --watch 启用,开发模板显式选择源码和配置监听。
10
+
11
+ 自变更消费配置应用回执,避免已处理配置被二次重载;持久化本次写入修订和应用结果,未知结果或修订冲突时停止自动续作并保留现场,恢复不会覆盖未经本次变更确认的配置。
12
+
13
+ - Updated dependencies [b249e5e]
14
+ - Updated dependencies [57661f6]
15
+ - Updated dependencies [cbd3f14]
16
+ - Updated dependencies [299863a]
17
+ - Updated dependencies [aaa93d7]
18
+ - Updated dependencies [bd524d1]
19
+ - Updated dependencies [126ae99]
20
+ - Updated dependencies [4fe49b5]
21
+ - Updated dependencies [cce07db]
22
+ - Updated dependencies [b652db3]
23
+ - Updated dependencies [0315ba0]
24
+ - Updated dependencies [00be764]
25
+ - @zhin.js/core@1.1.56
26
+ - @zhin.js/adapter@1.1.28
27
+ - @zhin.js/im-contract@1.1.11
28
+ - @zhin.js/tool@1.1.15
29
+ - @zhin.js/skill@1.1.13
30
+ - @zhin.js/host-http@1.1.14
31
+ - @zhin.js/feature-kit@1.1.12
32
+ - @zhin.js/client@1.1.24
33
+ - @zhin.js/page@1.1.14
34
+ - @zhin.js/agent-feature@1.1.13
35
+ - @zhin.js/permission@1.1.12
36
+
37
+ ## 1.1.21
38
+
39
+ ### Patch Changes
40
+
41
+ - b88e988: Preserve account-profile write acknowledgements already observed before cancellation, distinguish native Discord REST rejection from unknown outcomes, and retain the last valid profile when a newer concurrent read fails. Keep scene-scoped SOUL presence consistent with the actual prompt and allow discovered URLs to pass through the existing network authorization policy.
42
+ - af8f803: Use the current Endpoint's platform profile for Agent account identity, with per-field configured fallbacks and scoped, identity-approved profile tools. Keep SOUL first-person and independent of account names. Profile ports preserve the source Client, cancellation, truthful update outcomes and private image authority.
43
+
44
+ Adapter Feature API 2.1 and Tool Feature API 1.1 describe the additive profile requirements; new Core, official adapters and generated plugins declare the corresponding floors. Existing compatible API-2.0/API-1.0 plugins remain accepted by the newer providers. npm releases remain patch.
45
+
46
+ Private IM assets now bind to the full canonical conversation and principal. Existing IM asset records that contain only the earlier lossy hash remain on disk until their original expiry, but cannot be safely rebound; recapture them. No automatic file deletion, platform profile mutation or workspace SOUL rewrite occurs during upgrade.
47
+
48
+ - Updated dependencies [b88e988]
49
+ - Updated dependencies [f78eb84]
50
+ - Updated dependencies [c15cef9]
51
+ - Updated dependencies [b0c02a2]
52
+ - Updated dependencies [af8f803]
53
+ - Updated dependencies [26fda97]
54
+ - Updated dependencies [976886c]
55
+ - Updated dependencies [032dc96]
56
+ - @zhin.js/adapter@1.1.27
57
+ - @zhin.js/client@1.1.23
58
+ - @zhin.js/im-contract@1.1.10
59
+ - @zhin.js/core@1.1.55
60
+ - @zhin.js/tool@1.1.14
61
+ - @zhin.js/page@1.1.13
62
+ - @zhin.js/host-http@1.1.13
63
+ - @zhin.js/agent-feature@1.1.12
64
+ - @zhin.js/feature-kit@1.1.11
65
+ - @zhin.js/permission@1.1.11
66
+ - @zhin.js/skill@1.1.12
67
+
3
68
  ## 1.1.20
4
69
 
5
70
  ### Patch Changes
package/README.md CHANGED
@@ -9,7 +9,7 @@ Zhin.js Discord 适配器(Plugin Runtime),默认通过 **Gateway WebSocket
9
9
  - 支持私聊、群组与服务器频道
10
10
  - 出站 `send({ conversation, payload })` → Discord channel message(Markdown content / media / embed / keyboard)
11
11
  - 约定式 `defineAdapter` / `definePlugin`(无需 `usePlugin`)
12
- - Interactions HTTP webhook 延期(需 `httpHostToken`);配置 `connection: interactions` 会明确报错
12
+ - Interactions HTTP webhook(需 `httpHostToken`),支持验签、PING 与 slash command
13
13
 
14
14
  ## 安装
15
15
 
@@ -41,7 +41,7 @@ pnpm add @zhin.js/adapter-discord discord.js
41
41
  | **Bot Token** | [Discord Developer Portal](https://discord.com/developers/applications) 创建应用并获取 Token |
42
42
  | **MESSAGE CONTENT INTENT** | 需开启才能读取消息正文 |
43
43
  | **Gateway(默认)** | 本地/生产均可;discord.js 连接 Gateway,无需公网 HTTPS |
44
- | **host-http** | Gateway **不需要**;Interactions webhook 延期至下一棒 |
44
+ | **host-http** | Gateway **不需要**;Interactions webhook 需要 |
45
45
 
46
46
  必填字段(`endpoints[i]`):`id`、`token`。
47
47
 
@@ -111,7 +111,7 @@ Discord 的 token / publicKey 更新通过只读 GET `/applications/@me` 核验
111
111
  | Gateway 反复断线 | 检查 Token、网络代理、Gateway Intents 与应用后台配置 |
112
112
  | 能上线但收不到正文 | 启用 Message Content Intent,并给 Bot 频道读取权限 |
113
113
  | 能收不能发 | 检查 Send Messages、Embed Links 与附件权限 |
114
- | `connection: interactions` 启动失败 | 当前生产路径使用 Gateway;改回 `gateway` |
114
+ | `connection: interactions` 启动失败 | 检查 HTTP Host、`applicationId`、`publicKey` 与交互回调地址 |
115
115
 
116
116
  ## 许可证
117
117
 
@@ -143,3 +143,11 @@ DISCORD_REST_API_PROXY_PORT=18570 pnpm --filter platform-acceptance-bot dev:disc
143
143
  forwarding 下以新sample确认真实消息基线;control `POST /cut` 后另发新sample,出站结果须失败/unknown且无自动POST重发;`POST /recover` 后先确认旧sample未补发,再新sample确认恢复。检查平台可见消息与报告回执,不重发未知sample。此模式仅Gateway REST、JSON消息能力;Interactions Endpoint 与其他CDN目的地不适用,配置Interactions会明确拒绝。本地真实SDK TLS fixture覆盖服务收到POST后切断、可信CA但SAN不匹配拒绝、默认直连与手动恢复,不能当成实机结果。停止/连接失败会销毁专用Dispatcher,并取消尚未完成TLS握手。
144
144
 
145
145
  公开扩展点参考:[Discord RESTOptions.agent](https://discord.js.org/docs/packages/rest/main/RESTOptions:Interface)、[Undici 6.28 Agent](https://github.com/nodejs/undici/blob/v6.28.0/docs/docs/api/Agent.md)。
146
+
147
+ ## 当前账号资料
148
+
149
+ Gateway 和 Interactions 模式均通过资料端口暴露当前认证账号的用户名(`nickname`)及头像。Gateway 使用原生 `ClientUser`;Interactions 使用 `GET/PATCH /users/@me`。修改沿用 Agent 身份类审批及当前账号生命周期,与服务器内的成员昵称分开处理。
150
+
151
+ 支持修改昵称和头像;其他资料字段没有对应适配器操作时不声明可写。平台响应提供有效头像资料时返回其 CDN URL;若仅确认修改成功,则不返回未确认的资料。图片字节只在请求内部使用,不返回模型;Agent 解析图片沿用现有本地图片和网络权限。
152
+
153
+ `nickname` / `profile` 配置仅补足平台缺失字段,Endpoint 配置优先于适配器默认值。接口依据 [discord.js ClientUser](https://discord.js.org/docs/packages/discord.js/main/ClientUser:Class)。
@@ -11,11 +11,27 @@ export default defineAdapter({
11
11
  operations: (context) => context.config.connection === 'interactions'
12
12
  ? ['recall']
13
13
  : ['recall', 'reaction'],
14
+ // 反应(reaction)留在上面的 operations:profile 对 Adapter 静态,而 interactions 传输不能加反应。
14
15
  // 媒体 url 直发或由 AttachmentBuilder 物化本地文件上传;message components 原生按钮承载交互段。
15
- segments: {
16
- outboundMedia: ['url', 'upload'],
17
- interactive: 'native',
18
- markdown: 'native',
16
+ profile: {
17
+ default: {
18
+ messaging: {
19
+ segments: {
20
+ outboundMedia: ['url', 'upload'],
21
+ interactive: 'native',
22
+ markdown: 'native',
23
+ },
24
+ quote: true,
25
+ recall: {},
26
+ },
27
+ actions: {
28
+ recall: { scopes: ['private', 'group', 'channel'] },
29
+ quoteReply: { scopes: ['private', 'group', 'channel'] },
30
+ },
31
+ // Activity feedback prefers a reaction; the interactions transport has none, so it degrades to a status message.
32
+ feedback: { mode: 'reaction' },
33
+ delivery: { send: true, proactive: true, spool: true },
34
+ },
19
35
  },
20
36
  createAdapter: context => new DiscordAdapter(context),
21
37
  });
@@ -22,11 +22,27 @@ export default defineAdapter<DiscordEndpointConfig>({
22
22
  operations: (context) => context.config.connection === 'interactions'
23
23
  ? ['recall']
24
24
  : ['recall', 'reaction'],
25
+ // 反应(reaction)留在上面的 operations:profile 对 Adapter 静态,而 interactions 传输不能加反应。
25
26
  // 媒体 url 直发或由 AttachmentBuilder 物化本地文件上传;message components 原生按钮承载交互段。
26
- segments: {
27
- outboundMedia: ['url', 'upload'],
28
- interactive: 'native',
29
- markdown: 'native',
27
+ profile: {
28
+ default: {
29
+ messaging: {
30
+ segments: {
31
+ outboundMedia: ['url', 'upload'],
32
+ interactive: 'native',
33
+ markdown: 'native',
34
+ },
35
+ quote: true,
36
+ recall: {},
37
+ },
38
+ actions: {
39
+ recall: { scopes: ['private', 'group', 'channel'] },
40
+ quoteReply: { scopes: ['private', 'group', 'channel'] },
41
+ },
42
+ // Activity feedback prefers a reaction; the interactions transport has none, so it degrades to a status message.
43
+ feedback: { mode: 'reaction' },
44
+ delivery: { send: true, proactive: true, spool: true },
45
+ },
30
46
  },
31
47
  createAdapter: context => new DiscordAdapter(context),
32
48
  });
package/lib/endpoint.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createDiscordSelfProfile, createDiscordRestSelfProfile } from './self-profile.js';
2
+ import { DiscordRestRequestError } from './rest-request-error.js';
1
3
  /**
2
4
  * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
3
5
  */
@@ -40,7 +42,7 @@ export class DiscordGatewayEndpoint extends Client {
40
42
  start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
41
43
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
42
44
  get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
43
- management: this.#management, control: this.#control, content: this.#content,
45
+ management: this.#management, control: this.#control, content: this.#content, profile: createDiscordSelfProfile(this),
44
46
  });
45
47
  }
46
48
  get #transportState() {
@@ -93,6 +95,8 @@ export class DiscordGatewayEndpoint extends Client {
93
95
  }, AbortSignal.any([abort.signal, executionSignal]));
94
96
  if (abort.signal.aborted || this.#connection !== abort)
95
97
  return;
98
+ if (this.user?.id)
99
+ this[endpointSpec].reportSelfUserId(this.user.id);
96
100
  this.#logger.info(formatCompact({
97
101
  op: 'connect',
98
102
  endpoint: this.#options.config.id,
@@ -290,16 +294,26 @@ export class DiscordRestClient {
290
294
  const operationSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
291
295
  const operation = createEndpointOperation({ signal: operationSignal });
292
296
  const execute = async () => {
293
- const response = await this.fetch(`${DISCORD_API}${path}`, {
294
- method,
295
- headers: { Authorization: `Bot ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
296
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
297
- signal: operationSignal,
298
- });
299
- const text = await response.text();
300
- if (!response.ok)
301
- throw new Error(`Discord API ${method} ${path} failed (${response.status}): ${text.slice(0, 200)}`);
302
- return (text ? JSON.parse(text) : undefined);
297
+ const encodedBody = body === undefined ? undefined : JSON.stringify(body);
298
+ let response;
299
+ let text;
300
+ try {
301
+ response = await this.fetch(`${DISCORD_API}${path}`, {
302
+ method,
303
+ headers: { Authorization: `Bot ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
304
+ ...(encodedBody === undefined ? {} : { body: encodedBody }),
305
+ signal: operationSignal,
306
+ });
307
+ text = await response.text();
308
+ if (!response.ok)
309
+ throw new DiscordRestRequestError({ status: response.status, body: text });
310
+ return (text ? JSON.parse(text) : undefined);
311
+ }
312
+ catch (cause) {
313
+ if (cause instanceof DiscordRestRequestError)
314
+ throw cause;
315
+ throw new DiscordRestRequestError({ status: response?.status, body: text, cause });
316
+ }
303
317
  };
304
318
  return operation.run(execute);
305
319
  }
@@ -333,6 +347,7 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient {
333
347
  start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
334
348
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
335
349
  control: this.#control, content: this.#content,
350
+ profile: createDiscordRestSelfProfile(this, id => this[endpointSpec].reportSelfUserId(id)),
336
351
  });
337
352
  }
338
353
  get isOpen() {
package/lib/protocol.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
1
2
  import { type ConversationRef } from '@zhin.js/im-contract';
2
3
  import type { Segment } from '@zhin.js/core/runtime';
3
4
  export interface DiscordActivity {
@@ -6,7 +7,7 @@ export interface DiscordActivity {
6
7
  readonly url?: string;
7
8
  }
8
9
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
9
- export interface DiscordEndpointConfig {
10
+ export interface DiscordEndpointConfig extends EndpointProfileConfiguration {
10
11
  readonly id: string;
11
12
  readonly token: string;
12
13
  /** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
@@ -0,0 +1,13 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ /** Discord response evidence; request URLs, authorization headers and submitted bodies are excluded. */
3
+ export declare class DiscordRestRequestError extends EndpointDeliveryError {
4
+ readonly status?: number;
5
+ readonly body?: string;
6
+ constructor({ status, body, cause }: Readonly<{
7
+ status?: number;
8
+ body?: string;
9
+ cause?: unknown;
10
+ }>);
11
+ /** HTTP success survives an unreadable response body; it does not prove any profile field. */
12
+ get acknowledged(): boolean;
13
+ }
@@ -0,0 +1,39 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ /** Discord response evidence; request URLs, authorization headers and submitted bodies are excluded. */
3
+ export class DiscordRestRequestError extends EndpointDeliveryError {
4
+ status;
5
+ body;
6
+ constructor({ status, body, cause }) {
7
+ const rejection = nativeRejection(status, body);
8
+ const rejected = rejection !== undefined;
9
+ super(rejected ? 'platform_rejected' : 'delivery_unconfirmed', status === undefined ? 'Discord REST request failed before a response was available'
10
+ : `Discord REST request ${rejected ? 'rejected' : 'unconfirmed'} (${status})${rejected ? `: ${rejection}` : ''}`, rejected ? 'rejected' : 'unknown', { cause });
11
+ this.name = 'DiscordRestRequestError';
12
+ this.status = status;
13
+ this.body = body;
14
+ }
15
+ /** HTTP success survives an unreadable response body; it does not prove any profile field. */
16
+ get acknowledged() { return this.status !== undefined && this.status >= 200 && this.status < 300; }
17
+ }
18
+ /** Official rejection statuses plus a native JSON error, never a proxy timeout/body alone.
19
+ * @see https://docs.discord.com/developers/topics/opcodes-and-status-codes#http
20
+ * @see https://docs.discord.com/developers/topics/rate-limits#rate-limit-response-structure
21
+ */
22
+ function nativeRejection(status, body) {
23
+ if (status === undefined || ![400, 401, 403, 404, 405, 429].includes(status) || !body)
24
+ return undefined;
25
+ try {
26
+ const value = JSON.parse(body);
27
+ if (typeof value?.message !== 'string')
28
+ return undefined;
29
+ if (typeof value.code === 'number' && Number.isSafeInteger(value.code))
30
+ return `code ${value.code}`;
31
+ if (status === 429 && typeof value.retry_after === 'number' && Number.isFinite(value.retry_after)
32
+ && value.retry_after >= 0 && typeof value.global === 'boolean')
33
+ return 'rate limited';
34
+ return undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
@@ -0,0 +1,11 @@
1
+ import { type EndpointProfile } from 'zhin.js/adapter';
2
+ import type { Client } from 'discord.js';
3
+ import type { DiscordRestRequest } from './endpoint.js';
4
+ /** The Gateway's native ClientUser owns its current username/avatar and updates its own cache. */
5
+ export declare function createDiscordSelfProfile(client: Pick<Client, 'user' | 'users'>): EndpointProfile;
6
+ interface DiscordProfileRestClient {
7
+ request<T>(request: DiscordRestRequest): Promise<T>;
8
+ }
9
+ /** Interactions-only accounts use the same public identity fields through authenticated REST. */
10
+ export declare function createDiscordRestSelfProfile(client: DiscordProfileRestClient, reportAccount: (id: string) => void): EndpointProfile;
11
+ export {};
@@ -0,0 +1,82 @@
1
+ import { createEndpointOperation } from 'zhin.js/adapter';
2
+ import { DiscordRestRequestError } from './rest-request-error.js';
3
+ function profileOf(user) {
4
+ return { nickname: user.username, avatar: user.avatarURL() };
5
+ }
6
+ /** The Gateway's native ClientUser owns its current username/avatar and updates its own cache. */
7
+ export function createDiscordSelfProfile(client) {
8
+ const user = () => {
9
+ if (!client.user)
10
+ throw new Error('Discord self account is unavailable');
11
+ return client.user;
12
+ };
13
+ return Object.freeze({
14
+ writableFields: Object.freeze(['nickname', 'avatar']),
15
+ get current() { return client.user ? profileOf(client.user) : {}; },
16
+ async read({ signal }) {
17
+ return profileOf(await createEndpointOperation({ signal }).prepare(() => client.users.fetch(user().id, { force: true, cache: false })));
18
+ },
19
+ async write({ change, signal }) {
20
+ const operation = createEndpointOperation({ signal });
21
+ if (change.field === 'nickname') {
22
+ const updated = await operation.run(() => user().setUsername(change.value));
23
+ return { status: 'updated', profile: profileOf(updated) };
24
+ }
25
+ if (change.field === 'avatar') {
26
+ if (change.value.kind === 'file') {
27
+ return { status: 'rejected', message: 'Discord avatar requires resolved image bytes, URL or local path' };
28
+ }
29
+ const image = change.value.kind === 'base64' ? Buffer.from(change.value.value, 'base64') : change.value.value;
30
+ const updated = await operation.run(() => user().setAvatar(image));
31
+ return { status: 'updated', profile: profileOf(updated) };
32
+ }
33
+ return { status: 'rejected', message: `Discord does not expose ${change.field} through this port` };
34
+ },
35
+ });
36
+ }
37
+ /** Interactions-only accounts use the same public identity fields through authenticated REST. */
38
+ export function createDiscordRestSelfProfile(client, reportAccount) {
39
+ const observed = (user) => {
40
+ if (!user?.id || typeof user.username !== 'string' || (user.avatar !== null && typeof user.avatar !== 'string')) {
41
+ return undefined;
42
+ }
43
+ reportAccount(user.id);
44
+ return { nickname: user.username, avatar: user.avatar === null ? null
45
+ : `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith('a_') ? 'gif' : 'png'}` };
46
+ };
47
+ return Object.freeze({
48
+ writableFields: Object.freeze(['nickname', 'avatar']),
49
+ async read({ signal }) {
50
+ const profile = observed(await client.request({ method: 'GET', path: '/users/@me', signal }));
51
+ if (!profile)
52
+ throw new Error('Discord returned an incomplete self profile');
53
+ return profile;
54
+ },
55
+ async write({ change, signal }) {
56
+ let body;
57
+ if (change.field === 'nickname')
58
+ body = { username: change.value };
59
+ else if (change.field === 'avatar') {
60
+ if (change.value.kind !== 'base64' || !change.value.mime_type) {
61
+ return { status: 'rejected', message: 'Discord REST avatar requires resolved image bytes and MIME type' };
62
+ }
63
+ body = { avatar: `data:${change.value.mime_type};base64,${change.value.value}` };
64
+ }
65
+ else
66
+ return { status: 'rejected', message: `Discord does not expose ${change.field} through this port` };
67
+ let updated;
68
+ try {
69
+ updated = await client.request({ method: 'PATCH', path: '/users/@me', body, signal });
70
+ }
71
+ catch (error) {
72
+ if (!(error instanceof DiscordRestRequestError))
73
+ throw error;
74
+ if (error.acknowledged)
75
+ return { status: 'updated' };
76
+ return { status: error.disposition === 'rejected' ? 'rejected' : 'unknown', message: error.message };
77
+ }
78
+ const profile = observed(updated);
79
+ return { status: 'updated', ...(profile ? { profile } : {}) };
80
+ },
81
+ });
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-discord",
3
- "version": "1.1.20",
3
+ "version": "1.1.22",
4
4
  "description": "Zhin.js Discord adapter for Plugin Runtime (Gateway WebSocket)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -35,26 +35,26 @@
35
35
  "@discordjs/ws": "1.2.3",
36
36
  "discord.js": "^14.27.0",
37
37
  "undici": "6.29.1",
38
- "@zhin.js/adapter": "1.1.26",
39
- "@zhin.js/agent-feature": "1.1.11",
40
- "@zhin.js/client": "1.1.22",
38
+ "@zhin.js/adapter": "1.1.28",
39
+ "@zhin.js/agent-feature": "1.1.13",
40
+ "@zhin.js/client": "1.1.24",
41
41
  "@zhin.js/console-contract": "1.1.3",
42
- "@zhin.js/core": "1.1.54",
43
- "@zhin.js/feature-kit": "1.1.10",
44
- "@zhin.js/host-http": "1.1.12",
45
- "@zhin.js/im-contract": "1.1.9",
42
+ "@zhin.js/core": "1.1.56",
43
+ "@zhin.js/feature-kit": "1.1.12",
44
+ "@zhin.js/host-http": "1.1.14",
45
+ "@zhin.js/im-contract": "1.1.11",
46
46
  "@zhin.js/logger": "1.1.2",
47
- "@zhin.js/page": "1.1.12",
48
- "@zhin.js/permission": "1.1.10",
49
- "@zhin.js/skill": "1.1.11",
50
- "@zhin.js/tool": "1.1.13"
47
+ "@zhin.js/page": "1.1.14",
48
+ "@zhin.js/permission": "1.1.12",
49
+ "@zhin.js/skill": "1.1.13",
50
+ "@zhin.js/tool": "1.1.15"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "zod": "^4.0.0",
54
- "@zhin.js/adapter": "^1.1.26",
55
- "@zhin.js/command": "^1.1.12",
56
- "@zhin.js/core": "^1.1.54",
57
- "zhin.js": "^1.1.19"
54
+ "@zhin.js/adapter": "^1.1.28",
55
+ "@zhin.js/command": "^1.1.14",
56
+ "@zhin.js/core": "^1.1.56",
57
+ "zhin.js": "^1.1.21"
58
58
  },
59
59
  "peerDependenciesMeta": {
60
60
  "@zhin.js/command": {
@@ -72,8 +72,8 @@
72
72
  "typescript": "^6.0.3",
73
73
  "vitest": "^4.1.10",
74
74
  "zod": "^4.4.3",
75
- "@zhin.js/host-http": "1.1.12",
76
- "zhin.js": "1.1.19"
75
+ "@zhin.js/host-http": "1.1.14",
76
+ "zhin.js": "1.1.21"
77
77
  },
78
78
  "files": [
79
79
  "adapters",
@@ -104,7 +104,7 @@
104
104
  "features": [
105
105
  {
106
106
  "package": "@zhin.js/adapter",
107
- "api": "^2.0.0"
107
+ "api": "^2.1.0"
108
108
  },
109
109
  {
110
110
  "package": "@zhin.js/command",
@@ -112,7 +112,7 @@
112
112
  },
113
113
  {
114
114
  "package": "@zhin.js/tool",
115
- "api": "^1.0.0"
115
+ "api": "^1.1.0"
116
116
  },
117
117
  {
118
118
  "package": "@zhin.js/skill",
package/plugin.js CHANGED
@@ -5,6 +5,7 @@ import { checkDiscordPlatformPermit } from "./lib/platform-permit.js";
5
5
  import { createDiscordConsoleRpc } from "./lib/console.js";
6
6
  export default definePlugin({
7
7
  name: 'discord',
8
+ configUpdate: { featurePaths: [['nickname'], ['profile'], ['endpoints']] },
8
9
  metadata: {
9
10
  displayName: 'Discord Gateway Adapter',
10
11
  },
package/schema.json CHANGED
@@ -230,6 +230,27 @@
230
230
  "description": "REST-only fault proxy for Gateway tests: pin discord.com:443 TCP to loopback while preserving SNI and certificate validation, independently of the Gateway proxy."
231
231
  }
232
232
  }
233
+ },
234
+ "nickname": {
235
+ "type": [
236
+ "string",
237
+ "null"
238
+ ],
239
+ "description": "账号昵称兜底",
240
+ "x-i18n": {
241
+ "en": {
242
+ "description": "Fallback account nickname"
243
+ }
244
+ }
245
+ },
246
+ "profile": {
247
+ "$ref": "#/$defs/selfProfileFallback",
248
+ "description": "账号资料兜底",
249
+ "x-i18n": {
250
+ "en": {
251
+ "description": "Fallback account profile"
252
+ }
253
+ }
233
254
  }
234
255
  },
235
256
  "required": [
@@ -257,9 +278,93 @@
257
278
  "type": "string",
258
279
  "pattern": "^(?:ws://127\\.0\\.0\\.1:[0-9]+/?|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$",
259
280
  "description": "Isolated acceptance only: loopback Gateway fault proxy; REST unchanged."
281
+ },
282
+ "nickname": {
283
+ "type": [
284
+ "string",
285
+ "null"
286
+ ],
287
+ "description": "账号昵称兜底",
288
+ "x-i18n": {
289
+ "en": {
290
+ "description": "Fallback account nickname"
291
+ }
292
+ }
293
+ },
294
+ "profile": {
295
+ "$ref": "#/$defs/selfProfileFallback",
296
+ "description": "账号资料兜底",
297
+ "x-i18n": {
298
+ "en": {
299
+ "description": "Fallback account profile"
300
+ }
301
+ }
260
302
  }
261
303
  },
262
304
  "required": [
263
305
  "endpoints"
264
- ]
306
+ ],
307
+ "$defs": {
308
+ "selfProfileFallback": {
309
+ "type": "object",
310
+ "additionalProperties": false,
311
+ "properties": {
312
+ "gender": {
313
+ "type": [
314
+ "string",
315
+ "null"
316
+ ],
317
+ "enum": [
318
+ "male",
319
+ "female",
320
+ "other",
321
+ "unknown",
322
+ null
323
+ ]
324
+ },
325
+ "age": {
326
+ "type": [
327
+ "integer",
328
+ "null"
329
+ ],
330
+ "minimum": 0
331
+ },
332
+ "birthday": {
333
+ "type": [
334
+ "string",
335
+ "null"
336
+ ],
337
+ "pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"
338
+ },
339
+ "signature": {
340
+ "type": [
341
+ "string",
342
+ "null"
343
+ ]
344
+ },
345
+ "avatar": {
346
+ "type": [
347
+ "string",
348
+ "null"
349
+ ],
350
+ "pattern": "^(https?://[^\\s/?#]+(?:[/?#].*)?|)$"
351
+ },
352
+ "interests": {
353
+ "type": [
354
+ "array",
355
+ "null"
356
+ ],
357
+ "items": {
358
+ "type": "string"
359
+ }
360
+ },
361
+ "bio": {
362
+ "type": [
363
+ "string",
364
+ "null"
365
+ ]
366
+ }
367
+ }
368
+ }
369
+ }
265
370
  }
package/src/endpoint.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { createDiscordRestApiAgent } from './rest-api-proxy.js';
2
+ import { createDiscordSelfProfile, createDiscordRestSelfProfile } from './self-profile.js';
3
+ import { DiscordRestRequestError } from './rest-request-error.js';
2
4
  /**
3
5
  * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
4
6
  */
@@ -104,7 +106,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
104
106
  start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
105
107
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
106
108
  get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
107
- management: this.#management, control: this.#control, content: this.#content,
109
+ management: this.#management, control: this.#control, content: this.#content, profile: createDiscordSelfProfile(this),
108
110
  });
109
111
  }
110
112
 
@@ -160,6 +162,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
160
162
  },
161
163
  }, AbortSignal.any([abort.signal, executionSignal]));
162
164
  if (abort.signal.aborted || this.#connection !== abort) return;
165
+ if (this.user?.id) this[endpointSpec].reportSelfUserId(this.user.id);
163
166
  this.#logger.info(formatCompact({
164
167
  op: 'connect',
165
168
  endpoint: this.#options.config.id,
@@ -373,15 +376,23 @@ export class DiscordRestClient {
373
376
  const operationSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
374
377
  const operation = createEndpointOperation({ signal: operationSignal });
375
378
  const execute = async () => {
376
- const response = await this.fetch(`${DISCORD_API}${path}`, {
377
- method,
378
- headers: { Authorization: `Bot ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
379
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
380
- signal: operationSignal,
381
- });
382
- const text = await response.text();
383
- if (!response.ok) throw new Error(`Discord API ${method} ${path} failed (${response.status}): ${text.slice(0, 200)}`);
384
- return (text ? JSON.parse(text) : undefined) as T;
379
+ const encodedBody = body === undefined ? undefined : JSON.stringify(body);
380
+ let response: Response | undefined;
381
+ let text: string | undefined;
382
+ try {
383
+ response = await this.fetch(`${DISCORD_API}${path}`, {
384
+ method,
385
+ headers: { Authorization: `Bot ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
386
+ ...(encodedBody === undefined ? {} : { body: encodedBody }),
387
+ signal: operationSignal,
388
+ });
389
+ text = await response.text();
390
+ if (!response.ok) throw new DiscordRestRequestError({ status: response.status, body: text });
391
+ return (text ? JSON.parse(text) : undefined) as T;
392
+ } catch (cause) {
393
+ if (cause instanceof DiscordRestRequestError) throw cause;
394
+ throw new DiscordRestRequestError({ status: response?.status, body: text, cause });
395
+ }
385
396
  };
386
397
  return operation.run(execute);
387
398
  }
@@ -419,6 +430,7 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
419
430
  start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
420
431
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
421
432
  control: this.#control, content: this.#content,
433
+ profile: createDiscordRestSelfProfile(this, id => this[endpointSpec].reportSelfUserId(id)),
422
434
  });
423
435
  }
424
436
 
package/src/protocol.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
1
2
  import { validateDiscordGatewayFaultProxy } from './gateway-fault-proxy.js';
2
3
  /** Discord Gateway protocol helpers. Canonicalization is owned by Core. */
3
4
 
@@ -15,7 +16,7 @@ export interface DiscordActivity {
15
16
  }
16
17
 
17
18
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
18
- export interface DiscordEndpointConfig {
19
+ export interface DiscordEndpointConfig extends EndpointProfileConfiguration {
19
20
  readonly id: string;
20
21
  readonly token: string;
21
22
  /** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
@@ -0,0 +1,38 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+
3
+ /** Discord response evidence; request URLs, authorization headers and submitted bodies are excluded. */
4
+ export class DiscordRestRequestError extends EndpointDeliveryError {
5
+ readonly status?: number;
6
+ readonly body?: string;
7
+
8
+ constructor({ status, body, cause }: Readonly<{ status?: number; body?: string; cause?: unknown }>) {
9
+ const rejection = nativeRejection(status, body);
10
+ const rejected = rejection !== undefined;
11
+ super(rejected ? 'platform_rejected' : 'delivery_unconfirmed',
12
+ status === undefined ? 'Discord REST request failed before a response was available'
13
+ : `Discord REST request ${rejected ? 'rejected' : 'unconfirmed'} (${status})${rejected ? `: ${rejection}` : ''}`,
14
+ rejected ? 'rejected' : 'unknown', { cause });
15
+ this.name = 'DiscordRestRequestError';
16
+ this.status = status;
17
+ this.body = body;
18
+ }
19
+
20
+ /** HTTP success survives an unreadable response body; it does not prove any profile field. */
21
+ get acknowledged(): boolean { return this.status !== undefined && this.status >= 200 && this.status < 300; }
22
+ }
23
+
24
+ /** Official rejection statuses plus a native JSON error, never a proxy timeout/body alone.
25
+ * @see https://docs.discord.com/developers/topics/opcodes-and-status-codes#http
26
+ * @see https://docs.discord.com/developers/topics/rate-limits#rate-limit-response-structure
27
+ */
28
+ function nativeRejection(status: number | undefined, body: string | undefined): string | undefined {
29
+ if (status === undefined || ![400, 401, 403, 404, 405, 429].includes(status) || !body) return undefined;
30
+ try {
31
+ const value = JSON.parse(body) as { code?: unknown; message?: unknown; retry_after?: unknown; global?: unknown } | null;
32
+ if (typeof value?.message !== 'string') return undefined;
33
+ if (typeof value.code === 'number' && Number.isSafeInteger(value.code)) return `code ${value.code}`;
34
+ if (status === 429 && typeof value.retry_after === 'number' && Number.isFinite(value.retry_after)
35
+ && value.retry_after >= 0 && typeof value.global === 'boolean') return 'rate limited';
36
+ return undefined;
37
+ } catch { return undefined; }
38
+ }
@@ -0,0 +1,83 @@
1
+ import { createEndpointOperation, type EndpointProfile } from 'zhin.js/adapter';
2
+ import type { Client, ClientUser, User } from 'discord.js';
3
+ import type { SelfProfile } from '@zhin.js/im-contract';
4
+ import type { DiscordRestRequest } from './endpoint.js';
5
+ import { DiscordRestRequestError } from './rest-request-error.js';
6
+
7
+ function profileOf(user: Pick<User, 'username' | 'avatarURL'>): SelfProfile {
8
+ return { nickname: user.username, avatar: user.avatarURL() };
9
+ }
10
+
11
+ /** The Gateway's native ClientUser owns its current username/avatar and updates its own cache. */
12
+ export function createDiscordSelfProfile(client: Pick<Client, 'user' | 'users'>): EndpointProfile {
13
+ const user = (): ClientUser => {
14
+ if (!client.user) throw new Error('Discord self account is unavailable');
15
+ return client.user;
16
+ };
17
+ return Object.freeze<EndpointProfile>({
18
+ writableFields: Object.freeze(['nickname', 'avatar']),
19
+ get current() { return client.user ? profileOf(client.user) : {}; },
20
+ async read({ signal }) {
21
+ return profileOf(await createEndpointOperation({ signal }).prepare(() =>
22
+ client.users.fetch(user().id, { force: true, cache: false })));
23
+ },
24
+ async write({ change, signal }) {
25
+ const operation = createEndpointOperation({ signal });
26
+ if (change.field === 'nickname') {
27
+ const updated = await operation.run(() => user().setUsername(change.value));
28
+ return { status: 'updated', profile: profileOf(updated) };
29
+ }
30
+ if (change.field === 'avatar') {
31
+ if (change.value.kind === 'file') {
32
+ return { status: 'rejected', message: 'Discord avatar requires resolved image bytes, URL or local path' };
33
+ }
34
+ const image = change.value.kind === 'base64' ? Buffer.from(change.value.value, 'base64') : change.value.value;
35
+ const updated = await operation.run(() => user().setAvatar(image));
36
+ return { status: 'updated', profile: profileOf(updated) };
37
+ }
38
+ return { status: 'rejected', message: `Discord does not expose ${change.field} through this port` };
39
+ },
40
+ });
41
+ }
42
+
43
+ interface DiscordOwnUser { readonly id: string; readonly username: string; readonly avatar: string | null; }
44
+ interface DiscordProfileRestClient { request<T>(request: DiscordRestRequest): Promise<T>; }
45
+
46
+ /** Interactions-only accounts use the same public identity fields through authenticated REST. */
47
+ export function createDiscordRestSelfProfile(client: DiscordProfileRestClient, reportAccount: (id: string) => void): EndpointProfile {
48
+ const observed = (user: DiscordOwnUser): SelfProfile | undefined => {
49
+ if (!user?.id || typeof user.username !== 'string' || (user.avatar !== null && typeof user.avatar !== 'string')) {
50
+ return undefined;
51
+ }
52
+ reportAccount(user.id);
53
+ return { nickname: user.username, avatar: user.avatar === null ? null
54
+ : `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith('a_') ? 'gif' : 'png'}` };
55
+ };
56
+ return Object.freeze<EndpointProfile>({
57
+ writableFields: Object.freeze(['nickname', 'avatar']),
58
+ async read({ signal }) {
59
+ const profile = observed(await client.request<DiscordOwnUser>({ method: 'GET', path: '/users/@me', signal }));
60
+ if (!profile) throw new Error('Discord returned an incomplete self profile');
61
+ return profile;
62
+ },
63
+ async write({ change, signal }) {
64
+ let body: { username: string } | { avatar: string };
65
+ if (change.field === 'nickname') body = { username: change.value };
66
+ else if (change.field === 'avatar') {
67
+ if (change.value.kind !== 'base64' || !change.value.mime_type) {
68
+ return { status: 'rejected', message: 'Discord REST avatar requires resolved image bytes and MIME type' };
69
+ }
70
+ body = { avatar: `data:${change.value.mime_type};base64,${change.value.value}` };
71
+ } else return { status: 'rejected', message: `Discord does not expose ${change.field} through this port` };
72
+ let updated: DiscordOwnUser;
73
+ try { updated = await client.request<DiscordOwnUser>({ method: 'PATCH', path: '/users/@me', body, signal }); }
74
+ catch (error) {
75
+ if (!(error instanceof DiscordRestRequestError)) throw error;
76
+ if (error.acknowledged) return { status: 'updated' };
77
+ return { status: error.disposition === 'rejected' ? 'rejected' : 'unknown', message: error.message };
78
+ }
79
+ const profile = observed(updated);
80
+ return { status: 'updated', ...(profile ? { profile } : {}) };
81
+ },
82
+ });
83
+ }