@zhin.js/adapter-satori 6.0.0 → 6.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # @zhin.js/adapter-satori
2
2
 
3
+ ## 6.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [a5ee497]
8
+ - Updated dependencies [ba7e17a]
9
+ - Updated dependencies [7108d0b]
10
+ - @zhin.js/host-http@1.0.14
11
+ - @zhin.js/core@1.5.15
12
+ - zhin.js@6.0.15
13
+
14
+ ## 6.0.1
15
+
16
+ ### Patch Changes
17
+
18
+ - 4e8117c: Expose the published imhelper protocol Clients from every Endpoint, preserve native action responses, and route host-owned HTTP, WebSocket, and raw ingress through the Client APIs. Add the shared `ClientEndpoint` deep base so concrete transports inherit one open-gated Client event bridge, and move reverse WebSocket lifecycle and heartbeat cleanup onto the common lifecycle state machine.
19
+ - 1fc78bc: Unify native platform Client access behind the literal `adapter` discriminant. Handlers infer both native events and Clients, while command, inbound/outbound middleware, and both Agent tool authoring surfaces expose the exact operation-scoped Client through a lazy `$client` getter. Definitions without `adapter` keep `$client` typed as `unknown`, and runtime dispatch rejects adapter mismatches before resolving the Client. Bundled platform tools now use this single path instead of model-provided endpoint ids and adapter-specific dependency wrappers. Every adapter registers one Client/EventMap contract, and protocol adapters including NapCat, Milky, OneBot and Satori now produce transport-independent Client objects rather than letting Endpoint instances impersonate Clients.
20
+ - 2ca9a52: Upgrade the protocol Clients to imhelper 1.0.7, keep receiver ownership exclusively in Endpoint through manual mode, expose structured protocol errors, and support canonical non-message Client events.
21
+ - Updated dependencies [4e8117c]
22
+ - Updated dependencies [54bfd6b]
23
+ - Updated dependencies [12025ee]
24
+ - Updated dependencies [09b14d6]
25
+ - Updated dependencies [1fc78bc]
26
+ - @zhin.js/adapter@1.2.1
27
+ - @zhin.js/core@1.5.14
28
+ - @zhin.js/host-http@1.0.13
29
+ - @zhin.js/command@1.0.16
30
+ - @zhin.js/logger@1.0.77
31
+ - zhin.js@6.0.14
32
+ - @zhin.js/feature-kit@1.0.13
33
+
3
34
  ## 6.0.0
4
35
 
5
36
  ### Patch Changes
package/README.md CHANGED
@@ -22,7 +22,7 @@ pnpm add @zhin.js/adapter-satori
22
22
  ## Plugin Runtime
23
23
 
24
24
  - `@zhin.js/adapter` — 约定式 `adapters/satori.ts`(`defineAdapter`)
25
- - `@zhin.js/core` — `messageGatewayToken` 入站/出站
25
+ - `@zhin.js/core` — `Endpoint.emit(...)` 入站、`outboundMessageToken` 出站
26
26
  - `zhin.js` — `plugin.ts`(`definePlugin`)
27
27
  - `@zhin.js/host-http` — Webhook 模式需 `httpHostToken` 注册 POST 路由
28
28
  - 配置经插件 `schema.json` 落到 `plugins.<instanceKey>`(`baseUrl` / `token` / …)
@@ -32,6 +32,16 @@ pnpm add @zhin.js/adapter-satori
32
32
 
33
33
  入站 `metadata.mentioned`:消息 content 中 `<at id="…"/>` 元素的 id 等于登录 selfId(READY/事件 `login.user.id`)时置 `true`。
34
34
 
35
+ 每个 Endpoint 的 `$client` 是 `@imhelper/satori-v1` 的 `SatoriV1Client`。业务代码直接调用
36
+ `$client.call(resource, method, params)` 或其公开便捷方法;Client 负责协议事件变换,Endpoint
37
+ 只负责 Satori IDENTIFY/心跳、Webhook 鉴权和 Zhin 投影。
38
+
39
+ ## 前置条件
40
+
41
+ 1. 准备兼容 Satori 的服务端,记录 API Base、平台标识和用户标识。
42
+ 2. 正向 WS 需 Zhin 可达 Satori 服务;Webhook 需服务端可达 Zhin HTTP Host。
43
+ 3. 若服务启用鉴权,在两端配置相同的 Bearer token。
44
+
35
45
  ## 最小配置
36
46
 
