@zhin.js/adapter-satori 6.0.0 → 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 +20 -0
- package/README.md +20 -1
- package/adapters/satori.js +0 -7
- package/adapters/satori.ts +0 -7
- package/lib/client.d.ts +18 -0
- package/lib/client.js +21 -0
- package/lib/endpoint.d.ts +9 -20
- package/lib/endpoint.js +63 -50
- package/lib/index.d.ts +5 -1
- package/lib/index.js +3 -1
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +1 -1
- package/lib/satori-endpoint-commands.d.ts +1 -1
- package/lib/webhook.d.ts +2 -4
- package/lib/webhook.js +5 -30
- package/package.json +14 -11
- package/src/client.ts +57 -0
- package/src/endpoint.ts +83 -69
- package/src/index.ts +17 -1
- package/src/protocol.ts +1 -1
- package/src/webhook.ts +6 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
3
23
|
## 6.0.0
|
|
4
24
|
|
|
5
25
|
### 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` — `
|
|
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 |
|
package/adapters/satori.js
CHANGED
|
@@ -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
|
},
|
package/adapters/satori.ts
CHANGED
|
@@ -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
|
},
|
package/lib/client.d.ts
ADDED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
121
|
+
await this.client.call('message', 'delete', {
|
|
123
122
|
channel_id: channelId || fallbackChannelId || '',
|
|
124
123
|
message_id: messageId,
|
|
125
124
|
});
|
|
126
125
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
368
|
+
await this.client.call('message', 'delete', {
|
|
355
369
|
channel_id: channelId || fallbackChannelId || '',
|
|
356
370
|
message_id: messageId,
|
|
357
371
|
});
|
|
358
372
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
3
|
+
import { type ResolvedSatoriWebhookConfig } from './protocol.js';
|
|
4
4
|
export interface SatoriWebhookHandler {
|
|
5
5
|
readonly config: ResolvedSatoriWebhookConfig;
|
|
6
6
|
readonly isOpen: boolean;
|
|
7
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
body = JSON.parse(raw);
|
|
29
|
+
if (handler.isOpen) {
|
|
30
|
+
await handler.acceptHttp(request, response);
|
|
33
31
|
}
|
|
34
|
-
|
|
35
|
-
response.writeHead(
|
|
36
|
-
response.end(JSON.stringify({
|
|
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.
|
|
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.2.
|
|
43
|
-
"@zhin.js/core": "1.5.
|
|
44
|
-
"@zhin.js/
|
|
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.
|
|
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.
|
|
54
|
-
"zhin.js": "6.0.
|
|
56
|
+
"@zhin.js/host-http": "1.0.13",
|
|
57
|
+
"zhin.js": "6.0.14"
|
|
55
58
|
},
|
|
56
59
|
"peerDependencies": {
|
|
57
|
-
"@zhin.js/adapter": "1.2.
|
|
58
|
-
"@zhin.js/command": "1.0.
|
|
59
|
-
"@zhin.js/core": "1.5.
|
|
60
|
-
"zhin.js": "6.0.
|
|
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,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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
#warnPlatformEvent(name: string, error: unknown): void {
|
|
187
|
+
this.#logger.warn(formatCompact({
|
|
188
|
+
op: 'satori_platform_event_failed',
|
|
189
|
+
event: name,
|
|
190
|
+
error: error instanceof Error ? error.message : String(error),
|
|
191
|
+
}));
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
}
|