@zhin.js/adapter-satori 1.0.1 → 1.1.2

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +912 -0
  2. package/README.md +60 -38
  3. package/adapters/satori/index.js +38 -0
  4. package/adapters/satori/index.ts +49 -0
  5. package/commands/satori/endpoint/add/[id]/index.js +3 -0
  6. package/commands/satori/endpoint/add/[id]/index.ts +3 -0
  7. package/commands/satori/endpoint/definition.js +19 -0
  8. package/commands/satori/endpoint/definition.ts +19 -0
  9. package/commands/satori/endpoint/list/index.js +3 -0
  10. package/commands/satori/endpoint/list/index.ts +3 -0
  11. package/commands/satori/endpoint/remove/[id]/index.js +3 -0
  12. package/commands/satori/endpoint/remove/[id]/index.ts +3 -0
  13. package/lib/client.d.ts +18 -0
  14. package/lib/client.js +21 -0
  15. package/lib/endpoint.d.ts +56 -0
  16. package/lib/endpoint.js +472 -0
  17. package/lib/index.d.ts +8 -18
  18. package/lib/index.js +6 -25
  19. package/lib/protocol.d.ts +148 -0
  20. package/lib/protocol.js +230 -0
  21. package/lib/satori-runtime-state.d.ts +1 -0
  22. package/lib/satori-runtime-state.js +6 -0
  23. package/lib/webhook.d.ts +12 -0
  24. package/lib/webhook.js +60 -0
  25. package/lib/ws.d.ts +13 -0
  26. package/lib/ws.js +5 -0
  27. package/package.json +68 -17
  28. package/plugin.js +14 -0
  29. package/schema.json +109 -0
  30. package/src/client.ts +57 -0
  31. package/src/endpoint.ts +580 -0
  32. package/src/index.ts +61 -35
  33. package/src/protocol.ts +353 -0
  34. package/src/satori-runtime-state.ts +7 -0
  35. package/src/webhook.ts +79 -0
  36. package/src/ws.ts +22 -0
  37. package/lib/adapter.d.ts +0 -17
  38. package/lib/adapter.d.ts.map +0 -1
  39. package/lib/adapter.js +0 -35
  40. package/lib/adapter.js.map +0 -1
  41. package/lib/api.d.ts +0 -15
  42. package/lib/api.d.ts.map +0 -1
  43. package/lib/api.js +0 -37
  44. package/lib/api.js.map +0 -1
  45. package/lib/endpoint-webhook.d.ts +0 -27
  46. package/lib/endpoint-webhook.d.ts.map +0 -1
  47. package/lib/endpoint-webhook.js +0 -99
  48. package/lib/endpoint-webhook.js.map +0 -1
  49. package/lib/endpoint-ws.d.ts +0 -30
  50. package/lib/endpoint-ws.d.ts.map +0 -1
  51. package/lib/endpoint-ws.js +0 -195
  52. package/lib/endpoint-ws.js.map +0 -1
  53. package/lib/index.d.ts.map +0 -1
  54. package/lib/index.js.map +0 -1
  55. package/lib/types.d.ts +0 -91
  56. package/lib/types.d.ts.map +0 -1
  57. package/lib/types.js +0 -13
  58. package/lib/types.js.map +0 -1
  59. package/lib/utils.d.ts +0 -40
  60. package/lib/utils.d.ts.map +0 -1
  61. package/lib/utils.js +0 -33
  62. package/lib/utils.js.map +0 -1
  63. package/skills/satori/SKILL.md +0 -33
  64. package/src/adapter.ts +0 -48
  65. package/src/api.ts +0 -48
  66. package/src/endpoint-webhook.ts +0 -117
  67. package/src/endpoint-ws.ts +0 -214
  68. package/src/types.ts +0 -91
  69. package/src/utils.ts +0 -64
