@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,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createEndpointCommands — 适配器 endpoint 管理命令套件(list / add / remove)。
|
|
3
|
+
*
|
|
4
|
+
* 把 QQ 适配器独有的 `qq endpoint` 命令族泛化为任意适配器可复用的套件:
|
|
5
|
+
*
|
|
6
|
+
* - `<adapter> endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
|
|
7
|
+
* + zhin.config.yml 配置里的 `plugins.<adapterKey>.endpoints`。
|
|
8
|
+
* - `<adapter> endpoint add <name> <key=value...>`:手动录入字段,
|
|
9
|
+
* 凭据类字段(env: true)值写入 .env(`<ADAPTER>_<NAME>_<FIELD>` 大写键),
|
|
10
|
+
* yaml 中保存 `${REF}` 引用;其余字段内联写入。yaml 用 Document 节点级操作保留注释。
|
|
11
|
+
* - `<adapter> endpoint remove <name>`:从 `plugins.<adapterKey>.endpoints` 移除(重启生效)。
|
|
12
|
+
* - 权限:实例 config 声明了 master(顶层或 endpoints[i])时仅 master 可用 add/remove,
|
|
13
|
+
* 未配置放行(isEndpointOperator)。
|
|
14
|
+
* - 特殊 add 流程(如 QQ 扫码绑定)经 spec.bindFlow 钩子接管 add 命令。
|
|
15
|
+
*
|
|
16
|
+
* 接入步骤(以 telegram 为例):
|
|
17
|
+
* 1. plugin.ts setup 里 `context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState())`,
|
|
18
|
+
* token 由 `defineEndpointRuntimeStateToken('telegram')` 创建。
|
|
19
|
+
* 2. adapters/telegram.ts create() 里 `context.use(token).endpoints.set(config.name, { name, mode })`。
|
|
20
|
+
* 3. src 下 `export const telegramEndpointCommands = createEndpointCommands({ adapterKey: 'telegram', ... }, defineCommand)`
|
|
21
|
+
* (defineCommand 由调用方从 @zhin.js/command 传入——provider 包之间禁止互相 import,
|
|
22
|
+
* 见 scripts/check-architecture-layers.mjs,故 defineCommand 走依赖注入)。
|
|
23
|
+
* 4. commands/endpoint/{list.ts, add/[name:string].ts, remove/[name:string].ts} 分别
|
|
24
|
+
* `export default telegramEndpointCommands.list|add|remove`。
|
|
25
|
+
*
|
|
26
|
+
* 注意:adapterKey 即实例 key(zhin.config.yml 的 plugins.<key>);多实例自定义 key 时
|
|
27
|
+
* 写回目标以 spec.adapterKey 为准(与 QQ 现状一致)。
|
|
28
|
+
*/
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { createToken } from '@zhin.js/plugin-runtime';
|
|
32
|
+
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// 权限:master 判定
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
|
|
38
|
+
* 仅 master 可执行管理命令;未配置则放行。
|
|
39
|
+
*/
|
|
40
|
+
export function isEndpointOperator(config, input) {
|
|
41
|
+
const cfg = (config ?? {});
|
|
42
|
+
const masters = new Set();
|
|
43
|
+
const collect = (value) => {
|
|
44
|
+
if (value === undefined || value === null)
|
|
45
|
+
return;
|
|
46
|
+
const text = String(value).trim();
|
|
47
|
+
if (text)
|
|
48
|
+
masters.add(text);
|
|
49
|
+
};
|
|
50
|
+
collect(cfg.master);
|
|
51
|
+
if (Array.isArray(cfg.endpoints)) {
|
|
52
|
+
for (const entry of cfg.endpoints) {
|
|
53
|
+
collect(entry?.master);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (masters.size === 0)
|
|
57
|
+
return true;
|
|
58
|
+
const sender = String(input?.sender ?? '').trim();
|
|
59
|
+
return !!sender && masters.has(sender);
|
|
60
|
+
}
|
|
61
|
+
/** add/remove 的拒绝文案(list 只读,不校验)。 */
|
|
62
|
+
export function endpointCommandForbidden(adapterDisplayName) {
|
|
63
|
+
return `仅 master 可执行 ${adapterDisplayName} endpoint 管理命令`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
|
|
67
|
+
*/
|
|
68
|
+
export function extractEndpointCommandReply(input) {
|
|
69
|
+
const reply = input?.$reply;
|
|
70
|
+
if (typeof reply === 'function') {
|
|
71
|
+
return (text) => reply.call(input, text);
|
|
72
|
+
}
|
|
73
|
+
return async () => undefined;
|
|
74
|
+
}
|
|
75
|
+
export function createEndpointRuntimeState() {
|
|
76
|
+
return { endpoints: new Map() };
|
|
77
|
+
}
|
|
78
|
+
/** 每个适配器在模块顶层调用一次,创建自己的 runtime state token。 */
|
|
79
|
+
export function defineEndpointRuntimeStateToken(adapterKey) {
|
|
80
|
+
return createToken(`zhin.${adapterKey}.runtime-state`, `${adapterKey} adapter runtime state (running endpoints)`);
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// .env 凭据持久化
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
|
|
86
|
+
export function resolveProjectRoot() {
|
|
87
|
+
const envRoot = process.env.ZHIN_PROJECT_ROOT?.trim();
|
|
88
|
+
return path.resolve(envRoot || process.cwd());
|
|
89
|
+
}
|
|
90
|
+
function envSlug(text) {
|
|
91
|
+
return text
|
|
92
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
93
|
+
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
94
|
+
.toUpperCase();
|
|
95
|
+
}
|
|
96
|
+
/** 派生 endpoint 凭据的 env 键:`${ADAPTER}_${NAME}_${FIELD}`(如 `TELEGRAM_MY_BOT_TOKEN`) */
|
|
97
|
+
export function buildEndpointEnvKey(adapterKey, endpointName, fieldKey) {
|
|
98
|
+
return `${envSlug(adapterKey)}_${envSlug(endpointName)}_${envSlug(fieldKey)}`;
|
|
99
|
+
}
|
|
100
|
+
function escapeRegExp(text) {
|
|
101
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
102
|
+
}
|
|
103
|
+
function upsertEnvLine(content, key, value) {
|
|
104
|
+
const lineRe = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, 'm');
|
|
105
|
+
const newLine = `${key}=${value}`;
|
|
106
|
+
if (lineRe.test(content)) {
|
|
107
|
+
return content.replace(lineRe, newLine);
|
|
108
|
+
}
|
|
109
|
+
const trimmed = content.replace(/\s*$/, '');
|
|
110
|
+
if (trimmed.length === 0) {
|
|
111
|
+
return `${newLine}\n`;
|
|
112
|
+
}
|
|
113
|
+
return `${trimmed}\n${newLine}\n`;
|
|
114
|
+
}
|
|
115
|
+
/** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
|
|
116
|
+
export function persistEndpointEnvValues(values, projectRoot) {
|
|
117
|
+
const root = projectRoot ?? resolveProjectRoot();
|
|
118
|
+
const envPath = path.join(root, '.env');
|
|
119
|
+
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
|
|
120
|
+
for (const [key, value] of Object.entries(values)) {
|
|
121
|
+
content = upsertEnvLine(content, key, value);
|
|
122
|
+
process.env[key] = value;
|
|
123
|
+
}
|
|
124
|
+
fs.writeFileSync(envPath, content);
|
|
125
|
+
}
|
|
126
|
+
const CONFIG_BASENAME = 'zhin.config';
|
|
127
|
+
const YAML_EXTENSIONS = ['.yml', '.yaml'];
|
|
128
|
+
/** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
|
|
129
|
+
export function findEndpointConfigFile(adapterKey, projectRoot) {
|
|
130
|
+
const root = projectRoot ?? resolveProjectRoot();
|
|
131
|
+
const envConfig = process.env.ZHIN_CONFIG?.trim();
|
|
132
|
+
if (envConfig)
|
|
133
|
+
return path.resolve(root, envConfig);
|
|
134
|
+
for (const ext of YAML_EXTENSIONS) {
|
|
135
|
+
const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
|
|
136
|
+
if (fs.existsSync(candidate))
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
for (const ext of ['.json', '.toml', '.ts']) {
|
|
140
|
+
const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
|
|
141
|
+
if (fs.existsSync(candidate)) {
|
|
142
|
+
throw new Error(`暂不支持写入 ${ext} 配置文件,请手动在 ${CONFIG_BASENAME}${ext} 的 plugins.${adapterKey}.endpoints 中维护`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return path.join(root, `${CONFIG_BASENAME}.yml`);
|
|
146
|
+
}
|
|
147
|
+
function readConfigDocument(adapterKey, projectRoot) {
|
|
148
|
+
const filePath = findEndpointConfigFile(adapterKey, projectRoot);
|
|
149
|
+
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
150
|
+
const doc = parseDocument(content || '{}');
|
|
151
|
+
return { filePath, doc };
|
|
152
|
+
}
|
|
153
|
+
function writeConfigDocument({ filePath, doc }) {
|
|
154
|
+
fs.writeFileSync(filePath, doc.toString());
|
|
155
|
+
}
|
|
156
|
+
/** 读取 plugins.<adapterKey>.endpoints(plain JS);plugins/<adapterKey> 缺失或形态不符时返回 [] */
|
|
157
|
+
export function listConfiguredEndpoints(adapterKey, projectRoot) {
|
|
158
|
+
const { doc } = readConfigDocument(adapterKey, projectRoot);
|
|
159
|
+
const plugins = doc.toJS()?.plugins;
|
|
160
|
+
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins))
|
|
161
|
+
return [];
|
|
162
|
+
const endpoints = plugins[adapterKey]?.endpoints;
|
|
163
|
+
if (!Array.isArray(endpoints))
|
|
164
|
+
return [];
|
|
165
|
+
return endpoints.filter((entry) => !!entry && typeof entry === 'object' && typeof entry.name === 'string');
|
|
166
|
+
}
|
|
167
|
+
function entryName(item) {
|
|
168
|
+
if (!isMap(item))
|
|
169
|
+
return undefined;
|
|
170
|
+
const name = item.get('name');
|
|
171
|
+
return typeof name === 'string' && name ? name : undefined;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 确保 plugins.<adapterKey>.endpoints 存在并返回其 YAMLSeq(节点级操作,保留既有条目与注释)。
|
|
175
|
+
* `plugins: []`(legacy 空列表,Runtime 忽略)可直接替换为 map;非空数组拒绝写入。
|
|
176
|
+
*/
|
|
177
|
+
function ensureEndpointsSeq(doc, adapterKey) {
|
|
178
|
+
const plugins = doc.get('plugins');
|
|
179
|
+
if (isSeq(plugins) && plugins.items.length > 0) {
|
|
180
|
+
throw new Error('配置的 plugins 是数组形态(legacy 插件名列表),请手动迁移为 map 后再试');
|
|
181
|
+
}
|
|
182
|
+
if (plugins !== undefined && !isMap(plugins) && !isSeq(plugins)) {
|
|
183
|
+
throw new Error('配置的 plugins 字段形态异常,请手动检查 zhin.config.yml');
|
|
184
|
+
}
|
|
185
|
+
if (!isMap(doc.get('plugins'))) {
|
|
186
|
+
// 注意:空对象 {} 不会被 doc.set 自动包装为 YAMLMap,必须显式 createNode
|
|
187
|
+
doc.set('plugins', doc.createNode({}));
|
|
188
|
+
}
|
|
189
|
+
const adapterNode = doc.getIn(['plugins', adapterKey]);
|
|
190
|
+
if (adapterNode !== undefined && !isMap(adapterNode)) {
|
|
191
|
+
throw new Error(`配置的 plugins.${adapterKey} 字段形态异常,请手动检查 zhin.config.yml`);
|
|
192
|
+
}
|
|
193
|
+
if (!isMap(doc.getIn(['plugins', adapterKey]))) {
|
|
194
|
+
doc.setIn(['plugins', adapterKey], doc.createNode({}));
|
|
195
|
+
}
|
|
196
|
+
const endpoints = doc.getIn(['plugins', adapterKey, 'endpoints']);
|
|
197
|
+
if (endpoints !== undefined && !isSeq(endpoints)) {
|
|
198
|
+
throw new Error(`配置的 plugins.${adapterKey}.endpoints 字段形态异常,请手动检查 zhin.config.yml`);
|
|
199
|
+
}
|
|
200
|
+
if (!isSeq(doc.getIn(['plugins', adapterKey, 'endpoints']))) {
|
|
201
|
+
doc.setIn(['plugins', adapterKey, 'endpoints'], doc.createNode([]));
|
|
202
|
+
}
|
|
203
|
+
return doc.getIn(['plugins', adapterKey, 'endpoints']);
|
|
204
|
+
}
|
|
205
|
+
/** 追加 endpoint 到 plugins.<adapterKey>.endpoints;name 已存在时报错 */
|
|
206
|
+
export function addEndpointToConfig(adapterKey, entry, projectRoot) {
|
|
207
|
+
const document = readConfigDocument(adapterKey, projectRoot);
|
|
208
|
+
const seq = ensureEndpointsSeq(document.doc, adapterKey);
|
|
209
|
+
if (seq.items.some((item) => entryName(item) === entry.name)) {
|
|
210
|
+
throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey} endpoint remove ${entry.name} 再重新添加`);
|
|
211
|
+
}
|
|
212
|
+
seq.items.push(document.doc.createNode(entry));
|
|
213
|
+
writeConfigDocument(document);
|
|
214
|
+
return document.filePath;
|
|
215
|
+
}
|
|
216
|
+
/** 按 name 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
|
|
217
|
+
export function removeEndpointFromConfig(adapterKey, name, projectRoot) {
|
|
218
|
+
const document = readConfigDocument(adapterKey, projectRoot);
|
|
219
|
+
const seq = ensureEndpointsSeq(document.doc, adapterKey);
|
|
220
|
+
const next = seq.items.filter((item) => entryName(item) !== name);
|
|
221
|
+
if (next.length === seq.items.length) {
|
|
222
|
+
return { removed: false, filePath: document.filePath };
|
|
223
|
+
}
|
|
224
|
+
seq.items = next;
|
|
225
|
+
writeConfigDocument(document);
|
|
226
|
+
return { removed: true, filePath: document.filePath };
|
|
227
|
+
}
|
|
228
|
+
function endpointNameParam(params) {
|
|
229
|
+
const name = params.name;
|
|
230
|
+
return typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
|
231
|
+
}
|
|
232
|
+
/** list 文案:运行中 + 配置中两段,footer 可选。 */
|
|
233
|
+
export function formatEndpointList(spec, source) {
|
|
234
|
+
const running = [...source.running];
|
|
235
|
+
const lines = [];
|
|
236
|
+
lines.push(`【运行中的 ${spec.adapterDisplayName} endpoints】`);
|
|
237
|
+
if (running.length === 0) {
|
|
238
|
+
lines.push(' (无)');
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
for (const endpoint of running) {
|
|
242
|
+
lines.push(endpoint.mode ? ` - ${endpoint.name}(${endpoint.mode})` : ` - ${endpoint.name}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
lines.push(`【配置中的 ${spec.adapterDisplayName} endpoints】(zhin.config.yml → plugins.${spec.adapterKey}.endpoints)`);
|
|
246
|
+
if (source.configured.length === 0) {
|
|
247
|
+
lines.push(' (无)');
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
for (const entry of source.configured) {
|
|
251
|
+
const detail = spec.describeEntry?.(entry);
|
|
252
|
+
lines.push(detail ? ` - ${entry.name}(${detail})` : ` - ${entry.name}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (source.footer)
|
|
256
|
+
lines.push(source.footer);
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
259
|
+
function addUsage(spec) {
|
|
260
|
+
const fields = spec.fields ?? [];
|
|
261
|
+
const fieldText = fields.length === 0
|
|
262
|
+
? ''
|
|
263
|
+
: `\n字段:${fields.map((field) => {
|
|
264
|
+
const marks = [
|
|
265
|
+
field.required ? '必填' : '',
|
|
266
|
+
field.env ? '写入 .env' : '',
|
|
267
|
+
field.description ?? '',
|
|
268
|
+
].filter(Boolean).join(',');
|
|
269
|
+
return marks ? `${field.key}(${marks})` : field.key;
|
|
270
|
+
}).join('、')}`;
|
|
271
|
+
return `用法:${spec.adapterKey} endpoint add <name> <key=value...>${fieldText}`;
|
|
272
|
+
}
|
|
273
|
+
/** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
|
|
274
|
+
export function addEndpointFromKeyValues(spec, name, args, projectRoot) {
|
|
275
|
+
const fields = spec.fields ?? [];
|
|
276
|
+
const known = new Map(fields.map((field) => [field.key, field]));
|
|
277
|
+
const values = new Map();
|
|
278
|
+
for (const arg of args) {
|
|
279
|
+
const eq = arg.indexOf('=');
|
|
280
|
+
if (eq <= 0)
|
|
281
|
+
return `参数「${arg}」不是 key=value 形式。${addUsage(spec)}`;
|
|
282
|
+
const key = arg.slice(0, eq);
|
|
283
|
+
const value = arg.slice(eq + 1).trim();
|
|
284
|
+
const field = known.get(key);
|
|
285
|
+
if (!field) {
|
|
286
|
+
return `未知字段「${key}」,可用字段:${fields.map((item) => item.key).join('、')}`;
|
|
287
|
+
}
|
|
288
|
+
if (!value)
|
|
289
|
+
return `字段「${key}」的值不能为空`;
|
|
290
|
+
values.set(key, value);
|
|
291
|
+
}
|
|
292
|
+
const missing = fields.filter((field) => field.required && !values.has(field.key));
|
|
293
|
+
if (missing.length > 0) {
|
|
294
|
+
return `缺少必填字段:${missing.map((field) => field.key).join('、')}。${addUsage(spec)}`;
|
|
295
|
+
}
|
|
296
|
+
const entry = { name };
|
|
297
|
+
const envValues = {};
|
|
298
|
+
for (const field of fields) {
|
|
299
|
+
const value = values.get(field.key);
|
|
300
|
+
if (value === undefined)
|
|
301
|
+
continue;
|
|
302
|
+
if (field.env) {
|
|
303
|
+
const envKey = buildEndpointEnvKey(spec.adapterKey, name, field.key);
|
|
304
|
+
envValues[envKey] = value;
|
|
305
|
+
entry[field.key] = `\${${envKey}}`;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
entry[field.key] = value;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
// 先写配置(重名等校验失败时不留孤儿 .env 键),再落 .env 凭据
|
|
313
|
+
const filePath = addEndpointToConfig(spec.adapterKey, entry, projectRoot);
|
|
314
|
+
if (Object.keys(envValues).length > 0)
|
|
315
|
+
persistEndpointEnvValues(envValues, projectRoot);
|
|
316
|
+
return (`✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.${spec.adapterKey}.endpoints` +
|
|
317
|
+
`${Object.keys(envValues).length > 0 ? '(凭据已写入 .env)' : ''}。\n` +
|
|
318
|
+
'⚠️ 需重启 zhin 后新 endpoint 才会生效。');
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
return `添加失败:${error instanceof Error ? error.message : String(error)}`;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** remove 的完整业务逻辑:从 yaml 移除;返回回复文本。 */
|
|
325
|
+
export function removeEndpointByName(spec, name, projectRoot) {
|
|
326
|
+
const trimmed = name.trim();
|
|
327
|
+
if (!trimmed)
|
|
328
|
+
return `用法:${spec.adapterKey} endpoint remove <name>`;
|
|
329
|
+
try {
|
|
330
|
+
const { removed, filePath } = removeEndpointFromConfig(spec.adapterKey, trimmed, projectRoot);
|
|
331
|
+
if (!removed) {
|
|
332
|
+
return `配置中不存在 ${spec.adapterKey} endpoint「${trimmed}」(${filePath} → plugins.${spec.adapterKey}.endpoints)`;
|
|
333
|
+
}
|
|
334
|
+
return (`已从 ${filePath} 的 plugins.${spec.adapterKey}.endpoints 移除「${trimmed}」。\n` +
|
|
335
|
+
'⚠️ 需重启 zhin 后生效(运行中的连接届时才会断开);.env 中的凭据键未删除,可手动清理。');
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
return `移除失败:${error instanceof Error ? error.message : String(error)}`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** 生成 `<adapter> endpoint` 的 list / add / remove 三个命令定义(见文件头接入步骤)。 */
|
|
342
|
+
export function createEndpointCommands(spec, defineCommand) {
|
|
343
|
+
const forbidden = endpointCommandForbidden(spec.adapterDisplayName);
|
|
344
|
+
return Object.freeze({
|
|
345
|
+
list: defineCommand({
|
|
346
|
+
description: `列出 ${spec.adapterDisplayName} endpoints(运行中 + zhin.config.yml 配置)`,
|
|
347
|
+
execute({ use }) {
|
|
348
|
+
return formatEndpointList(spec, {
|
|
349
|
+
running: spec.running?.(use) ?? [],
|
|
350
|
+
configured: listConfiguredEndpoints(spec.adapterKey),
|
|
351
|
+
footer: spec.listFooter?.(use),
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
add: defineCommand({
|
|
356
|
+
description: spec.addDescription
|
|
357
|
+
?? `手动添加 ${spec.adapterDisplayName} endpoint(凭据写入 .env 并追加到 zhin.config.yml,重启生效)`,
|
|
358
|
+
execute({ config, input, params, args, use }) {
|
|
359
|
+
if (!isEndpointOperator(config, input))
|
|
360
|
+
return forbidden;
|
|
361
|
+
const name = endpointNameParam(params);
|
|
362
|
+
if (spec.bindFlow) {
|
|
363
|
+
return spec.bindFlow({
|
|
364
|
+
name,
|
|
365
|
+
reply: extractEndpointCommandReply(input),
|
|
366
|
+
config,
|
|
367
|
+
input,
|
|
368
|
+
use,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (!name)
|
|
372
|
+
return addUsage(spec);
|
|
373
|
+
return addEndpointFromKeyValues(spec, name, args);
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
remove: defineCommand({
|
|
377
|
+
description: `从 zhin.config.yml 的 plugins.${spec.adapterKey}.endpoints 移除指定 endpoint(重启生效)`,
|
|
378
|
+
execute({ config, input, params }) {
|
|
379
|
+
if (!isEndpointOperator(config, input))
|
|
380
|
+
return forbidden;
|
|
381
|
+
return removeEndpointByName(spec, String(params.name ?? ''));
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type EndpointLifecycleState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed' | 'stopped';
|
|
2
|
+
export interface EndpointLifecycleReconnectOptions {
|
|
3
|
+
/** 首次重连间隔(ms),默认 5000。 */
|
|
4
|
+
readonly initialIntervalMs?: number;
|
|
5
|
+
/** 退避倍数,默认 2(1 = 固定间隔,兼容旧 reconnect_interval 语义)。 */
|
|
6
|
+
readonly multiplier?: number;
|
|
7
|
+
/** 退避封顶(ms),默认 60000。 */
|
|
8
|
+
readonly maxIntervalMs?: number;
|
|
9
|
+
/** 每次重连附加的随机抖动上限(ms),默认 250;测试可配 random 使其确定。 */
|
|
10
|
+
readonly jitterMs?: number;
|
|
11
|
+
/** 最大连续重连失败次数,默认 Infinity;耗尽后进入 closed 终态。 */
|
|
12
|
+
readonly maxAttempts?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface EndpointLifecycleHeartbeatOptions {
|
|
15
|
+
/** startHeartbeat 缺省间隔(ms),默认 30000;<=0 表示不开心跳。 */
|
|
16
|
+
readonly intervalMs?: number;
|
|
17
|
+
/**
|
|
18
|
+
* 看门狗轮数:连续 N 次心跳未收到回包(notifyHeartbeatAck)后,
|
|
19
|
+
* 下一心跳周期主动调用强关函数。默认 0 = 关闭看门狗。
|
|
20
|
+
*/
|
|
21
|
+
readonly watchdogMisses?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface EndpointLifecycleOptions {
|
|
24
|
+
/** 端点名,仅用于日志字段。 */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/** 重连配置;传 false 禁用自动重连(对端断开后进入 closed)。 */
|
|
27
|
+
readonly reconnect?: EndpointLifecycleReconnectOptions | false;
|
|
28
|
+
/** 心跳配置。 */
|
|
29
|
+
readonly heartbeat?: EndpointLifecycleHeartbeatOptions;
|
|
30
|
+
/** 随机源(jitter 用),默认 Math.random;测试注入 () => 0 获得确定退避序列。 */
|
|
31
|
+
readonly random?: () => number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 每次 connect 尝试获得一个句柄;generation 过期后其方法自动失效,
|
|
35
|
+
* 因此适配器无需担心旧 socket 的迟到事件污染新连接。
|
|
36
|
+
*/
|
|
37
|
+
export interface EndpointConnectHandle {
|
|
38
|
+
/**
|
|
39
|
+
* 底层连接关闭(对端断开 / 看门狗强关 / 任意 close 事件)时调用。
|
|
40
|
+
* 仅当本次连接曾 open(即 connectFn 已 resolve)才武装退避重连;
|
|
41
|
+
* 初始连接失败由 start() 的拒绝路径复位,不武装重连。
|
|
42
|
+
*/
|
|
43
|
+
notifyClosed(reason?: unknown): void;
|
|
44
|
+
/** 注册当前连接的强制关闭函数(心跳看门狗与 stop 使用);每次 connect 覆盖。 */
|
|
45
|
+
onForceClose(close: () => void): void;
|
|
46
|
+
}
|
|
47
|
+
export type EndpointConnectFn = (handle: EndpointConnectHandle) => Promise<void>;
|
|
48
|
+
export interface EndpointLifecycle {
|
|
49
|
+
readonly state: EndpointLifecycleState;
|
|
50
|
+
/** start 已成功且未 stop(含 connecting / open / reconnecting)。 */
|
|
51
|
+
readonly started: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* 启动并建立首连。重复调用幂等(进行中/已连接时直接返回)。
|
|
54
|
+
* connectFn 须在连接 open 时 resolve、失败或 open 前 close 时 reject;
|
|
55
|
+
* stop-during-connect 时本方法静默 resolve(主动停止不算失败)。
|
|
56
|
+
*/
|
|
57
|
+
start(connect: EndpointConnectFn): Promise<void>;
|
|
58
|
+
/** 主动停止:清全部定时器、强关连接、唤醒竞态等待;幂等,绝不触发重连。 */
|
|
59
|
+
stop(): Promise<void>;
|
|
60
|
+
/** 启动心跳;重复调用先清旧 timer。intervalMs 缺省取配置,<=0 不开。 */
|
|
61
|
+
startHeartbeat(beat: () => void, intervalMs?: number): void;
|
|
62
|
+
/** 清理心跳 timer(close / stop / 看门狗触发时基座会自动调用)。 */
|
|
63
|
+
stopHeartbeat(): void;
|
|
64
|
+
/** 喂狗:收到任何回包(message / pong / 心跳响应)时调用,复位看门狗计数。 */
|
|
65
|
+
notifyHeartbeatAck(): void;
|
|
66
|
+
}
|
|
67
|
+
/** 创建端点生命周期基座实例(见文件头迁移指引)。 */
|
|
68
|
+
export declare function createEndpointLifecycle(options: EndpointLifecycleOptions): EndpointLifecycle;
|