@zhin.js/adapter 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/lib/adapter-index.d.ts +6 -1
- package/lib/adapter-index.js +9 -1
- package/lib/definition.d.ts +40 -0
- package/lib/definition.js +42 -0
- package/lib/endpoint-commands.d.ts +127 -0
- package/lib/endpoint-commands.js +385 -0
- package/lib/endpoint-lifecycle.d.ts +68 -0
- package/lib/endpoint-lifecycle.js +333 -0
- package/lib/endpoint-management.d.ts +4 -2
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- package/lib/provider.js +1 -0
- package/package.json +6 -4
- package/src/adapter-index.ts +13 -1
- package/src/definition.ts +104 -0
- package/src/endpoint-commands.ts +559 -0
- package/src/endpoint-lifecycle.ts +422 -0
- package/src/endpoint-management.ts +4 -2
- package/src/index.ts +4 -0
- package/src/provider.ts +1 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EndpointLifecycle — WS/SSE 长连接端点生命周期基座。
|
|
3
|
+
*
|
|
4
|
+
* 把 napcat / milky / onebot11·12 各自重复实现(且重复犯错)的状态机收敛为一处:
|
|
5
|
+
*
|
|
6
|
+
* 状态机:idle → connecting → open → reconnecting → open … → stopped / closed
|
|
7
|
+
*
|
|
8
|
+
* - start(connectFn):连接失败自动复位回 idle 且不武装重连(start 的 catch 语义);
|
|
9
|
+
* stop-during-connect 竞态时 start() 静默 settle(视为主动停止,不抛错)。
|
|
10
|
+
* - stop():主动断开,清全部定时器、调用已注册的强关函数、唤醒所有竞态等待,绝不重连。
|
|
11
|
+
* - handle.notifyClosed():对端断开(ws close / SSE 流结束)时由适配器调用;
|
|
12
|
+
* 仅在连接曾 open 时才按指数退避 + jitter 武装重连,初始连接失败不武装。
|
|
13
|
+
* - startHeartbeat(fn, interval):心跳 + 看门狗——连续 N 轮无回包(notifyHeartbeatAck
|
|
14
|
+
* 未复位计数)时主动调用 onForceClose 注册的强关函数,由底层 close 事件驱动重连。
|
|
15
|
+
* - 定时器集中管理:重连 timer 与心跳 timer 均在 close / stop / 看门狗触发时清理。
|
|
16
|
+
*
|
|
17
|
+
* 防叠套:重连循环单例(#reconnectRunning),且每次 connect 尝试递增 generation,
|
|
18
|
+
* 陈旧连接句柄的 notifyClosed / onForceClose 一律忽略。
|
|
19
|
+
*
|
|
20
|
+
* 迁移指引(以 napcat/milky/onebot WS endpoint 为例):
|
|
21
|
+
* 1. 删除 #started / #stopping / #reconnectTimer / #heartbeatTimer / opened 旗标,
|
|
22
|
+
* 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.name, reconnect, heartbeat })`。
|
|
23
|
+
* 2. `start()` 改为:
|
|
24
|
+
* ```ts
|
|
25
|
+
* this.#unregisterAgent = registerXxxAgentEndpoint(name, this); // agent 注册仍在适配器侧
|
|
26
|
+
* try {
|
|
27
|
+
* await this.#lifecycle.start(async (handle) => {
|
|
28
|
+
* this.#handle = handle; // 供 ws 'close' 回调引用
|
|
29
|
+
* await new Promise<void>((resolve, reject) => {
|
|
30
|
+
* const ws = createWebSocket(...); this.#ws = ws;
|
|
31
|
+
* ws.on('open', () => { this.#lifecycle.startHeartbeat(() => beat(), interval); resolve(); });
|
|
32
|
+
* ws.on('close', (code, reason) => { handle.notifyClosed(...); rejectIfNotSettled(...); });
|
|
33
|
+
* ws.on('error', (err) => rejectIfNotSettled(err));
|
|
34
|
+
* });
|
|
35
|
+
* });
|
|
36
|
+
* } catch (err) {
|
|
37
|
+
* this.#unregisterAgent?.(); this.#unregisterAgent = undefined; // 反注册对称
|
|
38
|
+
* throw err;
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
* start 失败复位由基座保证;agent 注册/反注册是适配器专有依赖,刻意不收入基座。
|
|
42
|
+
* 3. `stop()` 改为:先 `await this.#lifecycle.stop()`(清定时器 + 强关 ws + 竞态 settle),
|
|
43
|
+
* 再做适配器专有清理(rejectAllPending、deduper.clear、agent 反注册)。
|
|
44
|
+
* 4. `handle.onForceClose(() => this.#ws?.close())` 在每次拿到新 socket 后注册,
|
|
45
|
+
* 供心跳看门狗主动断开;ws 'message'/'pong' 回调里调 `notifyHeartbeatAck()` 喂狗。
|
|
46
|
+
* 5. 退避参数由配置映射:reconnect_interval → initialIntervalMs,可按需覆盖
|
|
47
|
+
* multiplier / maxIntervalMs / jitterMs / maxAttempts;重连成功后退避自动复位。
|
|
48
|
+
*/
|
|
49
|
+
// 使用全局定时器(而非 node:timers 导入):vitest fake timers 只接管全局绑定,
|
|
50
|
+
// 这样单测可用 vi.useFakeTimers 驱动退避/心跳。
|
|
51
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
52
|
+
const logger = getLogger('adapter');
|
|
53
|
+
const DEFAULT_RECONNECT = {
|
|
54
|
+
initialIntervalMs: 5_000,
|
|
55
|
+
multiplier: 2,
|
|
56
|
+
maxIntervalMs: 60_000,
|
|
57
|
+
jitterMs: 250,
|
|
58
|
+
maxAttempts: Number.POSITIVE_INFINITY,
|
|
59
|
+
};
|
|
60
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
61
|
+
class EndpointLifecycleImpl {
|
|
62
|
+
#name;
|
|
63
|
+
#reconnect;
|
|
64
|
+
#heartbeat;
|
|
65
|
+
#random;
|
|
66
|
+
#state = 'idle';
|
|
67
|
+
#connect;
|
|
68
|
+
/** 每次 connect 尝试 +1,识别陈旧句柄。 */
|
|
69
|
+
#generation = 0;
|
|
70
|
+
/** 连续重连失败计数,open 成功后复位。 */
|
|
71
|
+
#attempt = 0;
|
|
72
|
+
/** 重连循环单例旗标(防叠套)。 */
|
|
73
|
+
#reconnectRunning = false;
|
|
74
|
+
#reconnectTimer;
|
|
75
|
+
#reconnectWake;
|
|
76
|
+
#heartbeatTimer;
|
|
77
|
+
#heartbeatMisses = 0;
|
|
78
|
+
#forceClose;
|
|
79
|
+
/** stop() 时唤醒的竞态等待(start / 重连中的 connect 尝试)。 */
|
|
80
|
+
#stopWaiters = [];
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.#name = options.name;
|
|
83
|
+
this.#reconnect = options.reconnect === false
|
|
84
|
+
? false
|
|
85
|
+
: { ...DEFAULT_RECONNECT, ...options.reconnect };
|
|
86
|
+
this.#heartbeat = {
|
|
87
|
+
intervalMs: options.heartbeat?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
88
|
+
watchdogMisses: options.heartbeat?.watchdogMisses ?? 0,
|
|
89
|
+
};
|
|
90
|
+
this.#random = options.random ?? Math.random;
|
|
91
|
+
}
|
|
92
|
+
get state() {
|
|
93
|
+
return this.#state;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 并发安全的状态读取:stop() 可能在任意 await 点并发改写 #state,
|
|
97
|
+
* 经方法调用读取可避免 TS 对字段/getter 的控制流窄化误判(TS2367)。
|
|
98
|
+
*/
|
|
99
|
+
#currentState() {
|
|
100
|
+
return this.#state;
|
|
101
|
+
}
|
|
102
|
+
get started() {
|
|
103
|
+
return this.#state === 'connecting' || this.#state === 'open' || this.#state === 'reconnecting';
|
|
104
|
+
}
|
|
105
|
+
async start(connect) {
|
|
106
|
+
if (this.started)
|
|
107
|
+
return;
|
|
108
|
+
this.#connect = connect;
|
|
109
|
+
this.#state = 'connecting';
|
|
110
|
+
this.#attempt = 0;
|
|
111
|
+
try {
|
|
112
|
+
await this.#runConnect(connect);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// 注意:stop() 可能在 await 期间并发改写 #state,必须经 getter 读取避免 TS 窄化误判
|
|
116
|
+
if (this.#currentState() === 'stopped')
|
|
117
|
+
return; // stop-during-connect 竞态:静默 settle
|
|
118
|
+
// start 失败复位:回 idle、不武装重连,允许调用方重试
|
|
119
|
+
this.#state = 'idle';
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
if (this.#currentState() === 'stopped')
|
|
123
|
+
return; // stop 竞态先于 open
|
|
124
|
+
this.#state = 'open';
|
|
125
|
+
}
|
|
126
|
+
async stop() {
|
|
127
|
+
const wasActive = this.#state !== 'stopped';
|
|
128
|
+
this.#state = 'stopped';
|
|
129
|
+
this.#attempt = 0;
|
|
130
|
+
this.stopHeartbeat();
|
|
131
|
+
if (this.#reconnectTimer) {
|
|
132
|
+
clearTimeout(this.#reconnectTimer);
|
|
133
|
+
this.#reconnectTimer = undefined;
|
|
134
|
+
}
|
|
135
|
+
this.#reconnectWake?.(false);
|
|
136
|
+
this.#reconnectWake = undefined;
|
|
137
|
+
for (const wake of this.#stopWaiters.splice(0))
|
|
138
|
+
wake();
|
|
139
|
+
const close = this.#forceClose;
|
|
140
|
+
this.#forceClose = undefined;
|
|
141
|
+
if (close) {
|
|
142
|
+
try {
|
|
143
|
+
close();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* ignore */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (wasActive) {
|
|
150
|
+
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#name }));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
startHeartbeat(beat, intervalMs = this.#heartbeat.intervalMs) {
|
|
154
|
+
this.stopHeartbeat();
|
|
155
|
+
if (intervalMs <= 0)
|
|
156
|
+
return;
|
|
157
|
+
const watchdogMisses = this.#heartbeat.watchdogMisses;
|
|
158
|
+
this.#heartbeatMisses = 0;
|
|
159
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
160
|
+
if (watchdogMisses > 0) {
|
|
161
|
+
this.#heartbeatMisses += 1;
|
|
162
|
+
if (this.#heartbeatMisses > watchdogMisses) {
|
|
163
|
+
this.stopHeartbeat();
|
|
164
|
+
logger.warn(formatCompact({
|
|
165
|
+
op: 'heartbeat_watchdog',
|
|
166
|
+
endpoint: this.#name,
|
|
167
|
+
ok: false,
|
|
168
|
+
misses: this.#heartbeatMisses,
|
|
169
|
+
}));
|
|
170
|
+
const close = this.#forceClose;
|
|
171
|
+
if (close) {
|
|
172
|
+
try {
|
|
173
|
+
close();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* ignore */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
beat();
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
logger.warn(formatCompact({
|
|
187
|
+
op: 'heartbeat',
|
|
188
|
+
endpoint: this.#name,
|
|
189
|
+
ok: false,
|
|
190
|
+
error: err instanceof Error ? err.message : String(err),
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
}, intervalMs);
|
|
194
|
+
}
|
|
195
|
+
stopHeartbeat() {
|
|
196
|
+
if (this.#heartbeatTimer) {
|
|
197
|
+
clearInterval(this.#heartbeatTimer);
|
|
198
|
+
this.#heartbeatTimer = undefined;
|
|
199
|
+
}
|
|
200
|
+
this.#heartbeatMisses = 0;
|
|
201
|
+
}
|
|
202
|
+
notifyHeartbeatAck() {
|
|
203
|
+
this.#heartbeatMisses = 0;
|
|
204
|
+
}
|
|
205
|
+
#createHandle(generation) {
|
|
206
|
+
return {
|
|
207
|
+
notifyClosed: (reason) => {
|
|
208
|
+
if (generation !== this.#generation)
|
|
209
|
+
return; // 陈旧连接的迟到事件
|
|
210
|
+
this.#forceClose = undefined;
|
|
211
|
+
this.stopHeartbeat(); // close 清心跳
|
|
212
|
+
// 仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
|
|
213
|
+
if (this.#state !== 'open')
|
|
214
|
+
return;
|
|
215
|
+
logger.warn(formatCompact({
|
|
216
|
+
op: 'disconnect',
|
|
217
|
+
endpoint: this.#name,
|
|
218
|
+
ok: false,
|
|
219
|
+
error: reason instanceof Error ? reason.message : reason != null ? String(reason) : 'closed',
|
|
220
|
+
}));
|
|
221
|
+
if (!this.#reconnect) {
|
|
222
|
+
this.#state = 'closed';
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.#state = 'reconnecting';
|
|
226
|
+
this.#scheduleReconnect();
|
|
227
|
+
},
|
|
228
|
+
onForceClose: (close) => {
|
|
229
|
+
if (generation === this.#generation)
|
|
230
|
+
this.#forceClose = close;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** 跑一次 connect 尝试;与 stop 信号竞态,stop 先到则静默返回。 */
|
|
235
|
+
async #runConnect(connect) {
|
|
236
|
+
const generation = ++this.#generation;
|
|
237
|
+
this.#forceClose = undefined;
|
|
238
|
+
const handle = this.#createHandle(generation);
|
|
239
|
+
// Promise.resolve().then 兜底同步抛错;额外 catch 防止 stop 竞态后迟到拒绝变 unhandled
|
|
240
|
+
const connecting = Promise.resolve().then(() => connect(handle));
|
|
241
|
+
connecting.catch(() => { });
|
|
242
|
+
let wake;
|
|
243
|
+
const stopped = new Promise((resolve) => {
|
|
244
|
+
wake = resolve;
|
|
245
|
+
});
|
|
246
|
+
this.#stopWaiters.push(wake);
|
|
247
|
+
try {
|
|
248
|
+
await Promise.race([connecting, stopped]);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
const index = this.#stopWaiters.indexOf(wake);
|
|
252
|
+
if (index >= 0)
|
|
253
|
+
this.#stopWaiters.splice(index, 1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** 武装重连循环(单例,防叠套)。 */
|
|
257
|
+
#scheduleReconnect() {
|
|
258
|
+
if (this.#reconnectRunning)
|
|
259
|
+
return;
|
|
260
|
+
this.#reconnectRunning = true;
|
|
261
|
+
void this.#reconnectLoop().finally(() => {
|
|
262
|
+
this.#reconnectRunning = false;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
async #reconnectLoop() {
|
|
266
|
+
const config = this.#reconnect;
|
|
267
|
+
const connect = this.#connect;
|
|
268
|
+
if (!config || !connect)
|
|
269
|
+
return;
|
|
270
|
+
while (this.#state === 'reconnecting') {
|
|
271
|
+
if (this.#attempt >= config.maxAttempts) {
|
|
272
|
+
this.#state = 'closed';
|
|
273
|
+
logger.warn(formatCompact({
|
|
274
|
+
op: 'reconnect',
|
|
275
|
+
endpoint: this.#name,
|
|
276
|
+
ok: false,
|
|
277
|
+
error: `gave up after ${this.#attempt} attempts`,
|
|
278
|
+
}));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const base = Math.min(config.initialIntervalMs * config.multiplier ** this.#attempt, config.maxIntervalMs);
|
|
282
|
+
const delay = base + Math.floor(this.#random() * config.jitterMs);
|
|
283
|
+
// 首次断开 WARN,后续重试静默为 DEBUG,避免刷屏(对齐 icqq)
|
|
284
|
+
const log = this.#attempt === 0 ? logger.warn.bind(logger) : logger.debug.bind(logger);
|
|
285
|
+
log(formatCompact({
|
|
286
|
+
op: 'reconnect',
|
|
287
|
+
endpoint: this.#name,
|
|
288
|
+
delay_ms: delay,
|
|
289
|
+
attempt: this.#attempt + 1,
|
|
290
|
+
}));
|
|
291
|
+
const elapsed = await this.#sleep(delay);
|
|
292
|
+
if (!elapsed || this.#currentState() !== 'reconnecting')
|
|
293
|
+
return;
|
|
294
|
+
try {
|
|
295
|
+
await this.#runConnect(connect);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
if (this.#currentState() === 'stopped')
|
|
299
|
+
return;
|
|
300
|
+
this.#attempt += 1;
|
|
301
|
+
logger.debug(formatCompact({
|
|
302
|
+
op: 'reconnect',
|
|
303
|
+
endpoint: this.#name,
|
|
304
|
+
ok: false,
|
|
305
|
+
attempt: this.#attempt,
|
|
306
|
+
error: err instanceof Error ? err.message : String(err),
|
|
307
|
+
}));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (this.#currentState() === 'stopped')
|
|
311
|
+
return;
|
|
312
|
+
this.#state = 'open';
|
|
313
|
+
this.#attempt = 0;
|
|
314
|
+
logger.info(formatCompact({ op: 'reconnect', endpoint: this.#name, ok: true }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** 可中断 sleep:stop() 唤醒并返回 false。 */
|
|
319
|
+
#sleep(ms) {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
322
|
+
this.#reconnectTimer = undefined;
|
|
323
|
+
this.#reconnectWake = undefined;
|
|
324
|
+
resolve(true);
|
|
325
|
+
}, ms);
|
|
326
|
+
this.#reconnectWake = resolve;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** 创建端点生命周期基座实例(见文件头迁移指引)。 */
|
|
331
|
+
export function createEndpointLifecycle(options) {
|
|
332
|
+
return new EndpointLifecycleImpl(options);
|
|
333
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export interface EndpointFriend {
|
|
2
|
-
|
|
2
|
+
/** 数字平台(QQ 系)用 number;Slack/LINE/微信系用 string。 */
|
|
3
|
+
readonly user_id: number | string;
|
|
3
4
|
readonly nickname: string;
|
|
4
5
|
readonly remark: string;
|
|
5
6
|
}
|
|
6
7
|
export interface EndpointGroup {
|
|
7
|
-
|
|
8
|
+
/** 数字平台(QQ 系)用 number;Slack/LINE/微信系用 string。 */
|
|
9
|
+
readonly group_id: number | string;
|
|
8
10
|
readonly name: string;
|
|
9
11
|
}
|
|
10
12
|
export interface EndpointChannelParent {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
/** @internal 适配器 projection(AdapterIndex),框架内部机制,不承诺不 break。 */
|
|
1
2
|
export * from './adapter-index.js';
|
|
2
3
|
export * from './credentials.js';
|
|
4
|
+
/** @public 用户侧创作面:`defineAdapter`(`adapters/` 约定目录默认导出,承诺 semver)。 */
|
|
3
5
|
export * from './definition.js';
|
|
6
|
+
export * from './endpoint-commands.js';
|
|
7
|
+
export * from './endpoint-lifecycle.js';
|
|
4
8
|
export * from './endpoint-management.js';
|
|
5
9
|
export * from './provider.js';
|
|
6
10
|
export { default } from './provider.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
/** @internal 适配器 projection(AdapterIndex),框架内部机制,不承诺不 break。 */
|
|
1
2
|
export * from './adapter-index.js';
|
|
2
3
|
export * from './credentials.js';
|
|
4
|
+
/** @public 用户侧创作面:`defineAdapter`(`adapters/` 约定目录默认导出,承诺 semver)。 */
|
|
3
5
|
export * from './definition.js';
|
|
6
|
+
export * from './endpoint-commands.js';
|
|
7
|
+
export * from './endpoint-lifecycle.js';
|
|
4
8
|
export * from './endpoint-management.js';
|
|
5
9
|
export * from './provider.js';
|
|
6
10
|
export { default } from './provider.js';
|
package/lib/provider.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
"src"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"
|
|
20
|
+
"yaml": "^2.9.0",
|
|
21
|
+
"@zhin.js/feature-kit": "1.0.4",
|
|
21
22
|
"@zhin.js/logger": "1.0.75",
|
|
22
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
23
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@types/node": "^26.1.0",
|
|
26
|
-
"typescript": "^6.0.3"
|
|
27
|
+
"typescript": "^6.0.3",
|
|
28
|
+
"@zhin.js/command": "1.0.4"
|
|
27
29
|
},
|
|
28
30
|
"zhin": {
|
|
29
31
|
"protocol": 1,
|
package/src/adapter-index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
|
10
10
|
import type {
|
|
11
11
|
AdapterCapability,
|
|
12
12
|
AdapterDefinition,
|
|
13
|
+
AdapterSegmentPolicy,
|
|
13
14
|
EndpointInstance,
|
|
14
15
|
EndpointSendRequest,
|
|
15
16
|
} from './definition.js';
|
|
@@ -41,6 +42,7 @@ export type AdapterEndpointPhase =
|
|
|
41
42
|
|
|
42
43
|
interface AdapterRecord extends AdapterDescriptor {
|
|
43
44
|
readonly endpoint: EndpointInstance;
|
|
45
|
+
readonly segments?: AdapterSegmentPolicy;
|
|
44
46
|
readonly unconfigured: boolean;
|
|
45
47
|
started: boolean;
|
|
46
48
|
open: boolean;
|
|
@@ -96,6 +98,7 @@ export class AdapterIndex {
|
|
|
96
98
|
source: slot.source,
|
|
97
99
|
capabilities: slot.definition.capabilities,
|
|
98
100
|
endpoint: endpoint.instance,
|
|
101
|
+
...(slot.definition.segments ? { segments: slot.definition.segments } : {}),
|
|
99
102
|
unconfigured: endpoint.unconfigured,
|
|
100
103
|
started: false,
|
|
101
104
|
open: false,
|
|
@@ -127,7 +130,8 @@ export class AdapterIndex {
|
|
|
127
130
|
list(): readonly AdapterDescriptor[] {
|
|
128
131
|
return this.#order.map(({ endpoint: _endpoint, unconfigured: _unconfigured,
|
|
129
132
|
started: _started, open: _open, stopped: _stopped, failed: _failed,
|
|
130
|
-
startAttempted: _startAttempted,
|
|
133
|
+
startAttempted: _startAttempted, segments: _segments,
|
|
134
|
+
...descriptor }) => Object.freeze(descriptor));
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/** Endpoint rows for Console `endpoint.list` / `endpoint.info`. */
|
|
@@ -175,6 +179,14 @@ export class AdapterIndex {
|
|
|
175
179
|
return record.owner;
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Endpoint 的消息段能力声明(出站协商降级依据);
|
|
184
|
+
* 未声明或未知 id 返回 undefined(调用方按历史行为处理)。
|
|
185
|
+
*/
|
|
186
|
+
segmentPolicy(id: CapabilityId): AdapterSegmentPolicy | undefined {
|
|
187
|
+
return this.#records.get(id)?.segments;
|
|
188
|
+
}
|
|
189
|
+
|
|
178
190
|
async start(): Promise<void> {
|
|
179
191
|
// Soft-start in parallel with a short wait so kitchen-sink Roots do not
|
|
180
192
|
// stall generation. Configured platforms that need longer (QQ auth, Slack
|
package/src/definition.ts
CHANGED
|
@@ -6,6 +6,12 @@ const adapterBrand = 'zhin.adapter/1' as const;
|
|
|
6
6
|
|
|
7
7
|
export type AdapterCapability = 'inbound' | 'outbound';
|
|
8
8
|
|
|
9
|
+
/** 端点可消费的出站媒体来源形式。 */
|
|
10
|
+
export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
|
|
11
|
+
|
|
12
|
+
/** 交互段(卡片/按钮等富交互)的端点消费方式。 */
|
|
13
|
+
export type AdapterInteractiveMode = 'native' | 'text';
|
|
14
|
+
|
|
9
15
|
export interface EndpointSendRequest {
|
|
10
16
|
readonly target: string;
|
|
11
17
|
readonly payload: unknown;
|
|
@@ -31,14 +37,62 @@ export interface AdapterContext<TConfig = unknown> extends CapabilityContext<TCo
|
|
|
31
37
|
readonly name: string;
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* html 段出站策略:
|
|
42
|
+
* - `direct`:端点直接消费 html 段(Console UI 类端点),核心不做任何转换;
|
|
43
|
+
* - `image`:经 html-renderer 渲染成 image 段,无渲染器时降级 text(缺省);
|
|
44
|
+
* - `text`:直接降级为 text 段。
|
|
45
|
+
*/
|
|
46
|
+
export type HtmlOutboundMode = 'direct' | 'image' | 'text';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 端点消息段能力声明(出站协商降级的依据)。
|
|
50
|
+
* 缺省(未声明 `segments`)保持历史行为:仅 html 段按 image/text 处理,
|
|
51
|
+
* 其余段原样透传给端点。
|
|
52
|
+
*/
|
|
53
|
+
export interface AdapterSegmentPolicy {
|
|
54
|
+
/**
|
|
55
|
+
* 端点原生可消费的 wire 段类型(如 `['text', 'image', 'at']`)。
|
|
56
|
+
* 声明后,未列出的段由核心按 `formatSegmentPreview` 降级为 text 段;
|
|
57
|
+
* 不声明则不过滤(全部透传)。
|
|
58
|
+
*/
|
|
59
|
+
readonly supported?: readonly string[];
|
|
60
|
+
/** html 段处理策略,缺省 `image`。 */
|
|
61
|
+
readonly html?: HtmlOutboundMode;
|
|
62
|
+
/** 端点可消费的媒体来源形式;缺省表示不做媒体来源协商。 */
|
|
63
|
+
readonly outboundMedia?: readonly AdapterOutboundMedia[];
|
|
64
|
+
/**
|
|
65
|
+
* 交互段(卡片/按钮等富交互)消费方式:`native` 原生渲染 / `text` 降级纯文本。
|
|
66
|
+
* 目前仅为声明(供出站协商与门禁消费),`text` 的降级执行随 Wave 2 落地。
|
|
67
|
+
*/
|
|
68
|
+
readonly interactive?: AdapterInteractiveMode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const HTML_OUTBOUND_MODES: readonly HtmlOutboundMode[] = ['direct', 'image', 'text'];
|
|
72
|
+
|
|
73
|
+
const OUTBOUND_MEDIA_FORMS: readonly AdapterOutboundMedia[] = [
|
|
74
|
+
'url', 'path', 'base64', 'upload',
|
|
75
|
+
];
|
|
76
|
+
|
|
34
77
|
export interface AdapterDefinition<TConfig = unknown, TResult = unknown> {
|
|
35
78
|
readonly $feature: typeof adapterBrand;
|
|
36
79
|
readonly capabilities: readonly AdapterCapability[];
|
|
80
|
+
/** 可选:端点消息段能力声明(出站协商降级挂载点)。 */
|
|
81
|
+
readonly segments?: AdapterSegmentPolicy;
|
|
37
82
|
create(
|
|
38
83
|
context: AdapterContext<TConfig>,
|
|
39
84
|
): EndpointInstance<TResult> | Promise<EndpointInstance<TResult>>;
|
|
40
85
|
}
|
|
41
86
|
|
|
87
|
+
declare module '@zhin.js/plugin-runtime' {
|
|
88
|
+
interface PluginSetupContext<TConfig> {
|
|
89
|
+
addAdapter<TResult = unknown>(
|
|
90
|
+
localName: string,
|
|
91
|
+
definition: AdapterDefinition<TConfig, TResult>,
|
|
92
|
+
): void;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
42
96
|
export function defineAdapter<TConfig = unknown, TResult = unknown>(
|
|
43
97
|
definition: Omit<AdapterDefinition<TConfig, TResult>, '$feature'>,
|
|
44
98
|
): Readonly<AdapterDefinition<TConfig, TResult>> {
|
|
@@ -52,10 +106,58 @@ export function defineAdapter<TConfig = unknown, TResult = unknown>(
|
|
|
52
106
|
) {
|
|
53
107
|
throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
|
|
54
108
|
}
|
|
109
|
+
const segments = normalizeSegmentPolicy(definition.segments);
|
|
55
110
|
return Object.freeze({
|
|
56
111
|
...definition,
|
|
57
112
|
$feature: adapterBrand,
|
|
58
113
|
capabilities: Object.freeze(capabilities),
|
|
114
|
+
...(segments ? { segments } : {}),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeSegmentPolicy(
|
|
119
|
+
policy: AdapterSegmentPolicy | undefined,
|
|
120
|
+
): AdapterSegmentPolicy | undefined {
|
|
121
|
+
if (policy === undefined) return undefined;
|
|
122
|
+
if (!policy || typeof policy !== 'object') {
|
|
123
|
+
throw new TypeError('Adapter segments policy must be an object');
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
policy.supported !== undefined
|
|
127
|
+
&& (!Array.isArray(policy.supported)
|
|
128
|
+
|| policy.supported.some((type) => typeof type !== 'string' || !type))
|
|
129
|
+
) {
|
|
130
|
+
throw new TypeError('Adapter segments.supported must be an array of segment type names');
|
|
131
|
+
}
|
|
132
|
+
if (policy.html !== undefined && !HTML_OUTBOUND_MODES.includes(policy.html)) {
|
|
133
|
+
throw new TypeError('Adapter segments.html must be direct, image or text');
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
policy.outboundMedia !== undefined
|
|
137
|
+
&& (!Array.isArray(policy.outboundMedia)
|
|
138
|
+
|| policy.outboundMedia.length === 0
|
|
139
|
+
|| policy.outboundMedia.some(
|
|
140
|
+
(form) => !OUTBOUND_MEDIA_FORMS.includes(form as AdapterOutboundMedia),
|
|
141
|
+
))
|
|
142
|
+
) {
|
|
143
|
+
throw new TypeError(
|
|
144
|
+
"Adapter segments.outboundMedia must be a non-empty array of 'url' | 'path' | 'base64' | 'upload'",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
policy.interactive !== undefined
|
|
149
|
+
&& policy.interactive !== 'native'
|
|
150
|
+
&& policy.interactive !== 'text'
|
|
151
|
+
) {
|
|
152
|
+
throw new TypeError("Adapter segments.interactive must be 'native' or 'text'");
|
|
153
|
+
}
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
...(policy.supported ? { supported: Object.freeze([...new Set(policy.supported)]) } : {}),
|
|
156
|
+
...(policy.html ? { html: policy.html } : {}),
|
|
157
|
+
...(policy.outboundMedia
|
|
158
|
+
? { outboundMedia: Object.freeze([...new Set(policy.outboundMedia)]) }
|
|
159
|
+
: {}),
|
|
160
|
+
...(policy.interactive ? { interactive: policy.interactive } : {}),
|
|
59
161
|
});
|
|
60
162
|
}
|
|
61
163
|
|
|
@@ -71,6 +173,8 @@ export function parseAdapterDefinition(value: unknown): AdapterDefinition {
|
|
|
71
173
|
(capability) => capability !== 'inbound' && capability !== 'outbound',
|
|
72
174
|
)
|
|
73
175
|
) throw invalidAdapter();
|
|
176
|
+
// defineAdapter 已校验过形状;外部手工构造的 definition 也在此兜底
|
|
177
|
+
normalizeSegmentPolicy(definition.segments);
|
|
74
178
|
return definition as AdapterDefinition;
|
|
75
179
|
}
|
|
76
180
|
|