@@ -0,0 +1,580 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /**
3
+ * SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
4
+ */
5
+ import {
6
+ ClientEndpoint,
7
+ createEndpointLifecycle,
8
+ type EndpointChannel,
9
+ type EndpointConnectHandle,
10
+ type EndpointControl,
11
+ type EndpointGroup,
12
+ type EndpointLifecycle,
13
+ type EndpointManagement,
14
+ type EndpointSendRequest,
15
+ } from 'zhin.js/adapter';
16
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
17
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
18
+ import type { CapabilityId } from 'zhin.js';
19
+ import {
20
+ SatoriOpcode,
21
+ buildWsUrl,
22
+ callSatoriApi,
23
+ extractCreatedMessageId,
24
+ formatInboundContent,
25
+ formatMessageId,
26
+ formatSatoriOutbound,
27
+ isMessageEvent,
28
+ isSelfMentioned,
29
+ parseMessageRef,
30
+ resolveInboundSender,
31
+ satoriInboundConversation,
32
+ type ResolvedSatoriWebhookConfig,
33
+ type ResolvedSatoriWsConfig,
34
+ type SatoriApiOptions,
35
+ type SatoriEventBody,
36
+ type SatoriLogin,
37
+ type SatoriSignal,
38
+ } from './protocol.js';
39
+ import { registerSatoriWebhookRoutes } from './webhook.js';
40
+ import { createSatoriEndpointClient, forwardSatoriClientEvents, type SatoriClient } from './client.js';
41
+ import {
42
+ WS_OPEN,
43
+ defaultCreateWebSocket,
44
+ type CreateSatoriWebSocket,
45
+ type SatoriWsSocket,
46
+ } from './ws.js';
47
+
48
+ export type SatoriApiCaller = typeof callSatoriApi;
49
+
50
+ export interface SatoriWsEndpointOptions {
51
+ readonly id: CapabilityId;
52
+ readonly config: ResolvedSatoriWsConfig;
53
+ readonly createWebSocket?: CreateSatoriWebSocket;
54
+ readonly callApi?: SatoriApiCaller;
55
+ }
56
+
57
+ export class SatoriWsEndpoint extends ClientEndpoint<SatoriClient> {
58
+ readonly client: SatoriClient;
59
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
60
+
61
+ readonly #options: SatoriWsEndpointOptions;
62
+ readonly #lifecycle: EndpointLifecycle;
63
+ #ws: SatoriWsSocket | null = null;
64
+ #login: SatoriLogin | undefined;
65
+ #lastSn: number | undefined;
66
+ readonly management: EndpointManagement;
67
+ readonly control: EndpointControl = Object.freeze<EndpointControl>({
68
+ recall: (message) => this.recall(message.id, message.conversation.id),
69
+ });
70
+
71
+ constructor(options: SatoriWsEndpointOptions) {
72
+ super();
73
+ this.#logger = getAdapterLogger('satori', options.config.id);
74
+ this.#options = options;
75
+ this.client = createSatoriEndpointClient(
76
+ options.config,
77
+ options.callApi ?? callSatoriApi,
78
+ () => this.#apiOptions(),
79
+ );
80
+ this.management = createSatoriEndpointManagement(
81
+ (resource, method, params) => this.client.call(resource, method, params),
82
+ );
83
+ this.bindClientEvents(
84
+ (receive) => forwardSatoriClientEvents(this.client, receive),
85
+ (name, payload) => {
86
+ if (name === 'event') this.#admitRaw(payload as SatoriEventBody);
87
+ },
88
+ (name, error) => this.#warnPlatformEvent(name, error),
89
+ );
90
+ const { config } = options;
91
+ this.#lifecycle = createEndpointLifecycle({
92
+ name: config.id,
93
+ reconnect: {
94
+ initialIntervalMs: 5_000,
95
+ // 固定间隔(multiplier 1、无抖动),对齐旧 5s 固定重连语义
96
+ multiplier: 1,
97
+ maxIntervalMs: 5_000,
98
+ jitterMs: 0,
99
+ },
100
+ heartbeat: {
101
+ intervalMs: config.heartbeat_interval,
102
+ // PONG 看门狗:连续 2 轮无回包,下一轮心跳由基座强关连接触发重连
103
+ watchdogMisses: 2,
104
+ },
105
+ });
106
+ }
107
+
108
+ async start(): Promise<void> {
109
+ try {
110
+ await this.#lifecycle.start((handle) => this.#connect(handle));
111
+ } catch (err) {
112
+ // start 失败清理现场(状态复位由基座保证)
113
+ if (this.#ws) {
114
+ try {
115
+ this.#ws.close();
116
+ } catch {
117
+ /* ignore */
118
+ }
119
+ this.#ws = null;
120
+ }
121
+ throw err;
122
+ }
123
+ }
124
+
125
+ async stop(): Promise<void> {
126
+ this.close();
127
+ // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
128
+ await this.#lifecycle.stop();
129
+ this.#ws = null;
130
+ }
131
+
132
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
133
+ const content = formatSatoriOutbound(payload);
134
+ const result = await this.client.call('message', 'create', {
135
+ channel_id: conversation.id,
136
+ content,
137
+ });
138
+ const msgId = extractCreatedMessageId(result);
139
+ this.#logger.debug(formatCompact({
140
+ op: 'satori_send',
141
+ endpoint: this.#options.config.id,
142
+ channel: conversation.id,
143
+ messageId: msgId || undefined,
144
+ }));
145
+ return msgId ? formatMessageId(conversation.id, msgId) : '';
146
+ }
147
+
148
+ #admitRaw(body: SatoriEventBody): void {
149
+ if (body.login && !this.#login) this.#login = body.login;
150
+ if (!isMessageEvent(body)) return;
151
+ const conversation = satoriInboundConversation(String(this.#options.id), body);
152
+ const content = formatInboundContent(body);
153
+ const sender = resolveInboundSender(body);
154
+ const selfId = this.#login?.user?.id ?? body.login?.user?.id;
155
+ const mentioned = isSelfMentioned(body, selfId);
156
+ void this.emit('message.receive', {
157
+ conversation,
158
+ message: { conversation, id: body.message.id },
159
+ content,
160
+ sender,
161
+ endpointId: this.#options.config.id,
162
+ ...(mentioned ? { mentioned: true } : {}),
163
+ metadata: Object.freeze({
164
+ type: body.type,
165
+ channelType: isPrivateChannelType(body) ? 'private' : 'group',
166
+ sn: body.sn,
167
+ platform: this.#login?.platform,
168
+ }),
169
+ }).catch((err) => {
170
+ this.#logger.warn(formatCompact({
171
+ op: 'satori_gateway_receive_failed',
172
+ channel: conversation.id,
173
+ error: err instanceof Error ? err.message : String(err),
174
+ }));
175
+ });
176
+ }
177
+
178
+ async recall(id: string, fallbackChannelId?: string): Promise<void> {
179
+ const { channelId, messageId } = parseMessageRef(id);
180
+ await this.client.call('message', 'delete', {
181
+ channel_id: channelId || fallbackChannelId || '',
182
+ message_id: messageId,
183
+ });
184
+ }
185
+
186
+ #warnPlatformEvent(name: string, error: unknown): void {
187
+ this.#logger.warn(formatCompact({
188
+ op: 'satori_platform_event_failed',
189
+ event: name,
190
+ error: error instanceof Error ? error.message : String(error),
191
+ }));
192
+ }
193
+
194
+ async #connect(handle: EndpointConnectHandle): Promise<void> {
195
+ const { config } = this.#options;
196
+ const createWs = this.#options.createWebSocket ?? defaultCreateWebSocket;
197
+ const headers: Record<string, string> = {};
198
+ if (config.token) headers.Authorization = `Bearer ${config.token}`;
199
+
200
+ return new Promise((resolve, reject) => {
201
+ let settled = false;
202
+ const ws = createWs(buildWsUrl(config.baseUrl, config.token), { headers });
203
+ this.#ws = ws;
204
+ handle.onForceClose(() => {
205
+ try {
206
+ ws.close();
207
+ } catch {
208
+ /* already closed */
209
+ }
210
+ });
211
+
212
+ ws.on('open', () => {
213
+ this.#logger.debug(formatCompact({ mode: 'ws' }));
214
+ this.#sendSignal(SatoriOpcode.IDENTIFY, {
215
+ token: config.token,
216
+ sn: this.#lastSn,
217
+ });
218
+ this.#lifecycle.startHeartbeat(() => this.#sendSignal(SatoriOpcode.PING));
219
+ if (!settled) {
220
+ settled = true;
221
+ resolve();
222
+ }
223
+ });
224
+
225
+ ws.on('message', (data) => {
226
+ try {
227
+ const raw = typeof data === 'string'
228
+ ? data
229
+ : Buffer.isBuffer(data)
230
+ ? data.toString('utf8')
231
+ : String(data ?? '');
232
+ const signal = JSON.parse(raw) as SatoriSignal;
233
+ this.#handleSignal(signal);
234
+ } catch (error) {
235
+ this.#logger.warn(formatCompact({
236
+ op: 'ws_parse_error',
237
+ endpoint: config.id,
238
+ error: error instanceof Error ? error.message : String(error),
239
+ }));
240
+ }
241
+ });
242
+
243
+ ws.on('close', (code, reason) => {
244
+ const reasonStr = typeof reason === 'string'
245
+ ? reason
246
+ : Buffer.isBuffer(reason)
247
+ ? reason.toString('utf8')
248
+ : String(reason ?? '');
249
+ const numericCode = typeof code === 'number' ? code : 0;
250
+ if (!settled) {
251
+ settled = true;
252
+ reject(new Error(`Satori WS closed: ${numericCode} ${reasonStr}`));
253
+ }
254
+ // 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
255
+ // 初始连接失败由 start() 的拒绝路径复位。
256
+ handle.notifyClosed(`Satori WS closed: ${numericCode} ${reasonStr || 'closed'}`);
257
+ });
258
+
259
+ ws.on('error', (error) => {
260
+ this.#logger.warn(formatCompact({
261
+ op: 'ws_error',
262
+ endpoint: config.id,
263
+ ok: false,
264
+ error: error instanceof Error ? error.message : String(error),
265
+ }));
266
+ if (!settled) {
267
+ settled = true;
268
+ reject(error instanceof Error ? error : new Error(String(error)));
269
+ }
270
+ });
271
+ });
272
+ }
273
+
274
+ #handleSignal(signal: SatoriSignal): void {
275
+ if (signal.op === SatoriOpcode.PONG) {
276
+ // 喂狗:复位基座看门狗计数
277
+ this.#lifecycle.notifyHeartbeatAck();
278
+ void this.emitPlatform('pong', signal).catch((error) => {
279
+ this.#logger.warn(formatCompact({
280
+ op: 'satori_platform_event_failed',
281
+ event: 'pong',
282
+ error: error instanceof Error ? error.message : String(error),
283
+ }));
284
+ });
285
+ return;
286
+ }
287
+ if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
288
+ const logins = signal.body.logins as SatoriLogin[];
289
+ this.#login = logins[0];
290
+ void this.emitPlatform('ready', signal).catch((error) => {
291
+ this.#logger.warn(formatCompact({
292
+ op: 'satori_platform_event_failed',
293
+ event: 'ready',
294
+ error: error instanceof Error ? error.message : String(error),
295
+ }));
296
+ });
297
+ if (!this.#login?.platform || !this.#login?.user?.id) {
298
+ this.#logger.warn(formatCompact({ op: 'ready', ok: false, error: 'missing platform/user' }));
299
+ }
300
+ return;
301
+ }
302
+ if (signal.op === SatoriOpcode.EVENT && signal.body) {
303
+ if (typeof signal.body.sn === 'number') this.#lastSn = signal.body.sn;
304
+ this.client.ingest(signal.body as Parameters<SatoriClient['ingest']>[0]);
305
+ }
306
+ }
307
+
308
+ #sendSignal(op: number, body?: Record<string, unknown>): void {
309
+ if (!this.#ws || this.#ws.readyState !== WS_OPEN) return;
310
+ this.#ws.send(JSON.stringify({ op, body: body ?? {} }));
311
+ }
312
+
313
+ #apiOptions(): SatoriApiOptions {
314
+ return {
315
+ baseUrl: this.#options.config.baseUrl,
316
+ platform: this.#login?.platform ?? '',
317
+ userId: this.#login?.user?.id ?? '',
318
+ token: this.#options.config.token,
319
+ };
320
+ }
321
+
322
+ }
323
+
324
+ export interface SatoriWebhookEndpointOptions {
325
+ readonly id: CapabilityId;
326
+ readonly http: HttpHost;
327
+ readonly config: ResolvedSatoriWebhookConfig;
328
+ readonly callApi?: SatoriApiCaller;
329
+ }
330
+
331
+ export class SatoriWebhookEndpoint extends ClientEndpoint<SatoriClient> {
332
+ readonly client: SatoriClient;
333
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
334
+
335
+ readonly #options: SatoriWebhookEndpointOptions;
336
+ #login: SatoriLogin | undefined;
337
+ #routeReleases: HttpRouteRegistration[] = [];
338
+ #started = false;
339
+ readonly management: EndpointManagement;
340
+ readonly control: EndpointControl = Object.freeze<EndpointControl>({
341
+ recall: (message) => this.recall(message.id, message.conversation.id),
342
+ });
343
+
344
+ constructor(options: SatoriWebhookEndpointOptions) {
345
+ super();
346
+ this.#logger = getAdapterLogger('satori', options.config.id);
347
+ this.#options = options;
348
+ this.client = createSatoriEndpointClient(
349
+ options.config,
350
+ options.callApi ?? callSatoriApi,
351
+ () => this.#apiOptions(),
352
+ );
353
+ this.management = createSatoriEndpointManagement(
354
+ (resource, method, params) => this.client.call(resource, method, params),
355
+ );
356
+ this.bindClientEvents(
357
+ (receive) => forwardSatoriClientEvents(this.client, receive),
358
+ (name, payload) => {
359
+ if (name === 'event') this.#admitRaw(payload as SatoriEventBody);
360
+ },
361
+ (name, error) => this.#warnPlatformEvent(name, error),
362
+ );
363
+ }
364
+
365
+ /** Used by webhook handler. */
366
+ get isOpen(): boolean {
367
+ return this.clientEventsOpen;
368
+ }
369
+
370
+ get config(): ResolvedSatoriWebhookConfig {
371
+ return this.#options.config;
372
+ }
373
+
374
+ async acceptHttp(request: IncomingMessage, response: ServerResponse): Promise<void> {
375
+ await this.client.acceptHttp(request, response);
376
+ }
377
+
378
+ async start(): Promise<void> {
379
+ if (this.#started) return;
380
+ this.#started = true;
381
+ if (!this.#options.config.token) {
382
+ // 未配 token 时 webhook 无鉴权:任何人知道 path 即可注入假事件。
383
+ this.#logger.warn(formatCompact({
384
+ op: 'webhook_no_token',
385
+ endpoint: this.#options.config.id,
386
+ path: this.#options.config.path,
387
+ hint: 'set token to authenticate Satori webhook callbacks',
388
+ }));
389
+ }
390
+ this.#routeReleases.push(...registerSatoriWebhookRoutes(this.#options.http, this));
391
+ this.#logger.info(formatCompact({
392
+ op: 'listen',
393
+ endpoint: this.#options.config.id,
394
+ mode: 'webhook',
395
+ path: this.#options.config.path,
396
+ }));
397
+ }
398
+
399
+ async stop(): Promise<void> {
400
+ this.close();
401
+ for (const release of this.#routeReleases.splice(0)) release();
402
+ this.#started = false;
403
+ this.#logger.debug(formatCompact({
404
+ op: 'disconnect',
405
+ }));
406
+ }
407
+
408
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
409
+ const content = formatSatoriOutbound(payload);
410
+ const result = await this.client.call('message', 'create', {
411
+ channel_id: conversation.id,
412
+ content,
413
+ });
414
+ const msgId = extractCreatedMessageId(result);
415
+ this.#logger.debug(formatCompact({
416
+ op: 'satori_send',
417
+ endpoint: this.#options.config.id,
418
+ channel: conversation.id,
419
+ messageId: msgId || undefined,
420
+ }));
421
+ return msgId ? formatMessageId(conversation.id, msgId) : '';
422
+ }
423
+
424
+ #admitRaw(body: SatoriEventBody): void {
425
+ if (body.login && !this.#login) this.#login = body.login;
426
+ if (!isMessageEvent(body)) return;
427
+ const conversation = satoriInboundConversation(String(this.#options.id), body);
428
+ const content = formatInboundContent(body);
429
+ const sender = resolveInboundSender(body);
430
+ const selfId = this.#login?.user?.id ?? body.login?.user?.id;
431
+ const mentioned = isSelfMentioned(body, selfId);
432
+ void this.emit('message.receive', {
433
+ conversation,
434
+ message: { conversation, id: body.message.id },
435
+ content,
436
+ sender,
437
+ endpointId: this.#options.config.id,
438
+ ...(mentioned ? { mentioned: true } : {}),
439
+ metadata: Object.freeze({
440
+ type: body.type,
441
+ channelType: isPrivateChannelType(body) ? 'private' : 'group',
442
+ sn: body.sn,
443
+ platform: this.#login?.platform,
444
+ }),
445
+ }).catch((err) => {
446
+ this.#logger.warn(formatCompact({
447
+ op: 'satori_gateway_receive_failed',
448
+ channel: conversation.id,
449
+ error: err instanceof Error ? err.message : String(err),
450
+ }));
451
+ });
452
+ }
453
+
454
+ async recall(id: string, fallbackChannelId?: string): Promise<void> {
455
+ const { channelId, messageId } = parseMessageRef(id);
456
+ await this.client.call('message', 'delete', {
457
+ channel_id: channelId || fallbackChannelId || '',
458
+ message_id: messageId,
459
+ });
460
+ }
461
+
462
+ #warnPlatformEvent(name: string, error: unknown): void {
463
+ this.#logger.warn(formatCompact({
464
+ op: 'satori_platform_event_failed',
465
+ event: name,
466
+ error: error instanceof Error ? error.message : String(error),
467
+ }));
468
+ }
469
+
470
+ #apiOptions(): SatoriApiOptions {
471
+ return {
472
+ baseUrl: this.#options.config.baseUrl,
473
+ platform: this.#login?.platform ?? '',
474
+ userId: this.#login?.user?.id ?? '',
475
+ token: this.#options.config.token,
476
+ };
477
+ }
478
+
479
+ }
480
+
481
+ function isPrivateChannelType(body: SatoriEventBody): boolean {
482
+ const channel = body.channel ?? body.message?.channel;
483
+ return channel?.type === 1;
484
+ }
485
+
486
+ /**
487
+ * Satori guild id 是平台相关字符串(多数平台为雪花号,超
488
+ * Number.MAX_SAFE_INTEGER)。Console 社交面只把 group_id 当 JSON 值透传、
489
+ * 并以字符串回传给 listGroupMembers,因此保留原始字符串(仅按契约类型
490
+ * 声明强转)是全链路最不丢信息的方案。
491
+ */
492
+ function toGroupId(id: string): number {
493
+ return id as unknown as number;
494
+ }
495
+
496
+ export type SatoriManagementApi = (
497
+ resource: string,
498
+ method: string,
499
+ params: Record<string, unknown>,
500
+ ) => Promise<unknown>;
501
+
502
+ interface SatoriListPage {
503
+ readonly data?: unknown[];
504
+ readonly next?: string;
505
+ }
506
+
507
+ /** Satori 分页列表({data, next?})聚合;兼容直接返回数组的实现。 */
508
+ async function listSatoriPages(
509
+ api: SatoriManagementApi,
510
+ resource: string,
511
+ params: Record<string, unknown>,
512
+ ): Promise<unknown[]> {
513
+ const items: unknown[] = [];
514
+ let next: string | undefined;
515
+ do {
516
+ const page = await api(resource, 'list', next ? { ...params, next } : params);
517
+ if (Array.isArray(page)) {
518
+ items.push(...page);
519
+ break;
520
+ }
521
+ const typed = (page ?? {}) as SatoriListPage;
522
+ if (Array.isArray(typed.data)) items.push(...typed.data);
523
+ next = typeof typed.next === 'string' && typed.next ? typed.next : undefined;
524
+ } while (next);
525
+ return items;
526
+ }
527
+
528
+ function asRecord(value: unknown): Record<string, unknown> {
529
+ return value !== null && typeof value === 'object'
530
+ ? value as Record<string, unknown>
531
+ : {};
532
+ }
533
+
534
+ /**
535
+ * Satori endpoint 的 EndpointManagement 语义端口(ws / webhook 共用),
536
+ * 数据走协议 API:guild.list / channel.list / guild-member.list。
537
+ */
538
+ export function createSatoriEndpointManagement(api: SatoriManagementApi): EndpointManagement {
539
+ return Object.freeze<EndpointManagement>({
540
+ async listGroups(): Promise<readonly EndpointGroup[]> {
541
+ const groups: EndpointGroup[] = [];
542
+ for (const value of await listSatoriPages(api, 'guild', {})) {
543
+ const guild = asRecord(value);
544
+ if (guild.id == null) continue;
545
+ groups.push({
546
+ group_id: toGroupId(String(guild.id)),
547
+ name: String(guild.name ?? guild.id),
548
+ });
549
+ }
550
+ return groups;
551
+ },
552
+ async listChannels(): Promise<readonly EndpointChannel[]> {
553
+ const channels: EndpointChannel[] = [];
554
+ for (const value of await listSatoriPages(api, 'guild', {})) {
555
+ const guild = asRecord(value);
556
+ if (guild.id == null) continue;
557
+ const guildId = String(guild.id);
558
+ const guildName = String(guild.name ?? guildId);
559
+ for (const channelValue of await listSatoriPages(api, 'channel', { guild_id: guildId })) {
560
+ const channel = asRecord(channelValue);
561
+ if (channel.id == null) continue;
562
+ // Channel.type: 0=TEXT 1=DIRECT 2=CATEGORY 3=VOICE;缺省按 TEXT 处理
563
+ if (channel.type != null && Number(channel.type) !== 0) continue;
564
+ channels.push({
565
+ id: String(channel.id),
566
+ name: channel.name != null ? String(channel.name) : undefined,
567
+ parent: { type: 'guild', id: guildId, name: guildName },
568
+ });
569
+ }
570
+ }
571
+ return channels;
572
+ },
573
+ async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
574
+ // 平台形状(GuildMember[])原样返回
575
+ return listSatoriPages(api, 'guild-member', { guild_id: groupId });
576
+ },
577
+ });
578
+ }
579
+
580
+ export type { CreateSatoriWebSocket, SatoriWsSocket } from './ws.js';
package/src/index.ts CHANGED
@@ -1,38 +1,64 @@
1
- /**
2
- * Satori 适配器入口:单一适配器,支持 WS / Webhook,协议文档 https://satori.chat/zh-CN/introduction.html
3
- */
4
- import { usePlugin, type Plugin, type Context } from 'zhin.js';
5
- import type { Router } from '@zhin.js/host-router';
6
- import { SatoriAdapter } from './adapter.js';
1
+ export {
2
+ SatoriOpcode,
3
+ buildWsUrl,
4
+ callSatoriApi,
5
+ extractCreatedMessageId,
6
+ formatInboundContent,
7
+ formatSatoriOutbound,
8
+ isMessageEvent,
9
+ isPrivateChannel,
10
+ resolveInboundSender,
11
+ resolveSatoriConfig,
12
+ satoriInboundConversation,
13
+ type ResolvedSatoriWebhookConfig,
14
+ type ResolvedSatoriWsConfig,
15
+ type SatoriEndpointConfig,
16
+ type SatoriApiOptions,
17
+ type SatoriChannel,
18
+ type SatoriEventBody,
19
+ type SatoriLogin,
20
+ type SatoriMessage,
21
+ type SatoriSignal,
22
+ type SatoriUser,
23
+ type SatoriWireSegment,
24
+ } from './protocol.js';
7
25
 
