@zhin.js/adapter-satori 3.0.1 → 3.0.3

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 (59) hide show
  1. package/CHANGELOG.md +440 -0
  2. package/README.md +34 -34
  3. package/adapters/satori.ts +41 -0
  4. package/lib/endpoint.d.ts +61 -0
  5. package/lib/endpoint.js +379 -0
  6. package/lib/index.d.ts +4 -18
  7. package/lib/index.js +4 -25
  8. package/lib/protocol.d.ts +150 -0
  9. package/lib/protocol.js +196 -0
  10. package/lib/webhook.d.ts +17 -0
  11. package/lib/webhook.js +79 -0
  12. package/lib/ws.d.ts +13 -0
  13. package/lib/ws.js +5 -0
  14. package/package.json +42 -16
  15. package/plugin.ts +8 -0
  16. package/schema.json +35 -0
  17. package/src/endpoint.ts +462 -0
  18. package/src/index.ts +47 -35
  19. package/src/protocol.ts +315 -0
  20. package/src/webhook.ts +104 -0
  21. package/src/ws.ts +22 -0
  22. package/lib/adapter.d.ts +0 -19
  23. package/lib/adapter.d.ts.map +0 -1
  24. package/lib/adapter.js +0 -37
  25. package/lib/adapter.js.map +0 -1
  26. package/lib/api.d.ts +0 -15
  27. package/lib/api.d.ts.map +0 -1
  28. package/lib/api.js +0 -37
  29. package/lib/api.js.map +0 -1
  30. package/lib/endpoint-webhook.d.ts +0 -27
  31. package/lib/endpoint-webhook.d.ts.map +0 -1
  32. package/lib/endpoint-webhook.js +0 -99
  33. package/lib/endpoint-webhook.js.map +0 -1
  34. package/lib/endpoint-ws.d.ts +0 -30
  35. package/lib/endpoint-ws.d.ts.map +0 -1
  36. package/lib/endpoint-ws.js +0 -195
  37. package/lib/endpoint-ws.js.map +0 -1
  38. package/lib/index.d.ts.map +0 -1
  39. package/lib/index.js.map +0 -1
  40. package/lib/segment-mapper.d.ts +0 -2
  41. package/lib/segment-mapper.d.ts.map +0 -1
  42. package/lib/segment-mapper.js +0 -2
  43. package/lib/segment-mapper.js.map +0 -1
  44. package/lib/types.d.ts +0 -91
  45. package/lib/types.d.ts.map +0 -1
  46. package/lib/types.js +0 -13
  47. package/lib/types.js.map +0 -1
  48. package/lib/utils.d.ts +0 -40
  49. package/lib/utils.d.ts.map +0 -1
  50. package/lib/utils.js +0 -34
  51. package/lib/utils.js.map +0 -1
  52. package/src/adapter.ts +0 -50
  53. package/src/api.ts +0 -48
  54. package/src/endpoint-webhook.ts +0 -117
  55. package/src/endpoint-ws.ts +0 -214
  56. package/src/segment-mapper.ts +0 -1
  57. package/src/types.ts +0 -91
  58. package/src/utils.ts +0 -65
  59. /package/{skills/satori/SKILL.md → agent/skills/satori.md} +0 -0
