@zhin.js/adapter-satori 3.0.2 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +440 -0
  2. package/README.md +31 -36
  3. package/adapters/satori.ts +41 -0
  4. package/lib/endpoint.d.ts +61 -0
  5. package/lib/endpoint.js +379 -0
  6. package/lib/index.d.ts +4 -18
  7. package/lib/index.js +4 -25
  8. package/lib/protocol.d.ts +150 -0
  9. package/lib/protocol.js +196 -0
  10. package/lib/webhook.d.ts +17 -0
  11. package/lib/webhook.js +79 -0
  12. package/lib/ws.d.ts +13 -0
  13. package/lib/ws.js +5 -0
  14. package/package.json +41 -15
  15. package/plugin.ts +8 -0
  16. package/schema.json +35 -0
  17. package/src/endpoint.ts +462 -0
  18. package/src/index.ts +47 -35
  19. package/src/protocol.ts +315 -0
  20. package/src/webhook.ts +104 -0
  21. package/src/ws.ts +22 -0
  22. package/lib/adapter.d.ts +0 -19
  23. package/lib/adapter.d.ts.map +0 -1
  24. package/lib/adapter.js +0 -37
  25. package/lib/adapter.js.map +0 -1
  26. package/lib/api.d.ts +0 -15
  27. package/lib/api.d.ts.map +0 -1
  28. package/lib/api.js +0 -37
  29. package/lib/api.js.map +0 -1
  30. package/lib/endpoint-webhook.d.ts +0 -27
  31. package/lib/endpoint-webhook.d.ts.map +0 -1
  32. package/lib/endpoint-webhook.js +0 -99
  33. package/lib/endpoint-webhook.js.map +0 -1
  34. package/lib/endpoint-ws.d.ts +0 -30
  35. package/lib/endpoint-ws.d.ts.map +0 -1
  36. package/lib/endpoint-ws.js +0 -195
  37. package/lib/endpoint-ws.js.map +0 -1
  38. package/lib/index.d.ts.map +0 -1
  39. package/lib/index.js.map +0 -1
  40. package/lib/segment-mapper.d.ts +0 -2
  41. package/lib/segment-mapper.d.ts.map +0 -1
  42. package/lib/segment-mapper.js +0 -2
  43. package/lib/segment-mapper.js.map +0 -1
  44. package/lib/types.d.ts +0 -91
  45. package/lib/types.d.ts.map +0 -1
  46. package/lib/types.js +0 -13
  47. package/lib/types.js.map +0 -1
  48. package/lib/utils.d.ts +0 -40
  49. package/lib/utils.d.ts.map +0 -1
  50. package/lib/utils.js +0 -34
  51. package/lib/utils.js.map +0 -1
  52. package/src/adapter.ts +0 -45
  53. package/src/api.ts +0 -48
  54. package/src/endpoint-webhook.ts +0 -117
  55. package/src/endpoint-ws.ts +0 -214
  56. package/src/segment-mapper.ts +0 -1
  57. package/src/types.ts +0 -91
  58. package/src/utils.ts +0 -65
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Satori protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ * Spec: https://satori.chat/zh-CN/protocol/overview.html
5
+ */
6
+ import { pickCredential } from '@zhin.js/adapter';
7
+ /** Opcode:EVENT=0, PING=1, PONG=2, IDENTIFY=3, READY=4, META=5 */
8
+ export const SatoriOpcode = {
9
+ EVENT: 0,
10
+ PING: 1,
11
+ PONG: 2,
12
+ IDENTIFY: 3,
13
+ READY: 4,
14
+ META: 5,
15
+ };
16
+ export function resolveSatoriConfig(config = {}) {
17
+ const entry = config.endpoints?.find((item) => item.context === 'satori');
18
+ const connection = config.connection ?? entry?.connection ?? 'ws';
19
+ const baseUrl = pickCredential(config.baseUrl, entry?.baseUrl, process.env.SATORI_BASE_URL);
20
+ if (!baseUrl) {
21
+ throw new TypeError('Satori adapter requires baseUrl (plugins.<key>.baseUrl or endpoints with context: satori)');
22
+ }
23
+ const name = (typeof config.name === 'string' && config.name)
24
+ || (typeof entry?.name === 'string' && entry.name)
25
+ || process.env.SATORI_BOT_NAME
26
+ || 'satori-bot';
27
+ const token = (typeof config.token === 'string' && config.token)
28
+ || (typeof entry?.token === 'string' && entry.token)
29
+ || process.env.SATORI_TOKEN
30
+ || undefined;
31
+ if (connection === 'webhook') {
32
+ const path = config.path ?? entry?.path;
33
+ if (!path) {
34
+ throw new TypeError('Satori connection:webhook requires path');
35
+ }
36
+ return {
37
+ context: 'satori',
38
+ connection: 'webhook',
39
+ name,
40
+ baseUrl,
41
+ token,
42
+ path,
43
+ };
44
+ }
45
+ const heartbeat = config.heartbeat_interval
46
+ ?? entry?.heartbeat_interval
47
+ ?? 10_000;
48
+ return {
49
+ context: 'satori',
50
+ connection: 'ws',
51
+ name,
52
+ baseUrl,
53
+ token,
54
+ heartbeat_interval: heartbeat,
55
+ };
56
+ }
57
+ /** Channel.type 1 = DIRECT (private). */
58
+ export function isPrivateChannel(channel) {
59
+ return channel?.type === 1;
60
+ }
61
+ export function isMessageEvent(body) {
62
+ return (body.type === 'message-created' || body.type === 'message-updated')
63
+ && !!body.message?.id;
64
+ }
65
+ export function buildWsUrl(baseUrl, token) {
66
+ const url = new URL(baseUrl.replace(/\/$/, ''));
67
+ if (token)
68
+ url.searchParams.set('access_token', token);
69
+ return url.toString();
70
+ }
71
+ /**
72
+ * Call Satori HTTP API: POST {baseUrl}/v1/{resource}.{method}
73
+ * @see https://satori.chat/en-US/protocol/api.html
74
+ */
75
+ export async function callSatoriApi(options, resource, method, params = {}) {
76
+ const { baseUrl, platform, userId, token } = options;
77
+ const url = `${baseUrl.replace(/\/$/, '')}/v1/${resource}.${method}`;
78
+ const headers = {
79
+ 'Content-Type': 'application/json',
80
+ 'Satori-Platform': platform,
81
+ 'Satori-User-ID': userId,
82
+ };
83
+ if (token)
84
+ headers.Authorization = `Bearer ${token}`;
85
+ const res = await fetch(url, {
86
+ method: 'POST',
87
+ headers,
88
+ body: JSON.stringify(params),
89
+ });
90
+ const text = await res.text();
91
+ if (res.status === 401)
92
+ throw new Error(`Satori API 认证失败: ${text}`);
93
+ if (res.status === 403)
94
+ throw new Error(`Satori API 权限不足: ${text}`);
95
+ if (res.status === 404)
96
+ throw new Error(`Satori API 不存在: ${resource}.${method}`);
97
+ if (res.status >= 400)
98
+ throw new Error(`Satori API 错误 ${res.status}: ${text}`);
99
+ if (!text || text.trim() === '')
100
+ return undefined;
101
+ try {
102
+ return JSON.parse(text);
103
+ }
104
+ catch {
105
+ throw new Error(`Satori API 无效 JSON: ${text.slice(0, 200)}`);
106
+ }
107
+ }
108
+ /** Build inbound text for MessageGateway.receive. */
109
+ export function formatInboundContent(body) {
110
+ const content = body.message.content ?? '';
111
+ return typeof content === 'string' ? content : String(content);
112
+ }
113
+ export function resolveInboundTarget(body) {
114
+ const channel = body.channel ?? body.message.channel;
115
+ return channel?.id ?? '';
116
+ }
117
+ export function resolveInboundSender(body) {
118
+ const user = body.user ?? body.message.user ?? body.message.member?.user;
119
+ return user?.name ?? body.message.member?.nick ?? user?.id ?? '';
120
+ }
121
+ /**
122
+ * Detect `<at id="…"/>` elements in message content targeting the bot selfId.
123
+ * selfId 来源:READY/事件携带的 `login.user.id`。
124
+ */
125
+ export function isSelfMentioned(body, selfId) {
126
+ if (!selfId)
127
+ return false;
128
+ const content = body.message.content;
129
+ if (typeof content !== 'string' || !content.includes('<at'))
130
+ return false;
131
+ const tags = content.match(/<at\b[^>]*>/gi) ?? [];
132
+ return tags.some((tag) => /\bid\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1] === selfId);
133
+ }
134
+ export function formatMessageId(channelId, messageId) {
135
+ return `${channelId}:${messageId}`;
136
+ }
137
+ export function parseMessageRef(id) {
138
+ if (id.includes(':')) {
139
+ const [channelId, messageId] = id.split(':');
140
+ return { channelId: channelId ?? '', messageId: messageId ?? '' };
141
+ }
142
+ return { channelId: '', messageId: id };
143
+ }
144
+ /**
145
+ * Wire-encode an already-rendered outbound payload into Satori message content.
146
+ * Segment canonicalization is intentionally not done here.
147
+ */
148
+ export function formatSatoriOutbound(payload) {
149
+ if (typeof payload === 'string')
150
+ return payload;
151
+ if (payload == null)
152
+ return '';
153
+ const segments = Array.isArray(payload)
154
+ ? payload
155
+ : payload && typeof payload === 'object' && 'type' in payload
156
+ ? [payload]
157
+ : [];
158
+ if (segments.length === 0) {
159
+ return typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
160
+ }
161
+ const parts = [];
162
+ for (const item of segments) {
163
+ if (typeof item === 'string') {
164
+ parts.push(item);
165
+ continue;
166
+ }
167
+ const data = item.data ?? {};
168
+ switch (item.type) {
169
+ case 'text':
170
+ parts.push(String(data.text ?? data.content ?? ''));
171
+ break;
172
+ case 'at':
173
+ case 'mention':
174
+ parts.push(`@${String(data.name ?? data.id ?? data.target ?? '')}`);
175
+ break;
176
+ case 'image':
177
+ if (typeof data.url === 'string')
178
+ parts.push(`[image:${data.url}]`);
179
+ break;
180
+ case 'file':
181
+ if (typeof data.url === 'string')
182
+ parts.push(`[file:${data.url}]`);
183
+ break;
184
+ default:
185
+ break;
186
+ }
187
+ }
188
+ return parts.join('');
189
+ }
190
+ export function extractCreatedMessageId(result) {
191
+ const list = Array.isArray(result)
192
+ ? result
193
+ : result?.data;
194
+ const msg = list?.[0];
195
+ return msg?.id ?? '';
196
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Satori webhook HTTP: token → opcode → parse → admit.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { type ResolvedSatoriWebhookConfig, type SatoriEventBody, type SatoriLogin } from './protocol.js';
7
+ export interface SatoriWebhookHandler {
8
+ readonly config: ResolvedSatoriWebhookConfig;
9
+ readonly isOpen: boolean;
10
+ admit(body: SatoriEventBody): void;
11
+ setLogin(login: SatoriLogin): void;
12
+ }
13
+ export declare function registerSatoriWebhookRoutes(http: HttpHost, handler: SatoriWebhookHandler): HttpRouteRegistration[];
14
+ export declare function handleSatoriWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: SatoriWebhookHandler): Promise<void>;
15
+ export declare function resolveSatoriOpcode(request: IncomingMessage): number | undefined;
16
+ export declare function verifySatoriToken(token: string | undefined, request: IncomingMessage): boolean;
17
+ export declare function readRequestBody(request: IncomingMessage): Promise<string>;
package/lib/webhook.js ADDED
@@ -0,0 +1,79 @@
1
+ import { getLogger } from '@zhin.js/logger';
2
+ import { SatoriOpcode, } from './protocol.js';
3
+ const logger = getLogger('satori');
4
+ export function registerSatoriWebhookRoutes(http, handler) {
5
+ const path = handler.config.path;
6
+ return [
7
+ http.route('POST', path, async (request, response) => {
8
+ await handleSatoriWebhookRequest(request, response, handler);
9
+ }, { summary: 'Satori webhook callback', tags: ['satori'] }),
10
+ ];
11
+ }
12
+ export async function handleSatoriWebhookRequest(request, response, handler) {
13
+ try {
14
+ if (!verifySatoriToken(handler.config.token, request)) {
15
+ response.writeHead(403, { 'Content-Type': 'application/json' });
16
+ response.end(JSON.stringify({ message: 'Unauthorized' }));
17
+ return;
18
+ }
19
+ const opcode = resolveSatoriOpcode(request);
20
+ if (opcode !== SatoriOpcode.EVENT && opcode !== SatoriOpcode.META) {
21
+ response.writeHead(200, { 'Content-Type': 'application/json' });
22
+ response.end(JSON.stringify({ message: 'OK' }));
23
+ return;
24
+ }
25
+ const raw = await readRequestBody(request);
26
+ let body;
27
+ try {
28
+ body = JSON.parse(raw);
29
+ }
30
+ catch {
31
+ response.writeHead(400, { 'Content-Type': 'application/json' });
32
+ response.end(JSON.stringify({ message: 'Invalid JSON' }));
33
+ return;
34
+ }
35
+ if (opcode === SatoriOpcode.EVENT && handler.isOpen) {
36
+ handler.admit(body);
37
+ }
38
+ else if (opcode === SatoriOpcode.META && body.login && handler.isOpen) {
39
+ handler.setLogin(body.login);
40
+ }
41
+ response.writeHead(200, { 'Content-Type': 'application/json' });
42
+ response.end(JSON.stringify({ message: 'OK' }));
43
+ }
44
+ catch (error) {
45
+ logger.error('Satori webhook error:', error);
46
+ if (!response.headersSent) {
47
+ response.writeHead(500, { 'Content-Type': 'application/json' });
48
+ response.end(JSON.stringify({ message: 'Internal Server Error' }));
49
+ }
50
+ }
51
+ }
52
+ export function resolveSatoriOpcode(request) {
53
+ const raw = request.headers['satori-opcode'] ?? request.headers['Satori-Opcode'];
54
+ const value = Array.isArray(raw) ? raw[0] : raw;
55
+ if (value == null || value === '')
56
+ return undefined;
57
+ const parsed = Number.parseInt(String(value), 10);
58
+ return Number.isNaN(parsed) ? undefined : parsed;
59
+ }
60
+ export function verifySatoriToken(token, request) {
61
+ if (!token)
62
+ return true;
63
+ const auth = request.headers.authorization ?? '';
64
+ return auth === `Bearer ${token}`;
65
+ }
66
+ export async function readRequestBody(request) {
67
+ const chunks = [];
68
+ let size = 0;
69
+ for await (const chunk of request) {
70
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
71
+ size += buffer.length;
72
+ if (size > 1_048_576) {
73
+ request.destroy();
74
+ throw new Error('Request body exceeds 1MB');
75
+ }
76
+ chunks.push(buffer);
77
+ }
78
+ return Buffer.concat(chunks).toString('utf8');
79
+ }
package/lib/ws.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export declare const WS_OPEN = 1;
2
+ export interface SatoriWsSocket {
3
+ readonly readyState: number;
4
+ send(data: string): void;
5
+ close(code?: number, reason?: string): void;
6
+ on(event: 'open' | 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
7
+ }
8
+ export type CreateSatoriWebSocket = (url: string, options?: {
9
+ readonly headers?: Record<string, string>;
10
+ }) => SatoriWsSocket;
11
+ export declare function defaultCreateWebSocket(url: string, options?: {
12
+ readonly headers?: Record<string, string>;
13
+ }): SatoriWsSocket;
package/lib/ws.js ADDED
@@ -0,0 +1,5 @@
1
+ import WebSocket from 'ws';
2
+ export const WS_OPEN = 1;
3
+ export function defaultCreateWebSocket(url, options) {
4
+ return new WebSocket(url, { headers: options?.headers });
5
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-satori",
3
- "version": "3.0.2",
4
- "description": "Zhin.js adapter for Satori protocol",
3
+ "version": "3.0.3",
4
+ "description": "Zhin.js Satori adapter for Plugin Runtime (WebSocket client)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -13,12 +13,14 @@
13
13
  }
14
14
  },