8
- export * from './types.js';
9
- export { callSatoriApi } from './api.js';
10
- export * from './utils.js';
11
- export { SatoriWsClient } from './endpoint-ws.js';
12
- export { SatoriWebhookEndpoint } from './endpoint-webhook.js';
13
- export { SatoriAdapter, type SatoriBot } from './adapter.js';
26
+ export {
27
+ SatoriWebhookEndpoint,
28
+ SatoriWsEndpoint,
29
+ type CreateSatoriWebSocket,
30
+ type SatoriApiCaller,
31
+ type SatoriWebhookEndpointOptions,
32
+ type SatoriWsEndpointOptions,
33
+ type SatoriWsSocket,
34
+ } from './endpoint.js';
14
35
 
15
- declare module 'zhin.js' {
16
- namespace Plugin {
17
- interface Contexts {
18
- router: import('@zhin.js/host-router').Router;
19
- }
20
- }
21
- interface Adapters {
22
- satori: SatoriAdapter;
23
- }
24
- }
36
+ export {
37
+ handleSatoriWebhookRequest,
38
+ registerSatoriWebhookRoutes,
39
+ resolveSatoriOpcode,
40
+ verifySatoriToken,
41
+ type SatoriWebhookHandler,
42
+ } from './webhook.js';
25
43
 