@@ -0,0 +1,462 @@
1
+ /**
2
+ * SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
3
+ */
4
+ import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { MessageGateway } from '@zhin.js/core/runtime';
6
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
9
+ import {
10
+ SatoriOpcode,
11
+ buildWsUrl,
12
+ callSatoriApi,
13
+ extractCreatedMessageId,
14
+ formatInboundContent,
15
+ formatMessageId,
16
+ formatSatoriOutbound,
17
+ isMessageEvent,
18
+ isSelfMentioned,
19
+ parseMessageRef,
20
+ resolveInboundSender,
21
+ resolveInboundTarget,
22
+ type ResolvedSatoriWebhookConfig,
23
+ type ResolvedSatoriWsConfig,
24
+ type SatoriApiOptions,
25
+ type SatoriEventBody,
26
+ type SatoriLogin,
27
+ type SatoriSignal,
28
+ } from './protocol.js';
29
+ import { registerSatoriWebhookRoutes } from './webhook.js';
30
+ import {
31
+ WS_OPEN,
32
+ defaultCreateWebSocket,
33
+ type CreateSatoriWebSocket,
34
+ type SatoriWsSocket,
35
+ } from './ws.js';
36
+
37
+ const logger = getLogger('satori');
38
+
39
+ export type SatoriApiCaller = typeof callSatoriApi;
40
+
41
+ export interface SatoriWsEndpointOptions {
42
+ readonly id: CapabilityId;
43
+ readonly gateway: MessageGateway;
44
+ readonly config: ResolvedSatoriWsConfig;
45
+ readonly createWebSocket?: CreateSatoriWebSocket;
46
+ readonly callApi?: SatoriApiCaller;
47
+ }
48
+
49
+ export class SatoriWsEndpoint implements EndpointInstance {
50
+ readonly #options: SatoriWsEndpointOptions;
51
+ #ws: SatoriWsSocket | null = null;
52
+ #login: SatoriLogin | undefined;
53
+ #lastSn: number | undefined;
54
+ #reconnectTimer: NodeJS.Timeout | null = null;
55
+ #heartbeatTimer: NodeJS.Timeout | null = null;
56
+ #open = false;
57
+ #started = false;
58
+ #stopping = false;
59
+
60
+ constructor(options: SatoriWsEndpointOptions) {
61
+ this.#options = options;
62
+ }
63
+
64
+ async start(): Promise<void> {
65
+ if (this.#started) return;
66
+ this.#started = true;
67
+ this.#stopping = false;
68
+ await this.#connect();
69
+ }
70
+
71
+ open(): void {
72
+ this.#open = true;
73
+ }
74
+
75
+ close(): void {
76
+ this.#open = false;
77
+ }
78
+
79
+ async stop(): Promise<void> {
80
+ 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
+ }));
97
+ }
98
+
99
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
100
+ const content = formatSatoriOutbound(payload);
101
+ const result = await this.#api('message', 'create', {
102
+ channel_id: target,
103
+ content,
104
+ });
105
+ const msgId = extractCreatedMessageId(result);
106
+ logger.debug(formatCompact({
107
+ op: 'satori_send',
108
+ endpoint: this.#options.config.name,
109
+ target,
110
+ messageId: msgId || undefined,
111
+ }));
112
+ return msgId ? formatMessageId(target, msgId) : '';
113
+ }
114
+
115
+ /** Test / internal: admit a gateway event when the endpoint is open. */
116
+ admit(body: SatoriEventBody): void {
117
+ if (!this.#open) return;
118
+ if (body.login && !this.#login) this.#login = body.login;
119
+ if (!isMessageEvent(body)) return;
120
+ const target = resolveInboundTarget(body);
121
+ const content = formatInboundContent(body);
122
+ const sender = resolveInboundSender(body);
123
+ const messageId = formatMessageId(target, body.message.id);
124
+ const selfId = this.#login?.user?.id ?? body.login?.user?.id;
125
+ const mentioned = isSelfMentioned(body, selfId);
126
+ void this.#options.gateway.receive({
127
+ adapter: this.#options.id,
128
+ target,
129
+ content,
130
+ sender,
131
+ id: messageId,
132
+ metadata: Object.freeze({
133
+ type: body.type,
134
+ channelType: isPrivateChannelType(body) ? 'private' : 'group',
135
+ sn: body.sn,
136
+ platform: this.#login?.platform,
137
+ endpoint: this.#options.config.name,
138
+ ...(mentioned ? { mentioned: true } : {}),
139
+ }),
140
+ }).catch((err) => {
141
+ logger.warn(formatCompact({
142
+ op: 'satori_gateway_receive_failed',
143
+ target,
144
+ error: err instanceof Error ? err.message : String(err),
145
+ }));
146
+ });
147
+ }
148
+
149
+ async recall(id: string): Promise<void> {
150
+ const { channelId, messageId } = parseMessageRef(id);
151
+ await this.#api('message', 'delete', {
152
+ channel_id: channelId,
153
+ message_id: messageId,
154
+ });
155
+ }
156
+
157
+ /** Test helper: inject a READY login without a live socket. */
158
+ setLogin(login: SatoriLogin): void {
159
+ this.#login = login;
160
+ }
161
+
162
+ async #connect(): Promise<void> {
163
+ const { config } = this.#options;
164
+ const createWs = this.#options.createWebSocket ?? defaultCreateWebSocket;
165
+ const headers: Record<string, string> = {};
166
+ if (config.token) headers.Authorization = `Bearer ${config.token}`;
167
+
168
+ return new Promise((resolve, reject) => {
169
+ let settled = false;
170
+ const ws = createWs(buildWsUrl(config.baseUrl, config.token), { headers });
171
+ this.#ws = ws;
172
+
173
+ ws.on('open', () => {
174
+ logger.debug(formatCompact({ endpoint: config.name, mode: 'ws' }));
175
+ this.#sendSignal(SatoriOpcode.IDENTIFY, {
176
+ token: config.token,
177
+ sn: this.#lastSn,
178
+ });
179
+ this.#startHeartbeat();
180
+ if (!settled) {
181
+ settled = true;
182
+ resolve();
183
+ }
184
+ });
185
+
186
+ ws.on('message', (data) => {
187
+ try {
188
+ const raw = typeof data === 'string'
189
+ ? data
190
+ : Buffer.isBuffer(data)
191
+ ? data.toString('utf8')
192
+ : String(data ?? '');
193
+ const signal = JSON.parse(raw) as SatoriSignal;
194
+ this.#handleSignal(signal);
195
+ } catch (error) {
196
+ logger.warn(formatCompact({
197
+ op: 'ws_parse_error',
198
+ endpoint: config.name,
199
+ error: error instanceof Error ? error.message : String(error),
200
+ }));
201
+ }
202
+ });
203
+
204
+ ws.on('close', (code, reason) => {
205
+ this.#clearHeartbeat();
206
+ const reasonStr = typeof reason === 'string'
207
+ ? reason
208
+ : Buffer.isBuffer(reason)
209
+ ? reason.toString('utf8')
210
+ : String(reason ?? '');
211
+ 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
+ if (!settled) {
220
+ settled = true;
221
+ reject(new Error(`Satori WS closed: ${numericCode} ${reasonStr}`));
222
+ return;
223
+ }
224
+ if (!this.#stopping) this.#scheduleReconnect();
225
+ });
226
+
227
+ ws.on('error', (error) => {
228
+ logger.warn(formatCompact({
229
+ op: 'ws_error',
230
+ endpoint: config.name,
231
+ ok: false,
232
+ error: error instanceof Error ? error.message : String(error),
233
+ }));
234
+ if (!settled) {
235
+ settled = true;
236
+ reject(error instanceof Error ? error : new Error(String(error)));
237
+ }
238
+ });
239
+ });
240
+ }
241
+
242
+ #handleSignal(signal: SatoriSignal): void {
243
+ if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
244
+ const logins = signal.body.logins as SatoriLogin[];
245
+ this.#login = logins[0];
246
+ if (!this.#login?.platform || !this.#login?.user?.id) {
247
+ logger.warn(formatCompact({ op: 'ready', ok: false, error: 'missing platform/user' }));
248
+ }
249
+ return;
250
+ }
251
+ if (signal.op === SatoriOpcode.EVENT && signal.body) {
252
+ if (typeof signal.body.sn === 'number') this.#lastSn = signal.body.sn;
253
+ this.admit(signal.body as SatoriEventBody);
254
+ }
255
+ }
256
+
257
+ #sendSignal(op: number, body?: Record<string, unknown>): void {
258
+ if (!this.#ws || this.#ws.readyState !== WS_OPEN) return;
259
+ this.#ws.send(JSON.stringify({ op, body: body ?? {} }));
260
+ }
261
+
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
+ #apiOptions(): SatoriApiOptions {
300
+ return {
301
+ baseUrl: this.#options.config.baseUrl,
302
+ platform: this.#login?.platform ?? '',
303
+ userId: this.#login?.user?.id ?? '',
304
+ token: this.#options.config.token,
305
+ };
306
+ }
307
+
308
+ #api(
309
+ resource: string,
310
+ method: string,
311
+ params: Record<string, unknown>,
312
+ ): Promise<unknown> {
313
+ const call = this.#options.callApi ?? callSatoriApi;
314
+ return call(this.#apiOptions(), resource, method, params);
315
+ }
316
+ }
317
+
318
+ export interface SatoriWebhookEndpointOptions {
319
+ readonly id: CapabilityId;
320
+ readonly gateway: MessageGateway;
321
+ readonly http: HttpHost;
322
+ readonly config: ResolvedSatoriWebhookConfig;
323
+ readonly callApi?: SatoriApiCaller;
324
+ }
325
+
326
+ export class SatoriWebhookEndpoint implements EndpointInstance {
327
+ readonly #options: SatoriWebhookEndpointOptions;
328
+ #login: SatoriLogin | undefined;
329
+ #routeReleases: HttpRouteRegistration[] = [];
330
+ #open = false;
331
+ #started = false;
332
+
333
+ constructor(options: SatoriWebhookEndpointOptions) {
334
+ this.#options = options;
335
+ }
336
+
337
+ /** Used by webhook handler. */
338
+ get isOpen(): boolean {
339
+ return this.#open;
340
+ }
341
+
342
+ get config(): ResolvedSatoriWebhookConfig {
343
+ return this.#options.config;
344
+ }
345
+
346
+ async start(): Promise<void> {
347
+ if (this.#started) return;
348
+ this.#started = true;
349
+ this.#routeReleases.push(...registerSatoriWebhookRoutes(this.#options.http, this));
350
+ logger.info(formatCompact({
351
+ op: 'listen',
352
+ endpoint: this.#options.config.name,
353
+ mode: 'webhook',
354
+ path: this.#options.config.path,
355
+ }));
356
+ }
357
+
358
+ open(): void {
359
+ this.#open = true;
360
+ }
361
+
362
+ close(): void {
363
+ this.#open = false;
364
+ }
365
+
366
+ async stop(): Promise<void> {
367
+ this.#open = false;
368
+ for (const release of this.#routeReleases.splice(0)) release();
369
+ this.#started = false;
370
+ logger.debug(formatCompact({
371
+ op: 'disconnect',
372
+ endpoint: this.#options.config.name,
373
+ }));
374
+ }
375
+
376
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
377
+ const content = formatSatoriOutbound(payload);
378
+ const result = await this.#api('message', 'create', {
379
+ channel_id: target,
380
+ content,
381
+ });
382
+ const msgId = extractCreatedMessageId(result);
383
+ logger.debug(formatCompact({
384
+ op: 'satori_send',
385
+ endpoint: this.#options.config.name,
386
+ target,
387
+ messageId: msgId || undefined,
388
+ }));
389
+ return msgId ? formatMessageId(target, msgId) : '';
390
+ }
391
+
392
+ admit(body: SatoriEventBody): void {
393
+ if (!this.#open) return;
394
+ if (body.login && !this.#login) this.#login = body.login;
395
+ if (!isMessageEvent(body)) return;
396
+ const target = resolveInboundTarget(body);
397
+ const content = formatInboundContent(body);
398
+ const sender = resolveInboundSender(body);
399
+ const messageId = formatMessageId(target, body.message.id);
400
+ const selfId = this.#login?.user?.id ?? body.login?.user?.id;
401
+ const mentioned = isSelfMentioned(body, selfId);
402
+ void this.#options.gateway.receive({
403
+ adapter: this.#options.id,
404
+ target,
405
+ content,
406
+ sender,
407
+ id: messageId,
408
+ metadata: Object.freeze({
409
+ type: body.type,
410
+ channelType: isPrivateChannelType(body) ? 'private' : 'group',
411
+ sn: body.sn,
412
+ platform: this.#login?.platform,
413
+ endpoint: this.#options.config.name,
414
+ ...(mentioned ? { mentioned: true } : {}),
415
+ }),
416
+ }).catch((err) => {
417
+ logger.warn(formatCompact({
418
+ op: 'satori_gateway_receive_failed',
419
+ target,
420
+ error: err instanceof Error ? err.message : String(err),
421
+ }));
422
+ });
423
+ }
424
+
425
+ async recall(id: string): Promise<void> {
426
+ const { channelId, messageId } = parseMessageRef(id);
427
+ await this.#api('message', 'delete', {
428
+ channel_id: channelId,
429
+ message_id: messageId,
430
+ });
431
+ }
432
+
433
+ /** Test helper: inject login without a live webhook push. */
434
+ setLogin(login: SatoriLogin): void {
435
+ this.#login = login;
436
+ }
437
+
438
+ #apiOptions(): SatoriApiOptions {
439
+ return {
440
+ baseUrl: this.#options.config.baseUrl,
441
+ platform: this.#login?.platform ?? '',
442
+ userId: this.#login?.user?.id ?? '',
443
+ token: this.#options.config.token,
444
+ };
445
+ }
446
+
447
+ #api(
448
+ resource: string,
449
+ method: string,
450
+ params: Record<string, unknown>,
451
+ ): Promise<unknown> {
452
+ const call = this.#options.callApi ?? callSatoriApi;
453
+ return call(this.#apiOptions(), resource, method, params);
454
+ }
455
+ }
456
+
457
+ function isPrivateChannelType(body: SatoriEventBody): boolean {
458
+ const channel = body.channel ?? body.message?.channel;
459
+ return channel?.type === 1;
460
+ }
461
+
462
+ export type { CreateSatoriWebSocket, SatoriWsSocket } from './ws.js';
package/src/index.ts CHANGED
@@ -1,38 +1,50 @@
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
+ formatMessageId,
8
+ formatSatoriOutbound,
9
+ isMessageEvent,
10
+ isPrivateChannel,
11
+ parseMessageRef,
12
+ resolveInboundSender,
13
+ resolveInboundTarget,
14
+ resolveSatoriConfig,
15
+ type ResolvedSatoriWebhookConfig,
16
+ type ResolvedSatoriWsConfig,
17
+ type SatoriAdapterConfig,
18
+ type SatoriApiOptions,
19
+ type SatoriChannel,
20
+ type SatoriEventBody,
21
+ type SatoriLogin,
22
+ type SatoriMessage,
23
+ type SatoriSignal,
24
+ type SatoriUser,
25
+ type SatoriWireSegment,
26
+ } from './protocol.js';
7
27
 
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';
28
+ export {
29
+ SatoriWebhookEndpoint,
30
+ SatoriWsEndpoint,
31
+ type CreateSatoriWebSocket,
32
+ type SatoriApiCaller,
33
+ type SatoriWebhookEndpointOptions,
34
+ type SatoriWsEndpointOptions,
35
+ type SatoriWsSocket,
36
+ } from './endpoint.js';
14
37
 
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
- }
38
+ export {
39
+ handleSatoriWebhookRequest,
40
+ readRequestBody,
41
+ registerSatoriWebhookRoutes,
42
+ resolveSatoriOpcode,
43
+ verifySatoriToken,
44
+ type SatoriWebhookHandler,
45
+ } from './webhook.js';
25
46
 
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'>);
47
+ export {
48
+ WS_OPEN,
49
+ defaultCreateWebSocket,
50
+ } from './ws.js';