@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 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
@@ -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,
@@ -0,0 +1,3 @@
1
+ import { satoriEndpointCommands } from '../../../src/satori-endpoint-commands.js';
2
+
3
+ export default satoriEndpointCommands.add;
@@ -0,0 +1,3 @@
1
+ import { satoriEndpointCommands } from '../../src/satori-endpoint-commands.js';
2
+
3
+ export default satoriEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { satoriEndpointCommands } from '../../../src/satori-endpoint-commands.js';
2
+
3
+ export default satoriEndpointCommands.remove;
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 { EndpointInstance } from '@zhin.js/adapter';
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
- #started = false;
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
- if (this.#started)
21
- return;
22
- this.#started = true;
23
- this.#stopping = false;
24
- await this.#connect();
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
- this.#stopping = true;
35
- this.#clearReconnect();
36
- this.#clearHeartbeat();
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
- if (!this.#stopping)
175
- this.#scheduleReconnect();
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
- return auth === `Bearer ${token}`;
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.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.0",
42
- "@zhin.js/core": "1.4.0",
43
- "@zhin.js/host-http": "1.0.2",
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.0"
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.2"
54
+ "@zhin.js/host-http": "1.0.3"
53
55
  },
54
56
  "peerDependencies": {
55
- "@zhin.js/adapter": "1.1.0",
56
- "@zhin.js/core": "1.4.0",
57
- "@zhin.js/plugin-runtime": "1.1.0",
58
- "zhin.js": "5.0.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 type { EndpointInstance } from '@zhin.js/adapter';
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
- #started = false;
58
- #stopping = false;
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
- if (this.#started) return;
66
- this.#started = true;
67
- this.#stopping = false;
68
- await this.#connect();
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
- this.#stopping = true;
82
- this.#clearReconnect();
83
- this.#clearHeartbeat();
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
- if (!this.#stopping) this.#scheduleReconnect();
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
- return auth === `Bearer ${token}`;
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> {