15
15
  "files": [
16
+ "adapters",
17
+ "plugin.ts",
18
+ "schema.json",
16
19
  "src",
17
20
  "lib",
18
- "client",
19
- "dist",
20
21
  "agent",
21
- "README.md"
22
+ "README.md",
23
+ "CHANGELOG.md"
22
24
  ],
23
25
  "keywords": [
24
26
  "zhin",
@@ -35,23 +37,28 @@
35
37
  "directory": "plugins/adapters/satori"
36
38
  },
37
39
  "dependencies": {
38
- "ws": "^8.21.0"
40
+ "ws": "^8.21.0",
41
+ "@zhin.js/adapter": "1.0.1",
42
+ "@zhin.js/core": "1.3.5",
43
+ "@zhin.js/host-http": "1.0.1",
44
+ "@zhin.js/logger": "1.0.75",
45
+ "@zhin.js/plugin-runtime": "1.0.1"
39
46
  },
40
47
  "devDependencies": {
41
48
  "@types/node": "^26.1.0",
42
49
  "@types/ws": "^8.18.1",
43
50
  "typescript": "^6.0.3",
44
- "@zhin.js/cli": "1.0.93",
45
- "@zhin.js/client": "2.0.5",
46
- "@zhin.js/host-router": "2.0.3",
47
- "zhin.js": "4.1.2"
51
+ "vitest": "^4.1.10",
52
+ "@zhin.js/host-http": "1.0.1"
48
53
  },
