@zhin.js/adapter-satori 5.0.13 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @zhin.js/adapter-satori
2
2
 
3
+ ## 6.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - Updated dependencies [4e8117c]
11
+ - Updated dependencies [54bfd6b]
12
+ - Updated dependencies [12025ee]
13
+ - Updated dependencies [09b14d6]
14
+ - Updated dependencies [1fc78bc]
15
+ - @zhin.js/adapter@1.2.1
16
+ - @zhin.js/core@1.5.14
17
+ - @zhin.js/host-http@1.0.13
18
+ - @zhin.js/command@1.0.16
19
+ - @zhin.js/logger@1.0.77
20
+ - zhin.js@6.0.14
21
+ - @zhin.js/feature-kit@1.0.13
22
+
23
+ ## 6.0.0
24
+
25
+ ### Patch Changes
26
+
27
+ - f2c532f: Expose exact per-Endpoint message operations through one validated Adapter capability model, route Core control calls through declared active capabilities, and connect existing recall, edit, reaction, and typing implementations across platform adapters.
28
+ - Updated dependencies [b10d058]
29
+ - Updated dependencies [f2c532f]
30
+ - @zhin.js/host-http@1.0.12
31
+ - @zhin.js/adapter@1.2.0
32
+ - @zhin.js/core@1.5.13
33
+ - zhin.js@6.0.13
34
+
3
35
  ## 5.0.13
4
36
 
5
37
  ### 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";
@@ -11,6 +10,7 @@ import { satoriRuntimeStateToken } from "../lib/satori-runtime-state.js";
11
10
  export { SatoriWebhookEndpoint, SatoriWsEndpoint } from "../lib/endpoint.js";
