@zhin.js/adapter-satori 4.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/adapters/satori.ts +11 -0
- package/commands/endpoint/add/[name:string].ts +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/endpoint.d.ts +9 -1
- package/lib/endpoint.js +149 -73
- package/lib/satori-endpoint-commands.d.ts +1 -0
- package/lib/satori-endpoint-commands.js +18 -0
- package/lib/satori-runtime-state.d.ts +1 -0
- package/lib/satori-runtime-state.js +6 -0
- package/lib/webhook.d.ts +0 -3
- package/lib/webhook.js +7 -1
- package/package.json +16 -10
- package/plugin.ts +6 -0
- package/src/endpoint.ts +169 -74
- package/src/satori-endpoint-commands.ts +19 -0
- package/src/satori-runtime-state.ts +7 -0
- package/src/webhook.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# @zhin.js/adapter-satori
|
|
2
2
|
|
|
3
|
+
## 4.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
|
|
8
|
+
|
|
9
|
+
- **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
|
|
10
|
+
- **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
|
|
11
|
+
- **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
|
|
12
|
+
- **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
|
|
13
|
+
- **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
|
|
14
|
+
- **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
|
|
15
|
+
|
|
16
|
+
- 078e3f7: 架构统一批(AURA):
|
|
17
|
+
|
|
18
|
+
- **EndpointLifecycle 基座**(@zhin.js/adapter 新增 `createEndpointLifecycle`):WS/SSE 端点的 start 失败复位、仅曾 open 才退避重连(指数+jitter 可配)、stop 不重连、PONG 看门狗、定时器集中清理、陈旧事件防叠套;napcat/milky/onebot11/onebot12/satori 已迁移(删除各自手写状态机),从此同类竞态在结构上不可能再犯。
|
|
19
|
+
- **Generation-store**(@zhin.js/plugin-runtime 新增 `createGenerationStore`):模块级运行时状态的一等能力,provide 自动挂 lifecycle 反注册(代际结束自动清理);lottery deps 与 rss db 已迁移,公开 API 兼容。
|
|
20
|
+
- **Resolver 管线收敛**(@zhin.js/runtime):解析规则统一为 local path → workspace → node_modules 单管线;optional 引用对所有 PackageResolutionError 容错(消除 message 前缀补丁)。
|
|
21
|
+
- **工具目录准入统一**(@zhin.js/agent):RegisteredToolSource 与 ExternalToolSource 共用同一 `canAccessTool` 准入(platforms/scopes/permissions/hidden 四元组全链路透传),同名覆盖 warn;AgentToolRegistration 补 platforms/scopes。两条注册通道(静态约定 vs 动态注册)职责边界已文档化。
|
|
22
|
+
|
|
23
|
+
- 74b035c: endpoint 管理命令扩展至 18/20 适配器:kook / discord / github(private_key 支持内联文件路径)/ icqq(bindFlow 登记式 add + `icqq login` 引导)/ dingtalk / lark / line / satori / wechat-mp / wecom / weixin-ilink 接入 `endpoint list/add/remove`(字段对齐各自 schema,凭据写 `.env`)。email(smtp/imap 嵌套对象)与 sandbox(无凭据)暂不接。
|
|
24
|
+
- 09d4f25: Console 社交读取面(management 语义端口)多平台落地:napcat/onebot11/onebot12/milky(好友+群+群成员,OneBot 标准动作);discord/kook/satori(guild+频道+成员,分页聚合,id 保精度留字符串);slack(workspace 成员+public channels+conversations.members);line(群/room 成员分页+profile 回退);wechat-mp(followers openid);weixin-ilink(context_token 对端推导);lark(chats+members 全分页)。`EndpointFriend.user_id`/`EndpointGroup.group_id` 放宽为 `number | string`(雪花 id 不丢精度)。telegram/wecom/dingtalk/github/email/sandbox 注明平台无列表面暂不接。
|
|
25
|
+
- Updated dependencies [cdf64e7]
|
|
26
|
+
- Updated dependencies [2d0a159]
|
|
27
|
+
- Updated dependencies [5691aba]
|
|
28
|
+
- Updated dependencies [078e3f7]
|
|
29
|
+
- Updated dependencies [50497a5]
|
|
30
|
+
- Updated dependencies [9c997b2]
|
|
31
|
+
- Updated dependencies [09d4f25]
|
|
32
|
+
- Updated dependencies [43485a9]
|
|
33
|
+
- Updated dependencies [f0ec5ab]
|
|
34
|
+
- Updated dependencies [3e925d0]
|
|
35
|
+
- Updated dependencies [fa66c4c]
|
|
36
|
+
- Updated dependencies [fa66c4c]
|
|
37
|
+
- Updated dependencies [6cb6152]
|
|
38
|
+
- @zhin.js/command@1.0.3
|
|
39
|
+
- @zhin.js/plugin-runtime@1.1.1
|
|
40
|
+
- @zhin.js/host-http@1.0.3
|
|
41
|
+
- zhin.js@5.0.1
|
|
42
|
+
- @zhin.js/adapter@1.1.1
|
|
43
|
+
- @zhin.js/core@1.4.1
|
|
44
|
+
|
|
3
45
|
## 4.0.0
|
|
4
46
|
|
|
5
47
|
### Patch Changes
|
package/adapters/satori.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveSatoriConfig,
|
|
10
10
|
type SatoriAdapterConfig,
|
|
11
11
|
} from '../src/protocol.js';
|
|
12
|
+
import { satoriRuntimeStateToken } from '../src/satori-runtime-state.js';
|
|
12
13
|
|
|
13
14
|
export { SatoriWebhookEndpoint, SatoriWsEndpoint } from '../src/endpoint.js';
|
|
14
15
|
export type {
|
|
@@ -21,9 +22,19 @@ export type {
|
|
|
21
22
|
|
|
22
23
|
export default defineAdapter<SatoriAdapterConfig>({
|
|
23
24
|
capabilities: ['inbound', 'outbound'],
|
|
25
|
+
// Satori 协议 img/file 元素消费 url 与 base64 内联数据;无卡片交互面,交互段降级纯文本。
|
|
26
|
+
segments: {
|
|
27
|
+
outboundMedia: ['url', 'base64'],
|
|
28
|
+
interactive: 'text',
|
|
29
|
+
},
|
|
24
30
|
create(context) {
|
|
25
31
|
const config = resolveSatoriConfig(context.config);
|
|
26
32
|
const gateway = context.use(messageGatewayToken);
|
|
33
|
+
// 注册到插件运行时状态(satori endpoint list 的"运行中"数据源)
|
|
34
|
+
context.use(satoriRuntimeStateToken).endpoints.set(config.name, {
|
|
35
|
+
name: config.name,
|
|
36
|
+
mode: config.connection,
|
|
37
|
+
});
|
|
27
38
|
if (config.connection === 'webhook') {
|
|
28
39
|
return new SatoriWebhookEndpoint({
|
|
29
40
|
id: context.id,
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
|
|
3
3
|
*/
|
|
4
|
-
import type
|
|
4
|
+
import { type EndpointInstance, type EndpointManagement } from '@zhin.js/adapter';
|
|
5
5
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
6
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
7
7
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
@@ -17,6 +17,7 @@ export interface SatoriWsEndpointOptions {
|
|
|
17
17
|
}
|
|
18
18
|
export declare class SatoriWsEndpoint implements EndpointInstance {
|
|
19
19
|
#private;
|
|
20
|
+
readonly management: EndpointManagement;
|
|
20
21
|
constructor(options: SatoriWsEndpointOptions);
|
|
21
22
|
start(): Promise<void>;
|
|
22
23
|
open(): void;
|
|
@@ -41,6 +42,7 @@ export interface SatoriWebhookEndpointOptions {
|
|
|
41
42
|
}
|
|
42
43
|
export declare class SatoriWebhookEndpoint implements EndpointInstance {
|
|
43
44
|
#private;
|
|
45
|
+
readonly management: EndpointManagement;
|
|
44
46
|
constructor(options: SatoriWebhookEndpointOptions);
|
|
45
47
|
/** Used by webhook handler. */
|
|
46
48
|
get isOpen(): boolean;
|
|
@@ -58,4 +60,10 @@ export declare class SatoriWebhookEndpoint implements EndpointInstance {
|
|
|
58
60
|
/** Test helper: inject login without a live webhook push. */
|
|
59
61
|
setLogin(login: SatoriLogin): void;
|
|
60
62
|
}
|
|
63
|
+
export type SatoriManagementApi = (resource: string, method: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
64
|
+
/**
|
|
65
|
+
* Satori endpoint 的 EndpointManagement 语义端口(ws / webhook 共用),
|
|
66
|
+
* 数据走协议 API:guild.list / channel.list / guild-member.list。
|
|
67
|
+
*/
|
|
68
|
+
export declare function createSatoriEndpointManagement(api: SatoriManagementApi): EndpointManagement;
|
|
61
69
|
export type { CreateSatoriWebSocket, SatoriWsSocket } from './ws.js';
|
package/lib/endpoint.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
|
|
3
|
+
*/
|
|
4
|
+
import { createEndpointLifecycle, } from '@zhin.js/adapter';
|
|
1
5
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
6
|
import { SatoriOpcode, buildWsUrl, callSatoriApi, extractCreatedMessageId, formatInboundContent, formatMessageId, formatSatoriOutbound, isMessageEvent, isSelfMentioned, parseMessageRef, resolveInboundSender, resolveInboundTarget, } from './protocol.js';
|
|
3
7
|
import { registerSatoriWebhookRoutes } from './webhook.js';
|
|
@@ -5,23 +9,48 @@ import { WS_OPEN, defaultCreateWebSocket, } from './ws.js';
|
|
|
5
9
|
const logger = getLogger('satori');
|
|
6
10
|
export class SatoriWsEndpoint {
|
|
7
11
|
#options;
|
|
12
|
+
#lifecycle;
|
|
8
13
|
#ws = null;
|
|
9
14
|
#login;
|
|
10
15
|
#lastSn;
|
|
11
|
-
#reconnectTimer = null;
|
|
12
|
-
#heartbeatTimer = null;
|
|
13
16
|
#open = false;
|
|
14
|
-
|
|
15
|
-
#stopping = false;
|
|
17
|
+
management = createSatoriEndpointManagement((resource, method, params) => this.#api(resource, method, params));
|
|
16
18
|
constructor(options) {
|
|
17
19
|
this.#options = options;
|
|
20
|
+
const { config } = options;
|
|
21
|
+
this.#lifecycle = createEndpointLifecycle({
|
|
22
|
+
name: config.name,
|
|
23
|
+
reconnect: {
|
|
24
|
+
initialIntervalMs: 5_000,
|
|
25
|
+
// 固定间隔(multiplier 1、无抖动),对齐旧 5s 固定重连语义
|
|
26
|
+
multiplier: 1,
|
|
27
|
+
maxIntervalMs: 5_000,
|
|
28
|
+
jitterMs: 0,
|
|
29
|
+
},
|
|
30
|
+
heartbeat: {
|
|
31
|
+
intervalMs: config.heartbeat_interval,
|
|
32
|
+
// PONG 看门狗:连续 2 轮无回包,下一轮心跳由基座强关连接触发重连
|
|
33
|
+
watchdogMisses: 2,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
18
36
|
}
|
|
19
37
|
async start() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
38
|
+
try {
|
|
39
|
+
await this.#lifecycle.start((handle) => this.#connect(handle));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// start 失败清理现场(状态复位由基座保证)
|
|
43
|
+
if (this.#ws) {
|
|
44
|
+
try {
|
|
45
|
+
this.#ws.close();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
this.#ws = null;
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
25
54
|
}
|
|
26
55
|
open() {
|
|
27
56
|
this.#open = true;
|
|
@@ -31,23 +60,9 @@ export class SatoriWsEndpoint {
|
|
|
31
60
|
}
|
|
32
61
|
async stop() {
|
|
33
62
|
this.#open = false;
|
|
34
|
-
|
|
35
|
-
this.#
|
|
36
|
-
this.#
|
|
37
|
-
if (this.#ws) {
|
|
38
|
-
try {
|
|
39
|
-
this.#ws.close();
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
/* ignore */
|
|
43
|
-
}
|
|
44
|
-
this.#ws = null;
|
|
45
|
-
}
|
|
46
|
-
this.#started = false;
|
|
47
|
-
logger.debug(formatCompact({
|
|
48
|
-
op: 'disconnect',
|
|
49
|
-
endpoint: this.#options.config.name,
|
|
50
|
-
}));
|
|
63
|
+
// 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
|
|
64
|
+
await this.#lifecycle.stop();
|
|
65
|
+
this.#ws = null;
|
|
51
66
|
}
|
|
52
67
|
async send({ target, payload }) {
|
|
53
68
|
const content = formatSatoriOutbound(payload);
|
|
@@ -111,7 +126,7 @@ export class SatoriWsEndpoint {
|
|
|
111
126
|
setLogin(login) {
|
|
112
127
|
this.#login = login;
|
|
113
128
|
}
|
|
114
|
-
async #connect() {
|
|
129
|
+
async #connect(handle) {
|
|
115
130
|
const { config } = this.#options;
|
|
116
131
|
const createWs = this.#options.createWebSocket ?? defaultCreateWebSocket;
|
|
117
132
|
const headers = {};
|
|
@@ -121,13 +136,21 @@ export class SatoriWsEndpoint {
|
|
|
121
136
|
let settled = false;
|
|
122
137
|
const ws = createWs(buildWsUrl(config.baseUrl, config.token), { headers });
|
|
123
138
|
this.#ws = ws;
|
|
139
|
+
handle.onForceClose(() => {
|
|
140
|
+
try {
|
|
141
|
+
ws.close();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* already closed */
|
|
145
|
+
}
|
|
146
|
+
});
|
|
124
147
|
ws.on('open', () => {
|
|
125
148
|
logger.debug(formatCompact({ endpoint: config.name, mode: 'ws' }));
|
|
126
149
|
this.#sendSignal(SatoriOpcode.IDENTIFY, {
|
|
127
150
|
token: config.token,
|
|
128
151
|
sn: this.#lastSn,
|
|
129
152
|
});
|
|
130
|
-
this.#startHeartbeat();
|
|
153
|
+
this.#lifecycle.startHeartbeat(() => this.#sendSignal(SatoriOpcode.PING));
|
|
131
154
|
if (!settled) {
|
|
132
155
|
settled = true;
|
|
133
156
|
resolve();
|
|
@@ -152,27 +175,19 @@ export class SatoriWsEndpoint {
|
|
|
152
175
|
}
|
|
153
176
|
});
|
|
154
177
|
ws.on('close', (code, reason) => {
|
|
155
|
-
this.#clearHeartbeat();
|
|
156
178
|
const reasonStr = typeof reason === 'string'
|
|
157
179
|
? reason
|
|
158
180
|
: Buffer.isBuffer(reason)
|
|
159
181
|
? reason.toString('utf8')
|
|
160
182
|
: String(reason ?? '');
|
|
161
183
|
const numericCode = typeof code === 'number' ? code : 0;
|
|
162
|
-
logger.warn(formatCompact({
|
|
163
|
-
op: 'disconnect',
|
|
164
|
-
endpoint: config.name,
|
|
165
|
-
code: numericCode,
|
|
166
|
-
error: reasonStr || 'closed',
|
|
167
|
-
reconnect_ms: this.#stopping ? undefined : 5000,
|
|
168
|
-
}));
|
|
169
184
|
if (!settled) {
|
|
170
185
|
settled = true;
|
|
171
186
|
reject(new Error(`Satori WS closed: ${numericCode} ${reasonStr}`));
|
|
172
|
-
return;
|
|
173
187
|
}
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
// 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
|
|
189
|
+
// 初始连接失败由 start() 的拒绝路径复位。
|
|
190
|
+
handle.notifyClosed(`Satori WS closed: ${numericCode} ${reasonStr || 'closed'}`);
|
|
176
191
|
});
|
|
177
192
|
ws.on('error', (error) => {
|
|
178
193
|
logger.warn(formatCompact({
|
|
@@ -189,6 +204,11 @@ export class SatoriWsEndpoint {
|
|
|
189
204
|
});
|
|
190
205
|
}
|
|
191
206
|
#handleSignal(signal) {
|
|
207
|
+
if (signal.op === SatoriOpcode.PONG) {
|
|
208
|
+
// 喂狗:复位基座看门狗计数
|
|
209
|
+
this.#lifecycle.notifyHeartbeatAck();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
192
212
|
if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
|
|
193
213
|
const logins = signal.body.logins;
|
|
194
214
|
this.#login = logins[0];
|
|
@@ -208,40 +228,6 @@ export class SatoriWsEndpoint {
|
|
|
208
228
|
return;
|
|
209
229
|
this.#ws.send(JSON.stringify({ op, body: body ?? {} }));
|
|
210
230
|
}
|
|
211
|
-
#startHeartbeat() {
|
|
212
|
-
this.#clearHeartbeat();
|
|
213
|
-
const interval = this.#options.config.heartbeat_interval;
|
|
214
|
-
this.#heartbeatTimer = setInterval(() => {
|
|
215
|
-
this.#sendSignal(SatoriOpcode.PING);
|
|
216
|
-
}, interval);
|
|
217
|
-
}
|
|
218
|
-
#scheduleReconnect() {
|
|
219
|
-
if (this.#reconnectTimer || this.#stopping)
|
|
220
|
-
return;
|
|
221
|
-
this.#reconnectTimer = setTimeout(() => {
|
|
222
|
-
this.#reconnectTimer = null;
|
|
223
|
-
void this.#connect().catch((err) => {
|
|
224
|
-
logger.warn(formatCompact({
|
|
225
|
-
op: 'reconnect',
|
|
226
|
-
endpoint: this.#options.config.name,
|
|
227
|
-
ok: false,
|
|
228
|
-
error: err instanceof Error ? err.message : String(err),
|
|
229
|
-
}));
|
|
230
|
-
});
|
|
231
|
-
}, 5000);
|
|
232
|
-
}
|
|
233
|
-
#clearReconnect() {
|
|
234
|
-
if (this.#reconnectTimer) {
|
|
235
|
-
clearTimeout(this.#reconnectTimer);
|
|
236
|
-
this.#reconnectTimer = null;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
#clearHeartbeat() {
|
|
240
|
-
if (this.#heartbeatTimer) {
|
|
241
|
-
clearInterval(this.#heartbeatTimer);
|
|
242
|
-
this.#heartbeatTimer = null;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
231
|
#apiOptions() {
|
|
246
232
|
return {
|
|
247
233
|
baseUrl: this.#options.config.baseUrl,
|
|
@@ -261,6 +247,7 @@ export class SatoriWebhookEndpoint {
|
|
|
261
247
|
#routeReleases = [];
|
|
262
248
|
#open = false;
|
|
263
249
|
#started = false;
|
|
250
|
+
management = createSatoriEndpointManagement((resource, method, params) => this.#api(resource, method, params));
|
|
264
251
|
constructor(options) {
|
|
265
252
|
this.#options = options;
|
|
266
253
|
}
|
|
@@ -275,6 +262,15 @@ export class SatoriWebhookEndpoint {
|
|
|
275
262
|
if (this.#started)
|
|
276
263
|
return;
|
|
277
264
|
this.#started = true;
|
|
265
|
+
if (!this.#options.config.token) {
|
|
266
|
+
// 未配 token 时 webhook 无鉴权:任何人知道 path 即可注入假事件。
|
|
267
|
+
logger.warn(formatCompact({
|
|
268
|
+
op: 'webhook_no_token',
|
|
269
|
+
endpoint: this.#options.config.name,
|
|
270
|
+
path: this.#options.config.path,
|
|
271
|
+
hint: 'set token to authenticate Satori webhook callbacks',
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
278
274
|
this.#routeReleases.push(...registerSatoriWebhookRoutes(this.#options.http, this));
|
|
279
275
|
logger.info(formatCompact({
|
|
280
276
|
op: 'listen',
|
|
@@ -377,3 +373,83 @@ function isPrivateChannelType(body) {
|
|
|
377
373
|
const channel = body.channel ?? body.message?.channel;
|
|
378
374
|
return channel?.type === 1;
|
|
379
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Satori guild id 是平台相关字符串(多数平台为雪花号,超
|
|
378
|
+
* Number.MAX_SAFE_INTEGER)。Console 社交面只把 group_id 当 JSON 值透传、
|
|
379
|
+
* 并以字符串回传给 listGroupMembers,因此保留原始字符串(仅按契约类型
|
|
380
|
+
* 声明强转)是全链路最不丢信息的方案。
|
|
381
|
+
*/
|
|
382
|
+
function toGroupId(id) {
|
|
383
|
+
return id;
|
|
384
|
+
}
|
|
385
|
+
/** Satori 分页列表({data, next?})聚合;兼容直接返回数组的实现。 */
|
|
386
|
+
async function listSatoriPages(api, resource, params) {
|
|
387
|
+
const items = [];
|
|
388
|
+
let next;
|
|
389
|
+
do {
|
|
390
|
+
const page = await api(resource, 'list', next ? { ...params, next } : params);
|
|
391
|
+
if (Array.isArray(page)) {
|
|
392
|
+
items.push(...page);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
const typed = (page ?? {});
|
|
396
|
+
if (Array.isArray(typed.data))
|
|
397
|
+
items.push(...typed.data);
|
|
398
|
+
next = typeof typed.next === 'string' && typed.next ? typed.next : undefined;
|
|
399
|
+
} while (next);
|
|
400
|
+
return items;
|
|
401
|
+
}
|
|
402
|
+
function asRecord(value) {
|
|
403
|
+
return value !== null && typeof value === 'object'
|
|
404
|
+
? value
|
|
405
|
+
: {};
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Satori endpoint 的 EndpointManagement 语义端口(ws / webhook 共用),
|
|
409
|
+
* 数据走协议 API:guild.list / channel.list / guild-member.list。
|
|
410
|
+
*/
|
|
411
|
+
export function createSatoriEndpointManagement(api) {
|
|
412
|
+
return Object.freeze({
|
|
413
|
+
async listGroups() {
|
|
414
|
+
const groups = [];
|
|
415
|
+
for (const value of await listSatoriPages(api, 'guild', {})) {
|
|
416
|
+
const guild = asRecord(value);
|
|
417
|
+
if (guild.id == null)
|
|
418
|
+
continue;
|
|
419
|
+
groups.push({
|
|
420
|
+
group_id: toGroupId(String(guild.id)),
|
|
421
|
+
name: String(guild.name ?? guild.id),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return groups;
|
|
425
|
+
},
|
|
426
|
+
async listChannels() {
|
|
427
|
+
const channels = [];
|
|
428
|
+
for (const value of await listSatoriPages(api, 'guild', {})) {
|
|
429
|
+
const guild = asRecord(value);
|
|
430
|
+
if (guild.id == null)
|
|
431
|
+
continue;
|
|
432
|
+
const guildId = String(guild.id);
|
|
433
|
+
const guildName = String(guild.name ?? guildId);
|
|
434
|
+
for (const channelValue of await listSatoriPages(api, 'channel', { guild_id: guildId })) {
|
|
435
|
+
const channel = asRecord(channelValue);
|
|
436
|
+
if (channel.id == null)
|
|
437
|
+
continue;
|
|
438
|
+
// Channel.type: 0=TEXT 1=DIRECT 2=CATEGORY 3=VOICE;缺省按 TEXT 处理
|
|
439
|
+
if (channel.type != null && Number(channel.type) !== 0)
|
|
440
|
+
continue;
|
|
441
|
+
channels.push({
|
|
442
|
+
id: String(channel.id),
|
|
443
|
+
name: channel.name != null ? String(channel.name) : undefined,
|
|
444
|
+
parent: { type: 'guild', id: guildId, name: guildName },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return channels;
|
|
449
|
+
},
|
|
450
|
+
async listGroupMembers(groupId) {
|
|
451
|
+
// 平台形状(GuildMember[])原样返回
|
|
452
|
+
return listSatoriPages(api, 'guild-member', { guild_id: groupId });
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const satoriEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `satori endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { satoriRuntimeStateToken } from './satori-runtime-state.js';
|
|
8
|
+
export const satoriEndpointCommands = createEndpointCommands({
|
|
9
|
+
adapterKey: 'satori',
|
|
10
|
+
adapterDisplayName: 'Satori',
|
|
11
|
+
fields: [
|
|
12
|
+
{ key: 'baseUrl', required: true, description: 'Satori 服务 base URL' },
|
|
13
|
+
{ key: 'path', description: 'webhook 路径(connection: webhook)' },
|
|
14
|
+
{ key: 'token', env: true, description: 'Satori access token' },
|
|
15
|
+
],
|
|
16
|
+
running: (use) => use(satoriRuntimeStateToken).endpoints.values(),
|
|
17
|
+
describeEntry: (entry) => `baseUrl: ${String(entry.baseUrl)}`,
|
|
18
|
+
}, defineCommand);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const satoriRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satori 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `satori endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
export const satoriRuntimeStateToken = defineEndpointRuntimeStateToken('satori');
|
package/lib/webhook.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Satori webhook HTTP: token → opcode → parse → admit.
|
|
3
|
-
*/
|
|
4
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
2
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
3
|
import { type ResolvedSatoriWebhookConfig, type SatoriEventBody, type SatoriLogin } from './protocol.js';
|
package/lib/webhook.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satori webhook HTTP: token → opcode → parse → admit.
|
|
3
|
+
*/
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
1
5
|
import { getLogger } from '@zhin.js/logger';
|
|
2
6
|
import { SatoriOpcode, } from './protocol.js';
|
|
3
7
|
const logger = getLogger('satori');
|
|
@@ -61,7 +65,9 @@ export function verifySatoriToken(token, request) {
|
|
|
61
65
|
if (!token)
|
|
62
66
|
return true;
|
|
63
67
|
const auth = request.headers.authorization ?? '';
|
|
64
|
-
|
|
68
|
+
const expected = Buffer.from(`Bearer ${token}`, 'utf8');
|
|
69
|
+
const actual = Buffer.from(auth, 'utf8');
|
|
70
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
65
71
|
}
|
|
66
72
|
export async function readRequestBody(request) {
|
|
67
73
|
const chunks = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-satori",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "Zhin.js Satori adapter for Plugin Runtime (WebSocket client)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"adapters",
|
|
17
|
+
"commands",
|
|
17
18
|
"plugin.ts",
|
|
18
19
|
"schema.json",
|
|
19
20
|
"src",
|
|
@@ -38,24 +39,25 @@
|
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"ws": "^8.21.0",
|
|
41
|
-
"@zhin.js/adapter": "1.1.
|
|
42
|
-
"@zhin.js/
|
|
43
|
-
"@zhin.js/
|
|
42
|
+
"@zhin.js/adapter": "1.1.1",
|
|
43
|
+
"@zhin.js/command": "1.0.3",
|
|
44
|
+
"@zhin.js/core": "1.4.1",
|
|
45
|
+
"@zhin.js/host-http": "1.0.3",
|
|
44
46
|
"@zhin.js/logger": "1.0.75",
|
|
45
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
47
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
46
48
|
},
|
|
47
49
|
"devDependencies": {
|
|
48
50
|
"@types/node": "^26.1.0",
|
|
49
51
|
"@types/ws": "^8.18.1",
|
|
50
52
|
"typescript": "^6.0.3",
|
|
51
53
|
"vitest": "^4.1.10",
|
|
52
|
-
"@zhin.js/host-http": "1.0.
|
|
54
|
+
"@zhin.js/host-http": "1.0.3"
|
|
53
55
|
},
|
|
54
56
|
"peerDependencies": {
|
|
55
|
-
"@zhin.js/adapter": "1.1.
|
|
56
|
-
"@zhin.js/core": "1.4.
|
|
57
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
58
|
-
"zhin.js": "5.0.
|
|
57
|
+
"@zhin.js/adapter": "1.1.1",
|
|
58
|
+
"@zhin.js/core": "1.4.1",
|
|
59
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
60
|
+
"zhin.js": "5.0.1"
|
|
59
61
|
},
|
|
60
62
|
"peerDependenciesMeta": {
|
|
61
63
|
"zhin.js": {
|
|
@@ -84,6 +86,10 @@
|
|
|
84
86
|
{
|
|
85
87
|
"package": "@zhin.js/adapter",
|
|
86
88
|
"api": "^1.0.0"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"package": "@zhin.js/command",
|
|
92
|
+
"api": "^1.0.0"
|
|
87
93
|
}
|
|
88
94
|
],
|
|
89
95
|
"plugins": []
|
package/plugin.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
1
2
|
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { satoriRuntimeStateToken } from './src/satori-runtime-state.js';
|
|
2
4
|
|
|
3
5
|
export default definePlugin({
|
|
4
6
|
name: 'satori',
|
|
5
7
|
metadata: {
|
|
6
8
|
displayName: 'Satori Adapter',
|
|
7
9
|
},
|
|
10
|
+
setup(context) {
|
|
11
|
+
// 运行中 endpoint 注册表(satori endpoint list 的"运行中"数据源)
|
|
12
|
+
context.resources.provide(satoriRuntimeStateToken, createEndpointRuntimeState());
|
|
13
|
+
},
|
|
8
14
|
});
|
package/src/endpoint.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
|
|
3
3
|
*/
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
createEndpointLifecycle,
|
|
6
|
+
type EndpointChannel,
|
|
7
|
+
type EndpointConnectHandle,
|
|
8
|
+
type EndpointGroup,
|
|
9
|
+
type EndpointInstance,
|
|
10
|
+
type EndpointLifecycle,
|
|
11
|
+
type EndpointManagement,
|
|
12
|
+
} from '@zhin.js/adapter';
|
|
5
13
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
14
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
15
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
@@ -48,24 +56,50 @@ export interface SatoriWsEndpointOptions {
|
|
|
48
56
|
|
|
49
57
|
export class SatoriWsEndpoint implements EndpointInstance {
|
|
50
58
|
readonly #options: SatoriWsEndpointOptions;
|
|
59
|
+
readonly #lifecycle: EndpointLifecycle;
|
|
51
60
|
#ws: SatoriWsSocket | null = null;
|
|
52
61
|
#login: SatoriLogin | undefined;
|
|
53
62
|
#lastSn: number | undefined;
|
|
54
|
-
#reconnectTimer: NodeJS.Timeout | null = null;
|
|
55
|
-
#heartbeatTimer: NodeJS.Timeout | null = null;
|
|
56
63
|
#open = false;
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
readonly management: EndpointManagement = createSatoriEndpointManagement(
|
|
65
|
+
(resource, method, params) => this.#api(resource, method, params),
|
|
66
|
+
);
|
|
59
67
|
|
|
60
68
|
constructor(options: SatoriWsEndpointOptions) {
|
|
61
69
|
this.#options = options;
|
|
70
|
+
const { config } = options;
|
|
71
|
+
this.#lifecycle = createEndpointLifecycle({
|
|
72
|
+
name: config.name,
|
|
73
|
+
reconnect: {
|
|
74
|
+
initialIntervalMs: 5_000,
|
|
75
|
+
// 固定间隔(multiplier 1、无抖动),对齐旧 5s 固定重连语义
|
|
76
|
+
multiplier: 1,
|
|
77
|
+
maxIntervalMs: 5_000,
|
|
78
|
+
jitterMs: 0,
|
|
79
|
+
},
|
|
80
|
+
heartbeat: {
|
|
81
|
+
intervalMs: config.heartbeat_interval,
|
|
82
|
+
// PONG 看门狗:连续 2 轮无回包,下一轮心跳由基座强关连接触发重连
|
|
83
|
+
watchdogMisses: 2,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
async start(): Promise<void> {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
try {
|
|
90
|
+
await this.#lifecycle.start((handle) => this.#connect(handle));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// start 失败清理现场(状态复位由基座保证)
|
|
93
|
+
if (this.#ws) {
|
|
94
|
+
try {
|
|
95
|
+
this.#ws.close();
|
|
96
|
+
} catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
this.#ws = null;
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
69
103
|
}
|
|
70
104
|
|
|
71
105
|
open(): void {
|
|
@@ -78,22 +112,9 @@ export class SatoriWsEndpoint implements EndpointInstance {
|
|
|
78
112
|
|
|
79
113
|
async stop(): Promise<void> {
|
|
80
114
|
this.#open = false;
|
|
81
|
-
|
|
82
|
-
this.#
|
|
83
|
-
this.#
|
|
84
|
-
if (this.#ws) {
|
|
85
|
-
try {
|
|
86
|
-
this.#ws.close();
|
|
87
|
-
} catch {
|
|
88
|
-
/* ignore */
|
|
89
|
-
}
|
|
90
|
-
this.#ws = null;
|
|
91
|
-
}
|
|
92
|
-
this.#started = false;
|
|
93
|
-
logger.debug(formatCompact({
|
|
94
|
-
op: 'disconnect',
|
|
95
|
-
endpoint: this.#options.config.name,
|
|
96
|
-
}));
|
|
115
|
+
// 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
|
|
116
|
+
await this.#lifecycle.stop();
|
|
117
|
+
this.#ws = null;
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
|
|
@@ -159,7 +180,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
|
|
|
159
180
|
this.#login = login;
|
|
160
181
|
}
|
|
161
182
|
|
|
162
|
-
async #connect(): Promise<void> {
|
|
183
|
+
async #connect(handle: EndpointConnectHandle): Promise<void> {
|
|
163
184
|
const { config } = this.#options;
|
|
164
185
|
const createWs = this.#options.createWebSocket ?? defaultCreateWebSocket;
|
|
165
186
|
const headers: Record<string, string> = {};
|
|
@@ -169,6 +190,13 @@ export class SatoriWsEndpoint implements EndpointInstance {
|
|
|
169
190
|
let settled = false;
|
|
170
191
|
const ws = createWs(buildWsUrl(config.baseUrl, config.token), { headers });
|
|
171
192
|
this.#ws = ws;
|
|
193
|
+
handle.onForceClose(() => {
|
|
194
|
+
try {
|
|
195
|
+
ws.close();
|
|
196
|
+
} catch {
|
|
197
|
+
/* already closed */
|
|
198
|
+
}
|
|
199
|
+
});
|
|
172
200
|
|
|
173
201
|
ws.on('open', () => {
|
|
174
202
|
logger.debug(formatCompact({ endpoint: config.name, mode: 'ws' }));
|
|
@@ -176,7 +204,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
|
|
|
176
204
|
token: config.token,
|
|
177
205
|
sn: this.#lastSn,
|
|
178
206
|
});
|
|
179
|
-
this.#startHeartbeat();
|
|
207
|
+
this.#lifecycle.startHeartbeat(() => this.#sendSignal(SatoriOpcode.PING));
|
|
180
208
|
if (!settled) {
|
|
181
209
|
settled = true;
|
|
182
210
|
resolve();
|
|
@@ -202,26 +230,19 @@ export class SatoriWsEndpoint implements EndpointInstance {
|
|
|
202
230
|
});
|
|
203
231
|
|
|
204
232
|
ws.on('close', (code, reason) => {
|
|
205
|
-
this.#clearHeartbeat();
|
|
206
233
|
const reasonStr = typeof reason === 'string'
|
|
207
234
|
? reason
|
|
208
235
|
: Buffer.isBuffer(reason)
|
|
209
236
|
? reason.toString('utf8')
|
|
210
237
|
: String(reason ?? '');
|
|
211
238
|
const numericCode = typeof code === 'number' ? code : 0;
|
|
212
|
-
logger.warn(formatCompact({
|
|
213
|
-
op: 'disconnect',
|
|
214
|
-
endpoint: config.name,
|
|
215
|
-
code: numericCode,
|
|
216
|
-
error: reasonStr || 'closed',
|
|
217
|
-
reconnect_ms: this.#stopping ? undefined : 5000,
|
|
218
|
-
}));
|
|
219
239
|
if (!settled) {
|
|
220
240
|
settled = true;
|
|
221
241
|
reject(new Error(`Satori WS closed: ${numericCode} ${reasonStr}`));
|
|
222
|
-
return;
|
|
223
242
|
}
|
|
224
|
-
|
|
243
|
+
// 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
|
|
244
|
+
// 初始连接失败由 start() 的拒绝路径复位。
|
|
245
|
+
handle.notifyClosed(`Satori WS closed: ${numericCode} ${reasonStr || 'closed'}`);
|
|
225
246
|
});
|
|
226
247
|
|
|
227
248
|
ws.on('error', (error) => {
|
|
@@ -240,6 +261,11 @@ export class SatoriWsEndpoint implements EndpointInstance {
|
|
|
240
261
|
}
|
|
241
262
|
|
|
242
263
|
#handleSignal(signal: SatoriSignal): void {
|
|
264
|
+
if (signal.op === SatoriOpcode.PONG) {
|
|
265
|
+
// 喂狗:复位基座看门狗计数
|
|
266
|
+
this.#lifecycle.notifyHeartbeatAck();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
243
269
|
if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
|
|
244
270
|
const logins = signal.body.logins as SatoriLogin[];
|
|
245
271
|
this.#login = logins[0];
|
|
@@ -259,43 +285,6 @@ export class SatoriWsEndpoint implements EndpointInstance {
|
|
|
259
285
|
this.#ws.send(JSON.stringify({ op, body: body ?? {} }));
|
|
260
286
|
}
|
|
261
287
|
|
|
262
|
-
#startHeartbeat(): void {
|
|
263
|
-
this.#clearHeartbeat();
|
|
264
|
-
const interval = this.#options.config.heartbeat_interval;
|
|
265
|
-
this.#heartbeatTimer = setInterval(() => {
|
|
266
|
-
this.#sendSignal(SatoriOpcode.PING);
|
|
267
|
-
}, interval);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
#scheduleReconnect(): void {
|
|
271
|
-
if (this.#reconnectTimer || this.#stopping) return;
|
|
272
|
-
this.#reconnectTimer = setTimeout(() => {
|
|
273
|
-
this.#reconnectTimer = null;
|
|
274
|
-
void this.#connect().catch((err) => {
|
|
275
|
-
logger.warn(formatCompact({
|
|
276
|
-
op: 'reconnect',
|
|
277
|
-
endpoint: this.#options.config.name,
|
|
278
|
-
ok: false,
|
|
279
|
-
error: err instanceof Error ? err.message : String(err),
|
|
280
|
-
}));
|
|
281
|
-
});
|
|
282
|
-
}, 5000);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
#clearReconnect(): void {
|
|
286
|
-
if (this.#reconnectTimer) {
|
|
287
|
-
clearTimeout(this.#reconnectTimer);
|
|
288
|
-
this.#reconnectTimer = null;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
#clearHeartbeat(): void {
|
|
293
|
-
if (this.#heartbeatTimer) {
|
|
294
|
-
clearInterval(this.#heartbeatTimer);
|
|
295
|
-
this.#heartbeatTimer = null;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
288
|
#apiOptions(): SatoriApiOptions {
|
|
300
289
|
return {
|
|
301
290
|
baseUrl: this.#options.config.baseUrl,
|
|
@@ -329,6 +318,9 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
|
|
|
329
318
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
330
319
|
#open = false;
|
|
331
320
|
#started = false;
|
|
321
|
+
readonly management: EndpointManagement = createSatoriEndpointManagement(
|
|
322
|
+
(resource, method, params) => this.#api(resource, method, params),
|
|
323
|
+
);
|
|
332
324
|
|
|
333
325
|
constructor(options: SatoriWebhookEndpointOptions) {
|
|
334
326
|
this.#options = options;
|
|
@@ -346,6 +338,15 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
|
|
|
346
338
|
async start(): Promise<void> {
|
|
347
339
|
if (this.#started) return;
|
|
348
340
|
this.#started = true;
|
|
341
|
+
if (!this.#options.config.token) {
|
|
342
|
+
// 未配 token 时 webhook 无鉴权:任何人知道 path 即可注入假事件。
|
|
343
|
+
logger.warn(formatCompact({
|
|
344
|
+
op: 'webhook_no_token',
|
|
345
|
+
endpoint: this.#options.config.name,
|
|
346
|
+
path: this.#options.config.path,
|
|
347
|
+
hint: 'set token to authenticate Satori webhook callbacks',
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
349
350
|
this.#routeReleases.push(...registerSatoriWebhookRoutes(this.#options.http, this));
|
|
350
351
|
logger.info(formatCompact({
|
|
351
352
|
op: 'listen',
|
|
@@ -459,4 +460,98 @@ function isPrivateChannelType(body: SatoriEventBody): boolean {
|
|
|
459
460
|
return channel?.type === 1;
|
|
460
461
|
}
|
|
461
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Satori guild id 是平台相关字符串(多数平台为雪花号,超
|
|
465
|
+
* Number.MAX_SAFE_INTEGER)。Console 社交面只把 group_id 当 JSON 值透传、
|
|
466
|
+
* 并以字符串回传给 listGroupMembers,因此保留原始字符串(仅按契约类型
|
|
467
|
+
* 声明强转)是全链路最不丢信息的方案。
|
|
468
|
+
*/
|
|
469
|
+
function toGroupId(id: string): number {
|
|
470
|
+
return id as unknown as number;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export type SatoriManagementApi = (
|
|
474
|
+
resource: string,
|
|
475
|
+
method: string,
|
|
476
|
+
params: Record<string, unknown>,
|
|
477
|
+
) => Promise<unknown>;
|
|
478
|
+
|
|
479
|
+
interface SatoriListPage {
|
|
480
|
+
readonly data?: unknown[];
|
|
481
|
+
readonly next?: string;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Satori 分页列表({data, next?})聚合;兼容直接返回数组的实现。 */
|
|
485
|
+
async function listSatoriPages(
|
|
486
|
+
api: SatoriManagementApi,
|
|
487
|
+
resource: string,
|
|
488
|
+
params: Record<string, unknown>,
|
|
489
|
+
): Promise<unknown[]> {
|
|
490
|
+
const items: unknown[] = [];
|
|
491
|
+
let next: string | undefined;
|
|
492
|
+
do {
|
|
493
|
+
const page = await api(resource, 'list', next ? { ...params, next } : params);
|
|
494
|
+
if (Array.isArray(page)) {
|
|
495
|
+
items.push(...page);
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
const typed = (page ?? {}) as SatoriListPage;
|
|
499
|
+
if (Array.isArray(typed.data)) items.push(...typed.data);
|
|
500
|
+
next = typeof typed.next === 'string' && typed.next ? typed.next : undefined;
|
|
501
|
+
} while (next);
|
|
502
|
+
return items;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
506
|
+
return value !== null && typeof value === 'object'
|
|
507
|
+
? value as Record<string, unknown>
|
|
508
|
+
: {};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Satori endpoint 的 EndpointManagement 语义端口(ws / webhook 共用),
|
|
513
|
+
* 数据走协议 API:guild.list / channel.list / guild-member.list。
|
|
514
|
+
*/
|
|
515
|
+
export function createSatoriEndpointManagement(api: SatoriManagementApi): EndpointManagement {
|
|
516
|
+
return Object.freeze<EndpointManagement>({
|
|
517
|
+
async listGroups(): Promise<readonly EndpointGroup[]> {
|
|
518
|
+
const groups: EndpointGroup[] = [];
|
|
519
|
+
for (const value of await listSatoriPages(api, 'guild', {})) {
|
|
520
|
+
const guild = asRecord(value);
|
|
521
|
+
if (guild.id == null) continue;
|
|
522
|
+
groups.push({
|
|
523
|
+
group_id: toGroupId(String(guild.id)),
|
|
524
|
+
name: String(guild.name ?? guild.id),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return groups;
|
|
528
|
+
},
|
|
529
|
+
async listChannels(): Promise<readonly EndpointChannel[]> {
|
|
530
|
+
const channels: EndpointChannel[] = [];
|
|
531
|
+
for (const value of await listSatoriPages(api, 'guild', {})) {
|
|
532
|
+
const guild = asRecord(value);
|
|
533
|
+
if (guild.id == null) continue;
|
|
534
|
+
const guildId = String(guild.id);
|
|
535
|
+
const guildName = String(guild.name ?? guildId);
|
|
536
|
+
for (const channelValue of await listSatoriPages(api, 'channel', { guild_id: guildId })) {
|
|
537
|
+
const channel = asRecord(channelValue);
|
|
538
|
+
if (channel.id == null) continue;
|
|
539
|
+
// Channel.type: 0=TEXT 1=DIRECT 2=CATEGORY 3=VOICE;缺省按 TEXT 处理
|
|
540
|
+
if (channel.type != null && Number(channel.type) !== 0) continue;
|
|
541
|
+
channels.push({
|
|
542
|
+
id: String(channel.id),
|
|
543
|
+
name: channel.name != null ? String(channel.name) : undefined,
|
|
544
|
+
parent: { type: 'guild', id: guildId, name: guildName },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return channels;
|
|
549
|
+
},
|
|
550
|
+
async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
|
|
551
|
+
// 平台形状(GuildMember[])原样返回
|
|
552
|
+
return listSatoriPages(api, 'guild-member', { guild_id: groupId });
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
462
557
|
export type { CreateSatoriWebSocket, SatoriWsSocket } from './ws.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `satori endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { satoriRuntimeStateToken } from './satori-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export const satoriEndpointCommands = createEndpointCommands({
|
|
10
|
+
adapterKey: 'satori',
|
|
11
|
+
adapterDisplayName: 'Satori',
|
|
12
|
+
fields: [
|
|
13
|
+
{ key: 'baseUrl', required: true, description: 'Satori 服务 base URL' },
|
|
14
|
+
{ key: 'path', description: 'webhook 路径(connection: webhook)' },
|
|
15
|
+
{ key: 'token', env: true, description: 'Satori access token' },
|
|
16
|
+
],
|
|
17
|
+
running: (use) => use(satoriRuntimeStateToken).endpoints.values(),
|
|
18
|
+
describeEntry: (entry) => `baseUrl: ${String(entry.baseUrl)}`,
|
|
19
|
+
}, defineCommand);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satori 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `satori endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
|
|
7
|
+
export const satoriRuntimeStateToken = defineEndpointRuntimeStateToken('satori');
|
package/src/webhook.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Satori webhook HTTP: token → opcode → parse → admit.
|
|
3
3
|
*/
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
6
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
7
|
import { getLogger } from '@zhin.js/logger';
|
|
@@ -85,7 +86,9 @@ export function resolveSatoriOpcode(request: IncomingMessage): number | undefine
|
|
|
85
86
|
export function verifySatoriToken(token: string | undefined, request: IncomingMessage): boolean {
|
|
86
87
|
if (!token) return true;
|
|
87
88
|
const auth = request.headers.authorization ?? '';
|
|
88
|
-
|
|
89
|
+
const expected = Buffer.from(`Bearer ${token}`, 'utf8');
|
|
90
|
+
const actual = Buffer.from(auth, 'utf8');
|
|
91
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
export async function readRequestBody(request: IncomingMessage): Promise<string> {
|