37
47
  ```yaml
@@ -89,3 +99,12 @@ SDK 会向 `path` 发送 POST,请求头 `Satori-Opcode: 0` 表示事件;适
89
99
  - [介绍](https://satori.chat/zh-CN/introduction.html)
90
100
  - [总览](https://satori.chat/zh-CN/protocol/overview.html)
91
101
  - [API](https://satori.chat/zh-CN/protocol/api.html)
102
+
103
+ ## 故障排查
104
+
105
+ | 现象 | 排查 |
106
+ | --- | --- |
107
+ | WS IDENTIFY 失败 | 检查 `baseUrl`、平台/用户标识与 token |
108
+ | Webhook 无事件 | 确认路径、`Satori-Opcode: 0` 与 HTTP Host |
109
+ | 401 | 检查 API、WS IDENTIFY 与 Webhook 使用的 token 是否一致 |
110
+ | 发送目标错误 | 核对 `channel_id` 与 Conversation kind;DIRECT 才映射为 private |
@@ -3,7 +3,6 @@
3
3
  * Convention entry: discover `adapters/satori.ts` → defineAdapter.
4
4
  */
5
5
  import { defineAdapter } from 'zhin.js/adapter';
6
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
7
6
  import { httpHostToken } from '@zhin.js/host-http';
8
7
  import { SatoriWebhookEndpoint, SatoriWsEndpoint } from "../lib/endpoint.js";
9
8
  import { resolveSatoriConfig, } from "../lib/protocol.js";
@@ -19,8 +18,6 @@ export default defineAdapter({
19
18
  },
20
19
  create(context) {
21
20
  const config = resolveSatoriConfig(context.config);
22
- const gateway = context.use(messageGatewayToken);
23
- const sideEvents = context.use(sideEventGatewayToken);
24
21
  // 注册到插件运行时状态(satori.endpoint list 的"运行中"数据源)
25
22
  context.use(satoriRuntimeStateToken).endpoints.set(config.id, {
26
23
  id: config.id,
@@ -29,16 +26,12 @@ export default defineAdapter({
29
26
  if (config.connection === 'webhook') {
30
27
  return new SatoriWebhookEndpoint({
31
28
  id: context.id,
32
- gateway,
33
- sideEvents,
34
29
  http: context.use(httpHostToken),
35
30
  config,
36
31
  });
37
32
  }
38
33
  return new SatoriWsEndpoint({
39
34
  id: context.id,
40
- gateway,
41
- sideEvents,
42
35
  config,
43
36
  });
44
37
  },
@@ -2,7 +2,6 @@
2
2
  * Convention entry: discover `adapters/satori.ts` → defineAdapter.
3
3
  */
4
4
  import { defineAdapter } from 'zhin.js/adapter';
5
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
6
5
  import { httpHostToken } from '@zhin.js/host-http';
7
6
  import { SatoriWebhookEndpoint, SatoriWsEndpoint } from '../src/endpoint.js';
8
7
  import {
@@ -30,8 +29,6 @@ export default defineAdapter<SatoriAdapterConfig>({
30
29
  },
31
30
  create(context) {
32
31
  const config = resolveSatoriConfig(context.config);
33
- const gateway = context.use(messageGatewayToken);
34
- const sideEvents = context.use(sideEventGatewayToken);
35
32
  // 注册到插件运行时状态(satori.endpoint list 的"运行中"数据源)
36
33
  context.use(satoriRuntimeStateToken).endpoints.set(config.id, {
37
34
  id: config.id,
@@ -40,16 +37,12 @@ export default defineAdapter<SatoriAdapterConfig>({
40
37
  if (config.connection === 'webhook') {
41
38
  return new SatoriWebhookEndpoint({
42
39
  id: context.id,
43
- gateway,
44
- sideEvents,
45
40
  http: context.use(httpHostToken),
46
41
  config,
47
42
  });
48
43
  }
49
44
  return new SatoriWsEndpoint({
50
45
  id: context.id,
51
- gateway,
52
- sideEvents,
53
46
  config,
54
47
  });
55
48
  },
@@ -0,0 +1,18 @@
1
+ import { SatoriV1Client, type SatoriV1Event } from '@imhelper/satori-v1';
2
+ import { type EventMap, type ImHelperEventMap } from 'imhelper';
3
+ import { type ClientEventPayloads } from 'zhin.js/adapter';
4
+ import type { ResolvedSatoriConfig, SatoriApiOptions, callSatoriApi } from './protocol.js';
5
+ export { SatoriV1Client as SatoriClient } from '@imhelper/satori-v1';
6
+ type SatoriClientEvents = ImHelperEventMap<string, SatoriV1Event, EventMap<string>>;
7
+ export type SatoriClientEventMap = ClientEventPayloads<SatoriClientEvents>;
8
+ export declare function createSatoriEndpointClient(config: ResolvedSatoriConfig, request: typeof callSatoriApi, apiOptions: () => SatoriApiOptions): SatoriV1Client;
9
+ export declare function forwardSatoriClientEvents(client: SatoriV1Client, receive: (name: string, payload: unknown) => void): () => void;
10
+ declare module '@zhin.js/feature-kit' {
11
+ interface AdapterClientRegistry {
12
+ readonly satori: {
13
+ readonly client: SatoriV1Client;
14
+ readonly events: SatoriClientEventMap;
15
+ };
16
+ }
17
+ }
18
+ export declare const satoriClient: import("@zhin.js/adapter").EndpointClientToken<SatoriV1Client, ClientEventPayloads<SatoriClientEvents>>;
package/lib/client.js ADDED
@@ -0,0 +1,21 @@
1
+ import { SatoriV1Client, } from '@imhelper/satori-v1';
2
+ import { EventFactory } from 'imhelper';
3
+ import { defineEndpointClient, forwardEndpointClientEvents, } from 'zhin.js/adapter';
4
+ export { SatoriV1Client as SatoriClient } from '@imhelper/satori-v1';
5
+ export function createSatoriEndpointClient(config, request, apiOptions) {
6
+ return new SatoriV1Client({
7
+ baseUrl: config.baseUrl,
8
+ selfId: config.id,
9
+ accessToken: config.token,
10
+ receiveMode: 'manual',
11
+ call: (resource, method, params) => request(apiOptions(), resource, method, params ?? {}),
12
+ });
13
+ }
14
+ const satoriClientEventNames = Object.freeze([
15
+ ...EventFactory.getSupportedEventTypes(),
16
+ 'event',
17
+ ]);
18
+ export function forwardSatoriClientEvents(client, receive) {
19
+ return forwardEndpointClientEvents(client, satoriClientEventNames, receive);
20
+ }
21
+ export const satoriClient = defineEndpointClient('satori');
package/lib/endpoint.d.ts CHANGED
@@ -1,62 +1,51 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
1
2
  /**
2
3
  * SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
3
4
  */
4
- import { type EndpointControl, type EndpointInstance, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
5
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
5
+ import { ClientEndpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
6
6
  import type { HttpHost } from '@zhin.js/host-http';
7
7
  import type { CapabilityId } from 'zhin.js';
8
- import { callSatoriApi, type ResolvedSatoriWebhookConfig, type ResolvedSatoriWsConfig, type SatoriEventBody, type SatoriLogin } from './protocol.js';
8
+ import { callSatoriApi, type ResolvedSatoriWebhookConfig, type ResolvedSatoriWsConfig } from './protocol.js';
9
+ import { type SatoriClient } from './client.js';
9
10
  import { type CreateSatoriWebSocket } from './ws.js';
10
11
  export type SatoriApiCaller = typeof callSatoriApi;
11
12
  export interface SatoriWsEndpointOptions {
12
13
  readonly id: CapabilityId;
13
- readonly gateway: MessageGateway;
14
- readonly sideEvents?: SideEventGateway;
15
14
  readonly config: ResolvedSatoriWsConfig;
16
15
  readonly createWebSocket?: CreateSatoriWebSocket;
17
16
  readonly callApi?: SatoriApiCaller;
18
17
  }
19
- export declare class SatoriWsEndpoint implements EndpointInstance {
18
+ export declare class SatoriWsEndpoint extends ClientEndpoint<SatoriClient> {
20
19
  #private;
20
+ readonly client: SatoriClient;
21
21
  readonly management: EndpointManagement;
22
22
  readonly control: EndpointControl;
23
23
  constructor(options: SatoriWsEndpointOptions);
24
24
  start(): Promise<void>;
25
- open(): void;
26
- close(): void;
27
25
  stop(): Promise<void>;
28
26
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
29
- /** Test / internal: admit a gateway event when the endpoint is open. */
30
- admit(body: SatoriEventBody): void;
31
27
  recall(id: string, fallbackChannelId?: string): Promise<void>;
32
- /** Test helper: inject a READY login without a live socket. */
33
- setLogin(login: SatoriLogin): void;
34
28
  }
35
29
  export interface SatoriWebhookEndpointOptions {
36
30
  readonly id: CapabilityId;
37
- readonly gateway: MessageGateway;
38
- readonly sideEvents?: SideEventGateway;
39
31
  readonly http: HttpHost;
40
32
  readonly config: ResolvedSatoriWebhookConfig;
41
33
  readonly callApi?: SatoriApiCaller;
42
34
  }
43
- export declare class SatoriWebhookEndpoint implements EndpointInstance {
35
+ export declare class SatoriWebhookEndpoint extends ClientEndpoint<SatoriClient> {
44
36
  #private;
37
+ readonly client: SatoriClient;
45
38
  readonly management: EndpointManagement;
46
39
  readonly control: EndpointControl;
47
40
  constructor(options: SatoriWebhookEndpointOptions);
48
41
  /** Used by webhook handler. */
49
42
  get isOpen(): boolean;
50
43
  get config(): ResolvedSatoriWebhookConfig;
44
+ acceptHttp(request: IncomingMessage, response: ServerResponse): Promise<void>;
51
45
  start(): Promise<void>;
52
- open(): void;
53
- close(): void;
54
46
  stop(): Promise<void>;
55
47
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
56
- admit(body: SatoriEventBody): void;
57
48
  recall(id: string, fallbackChannelId?: string): Promise<void>;
58
- /** Test helper: inject login without a live webhook push. */
59
- setLogin(login: SatoriLogin): void;
60
49
  }
61
50
  export type SatoriManagementApi = (resource: string, method: string, params: Record<string, unknown>) => Promise<unknown>;
62
51
  /**
package/lib/endpoint.js CHANGED
@@ -1,26 +1,34 @@
1
1
  /**
2
2
  * SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
3
3
  */
4
- import { createEndpointLifecycle, } from 'zhin.js/adapter';
4
+ import { ClientEndpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
5
5
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
6
6
  import { SatoriOpcode, buildWsUrl, callSatoriApi, extractCreatedMessageId, formatInboundContent, formatMessageId, formatSatoriOutbound, isMessageEvent, isSelfMentioned, parseMessageRef, resolveInboundSender, satoriInboundConversation, } from './protocol.js';
7
7
  import { registerSatoriWebhookRoutes } from './webhook.js';
8
+ import { createSatoriEndpointClient, forwardSatoriClientEvents } from './client.js';
8
9
  import { WS_OPEN, defaultCreateWebSocket, } from './ws.js';
9
- export class SatoriWsEndpoint {
10
+ export class SatoriWsEndpoint extends ClientEndpoint {
11
+ client;
10
12
  #logger;
11
13
  #options;
12
14
  #lifecycle;
13
15
  #ws = null;
14
16
  #login;
15
17
  #lastSn;
16
- #open = false;
17
- management = createSatoriEndpointManagement((resource, method, params) => this.#api(resource, method, params));
18
+ management;
18
19
  control = Object.freeze({
19
20
  recall: (message) => this.recall(message.id, message.conversation.id),
20
21
  });
21
22
  constructor(options) {
23
+ super();
22
24
  this.#logger = getAdapterLogger('satori', options.config.id);
23
25
  this.#options = options;
26
+ this.client = createSatoriEndpointClient(options.config, options.callApi ?? callSatoriApi, () => this.#apiOptions());
27
+ this.management = createSatoriEndpointManagement((resource, method, params) => this.client.call(resource, method, params));
28
+ this.bindClientEvents((receive) => forwardSatoriClientEvents(this.client, receive), (name, payload) => {
29
+ if (name === 'event')
30
+ this.#admitRaw(payload);
31
+ }, (name, error) => this.#warnPlatformEvent(name, error));
24
32
  const { config } = options;
25
33
  this.#lifecycle = createEndpointLifecycle({
26
34
  name: config.id,
@@ -56,21 +64,15 @@ export class SatoriWsEndpoint {
56
64
  throw err;
57
65
  }
58
66
  }
59
- open() {
60
- this.#open = true;
61
- }
62
- close() {
63
- this.#open = false;
64
- }
65
67
  async stop() {
66
- this.#open = false;
68
+ this.close();
67
69
  // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
68
70
  await this.#lifecycle.stop();
69
71
  this.#ws = null;
70
72
  }
71
73
  async send({ conversation, payload }) {
72
74
  const content = formatSatoriOutbound(payload);
73
- const result = await this.#api('message', 'create', {
75
+ const result = await this.client.call('message', 'create', {
74
76
  channel_id: conversation.id,
75
77
  content,
76
78
  });
@@ -83,10 +85,7 @@ export class SatoriWsEndpoint {
83
85
  }));
84
86
  return msgId ? formatMessageId(conversation.id, msgId) : '';
85
87
  }
86
- /** Test / internal: admit a gateway event when the endpoint is open. */
87
- admit(body) {
88
- if (!this.#open)
89
- return;
88
+ #admitRaw(body) {
90
89
  if (body.login && !this.#login)
91
90
  this.#login = body.login;
92
91
  if (!isMessageEvent(body))
@@ -96,7 +95,7 @@ export class SatoriWsEndpoint {
96
95
  const sender = resolveInboundSender(body);
97
96
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
98
97
  const mentioned = isSelfMentioned(body, selfId);
99
- void this.#options.gateway.receive({
98
+ void this.emit('message.receive', {
100
99
  conversation,
101
100
  message: { conversation, id: body.message.id },
102
101
  content,
@@ -119,14 +118,17 @@ export class SatoriWsEndpoint {
119
118
  }
120
119
  async recall(id, fallbackChannelId) {
121
120
  const { channelId, messageId } = parseMessageRef(id);
122
- await this.#api('message', 'delete', {
121
+ await this.client.call('message', 'delete', {
123
122
  channel_id: channelId || fallbackChannelId || '',
124
123
  message_id: messageId,
125
124
  });
126
125
  }
127
- /** Test helper: inject a READY login without a live socket. */
128
- setLogin(login) {
129
- this.#login = login;
126
+ #warnPlatformEvent(name, error) {
127
+ this.#logger.warn(formatCompact({
128
+ op: 'satori_platform_event_failed',
129
+ event: name,
130
+ error: error instanceof Error ? error.message : String(error),
131
+ }));
130
132
  }
131
133
  async #connect(handle) {
132
134
  const { config } = this.#options;
@@ -209,11 +211,25 @@ export class SatoriWsEndpoint {
209
211
  if (signal.op === SatoriOpcode.PONG) {
210
212
  // 喂狗:复位基座看门狗计数
211
213
  this.#lifecycle.notifyHeartbeatAck();
214
+ void this.emitPlatform('pong', signal).catch((error) => {
215
+ this.#logger.warn(formatCompact({
216
+ op: 'satori_platform_event_failed',
217
+ event: 'pong',
218
+ error: error instanceof Error ? error.message : String(error),
219
+ }));
220
+ });
212
221
  return;
213
222
  }
214
223
  if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
215
224
  const logins = signal.body.logins;
216
225
  this.#login = logins[0];
226
+ void this.emitPlatform('ready', signal).catch((error) => {
227
+ this.#logger.warn(formatCompact({
228
+ op: 'satori_platform_event_failed',
229
+ event: 'ready',
230
+ error: error instanceof Error ? error.message : String(error),
231
+ }));
232
+ });
217
233
  if (!this.#login?.platform || !this.#login?.user?.id) {
218
234
  this.#logger.warn(formatCompact({ op: 'ready', ok: false, error: 'missing platform/user' }));
219
235
  }
@@ -222,7 +238,7 @@ export class SatoriWsEndpoint {
222
238
  if (signal.op === SatoriOpcode.EVENT && signal.body) {
223
239
  if (typeof signal.body.sn === 'number')
224
240
  this.#lastSn = signal.body.sn;
225
- this.admit(signal.body);
241
+ this.client.ingest(signal.body);
226
242
  }
227
243
  }
228
244
  #sendSignal(op, body) {
@@ -238,33 +254,39 @@ export class SatoriWsEndpoint {
238
254
  token: this.#options.config.token,
239
255
  };
240
256
  }
241
- #api(resource, method, params) {
242
- const call = this.#options.callApi ?? callSatoriApi;
243
- return call(this.#apiOptions(), resource, method, params);
244
- }
245
257
  }
246
- export class SatoriWebhookEndpoint {
258
+ export class SatoriWebhookEndpoint extends ClientEndpoint {
259
+ client;
247
260
  #logger;
248
261
  #options;
249
262
  #login;
250
263
  #routeReleases = [];
251
- #open = false;
252
264
  #started = false;
253
- management = createSatoriEndpointManagement((resource, method, params) => this.#api(resource, method, params));
265
+ management;
254
266
  control = Object.freeze({
255
267
  recall: (message) => this.recall(message.id, message.conversation.id),
256
268
  });
257
269
  constructor(options) {
270
+ super();
258
271
  this.#logger = getAdapterLogger('satori', options.config.id);
259
272
  this.#options = options;
273
+ this.client = createSatoriEndpointClient(options.config, options.callApi ?? callSatoriApi, () => this.#apiOptions());
274
+ this.management = createSatoriEndpointManagement((resource, method, params) => this.client.call(resource, method, params));
275
+ this.bindClientEvents((receive) => forwardSatoriClientEvents(this.client, receive), (name, payload) => {
276
+ if (name === 'event')
277
+ this.#admitRaw(payload);
278
+ }, (name, error) => this.#warnPlatformEvent(name, error));
260
279
  }
261
280
  /** Used by webhook handler. */
262
281
  get isOpen() {
263
- return this.#open;
282
+ return this.clientEventsOpen;
264
283
  }
265
284
  get config() {
266
285
  return this.#options.config;
267
286
  }
287
+ async acceptHttp(request, response) {
288
+ await this.client.acceptHttp(request, response);
289
+ }
268
290
  async start() {
269
291
  if (this.#started)
270
292
  return;
@@ -286,14 +308,8 @@ export class SatoriWebhookEndpoint {
286
308
  path: this.#options.config.path,
287
309
  }));
288
310
  }
289
- open() {
290
- this.#open = true;
291
- }
292
- close() {
293
- this.#open = false;
294
- }
295
311
  async stop() {
296
- this.#open = false;
312
+ this.close();
297
313
  for (const release of this.#routeReleases.splice(0))
298
314
  release();
299
315
  this.#started = false;
@@ -303,7 +319,7 @@ export class SatoriWebhookEndpoint {
303
319
  }
304
320
  async send({ conversation, payload }) {
305
321
  const content = formatSatoriOutbound(payload);
306
- const result = await this.#api('message', 'create', {
322
+ const result = await this.client.call('message', 'create', {
307
323
  channel_id: conversation.id,
308
324
  content,
309
325
  });
@@ -316,9 +332,7 @@ export class SatoriWebhookEndpoint {
316
332
  }));
317
333
  return msgId ? formatMessageId(conversation.id, msgId) : '';
318
334
  }
319
- admit(body) {
320
- if (!this.#open)
321
- return;
335
+ #admitRaw(body) {
322
336
  if (body.login && !this.#login)
323
337
  this.#login = body.login;
324
338
  if (!isMessageEvent(body))
@@ -328,7 +342,7 @@ export class SatoriWebhookEndpoint {
328
342
  const sender = resolveInboundSender(body);
329
343
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
330
344
  const mentioned = isSelfMentioned(body, selfId);
331
- void this.#options.gateway.receive({
345
+ void this.emit('message.receive', {
332
346
  conversation,
333
347
  message: { conversation, id: body.message.id },
334
348
  content,
@@ -351,14 +365,17 @@ export class SatoriWebhookEndpoint {
351
365
  }
352
366
  async recall(id, fallbackChannelId) {
353
367
  const { channelId, messageId } = parseMessageRef(id);
354
- await this.#api('message', 'delete', {
368
+ await this.client.call('message', 'delete', {
355
369
  channel_id: channelId || fallbackChannelId || '',
356
370
  message_id: messageId,
357
371
  });
358
372
  }
359
- /** Test helper: inject login without a live webhook push. */
360
- setLogin(login) {
361
- this.#login = login;
373
+ #warnPlatformEvent(name, error) {
374
+ this.#logger.warn(formatCompact({
375
+ op: 'satori_platform_event_failed',
376
+ event: name,
377
+ error: error instanceof Error ? error.message : String(error),
378
+ }));
362
379
  }
363
380
  #apiOptions() {
364
381
  return {
@@ -368,10 +385,6 @@ export class SatoriWebhookEndpoint {
368
385
  token: this.#options.config.token,
369
386
  };
370
387
  }
371
- #api(resource, method, params) {
372
- const call = this.#options.callApi ?? callSatoriApi;
373
- return call(this.#apiOptions(), resource, method, params);
374
- }
375
388
  }
376
389
  function isPrivateChannelType(body) {
377
390
  const channel = body.channel ?? body.message?.channel;
package/lib/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { SatoriOpcode, buildWsUrl, callSatoriApi, extractCreatedMessageId, formatInboundContent, formatSatoriOutbound, isMessageEvent, isPrivateChannel, resolveInboundSender, resolveSatoriConfig, satoriInboundConversation, type ResolvedSatoriWebhookConfig, type ResolvedSatoriWsConfig, type SatoriAdapterConfig, type SatoriApiOptions, type SatoriChannel, type SatoriEventBody, type SatoriLogin, type SatoriMessage, type SatoriSignal, type SatoriUser, type SatoriWireSegment, } from './protocol.js';
2
2
  export { SatoriWebhookEndpoint, SatoriWsEndpoint, type CreateSatoriWebSocket, type SatoriApiCaller, type SatoriWebhookEndpointOptions, type SatoriWsEndpointOptions, type SatoriWsSocket, } from './endpoint.js';
3
- export { handleSatoriWebhookRequest, readRequestBody, registerSatoriWebhookRoutes, resolveSatoriOpcode, verifySatoriToken, type SatoriWebhookHandler, } from './webhook.js';
3
+ export { handleSatoriWebhookRequest, registerSatoriWebhookRoutes, resolveSatoriOpcode, verifySatoriToken, type SatoriWebhookHandler, } from './webhook.js';
4
4
  export { WS_OPEN, defaultCreateWebSocket, } from './ws.js';
5
+ export { SatoriClient, satoriClient, type SatoriClientEventMap, } from './client.js';
6
+ export type { SatoriAdapterConfig as ImHelperSatoriAdapterConfig, SatoriActionUrlResolver, SatoriCall, SatoriV1ClientConfig, SatoriV1Event, SatoriV1Response, } from '@imhelper/satori-v1';
7
+ export { ProtocolError } from '@imhelper/satori-v1';
8
+ export type { ProtocolErrorKind, ProtocolErrorOptions } from 'imhelper';
package/lib/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { SatoriOpcode, buildWsUrl, callSatoriApi, extractCreatedMessageId, formatInboundContent, formatSatoriOutbound, isMessageEvent, isPrivateChannel, resolveInboundSender, resolveSatoriConfig, satoriInboundConversation, } from './protocol.js';
2
2
  export { SatoriWebhookEndpoint, SatoriWsEndpoint, } from './endpoint.js';
3
- export { handleSatoriWebhookRequest, readRequestBody, registerSatoriWebhookRoutes, resolveSatoriOpcode, verifySatoriToken, } from './webhook.js';
3
+ export { handleSatoriWebhookRequest, registerSatoriWebhookRoutes, resolveSatoriOpcode, verifySatoriToken, } from './webhook.js';
4
4
  export { WS_OPEN, defaultCreateWebSocket, } from './ws.js';
5
+ export { SatoriClient, satoriClient, } from './client.js';
6
+ export { ProtocolError } from '@imhelper/satori-v1';
package/lib/protocol.d.ts CHANGED
@@ -121,7 +121,7 @@ export declare function buildWsUrl(baseUrl: string, token?: string): string;
121
121
  * @see https://satori.chat/en-US/protocol/api.html
122
122
  */
123
123
  export declare function callSatoriApi<T = unknown>(options: SatoriApiOptions, resource: string, method: string, params?: Record<string, unknown>): Promise<T>;
124
- /** Build inbound text for MessageGateway.receive. */
124
+ /** Build inbound text for OutboundMessageService.receive. */
125
125
  export declare function formatInboundContent(body: SatoriEventBody & {
126
126
  message: SatoriMessage;
127
127
  }): string;
package/lib/protocol.js CHANGED
@@ -108,7 +108,7 @@ export async function callSatoriApi(options, resource, method, params = {}) {
108
108
  throw new Error(`Satori API 无效 JSON: ${text.slice(0, 200)}`);
109
109
  }
110
110
  }
111
- /** Build inbound text for MessageGateway.receive. */
111
+ /** Build inbound text for OutboundMessageService.receive. */
112
112
  export function formatInboundContent(body) {
113
113
  const content = body.message.content ?? '';
114
114
  return typeof content === 'string' ? content : String(content);
@@ -1 +1 @@
1
- export declare const satoriEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
1
+ export declare const satoriEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;
package/lib/webhook.d.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
3
- import { type ResolvedSatoriWebhookConfig, type SatoriEventBody, type SatoriLogin } from './protocol.js';
3
+ import { type ResolvedSatoriWebhookConfig } from './protocol.js';
4
4
  export interface SatoriWebhookHandler {
5
5
  readonly config: ResolvedSatoriWebhookConfig;
6
6
  readonly isOpen: boolean;
7
- admit(body: SatoriEventBody): void;
8
- setLogin(login: SatoriLogin): void;
7
+ acceptHttp(request: IncomingMessage, response: ServerResponse): Promise<void>;
9
8
  }
10
9
  export declare function registerSatoriWebhookRoutes(http: HttpHost, handler: SatoriWebhookHandler): HttpRouteRegistration[];
11
10
  export declare function handleSatoriWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: SatoriWebhookHandler): Promise<void>;
12
11
  export declare function resolveSatoriOpcode(request: IncomingMessage): number | undefined;
13
12
  export declare function verifySatoriToken(token: string | undefined, request: IncomingMessage): boolean;
14
- export declare function readRequestBody(request: IncomingMessage): Promise<string>;
package/lib/webhook.js CHANGED
@@ -26,24 +26,13 @@ export async function handleSatoriWebhookRequest(request, response, handler) {
26
26
  response.end(JSON.stringify({ message: 'OK' }));
27
27
  return;
28
28
  }
29
- const raw = await readRequestBody(request);
30
- let body;
31
- try {
32
- body = JSON.parse(raw);
29
+ if (handler.isOpen) {
30
+ await handler.acceptHttp(request, response);
33
31
  }
34
- catch {
35
- response.writeHead(400, { 'Content-Type': 'application/json' });
36
- response.end(JSON.stringify({ message: 'Invalid JSON' }));
37
- return;
38
- }
39
- if (opcode === SatoriOpcode.EVENT && handler.isOpen) {
40
- handler.admit(body);
41
- }
42
- else if (opcode === SatoriOpcode.META && body.login && handler.isOpen) {
43
- handler.setLogin(body.login);
32
+ else {
33
+ response.writeHead(200, { 'Content-Type': 'application/json' });
34
+ response.end(JSON.stringify({ status: 'ok' }));
44
35
  }
45
- response.writeHead(200, { 'Content-Type': 'application/json' });
46
- response.end(JSON.stringify({ message: 'OK' }));
47
36
  }
48
37
  catch (error) {
49
38
  logger.error('Satori webhook error:', error);
@@ -69,17 +58,3 @@ export function verifySatoriToken(token, request) {
69
58
  const actual = Buffer.from(auth, 'utf8');
70
59
  return actual.length === expected.length && timingSafeEqual(actual, expected);
71
60
  }
72
- export async function readRequestBody(request) {
73
- const chunks = [];
74
- let size = 0;
75
- for await (const chunk of request) {
76
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
77
- size += buffer.length;
78
- if (size > 1_048_576) {
79
- request.destroy();
80
- throw new Error('Request body exceeds 1MB');
81
- }
82
- chunks.push(buffer);
83
- }
84
- return Buffer.concat(chunks).toString('utf8');
85
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-satori",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
4
4
  "description": "Zhin.js Satori adapter for Plugin Runtime (WebSocket client)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -38,26 +38,29 @@
38
38
  "directory": "plugins/adapters/satori"
39
39
  },
40
40
  "dependencies": {
41
+ "@imhelper/satori-v1": "1.0.7",
42
+ "imhelper": "1.0.7",
41
43
  "ws": "^8.21.1",
42
- "@zhin.js/adapter": "1.2.0",
43
- "@zhin.js/core": "1.5.13",
44
- "@zhin.js/host-http": "1.0.12",
44
+ "@zhin.js/adapter": "1.2.1",
45
+ "@zhin.js/core": "1.5.15",
46
+ "@zhin.js/feature-kit": "1.0.13",
47
+ "@zhin.js/host-http": "1.0.14",
45
48
  "@zhin.js/im-contract": "1.0.4",
46
- "@zhin.js/logger": "1.0.76"
49
+ "@zhin.js/logger": "1.0.77"
47
50
  },
48
51
  "devDependencies": {
49
52
  "@types/node": "^26.1.2",
50
53
  "@types/ws": "^8.18.1",
51
54
  "typescript": "^6.0.3",
52
55
  "vitest": "^4.1.10",
53
- "@zhin.js/host-http": "1.0.12",
54
- "zhin.js": "6.0.13"
56
+ "@zhin.js/host-http": "1.0.14",
57
+ "zhin.js": "6.0.15"
55
58
  },
56
59
  "peerDependencies": {
57
- "@zhin.js/adapter": "1.2.0",
58
- "@zhin.js/command": "1.0.15",
59
- "@zhin.js/core": "1.5.13",
60
- "zhin.js": "6.0.13"
60
+ "@zhin.js/adapter": "1.2.1",
61
+ "@zhin.js/command": "1.0.16",
62
+ "@zhin.js/core": "1.5.15",
63
+ "zhin.js": "6.0.15"
61
64
  },
62
65
  "peerDependenciesMeta": {
63
66
  "@zhin.js/command": {
package/src/client.ts ADDED
@@ -0,0 +1,57 @@
1
+ import {
2
+ SatoriV1Client,
3
+ type SatoriV1Event,
4
+ } from '@imhelper/satori-v1';
5
+ import { EventFactory, type EventMap, type ImHelperEventMap } from 'imhelper';
6
+ import {
7
+ defineEndpointClient,
8
+ forwardEndpointClientEvents,
9
+ type ClientEventPayloads,
10
+ } from 'zhin.js/adapter';
11
+ import type {
12
+ ResolvedSatoriConfig,
13
+ SatoriApiOptions,
14
+ callSatoriApi,
15
+ } from './protocol.js';
16
+
17
+ export { SatoriV1Client as SatoriClient } from '@imhelper/satori-v1';
18
+
19
+ type SatoriClientEvents = ImHelperEventMap<string, SatoriV1Event, EventMap<string>>;
20
+ export type SatoriClientEventMap = ClientEventPayloads<SatoriClientEvents>;
21
+
22
+ export function createSatoriEndpointClient(
23
+ config: ResolvedSatoriConfig,
24
+ request: typeof callSatoriApi,
25
+ apiOptions: () => SatoriApiOptions,
26
+ ): SatoriV1Client {
27
+ return new SatoriV1Client({
28
+ baseUrl: config.baseUrl,
29
+ selfId: config.id,
30
+ accessToken: config.token,
31
+ receiveMode: 'manual',
32
+ call: (resource, method, params) => request(apiOptions(), resource, method, params ?? {}),
33
+ });
34
+ }
35
+
36
+ const satoriClientEventNames = Object.freeze([
37
+ ...EventFactory.getSupportedEventTypes<string>(),
38
+ 'event',
39
+ ]);
40
+
41
+ export function forwardSatoriClientEvents(
42
+ client: SatoriV1Client,
43
+ receive: (name: string, payload: unknown) => void,
44
+ ): () => void {
45
+ return forwardEndpointClientEvents(client, satoriClientEventNames, receive);
46
+ }
47
+
48
+ declare module '@zhin.js/feature-kit' {
49
+ interface AdapterClientRegistry {
50
+ readonly satori: {
51
+ readonly client: SatoriV1Client;
52
+ readonly events: SatoriClientEventMap;
53
+ };
54
+ }
55
+ }
56
+
57
+ export const satoriClient = defineEndpointClient<SatoriV1Client, SatoriClientEventMap>('satori');
package/src/endpoint.ts CHANGED
@@ -1,18 +1,18 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
1
2
  /**
2
3
  * SatoriEndpoint — WebSocket and webhook lifecycle, outbound, admit.
3
4
  */
4
5
  import {
6
+ ClientEndpoint,
5
7
  createEndpointLifecycle,
6
8
  type EndpointChannel,
7
9
  type EndpointConnectHandle,
8
10
  type EndpointControl,
9
11
  type EndpointGroup,
10
- type EndpointInstance,
11
12
  type EndpointLifecycle,
12
13
  type EndpointManagement,
13
14
  type EndpointSendRequest,
14
15
  } from 'zhin.js/adapter';
15
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
16
16
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
17
17
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
18
18
  import type { CapabilityId } from 'zhin.js';
@@ -37,6 +37,7 @@ import {
37
37
  type SatoriSignal,
38
38
  } from './protocol.js';
39
39
  import { registerSatoriWebhookRoutes } from './webhook.js';
40
+ import { createSatoriEndpointClient, forwardSatoriClientEvents, type SatoriClient } from './client.js';
40
41
  import {
41
42
  WS_OPEN,
42
43
  defaultCreateWebSocket,
@@ -48,14 +49,13 @@ export type SatoriApiCaller = typeof callSatoriApi;
48
49
 
49
50
  export interface SatoriWsEndpointOptions {
50
51
  readonly id: CapabilityId;
51
- readonly gateway: MessageGateway;
52
- readonly sideEvents?: SideEventGateway;
53
52
  readonly config: ResolvedSatoriWsConfig;
54
53
  readonly createWebSocket?: CreateSatoriWebSocket;
55
54
  readonly callApi?: SatoriApiCaller;
56
55
  }
57
56
 
58
- export class SatoriWsEndpoint implements EndpointInstance {
57
+ export class SatoriWsEndpoint extends ClientEndpoint<SatoriClient> {
58
+ readonly client: SatoriClient;
59
59
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
60
60
 
61
61
  readonly #options: SatoriWsEndpointOptions;
@@ -63,17 +63,30 @@ export class SatoriWsEndpoint implements EndpointInstance {
63
63
  #ws: SatoriWsSocket | null = null;
64
64
  #login: SatoriLogin | undefined;
65
65
  #lastSn: number | undefined;
66
- #open = false;
67
- readonly management: EndpointManagement = createSatoriEndpointManagement(
68
- (resource, method, params) => this.#api(resource, method, params),
69
- );
66
+ readonly management: EndpointManagement;
70
67
  readonly control: EndpointControl = Object.freeze<EndpointControl>({
71
68
  recall: (message) => this.recall(message.id, message.conversation.id),
72
69
  });
73
70
 
74
71
  constructor(options: SatoriWsEndpointOptions) {
72
+ super();
75
73
  this.#logger = getAdapterLogger('satori', options.config.id);
76
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
+ );
77
90
  const { config } = options;
78
91
  this.#lifecycle = createEndpointLifecycle({
79
92
  name: config.id,
@@ -109,16 +122,8 @@ export class SatoriWsEndpoint implements EndpointInstance {
109
122
  }
110
123
  }
111
124
 
112
- open(): void {
113
- this.#open = true;
114
- }
115
-
116
- close(): void {
117
- this.#open = false;
118
- }
119
-
120
125
  async stop(): Promise<void> {
121
- this.#open = false;
126
+ this.close();
122
127
  // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
123
128
  await this.#lifecycle.stop();
124
129
  this.#ws = null;
@@ -126,7 +131,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
126
131
 
127
132
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
128
133
  const content = formatSatoriOutbound(payload);
129
- const result = await this.#api('message', 'create', {
134
+ const result = await this.client.call('message', 'create', {
130
135
  channel_id: conversation.id,
131
136
  content,
132
137
  });
@@ -140,9 +145,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
140
145
  return msgId ? formatMessageId(conversation.id, msgId) : '';
141
146
  }
142
147
 
143
- /** Test / internal: admit a gateway event when the endpoint is open. */
144
- admit(body: SatoriEventBody): void {
145
- if (!this.#open) return;
148
+ #admitRaw(body: SatoriEventBody): void {
146
149
  if (body.login && !this.#login) this.#login = body.login;
147
150
  if (!isMessageEvent(body)) return;
148
151
  const conversation = satoriInboundConversation(String(this.#options.id), body);
@@ -150,7 +153,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
150
153
  const sender = resolveInboundSender(body);
151
154
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
152
155
  const mentioned = isSelfMentioned(body, selfId);
153
- void this.#options.gateway.receive({
156
+ void this.emit('message.receive', {
154
157
  conversation,
155
158
  message: { conversation, id: body.message.id },
156
159
  content,
@@ -174,15 +177,18 @@ export class SatoriWsEndpoint implements EndpointInstance {
174
177
 
175
178
  async recall(id: string, fallbackChannelId?: string): Promise<void> {
176
179
  const { channelId, messageId } = parseMessageRef(id);
177
- await this.#api('message', 'delete', {
180
+ await this.client.call('message', 'delete', {
178
181
  channel_id: channelId || fallbackChannelId || '',
179
182
  message_id: messageId,
180
183
  });
181
184
  }
182
185
 
183
- /** Test helper: inject a READY login without a live socket. */
184
- setLogin(login: SatoriLogin): void {
185
- this.#login = login;
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
+ }));
186
192
  }
187
193
 
188
194
  async #connect(handle: EndpointConnectHandle): Promise<void> {
@@ -269,11 +275,25 @@ export class SatoriWsEndpoint implements EndpointInstance {
269
275
  if (signal.op === SatoriOpcode.PONG) {
270
276
  // 喂狗:复位基座看门狗计数
271
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
+ });
272
285
  return;
273
286
  }
274
287
  if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
275
288
  const logins = signal.body.logins as SatoriLogin[];
276
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
+ });
277
297
  if (!this.#login?.platform || !this.#login?.user?.id) {
278
298
  this.#logger.warn(formatCompact({ op: 'ready', ok: false, error: 'missing platform/user' }));
279
299
  }
@@ -281,7 +301,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
281
301
  }
282
302
  if (signal.op === SatoriOpcode.EVENT && signal.body) {
283
303
  if (typeof signal.body.sn === 'number') this.#lastSn = signal.body.sn;
284
- this.admit(signal.body as SatoriEventBody);
304
+ this.client.ingest(signal.body as Parameters<SatoriClient['ingest']>[0]);
285
305
  }
286
306
  }
287
307
 
@@ -299,54 +319,62 @@ export class SatoriWsEndpoint implements EndpointInstance {
299
319
  };
300
320
  }
301
321
 
302
- #api(
303
- resource: string,
304
- method: string,
305
- params: Record<string, unknown>,
306
- ): Promise<unknown> {
307
- const call = this.#options.callApi ?? callSatoriApi;
308
- return call(this.#apiOptions(), resource, method, params);
309
- }
310
322
  }
311
323
 
312
324
  export interface SatoriWebhookEndpointOptions {
313
325
  readonly id: CapabilityId;
314
- readonly gateway: MessageGateway;
315
- readonly sideEvents?: SideEventGateway;
316
326
  readonly http: HttpHost;
317
327
  readonly config: ResolvedSatoriWebhookConfig;
318
328
  readonly callApi?: SatoriApiCaller;
319
329
  }
320
330
 
321
- export class SatoriWebhookEndpoint implements EndpointInstance {
331
+ export class SatoriWebhookEndpoint extends ClientEndpoint<SatoriClient> {
332
+ readonly client: SatoriClient;
322
333
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
323
334
 
324
335
  readonly #options: SatoriWebhookEndpointOptions;
325
336
  #login: SatoriLogin | undefined;
326
337
  #routeReleases: HttpRouteRegistration[] = [];
327
- #open = false;
328
338
  #started = false;
329
- readonly management: EndpointManagement = createSatoriEndpointManagement(
330
- (resource, method, params) => this.#api(resource, method, params),
331
- );
339
+ readonly management: EndpointManagement;
332
340
  readonly control: EndpointControl = Object.freeze<EndpointControl>({
333
341
  recall: (message) => this.recall(message.id, message.conversation.id),
334
342
  });
335
343
 
336
344
  constructor(options: SatoriWebhookEndpointOptions) {
345
+ super();
337
346
  this.#logger = getAdapterLogger('satori', options.config.id);
338
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
+ );
339
363
  }
340
364
 
341
365
  /** Used by webhook handler. */
342
366
  get isOpen(): boolean {
343
- return this.#open;
367
+ return this.clientEventsOpen;
344
368
  }
345
369
 
346
370
  get config(): ResolvedSatoriWebhookConfig {
347
371
  return this.#options.config;
348
372
  }
349
373
 
374
+ async acceptHttp(request: IncomingMessage, response: ServerResponse): Promise<void> {
375
+ await this.client.acceptHttp(request, response);
376
+ }
377
+
350
378
  async start(): Promise<void> {
351
379
  if (this.#started) return;
352
380
  this.#started = true;
@@ -368,16 +396,8 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
368
396
  }));
369
397
  }
370
398
 
371
- open(): void {
372
- this.#open = true;
373
- }
374
-
375
- close(): void {
376
- this.#open = false;
377
- }
378
-
379
399
  async stop(): Promise<void> {
380
- this.#open = false;
400
+ this.close();
381
401
  for (const release of this.#routeReleases.splice(0)) release();
382
402
  this.#started = false;
383
403
  this.#logger.debug(formatCompact({
@@ -387,7 +407,7 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
387
407
 
388
408
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
389
409
  const content = formatSatoriOutbound(payload);
390
- const result = await this.#api('message', 'create', {
410
+ const result = await this.client.call('message', 'create', {
391
411
  channel_id: conversation.id,
392
412
  content,
393
413
  });
@@ -401,8 +421,7 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
401
421
  return msgId ? formatMessageId(conversation.id, msgId) : '';
402
422
  }
403
423
 
404
- admit(body: SatoriEventBody): void {
405
- if (!this.#open) return;
424
+ #admitRaw(body: SatoriEventBody): void {
406
425
  if (body.login && !this.#login) this.#login = body.login;
407
426
  if (!isMessageEvent(body)) return;
408
427
  const conversation = satoriInboundConversation(String(this.#options.id), body);
@@ -410,7 +429,7 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
410
429
  const sender = resolveInboundSender(body);
411
430
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
412
431
  const mentioned = isSelfMentioned(body, selfId);
413
- void this.#options.gateway.receive({
432
+ void this.emit('message.receive', {
414
433
  conversation,
415
434
  message: { conversation, id: body.message.id },
416
435
  content,
@@ -434,15 +453,18 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
434
453
 
435
454
  async recall(id: string, fallbackChannelId?: string): Promise<void> {
436
455
  const { channelId, messageId } = parseMessageRef(id);
437
- await this.#api('message', 'delete', {
456
+ await this.client.call('message', 'delete', {
438
457
  channel_id: channelId || fallbackChannelId || '',
439
458
  message_id: messageId,
440
459
  });
441
460
  }
442
461
 
443
- /** Test helper: inject login without a live webhook push. */
444
- setLogin(login: SatoriLogin): void {
445
- this.#login = login;
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
+ }));
446
468
  }
447
469
 
448
470
  #apiOptions(): SatoriApiOptions {
@@ -454,14 +476,6 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
454
476
  };
455
477
  }
456
478
 
457
- #api(
458
- resource: string,
459
- method: string,
460
- params: Record<string, unknown>,
461
- ): Promise<unknown> {
462
- const call = this.#options.callApi ?? callSatoriApi;
463
- return call(this.#apiOptions(), resource, method, params);
464
- }
465
479
  }
466
480
 
467
481
  function isPrivateChannelType(body: SatoriEventBody): boolean {
package/src/index.ts CHANGED
@@ -35,7 +35,6 @@ export {
35
35
 
36
36
  export {
37
37
  handleSatoriWebhookRequest,
38
- readRequestBody,
39
38
  registerSatoriWebhookRoutes,
40
39
  resolveSatoriOpcode,
41
40
  verifySatoriToken,
@@ -46,3 +45,20 @@ export {
46
45
  WS_OPEN,
47
46
  defaultCreateWebSocket,
48
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';
package/src/protocol.ts CHANGED
@@ -222,7 +222,7 @@ export async function callSatoriApi<T = unknown>(
222
222
  }
223
223
  }
224
224
 
225
- /** Build inbound text for MessageGateway.receive. */
225
+ /** Build inbound text for OutboundMessageService.receive. */
226
226
  export function formatInboundContent(body: SatoriEventBody & { message: SatoriMessage }): string {
227
227
  const content = body.message.content ?? '';
228
228
  return typeof content === 'string' ? content : String(content);
package/src/webhook.ts CHANGED
@@ -8,8 +8,6 @@ import { getLogger } from '@zhin.js/logger';
8
8
  import {
9
9
  SatoriOpcode,
10
10
  type ResolvedSatoriWebhookConfig,
11
- type SatoriEventBody,
12
- type SatoriLogin,
13
11
  } from './protocol.js';
14
12
 
15
13
  const logger = getLogger('satori');
@@ -17,8 +15,7 @@ const logger = getLogger('satori');
17
15
  export interface SatoriWebhookHandler {
18
16
  readonly config: ResolvedSatoriWebhookConfig;
19
17
  readonly isOpen: boolean;
20
- admit(body: SatoriEventBody): void;
21
- setLogin(login: SatoriLogin): void;
18
+ acceptHttp(request: IncomingMessage, response: ServerResponse): Promise<void>;
22
19
  }
23
20
 
24
21
  export function registerSatoriWebhookRoutes(
@@ -50,22 +47,12 @@ export async function handleSatoriWebhookRequest(
50
47
  response.end(JSON.stringify({ message: 'OK' }));
51
48
  return;
52
49
  }
53
- const raw = await readRequestBody(request);
54
- let body: SatoriEventBody;
55
- try {
56
- body = JSON.parse(raw) as SatoriEventBody;
57
- } catch {
58
- response.writeHead(400, { 'Content-Type': 'application/json' });
59
- response.end(JSON.stringify({ message: 'Invalid JSON' }));
60
- return;
61
- }
62
- if (opcode === SatoriOpcode.EVENT && handler.isOpen) {
63
- handler.admit(body);
64
- } else if (opcode === SatoriOpcode.META && body.login && handler.isOpen) {
65
- handler.setLogin(body.login);
50
+ if (handler.isOpen) {
51
+ await handler.acceptHttp(request, response);
52
+ } else {
53
+ response.writeHead(200, { 'Content-Type': 'application/json' });
54
+ response.end(JSON.stringify({ status: 'ok' }));
66
55
  }
67
- response.writeHead(200, { 'Content-Type': 'application/json' });
68
- response.end(JSON.stringify({ message: 'OK' }));
69
56
  } catch (error) {
70
57
  logger.error('Satori webhook error:', error);
71
58
  if (!response.headersSent) {
@@ -90,18 +77,3 @@ export function verifySatoriToken(token: string | undefined, request: IncomingMe
90
77
  const actual = Buffer.from(auth, 'utf8');
91
78
  return actual.length === expected.length && timingSafeEqual(actual, expected);
92
79
  }
93
-
94
- export async function readRequestBody(request: IncomingMessage): Promise<string> {
95
- const chunks: Buffer[] = [];
96
- let size = 0;
97
- for await (const chunk of request) {
98
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
99
- size += buffer.length;
100
- if (size > 1_048_576) {
101
- request.destroy();
102
- throw new Error('Request body exceeds 1MB');
103
- }
104
- chunks.push(buffer);
105
- }
106
- return Buffer.concat(chunks).toString('utf8');
107
- }