49
54
  "peerDependencies": {
50
- "zhin.js": "4.1.2",
51
- "@zhin.js/host-router": "2.0.3"
55
+ "@zhin.js/adapter": "1.0.1",
56
+ "@zhin.js/core": "1.3.5",
57
+ "@zhin.js/plugin-runtime": "1.0.1",
58
+ "zhin.js": "4.1.3"
52
59
  },
53
60
  "peerDependenciesMeta": {
54
- "@zhin.js/host-router": {
61
+ "zhin.js": {
55
62
  "optional": true
56
63
  }
57
64
  },
@@ -63,8 +70,27 @@
63
70
  "engines": {
64
71
  "node": "^20.19.0 || >=22.12.0"
65
72
  },
73
+ "publishConfig": {
74
+ "access": "public",
75
+ "registry": "https://registry.npmjs.org"
76
+ },
77
+ "zhin": {
78
+ "protocol": 1,
79
+ "type": "plugin",
80
+ "entry": "./plugin.ts",
81
+ "engine": "^1.0.0",
82
+ "runtime": "trusted",
83
+ "features": [
84
+ {
85
+ "package": "@zhin.js/adapter",
86
+ "api": "^1.0.0"
87
+ }
88
+ ],
89
+ "plugins": []
90
+ },
66
91
  "scripts": {
67
- "build": "zhin build",
68
- "clean": "rimraf lib"
92
+ "build": "tsc",
93
+ "clean": "rimraf lib",
94
+ "test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/satori/tests"
69
95
  }
70
96
  }
package/plugin.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { definePlugin } from '@zhin.js/plugin-runtime';
2
+
3
+ export default definePlugin({
4
+ name: 'satori',
5
+ metadata: {
6
+ displayName: 'Satori Adapter',
7
+ },
8
+ });
package/schema.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "name": {
7
+ "type": "string",
8
+ "default": "satori-bot"
9
+ },
10
+ "connection": {
11
+ "type": "string",
12
+ "enum": ["ws", "webhook"],
13
+ "default": "ws",
14
+ "description": "ws (default) or webhook (httpHostToken POST route)"
15
+ },
16
+ "baseUrl": {
17
+ "type": "string",
18
+ "description": "Satori SDK HTTP/WS base URL (e.g. http://127.0.0.1:5140)"
19
+ },
20
+ "token": {
21
+ "type": "string",
22
+ "description": "Bearer token for API and WS IDENTIFY"
23
+ },
24
+ "heartbeat_interval": {
25
+ "type": "number",
26
+ "default": 10000,
27
+ "description": "WS PING interval in milliseconds"
28
+ },
29
+ "path": {
30
+ "type": "string",
31
+ "description": "Webhook POST path (connection: webhook)"
32
+ }
33
+ },
34
+ "required": ["baseUrl"]
35
+ }