26
- const { provide } = usePlugin();
27
- provide({
28
- name: 'satori',
29
- description: 'Satori 协议适配器(WS 正向 / Webhook)',
30
- mounted: async (p: Plugin) => {
31
- const adapter = new SatoriAdapter(p);
32
- await adapter.start();
33
- return adapter;
34
- },
35
- dispose: async (adapter: SatoriAdapter) => {
36
- await adapter.stop();
37
- },
38
- } as unknown as Context<'satori'>);
44
+ export {
45
+ WS_OPEN,
46
+ defaultCreateWebSocket,
47
+ } from './ws.js';
48
+
49
+ export {
50
+ SatoriClient,
51
+ satoriClient,
52
+ type SatoriClientEventMap,
53
+ } from './client.js';
54
+
55
+ export type {
56
+ SatoriAdapterConfig as ImHelperSatoriAdapterConfig,
57
+ SatoriActionUrlResolver,
58
+ SatoriCall,
59
+ SatoriV1ClientConfig,
60
+ SatoriV1Event,
61
+ SatoriV1Response,
62
+ } from '@imhelper/satori-v1';
63
+ export { ProtocolError } from '@imhelper/satori-v1';
64
+ export type { ProtocolErrorKind, ProtocolErrorOptions } from 'imhelper';