@zhin.js/adapter-discord 1.1.20 → 1.1.21
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 +31 -0
- package/README.md +11 -3
- package/lib/endpoint.js +26 -11
- package/lib/protocol.d.ts +2 -1
- package/lib/rest-request-error.d.ts +13 -0
- package/lib/rest-request-error.js +39 -0
- package/lib/self-profile.d.ts +11 -0
- package/lib/self-profile.js +82 -0
- package/package.json +20 -20
- package/schema.json +106 -1
- package/src/endpoint.ts +22 -10
- package/src/protocol.ts +2 -1
- package/src/rest-request-error.ts +38 -0
- package/src/self-profile.ts +83 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @zhin.js/adapter-discord
|
|
2
2
|
|
|
3
|
+
## 1.1.21
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [b88e988]
|
|
15
|
+
- Updated dependencies [f78eb84]
|
|
16
|
+
- Updated dependencies [c15cef9]
|
|
17
|
+
- Updated dependencies [b0c02a2]
|
|
18
|
+
- Updated dependencies [af8f803]
|
|
19
|
+
- Updated dependencies [26fda97]
|
|
20
|
+
- Updated dependencies [976886c]
|
|
21
|
+
- Updated dependencies [032dc96]
|
|
22
|
+
- @zhin.js/adapter@1.1.27
|
|
23
|
+
- @zhin.js/client@1.1.23
|
|
24
|
+
- @zhin.js/im-contract@1.1.10
|
|
25
|
+
- @zhin.js/core@1.1.55
|
|
26
|
+
- @zhin.js/tool@1.1.14
|
|
27
|
+
- @zhin.js/page@1.1.13
|
|
28
|
+
- @zhin.js/host-http@1.1.13
|
|
29
|
+
- @zhin.js/agent-feature@1.1.12
|
|
30
|
+
- @zhin.js/feature-kit@1.1.11
|
|
31
|
+
- @zhin.js/permission@1.1.11
|
|
32
|
+
- @zhin.js/skill@1.1.12
|
|
33
|
+
|
|
3
34
|
## 1.1.20
|
|
4
35
|
|
|
5
36
|
### 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
|
|
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` 启动失败 |
|
|
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)。
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
3
|
+
"version": "1.1.21",
|
|
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.
|
|
39
|
-
"@zhin.js/agent-feature": "1.1.
|
|
40
|
-
"@zhin.js/client": "1.1.
|
|
38
|
+
"@zhin.js/adapter": "1.1.27",
|
|
39
|
+
"@zhin.js/agent-feature": "1.1.12",
|
|
40
|
+
"@zhin.js/client": "1.1.23",
|
|
41
41
|
"@zhin.js/console-contract": "1.1.3",
|
|
42
|
-
"@zhin.js/core": "1.1.
|
|
43
|
-
"@zhin.js/feature-kit": "1.1.
|
|
44
|
-
"@zhin.js/host-http": "1.1.
|
|
45
|
-
"@zhin.js/im-contract": "1.1.
|
|
42
|
+
"@zhin.js/core": "1.1.55",
|
|
43
|
+
"@zhin.js/feature-kit": "1.1.11",
|
|
44
|
+
"@zhin.js/host-http": "1.1.13",
|
|
45
|
+
"@zhin.js/im-contract": "1.1.10",
|
|
46
46
|
"@zhin.js/logger": "1.1.2",
|
|
47
|
-
"@zhin.js/page": "1.1.
|
|
48
|
-
"@zhin.js/permission": "1.1.
|
|
49
|
-
"@zhin.js/skill": "1.1.
|
|
50
|
-
"@zhin.js/tool": "1.1.
|
|
47
|
+
"@zhin.js/page": "1.1.13",
|
|
48
|
+
"@zhin.js/permission": "1.1.11",
|
|
49
|
+
"@zhin.js/skill": "1.1.12",
|
|
50
|
+
"@zhin.js/tool": "1.1.14"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"zod": "^4.0.0",
|
|
54
|
-
"@zhin.js/adapter": "^1.1.
|
|
55
|
-
"@zhin.js/command": "^1.1.
|
|
56
|
-
"@zhin.js/core": "^1.1.
|
|
57
|
-
"zhin.js": "^1.1.
|
|
54
|
+
"@zhin.js/adapter": "^1.1.27",
|
|
55
|
+
"@zhin.js/command": "^1.1.13",
|
|
56
|
+
"@zhin.js/core": "^1.1.55",
|
|
57
|
+
"zhin.js": "^1.1.20"
|
|
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.
|
|
76
|
-
"zhin.js": "1.1.
|
|
75
|
+
"@zhin.js/host-http": "1.1.13",
|
|
76
|
+
"zhin.js": "1.1.20"
|
|
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.
|
|
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.
|
|
115
|
+
"api": "^1.1.0"
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
118
|
"package": "@zhin.js/skill",
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
}
|