12
11
  export default defineAdapter({
13
12
  capabilities: ['inbound', 'outbound'],
13
+ operations: ['recall'],
14
14
  // Satori 协议 img/file 元素消费 url 与 base64 内联数据;无卡片交互面,交互段降级纯文本。
15
15
  segments: {
16
16
  outboundMedia: ['url', 'base64'],
@@ -18,8 +18,6 @@ export default defineAdapter({
18
18
  },
19
19
  create(context) {
20
20
  const config = resolveSatoriConfig(context.config);
21
- const gateway = context.use(messageGatewayToken);
22
- const sideEvents = context.use(sideEventGatewayToken);
23
21
  // 注册到插件运行时状态(satori.endpoint list 的"运行中"数据源)
24
22
  context.use(satoriRuntimeStateToken).endpoints.set(config.id, {
25
23
  id: config.id,
@@ -28,16 +26,12 @@ export default defineAdapter({
28
26
  if (config.connection === 'webhook') {
29
27
  return new SatoriWebhookEndpoint({
30
28
  id: context.id,
31
- gateway,
32
- sideEvents,
33
29
  http: context.use(httpHostToken),
34
30
  config,
35
31
  });
36
32
  }
37
33
  return new SatoriWsEndpoint({
38
34
  id: context.id,
39
- gateway,
40
- sideEvents,
41
35
  config,
42
36
  });
43
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 {
@@ -22,6 +21,7 @@ export type {
22
21
 
23
22
  export default defineAdapter<SatoriAdapterConfig>({
24
23
  capabilities: ['inbound', 'outbound'],
24
+ operations: ['recall'],
25
25
  // Satori 协议 img/file 元素消费 url 与 base64 内联数据;无卡片交互面,交互段降级纯文本。
26
26
  segments: {
27
27
  outboundMedia: ['url', 'base64'],
@@ -29,8 +29,6 @@ export default defineAdapter<SatoriAdapterConfig>({
29
29
  },
30
30
  create(context) {
31
31
  const config = resolveSatoriConfig(context.config);
32
- const gateway = context.use(messageGatewayToken);
33
- const sideEvents = context.use(sideEventGatewayToken);
34
32
  // 注册到插件运行时状态(satori.endpoint list 的"运行中"数据源)
35
33
  context.use(satoriRuntimeStateToken).endpoints.set(config.id, {
36
34
  id: config.id,
@@ -39,16 +37,12 @@ export default defineAdapter<SatoriAdapterConfig>({
39
37
  if (config.connection === 'webhook') {
40
38
  return new SatoriWebhookEndpoint({
41
39
  id: context.id,
42
- gateway,
43
- sideEvents,
44
40
  http: context.use(httpHostToken),
45
41
  config,
46
42
  });
47
43
  }
48
44
  return new SatoriWsEndpoint({
49
45
  id: context.id,
50
- gateway,
51
- sideEvents,
52
46
  config,
53
47
  });
54
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,60 +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 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
+ readonly control: EndpointControl;
22
23
  constructor(options: SatoriWsEndpointOptions);
23
24
  start(): Promise<void>;
24
- open(): void;
25
- close(): void;
26
25
  stop(): Promise<void>;
27
26
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
28
- /** Test / internal: admit a gateway event when the endpoint is open. */
29
- admit(body: SatoriEventBody): void;
30
- recall(id: string): Promise<void>;
31
- /** Test helper: inject a READY login without a live socket. */
32
- setLogin(login: SatoriLogin): void;
27
+ recall(id: string, fallbackChannelId?: string): Promise<void>;
33
28
  }
34
29
  export interface SatoriWebhookEndpointOptions {
35
30
  readonly id: CapabilityId;
36
- readonly gateway: MessageGateway;
37
- readonly sideEvents?: SideEventGateway;
38
31
  readonly http: HttpHost;
39
32
  readonly config: ResolvedSatoriWebhookConfig;
40
33
  readonly callApi?: SatoriApiCaller;
41
34
  }
42
- export declare class SatoriWebhookEndpoint implements EndpointInstance {
35
+ export declare class SatoriWebhookEndpoint extends ClientEndpoint<SatoriClient> {
43
36
  #private;
37
+ readonly client: SatoriClient;
44
38
  readonly management: EndpointManagement;
39
+ readonly control: EndpointControl;
45
40
  constructor(options: SatoriWebhookEndpointOptions);
46
41
  /** Used by webhook handler. */
47
42
  get isOpen(): boolean;
48
43
  get config(): ResolvedSatoriWebhookConfig;
44
+ acceptHttp(request: IncomingMessage, response: ServerResponse): Promise<void>;
49
45
  start(): Promise<void>;
50
- open(): void;
51
- close(): void;
52
46
  stop(): Promise<void>;
53
47
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
54
- admit(body: SatoriEventBody): void;
55
- recall(id: string): Promise<void>;
56
- /** Test helper: inject login without a live webhook push. */
57
- setLogin(login: SatoriLogin): void;
48
+ recall(id: string, fallbackChannelId?: string): Promise<void>;
58
49
  }
59
50
  export type SatoriManagementApi = (resource: string, method: string, params: Record<string, unknown>) => Promise<unknown>;
60
51
  /**
package/lib/endpoint.js CHANGED
@@ -1,23 +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;
19
+ control = Object.freeze({
20
+ recall: (message) => this.recall(message.id, message.conversation.id),
21
+ });
18
22
  constructor(options) {
23
+ super();
19
24
  this.#logger = getAdapterLogger('satori', options.config.id);
20
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));
21
32
  const { config } = options;
22
33
  this.#lifecycle = createEndpointLifecycle({
23
34
  name: config.id,
@@ -53,21 +64,15 @@ export class SatoriWsEndpoint {
53
64
  throw err;
54
65
  }
55
66
  }
56
- open() {
57
- this.#open = true;
58
- }
59
- close() {
60
- this.#open = false;
61
- }
62
67
  async stop() {
63
- this.#open = false;
68
+ this.close();
64
69
  // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
65
70
  await this.#lifecycle.stop();
66
71
  this.#ws = null;
67
72
  }
68
73
  async send({ conversation, payload }) {
69
74
  const content = formatSatoriOutbound(payload);
70
- const result = await this.#api('message', 'create', {
75
+ const result = await this.client.call('message', 'create', {
71
76
  channel_id: conversation.id,
72
77
  content,
73
78
  });
@@ -80,10 +85,7 @@ export class SatoriWsEndpoint {
80
85
  }));
81
86
  return msgId ? formatMessageId(conversation.id, msgId) : '';
82
87
  }
83
- /** Test / internal: admit a gateway event when the endpoint is open. */
84
- admit(body) {
85
- if (!this.#open)
86
- return;
88
+ #admitRaw(body) {
87
89
  if (body.login && !this.#login)
88
90
  this.#login = body.login;
89
91
  if (!isMessageEvent(body))
@@ -93,7 +95,7 @@ export class SatoriWsEndpoint {
93
95
  const sender = resolveInboundSender(body);
94
96
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
95
97
  const mentioned = isSelfMentioned(body, selfId);
96
- void this.#options.gateway.receive({
98
+ void this.emit('message.receive', {
97
99
  conversation,
98
100
  message: { conversation, id: body.message.id },
99
101
  content,
@@ -114,16 +116,19 @@ export class SatoriWsEndpoint {
114
116
  }));
115
117
  });
116
118
  }
117
- async recall(id) {
119
+ async recall(id, fallbackChannelId) {
118
120
  const { channelId, messageId } = parseMessageRef(id);
119
- await this.#api('message', 'delete', {
120
- channel_id: channelId,
121
+ await this.client.call('message', 'delete', {
122
+ channel_id: channelId || fallbackChannelId || '',
121
123
  message_id: messageId,
122
124
  });
123
125
  }
124
- /** Test helper: inject a READY login without a live socket. */
125
- setLogin(login) {
126
- 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
+ }));
127
132
  }
128
133
  async #connect(handle) {
129
134
  const { config } = this.#options;
@@ -206,11 +211,25 @@ export class SatoriWsEndpoint {
206
211
  if (signal.op === SatoriOpcode.PONG) {
207
212
  // 喂狗:复位基座看门狗计数
208
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
+ });
209
221
  return;
210
222
  }
211
223
  if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
212
224
  const logins = signal.body.logins;
213
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
+ });
214
233
  if (!this.#login?.platform || !this.#login?.user?.id) {
215
234
  this.#logger.warn(formatCompact({ op: 'ready', ok: false, error: 'missing platform/user' }));
216
235
  }
@@ -219,7 +238,7 @@ export class SatoriWsEndpoint {
219
238
  if (signal.op === SatoriOpcode.EVENT && signal.body) {
220
239
  if (typeof signal.body.sn === 'number')
221
240
  this.#lastSn = signal.body.sn;
222
- this.admit(signal.body);
241
+ this.client.ingest(signal.body);
223
242
  }
224
243
  }
225
244
  #sendSignal(op, body) {
@@ -235,30 +254,39 @@ export class SatoriWsEndpoint {
235
254
  token: this.#options.config.token,
236
255
  };
237
256
  }
238
- #api(resource, method, params) {
239
- const call = this.#options.callApi ?? callSatoriApi;
240
- return call(this.#apiOptions(), resource, method, params);
241
- }
242
257
  }
243
- export class SatoriWebhookEndpoint {
258
+ export class SatoriWebhookEndpoint extends ClientEndpoint {
259
+ client;
244
260
  #logger;
245
261
  #options;
246
262
  #login;
247
263
  #routeReleases = [];
248
- #open = false;
249
264
  #started = false;
250
- management = createSatoriEndpointManagement((resource, method, params) => this.#api(resource, method, params));
265
+ management;
266
+ control = Object.freeze({
267
+ recall: (message) => this.recall(message.id, message.conversation.id),
268
+ });
251
269
  constructor(options) {
270
+ super();
252
271
  this.#logger = getAdapterLogger('satori', options.config.id);
253
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));
254
279
  }
255
280
  /** Used by webhook handler. */
256
281
  get isOpen() {
257
- return this.#open;
282
+ return this.clientEventsOpen;
258
283
  }
259
284
  get config() {
260
285
  return this.#options.config;
261
286
  }
287
+ async acceptHttp(request, response) {
288
+ await this.client.acceptHttp(request, response);
289
+ }
262
290
  async start() {
263
291
  if (this.#started)
264
292
  return;
@@ -280,14 +308,8 @@ export class SatoriWebhookEndpoint {
280
308
  path: this.#options.config.path,
281
309
  }));
282
310
  }
283
- open() {
284
- this.#open = true;
285
- }
286
- close() {
287
- this.#open = false;
288
- }
289
311
  async stop() {
290
- this.#open = false;
312
+ this.close();
291
313
  for (const release of this.#routeReleases.splice(0))
292
314
  release();
293
315
  this.#started = false;
@@ -297,7 +319,7 @@ export class SatoriWebhookEndpoint {
297
319
  }
298
320
  async send({ conversation, payload }) {
299
321
  const content = formatSatoriOutbound(payload);
300
- const result = await this.#api('message', 'create', {
322
+ const result = await this.client.call('message', 'create', {
301
323
  channel_id: conversation.id,
302
324
  content,
303
325
  });
@@ -310,9 +332,7 @@ export class SatoriWebhookEndpoint {
310
332
  }));
311
333
  return msgId ? formatMessageId(conversation.id, msgId) : '';
312
334
  }
313
- admit(body) {
314
- if (!this.#open)
315
- return;
335
+ #admitRaw(body) {
316
336
  if (body.login && !this.#login)
317
337
  this.#login = body.login;
318
338
  if (!isMessageEvent(body))
@@ -322,7 +342,7 @@ export class SatoriWebhookEndpoint {
322
342
  const sender = resolveInboundSender(body);
323
343
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
324
344
  const mentioned = isSelfMentioned(body, selfId);
325
- void this.#options.gateway.receive({
345
+ void this.emit('message.receive', {
326
346
  conversation,
327
347
  message: { conversation, id: body.message.id },
328
348
  content,
@@ -343,16 +363,19 @@ export class SatoriWebhookEndpoint {
343
363
  }));
344
364
  });
345
365
  }
346
- async recall(id) {
366
+ async recall(id, fallbackChannelId) {
347
367
  const { channelId, messageId } = parseMessageRef(id);
348
- await this.#api('message', 'delete', {
349
- channel_id: channelId,
368
+ await this.client.call('message', 'delete', {
369
+ channel_id: channelId || fallbackChannelId || '',
350
370
  message_id: messageId,
351
371
  });
352
372
  }
353
- /** Test helper: inject login without a live webhook push. */
354
- setLogin(login) {
355
- 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
+ }));
356
379
  }
357
380
  #apiOptions() {
358
381
  return {
@@ -362,10 +385,6 @@ export class SatoriWebhookEndpoint {
362
385
  token: this.#options.config.token,
363
386
  };
364
387
  }
365
- #api(resource, method, params) {
366
- const call = this.#options.callApi ?? callSatoriApi;
367
- return call(this.#apiOptions(), resource, method, params);
368
- }
369
388
  }
370
389
  function isPrivateChannelType(body) {
371
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": "5.0.13",
3
+ "version": "6.0.1",
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.1.11",
43
- "@zhin.js/core": "1.5.12",
44
- "@zhin.js/host-http": "1.0.11",
44
+ "@zhin.js/adapter": "1.2.1",
45
+ "@zhin.js/core": "1.5.14",
46
+ "@zhin.js/feature-kit": "1.0.13",
47
+ "@zhin.js/host-http": "1.0.13",
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.11",
54
- "zhin.js": "6.0.12"
56
+ "@zhin.js/host-http": "1.0.13",
57
+ "zhin.js": "6.0.14"
55
58
  },
56
59
  "peerDependencies": {
57
- "@zhin.js/adapter": "1.1.11",
58
- "@zhin.js/command": "1.0.15",
59
- "@zhin.js/core": "1.5.12",
60
- "zhin.js": "6.0.12"
60
+ "@zhin.js/adapter": "1.2.1",
61
+ "@zhin.js/command": "1.0.16",
62
+ "@zhin.js/core": "1.5.14",
63
+ "zhin.js": "6.0.14"
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,17 +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,
10
+ type EndpointControl,
8
11
  type EndpointGroup,
9
- type EndpointInstance,
10
12
  type EndpointLifecycle,
11
13
  type EndpointManagement,
12
14
  type EndpointSendRequest,
13
15
  } from 'zhin.js/adapter';
14
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
15
16
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
16
17
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
17
18
  import type { CapabilityId } from 'zhin.js';
@@ -36,6 +37,7 @@ import {
36
37
  type SatoriSignal,
37
38
  } from './protocol.js';
38
39
  import { registerSatoriWebhookRoutes } from './webhook.js';
40
+ import { createSatoriEndpointClient, forwardSatoriClientEvents, type SatoriClient } from './client.js';
39
41
  import {
40
42
  WS_OPEN,
41
43
  defaultCreateWebSocket,
@@ -47,14 +49,13 @@ export type SatoriApiCaller = typeof callSatoriApi;
47
49
 
48
50
  export interface SatoriWsEndpointOptions {
49
51
  readonly id: CapabilityId;
50
- readonly gateway: MessageGateway;
51
- readonly sideEvents?: SideEventGateway;
52
52
  readonly config: ResolvedSatoriWsConfig;
53
53
  readonly createWebSocket?: CreateSatoriWebSocket;
54
54
  readonly callApi?: SatoriApiCaller;
55
55
  }
56
56
 
57
- export class SatoriWsEndpoint implements EndpointInstance {
57
+ export class SatoriWsEndpoint extends ClientEndpoint<SatoriClient> {
58
+ readonly client: SatoriClient;
58
59
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
59
60
 
60
61
  readonly #options: SatoriWsEndpointOptions;
@@ -62,14 +63,30 @@ export class SatoriWsEndpoint implements EndpointInstance {
62
63
  #ws: SatoriWsSocket | null = null;
63
64
  #login: SatoriLogin | undefined;
64
65
  #lastSn: number | undefined;
65
- #open = false;
66
- readonly management: EndpointManagement = createSatoriEndpointManagement(
67
- (resource, method, params) => this.#api(resource, method, params),
68
- );
66
+ readonly management: EndpointManagement;
67
+ readonly control: EndpointControl = Object.freeze<EndpointControl>({
68
+ recall: (message) => this.recall(message.id, message.conversation.id),
69
+ });
69
70
 
70
71
  constructor(options: SatoriWsEndpointOptions) {
72
+ super();
71
73
  this.#logger = getAdapterLogger('satori', options.config.id);
72
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
+ );
73
90
  const { config } = options;
74
91
  this.#lifecycle = createEndpointLifecycle({
75
92
  name: config.id,
@@ -105,16 +122,8 @@ export class SatoriWsEndpoint implements EndpointInstance {
105
122
  }
106
123
  }
107
124
 
108
- open(): void {
109
- this.#open = true;
110
- }
111
-
112
- close(): void {
113
- this.#open = false;
114
- }
115
-
116
125
  async stop(): Promise<void> {
117
- this.#open = false;
126
+ this.close();
118
127
  // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
119
128
  await this.#lifecycle.stop();
120
129
  this.#ws = null;
@@ -122,7 +131,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
122
131
 
123
132
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
124
133
  const content = formatSatoriOutbound(payload);
125
- const result = await this.#api('message', 'create', {
134
+ const result = await this.client.call('message', 'create', {
126
135
  channel_id: conversation.id,
127
136
  content,
128
137
  });
@@ -136,9 +145,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
136
145
  return msgId ? formatMessageId(conversation.id, msgId) : '';
137
146
  }
138
147
 
139
- /** Test / internal: admit a gateway event when the endpoint is open. */
140
- admit(body: SatoriEventBody): void {
141
- if (!this.#open) return;
148
+ #admitRaw(body: SatoriEventBody): void {
142
149
  if (body.login && !this.#login) this.#login = body.login;
143
150
  if (!isMessageEvent(body)) return;
144
151
  const conversation = satoriInboundConversation(String(this.#options.id), body);
@@ -146,7 +153,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
146
153
  const sender = resolveInboundSender(body);
147
154
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
148
155
  const mentioned = isSelfMentioned(body, selfId);
149
- void this.#options.gateway.receive({
156
+ void this.emit('message.receive', {
150
157
  conversation,
151
158
  message: { conversation, id: body.message.id },
152
159
  content,
@@ -168,17 +175,20 @@ export class SatoriWsEndpoint implements EndpointInstance {
168
175
  });
169
176
  }
170
177
 
171
- async recall(id: string): Promise<void> {
178
+ async recall(id: string, fallbackChannelId?: string): Promise<void> {
172
179
  const { channelId, messageId } = parseMessageRef(id);
173
- await this.#api('message', 'delete', {
174
- channel_id: channelId,
180
+ await this.client.call('message', 'delete', {
181
+ channel_id: channelId || fallbackChannelId || '',
175
182
  message_id: messageId,
176
183
  });
177
184
  }
178
185
 
179
- /** Test helper: inject a READY login without a live socket. */
180
- setLogin(login: SatoriLogin): void {
181
- 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
+ }));
182
192
  }
183
193
 
184
194
  async #connect(handle: EndpointConnectHandle): Promise<void> {
@@ -265,11 +275,25 @@ export class SatoriWsEndpoint implements EndpointInstance {
265
275
  if (signal.op === SatoriOpcode.PONG) {
266
276
  // 喂狗:复位基座看门狗计数
267
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
+ });
268
285
  return;
269
286
  }
270
287
  if (signal.op === SatoriOpcode.READY && signal.body?.logins) {
271
288
  const logins = signal.body.logins as SatoriLogin[];
272
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
+ });
273
297
  if (!this.#login?.platform || !this.#login?.user?.id) {
274
298
  this.#logger.warn(formatCompact({ op: 'ready', ok: false, error: 'missing platform/user' }));
275
299
  }
@@ -277,7 +301,7 @@ export class SatoriWsEndpoint implements EndpointInstance {
277
301
  }
278
302
  if (signal.op === SatoriOpcode.EVENT && signal.body) {
279
303
  if (typeof signal.body.sn === 'number') this.#lastSn = signal.body.sn;
280
- this.admit(signal.body as SatoriEventBody);
304
+ this.client.ingest(signal.body as Parameters<SatoriClient['ingest']>[0]);
281
305
  }
282
306
  }
283
307
 
@@ -295,51 +319,62 @@ export class SatoriWsEndpoint implements EndpointInstance {
295
319
  };
296
320
  }
297
321
 
298
- #api(
299
- resource: string,
300
- method: string,
301
- params: Record<string, unknown>,
302
- ): Promise<unknown> {
303
- const call = this.#options.callApi ?? callSatoriApi;
304
- return call(this.#apiOptions(), resource, method, params);
305
- }
306
322
  }
307
323
 
308
324
  export interface SatoriWebhookEndpointOptions {
309
325
  readonly id: CapabilityId;
310
- readonly gateway: MessageGateway;
311
- readonly sideEvents?: SideEventGateway;
312
326
  readonly http: HttpHost;
313
327
  readonly config: ResolvedSatoriWebhookConfig;
314
328
  readonly callApi?: SatoriApiCaller;
315
329
  }
316
330
 
317
- export class SatoriWebhookEndpoint implements EndpointInstance {
331
+ export class SatoriWebhookEndpoint extends ClientEndpoint<SatoriClient> {
332
+ readonly client: SatoriClient;
318
333
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
319
334
 
320
335
  readonly #options: SatoriWebhookEndpointOptions;
321
336
  #login: SatoriLogin | undefined;
322
337
  #routeReleases: HttpRouteRegistration[] = [];
323
- #open = false;
324
338
  #started = false;
325
- readonly management: EndpointManagement = createSatoriEndpointManagement(
326
- (resource, method, params) => this.#api(resource, method, params),
327
- );
339
+ readonly management: EndpointManagement;
340
+ readonly control: EndpointControl = Object.freeze<EndpointControl>({
341
+ recall: (message) => this.recall(message.id, message.conversation.id),
342
+ });
328
343
 
329
344
  constructor(options: SatoriWebhookEndpointOptions) {
345
+ super();
330
346
  this.#logger = getAdapterLogger('satori', options.config.id);
331
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
+ );
332
363
  }
333
364
 
334
365
  /** Used by webhook handler. */
335
366
  get isOpen(): boolean {
336
- return this.#open;
367
+ return this.clientEventsOpen;
337
368
  }
338
369
 
339
370
  get config(): ResolvedSatoriWebhookConfig {
340
371
  return this.#options.config;
341
372
  }
342
373
 
374
+ async acceptHttp(request: IncomingMessage, response: ServerResponse): Promise<void> {
375
+ await this.client.acceptHttp(request, response);
376
+ }
377
+
343
378
  async start(): Promise<void> {
344
379
  if (this.#started) return;
345
380
  this.#started = true;
@@ -361,16 +396,8 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
361
396
  }));
362
397
  }
363
398
 
364
- open(): void {
365
- this.#open = true;
366
- }
367
-
368
- close(): void {
369
- this.#open = false;
370
- }
371
-
372
399
  async stop(): Promise<void> {
373
- this.#open = false;
400
+ this.close();
374
401
  for (const release of this.#routeReleases.splice(0)) release();
375
402
  this.#started = false;
376
403
  this.#logger.debug(formatCompact({
@@ -380,7 +407,7 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
380
407
 
381
408
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
382
409
  const content = formatSatoriOutbound(payload);
383
- const result = await this.#api('message', 'create', {
410
+ const result = await this.client.call('message', 'create', {
384
411
  channel_id: conversation.id,
385
412
  content,
386
413
  });
@@ -394,8 +421,7 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
394
421
  return msgId ? formatMessageId(conversation.id, msgId) : '';
395
422
  }
396
423
 
397
- admit(body: SatoriEventBody): void {
398
- if (!this.#open) return;
424
+ #admitRaw(body: SatoriEventBody): void {
399
425
  if (body.login && !this.#login) this.#login = body.login;
400
426
  if (!isMessageEvent(body)) return;
401
427
  const conversation = satoriInboundConversation(String(this.#options.id), body);
@@ -403,7 +429,7 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
403
429
  const sender = resolveInboundSender(body);
404
430
  const selfId = this.#login?.user?.id ?? body.login?.user?.id;
405
431
  const mentioned = isSelfMentioned(body, selfId);
406
- void this.#options.gateway.receive({
432
+ void this.emit('message.receive', {
407
433
  conversation,
408
434
  message: { conversation, id: body.message.id },
409
435
  content,
@@ -425,17 +451,20 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
425
451
  });
426
452
  }
427
453
 
428
- async recall(id: string): Promise<void> {
454
+ async recall(id: string, fallbackChannelId?: string): Promise<void> {
429
455
  const { channelId, messageId } = parseMessageRef(id);
430
- await this.#api('message', 'delete', {
431
- channel_id: channelId,
456
+ await this.client.call('message', 'delete', {
457
+ channel_id: channelId || fallbackChannelId || '',
432
458
  message_id: messageId,
433
459
  });
434
460
  }
435
461
 
436
- /** Test helper: inject login without a live webhook push. */
437
- setLogin(login: SatoriLogin): void {
438
- 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
+ }));
439
468
  }
440
469
 
441
470
  #apiOptions(): SatoriApiOptions {
@@ -447,14 +476,6 @@ export class SatoriWebhookEndpoint implements EndpointInstance {
447
476
  };
448
477
  }
449
478
 
450
- #api(
451
- resource: string,
452
- method: string,
453
- params: Record<string, unknown>,
454
- ): Promise<unknown> {
455
- const call = this.#options.callApi ?? callSatoriApi;
456
- return call(this.#apiOptions(), resource, method, params);
457
- }
458
479
  }
459
480
 
460
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
- }