@zhin.js/adapter 1.1.4 → 1.1.7
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 +3 -3
- package/lib/adapter-index.d.ts +2 -2
- package/lib/adapter-index.js +33 -33
- package/lib/definition.d.ts +2 -12
- package/lib/endpoint-commands.d.ts +26 -15
- package/lib/endpoint-commands.js +89 -37
- package/lib/endpoint-control.d.ts +6 -2
- package/lib/endpoint-control.js +5 -2
- package/lib/endpoint-lifecycle.js +1 -1
- package/package.json +6 -6
- package/src/adapter-index.ts +35 -35
- package/src/definition.ts +2 -8
- package/src/endpoint-commands.ts +141 -47
- package/src/endpoint-control.ts +8 -23
- package/src/endpoint-lifecycle.ts +1 -1
package/README.md
CHANGED
|
@@ -27,9 +27,9 @@ Adapter definitions declare `capabilities` for inbound/outbound admission and
|
|
|
27
27
|
instead of probing optional endpoint methods. The zero-dependency types live in
|
|
28
28
|
[`@zhin.js/im-contract`](../im-contract/README.md).
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
`EndpointSendRequest
|
|
32
|
-
|
|
30
|
+
Framework-facing outbound code carries a structured `ConversationRef`.
|
|
31
|
+
`EndpointSendRequest` is `{ conversation, payload }`; platform adapters derive
|
|
32
|
+
their native target from `conversation` at the endpoint boundary.
|
|
33
33
|
|
|
34
34
|
## Endpoint Control Port
|
|
35
35
|
|
package/lib/adapter-index.d.ts
CHANGED
|
@@ -31,11 +31,11 @@ export declare class AdapterIndex {
|
|
|
31
31
|
* Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
|
|
32
32
|
* Matches local name, capability id, or owner path segments.
|
|
33
33
|
*/
|
|
34
|
-
resolve(adapter: string,
|
|
34
|
+
resolve(adapter: string, endpointKey: string): CapabilityId | undefined;
|
|
35
35
|
/**
|
|
36
36
|
* Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
|
|
37
37
|
*/
|
|
38
|
-
instance(adapter: string,
|
|
38
|
+
instance(adapter: string, endpointKey: string): EndpointInstance | undefined;
|
|
39
39
|
owner(id: CapabilityId): PluginId;
|
|
40
40
|
/**
|
|
41
41
|
* Endpoint 的消息段能力声明(出站协商降级依据);
|
package/lib/adapter-index.js
CHANGED
|
@@ -28,13 +28,13 @@ export class AdapterIndex {
|
|
|
28
28
|
for (const expansion of expandEndpointConfigs(slot, snapshot)) {
|
|
29
29
|
const endpoint = await createEndpointSoft(slot, snapshot, expansion);
|
|
30
30
|
if (endpoint.unconfigured)
|
|
31
|
-
unconfigured.push(expansion.
|
|
31
|
+
unconfigured.push(expansion.endpointId);
|
|
32
32
|
records.push({
|
|
33
33
|
id: expansion.id,
|
|
34
34
|
owner: slot.owner,
|
|
35
|
-
// 展开模式下 record name 即 endpoint
|
|
36
|
-
// 保证 Console 展示与 resolve/instance 按 entry
|
|
37
|
-
name: expansion.
|
|
35
|
+
// 展开模式下 record name 即 endpoint id(entry.id),
|
|
36
|
+
// 保证 Console 展示与 resolve/instance 按 entry id 命中唯一 record
|
|
37
|
+
name: expansion.endpointId,
|
|
38
38
|
source: slot.source,
|
|
39
39
|
capabilities: slot.definition.capabilities,
|
|
40
40
|
endpoint: endpoint.instance,
|
|
@@ -85,21 +85,21 @@ export class AdapterIndex {
|
|
|
85
85
|
* Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
|
|
86
86
|
* Matches local name, capability id, or owner path segments.
|
|
87
87
|
*/
|
|
88
|
-
resolve(adapter,
|
|
89
|
-
const matches = this.#order.filter((record) => matchesEndpoint(record, adapter,
|
|
88
|
+
resolve(adapter, endpointKey) {
|
|
89
|
+
const matches = this.#order.filter((record) => matchesEndpoint(record, adapter, endpointKey));
|
|
90
90
|
if (matches.length === 1)
|
|
91
91
|
return matches[0]?.id;
|
|
92
92
|
if (matches.length === 0)
|
|
93
93
|
return undefined;
|
|
94
|
-
// Prefer exact localName ===
|
|
95
|
-
const exact = matches.find((record) => record.name ===
|
|
94
|
+
// Prefer exact localName === endpointKey when ambiguous.
|
|
95
|
+
const exact = matches.find((record) => record.name === endpointKey);
|
|
96
96
|
return exact?.id ?? matches[0]?.id;
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
99
|
* Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
|
|
100
100
|
*/
|
|
101
|
-
instance(adapter,
|
|
102
|
-
const id = this.resolve(adapter,
|
|
101
|
+
instance(adapter, endpointKey) {
|
|
102
|
+
const id = this.resolve(adapter, endpointKey);
|
|
103
103
|
if (!id)
|
|
104
104
|
return undefined;
|
|
105
105
|
return this.#records.get(id)?.endpoint;
|
|
@@ -266,7 +266,7 @@ export function isAdapterIndex(value) {
|
|
|
266
266
|
return !!value && typeof value === 'object'
|
|
267
267
|
&& value.$projection === 'zhin.adapter-index/1';
|
|
268
268
|
}
|
|
269
|
-
function matchesEndpoint(record, adapter,
|
|
269
|
+
function matchesEndpoint(record, adapter, endpointKey) {
|
|
270
270
|
// 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
|
|
271
271
|
// `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
|
|
272
272
|
// 不能用 `/` 去 endsWith,否则永远匹配不上(endpoint not found)。
|
|
@@ -281,10 +281,10 @@ function matchesEndpoint(record, adapter, endpointId) {
|
|
|
281
281
|
// activity-feedback resolve with that id; slot.localName alone is not enough
|
|
282
282
|
// when multiple plugin instances share localName "icqq".
|
|
283
283
|
const liveName = endpointLiveName(record.endpoint);
|
|
284
|
-
const endpointOk = record.name ===
|
|
285
|
-
|| record.id ===
|
|
286
|
-
|| record.id.endsWith(`/${
|
|
287
|
-
|| (liveName !== undefined && liveName ===
|
|
284
|
+
const endpointOk = record.name === endpointKey
|
|
285
|
+
|| record.id === endpointKey
|
|
286
|
+
|| record.id.endsWith(`/${endpointKey}`)
|
|
287
|
+
|| (liveName !== undefined && liveName === endpointKey);
|
|
288
288
|
return adapterOk && endpointOk;
|
|
289
289
|
}
|
|
290
290
|
function endpointLiveName(endpoint) {
|
|
@@ -316,7 +316,7 @@ function isUnconfiguredError(error) {
|
|
|
316
316
|
&& /requires|not configured|missing|未配置|缺少/i.test(error.message));
|
|
317
317
|
}
|
|
318
318
|
/**
|
|
319
|
-
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{
|
|
319
|
+
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{id, ...覆盖}]` 时
|
|
320
320
|
* 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
|
|
321
321
|
* 否则按实例 config 创建单个 endpoint(历史行为)。
|
|
322
322
|
*/
|
|
@@ -325,53 +325,53 @@ function expandEndpointConfigs(slot, snapshot) {
|
|
|
325
325
|
const raw = config?.endpoints;
|
|
326
326
|
const entries = Array.isArray(raw)
|
|
327
327
|
? raw.filter((entry) => !!entry && typeof entry === 'object'
|
|
328
|
-
&& typeof entry.
|
|
329
|
-
&& entry.
|
|
328
|
+
&& typeof entry.id === 'string'
|
|
329
|
+
&& entry.id.length > 0)
|
|
330
330
|
: [];
|
|
331
331
|
if (entries.length === 0) {
|
|
332
332
|
if (Array.isArray(raw) && raw.length > 0) {
|
|
333
333
|
logger.warn(formatCompact({
|
|
334
334
|
op: 'adapter_endpoints_entries_dropped',
|
|
335
335
|
id: slot.id,
|
|
336
|
-
reason: 'every endpoints entry is missing a non-empty string
|
|
336
|
+
reason: 'every endpoints entry is missing a non-empty string id',
|
|
337
337
|
}));
|
|
338
338
|
}
|
|
339
|
-
return Object.freeze([{ id: slot.id,
|
|
339
|
+
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
340
340
|
}
|
|
341
341
|
// `~` 是 record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析
|
|
342
342
|
const valid = entries.filter((entry) => {
|
|
343
|
-
if (/[~\0]/u.test(entry.
|
|
343
|
+
if (/[~\0]/u.test(entry.id)) {
|
|
344
344
|
logger.warn(formatCompact({
|
|
345
|
-
op: '
|
|
345
|
+
op: 'adapter_endpoint_id_invalid',
|
|
346
346
|
id: slot.id,
|
|
347
|
-
|
|
347
|
+
endpointId: entry.id,
|
|
348
348
|
}));
|
|
349
349
|
return false;
|
|
350
350
|
}
|
|
351
351
|
return true;
|
|
352
352
|
});
|
|
353
|
-
//
|
|
353
|
+
// 重 id 会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
|
|
354
354
|
const seen = new Set();
|
|
355
355
|
const deduped = valid.filter((entry) => {
|
|
356
|
-
if (seen.has(entry.
|
|
356
|
+
if (seen.has(entry.id)) {
|
|
357
357
|
logger.warn(formatCompact({
|
|
358
|
-
op: '
|
|
358
|
+
op: 'adapter_endpoint_id_duplicate',
|
|
359
359
|
id: slot.id,
|
|
360
|
-
|
|
360
|
+
endpointId: entry.id,
|
|
361
361
|
}));
|
|
362
362
|
return false;
|
|
363
363
|
}
|
|
364
|
-
seen.add(entry.
|
|
364
|
+
seen.add(entry.id);
|
|
365
365
|
return true;
|
|
366
366
|
});
|
|
367
367
|
if (deduped.length === 0) {
|
|
368
|
-
return Object.freeze([{ id: slot.id,
|
|
368
|
+
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
369
369
|
}
|
|
370
370
|
const { endpoints: _drop, ...base } = (config ?? {});
|
|
371
371
|
return Object.freeze(deduped.map((entry) => Object.freeze({
|
|
372
|
-
id: `${slot.id}~${entry.
|
|
373
|
-
|
|
374
|
-
config: Object.freeze({ ...base, ...entry,
|
|
372
|
+
id: `${slot.id}~${entry.id}`,
|
|
373
|
+
endpointId: entry.id,
|
|
374
|
+
config: Object.freeze({ ...base, ...entry, id: entry.id }),
|
|
375
375
|
})));
|
|
376
376
|
}
|
|
377
377
|
async function createEndpointSoft(slot, snapshot, expansion) {
|
|
@@ -394,7 +394,7 @@ async function createEndpointSoft(slot, snapshot, expansion) {
|
|
|
394
394
|
log(formatCompact({
|
|
395
395
|
op: 'adapter_create_soft_fail',
|
|
396
396
|
id: expansion?.id ?? slot.id,
|
|
397
|
-
name: expansion?.
|
|
397
|
+
name: expansion?.endpointId ?? slot.localName,
|
|
398
398
|
error: message,
|
|
399
399
|
}));
|
|
400
400
|
return {
|
package/lib/definition.d.ts
CHANGED
|
@@ -12,19 +12,9 @@ export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
|
|
|
12
12
|
/** 交互段(卡片/按钮等富交互)的端点消费方式。 */
|
|
13
13
|
export type AdapterInteractiveMode = 'native' | 'text';
|
|
14
14
|
export interface EndpointSendRequest {
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
* platform adapter has migrated its native boundary codec.
|
|
18
|
-
*/
|
|
19
|
-
readonly conversation?: ConversationRef;
|
|
20
|
-
/** @deprecated Use conversation for framework-facing code. */
|
|
21
|
-
readonly target: string;
|
|
15
|
+
/** 结构化会话寻址;端点在平台边界自行派生原生 target。 */
|
|
16
|
+
readonly conversation: ConversationRef;
|
|
22
17
|
readonly payload: unknown;
|
|
23
|
-
readonly parent?: {
|
|
24
|
-
readonly type?: string;
|
|
25
|
-
readonly id?: string;
|
|
26
|
-
readonly name?: string;
|
|
27
|
-
};
|
|
28
18
|
}
|
|
29
19
|
export interface EndpointInstance<TResult = unknown> {
|
|
30
20
|
/** Optional platform-neutral Console/Host management surface. */
|
|
@@ -11,13 +11,18 @@ export type EndpointCommandReply = (text: string) => Promise<unknown>;
|
|
|
11
11
|
* 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
|
|
12
12
|
*/
|
|
13
13
|
export declare function extractEndpointCommandReply(input: unknown): EndpointCommandReply;
|
|
14
|
+
/**
|
|
15
|
+
* bindFlow 后续状态推送:优先走 OutboundHost(不受 inbound Message reply scope 限制)。
|
|
16
|
+
* 扫码绑定等长流程会在命令结果已送达、`$reply` 已冻结后继续 notify,必须用 durable 出站。
|
|
17
|
+
*/
|
|
18
|
+
export declare function createDurableEndpointCommandReply(input: unknown, use: EndpointCommandUse): EndpointCommandReply;
|
|
14
19
|
export interface EndpointRunningInfo {
|
|
15
|
-
readonly
|
|
20
|
+
readonly id: string;
|
|
16
21
|
/** 连接模式(ws / wss / polling / socket-mode …),仅用于 list 展示。 */
|
|
17
22
|
readonly mode?: string;
|
|
18
23
|
}
|
|
19
24
|
export interface EndpointRuntimeState {
|
|
20
|
-
/** 当前 generation 已成功创建的 endpoint(
|
|
25
|
+
/** 当前 generation 已成功创建的 endpoint(id → 描述) */
|
|
21
26
|
readonly endpoints: Map<string, EndpointRunningInfo>;
|
|
22
27
|
}
|
|
23
28
|
export declare function createEndpointRuntimeState(): EndpointRuntimeState;
|
|
@@ -26,21 +31,21 @@ export declare function defineEndpointRuntimeStateToken(adapterKey: string): Tok
|
|
|
26
31
|
/** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
|
|
27
32
|
export declare function resolveProjectRoot(): string;
|
|
28
33
|
/** 派生 endpoint 凭据的 env 键:`${ADAPTER}_${NAME}_${FIELD}`(如 `TELEGRAM_MY_BOT_TOKEN`) */
|
|
29
|
-
export declare function buildEndpointEnvKey(adapterKey: string,
|
|
34
|
+
export declare function buildEndpointEnvKey(adapterKey: string, endpointId: string, fieldKey: string): string;
|
|
30
35
|
/** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
|
|
31
36
|
export declare function persistEndpointEnvValues(values: Readonly<Record<string, string>>, projectRoot?: string): void;
|
|
32
37
|
export interface ConfiguredEndpointEntry {
|
|
33
|
-
|
|
38
|
+
id: string;
|
|
34
39
|
[key: string]: unknown;
|
|
35
40
|
}
|
|
36
41
|
/** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
|
|
37
42
|
export declare function findEndpointConfigFile(adapterKey: string, projectRoot?: string): string;
|
|
38
43
|
/** 读取 plugins.<adapterKey>.endpoints(plain JS);plugins/<adapterKey> 缺失或形态不符时返回 [] */
|
|
39
44
|
export declare function listConfiguredEndpoints(adapterKey: string, projectRoot?: string): ConfiguredEndpointEntry[];
|
|
40
|
-
/** 追加 endpoint 到 plugins.<adapterKey>.endpoints;
|
|
45
|
+
/** 追加 endpoint 到 plugins.<adapterKey>.endpoints;id 已存在时报错 */
|
|
41
46
|
export declare function addEndpointToConfig(adapterKey: string, entry: ConfiguredEndpointEntry, projectRoot?: string): string;
|
|
42
|
-
/** 按
|
|
43
|
-
export declare function removeEndpointFromConfig(adapterKey: string,
|
|
47
|
+
/** 按 id 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
|
|
48
|
+
export declare function removeEndpointFromConfig(adapterKey: string, id: string, projectRoot?: string): {
|
|
44
49
|
removed: boolean;
|
|
45
50
|
filePath: string;
|
|
46
51
|
};
|
|
@@ -58,9 +63,9 @@ export interface EndpointFieldSpec {
|
|
|
58
63
|
export type EndpointCommandUse = <T>(token: Token<T>) => T;
|
|
59
64
|
/** bindFlow 钩子上下文:接管 add 命令的自定义绑定流程(如 QQ 扫码)。 */
|
|
60
65
|
export interface EndpointBindFlowContext {
|
|
61
|
-
/** 命令参数
|
|
62
|
-
readonly
|
|
63
|
-
/** 向当前会话推送后续状态(二维码刷新 / 成功 /
|
|
66
|
+
/** 命令参数 id(未指定时为 undefined,流程可自行决定终名) */
|
|
67
|
+
readonly id?: string;
|
|
68
|
+
/** 向当前会话推送后续状态(二维码刷新 / 成功 / 失败;走 durable OutboundHost,可在命令 reply scope 结束后调用) */
|
|
64
69
|
readonly reply: EndpointCommandReply;
|
|
65
70
|
readonly config: unknown;
|
|
66
71
|
readonly input: unknown;
|
|
@@ -89,8 +94,9 @@ export interface EndpointCommandsSpec {
|
|
|
89
94
|
* provider 层不允许 import @zhin.js/command,故 defineCommand 由调用方注入,
|
|
90
95
|
* 这里只描述结构;适配器侧传入 defineCommand 后 TCommand 即 Readonly<CommandDefinition>。
|
|
91
96
|
*
|
|
92
|
-
* `params` 值域须与 CommandParameterValue 对齐(含 null /
|
|
93
|
-
*
|
|
97
|
+
* `params` 值域须与 CommandParameterValue 对齐(含 null / 结构化对象 / rest 段的
|
|
98
|
+
* `ReadonlyArray<string | number | boolean>`),否则注入的 defineCommand 会因
|
|
99
|
+
* TS 逆变检查失败(TS2345)。
|
|
94
100
|
*/
|
|
95
101
|
export interface EndpointCommandContext {
|
|
96
102
|
readonly config: unknown;
|
|
@@ -101,11 +107,16 @@ export interface EndpointCommandContext {
|
|
|
101
107
|
*/
|
|
102
108
|
readonly input?: unknown;
|
|
103
109
|
readonly args: readonly string[];
|
|
104
|
-
readonly params: Readonly<Record<string, string | number | boolean | Readonly<Record<string, unknown>> | null>>;
|
|
110
|
+
readonly params: Readonly<Record<string, string | number | boolean | ReadonlyArray<string | number | boolean> | Readonly<Record<string, unknown>> | null>>;
|
|
105
111
|
readonly use: EndpointCommandUse;
|
|
106
112
|
}
|
|
107
113
|
export interface EndpointCommandDefinition {
|
|
108
114
|
readonly description?: string;
|
|
115
|
+
readonly params?: Readonly<Record<string, {
|
|
116
|
+
readonly type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text' | 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
|
|
117
|
+
readonly default?: string | number | boolean | ReadonlyArray<string | number | boolean> | Readonly<Record<string, unknown>> | null;
|
|
118
|
+
readonly description?: string;
|
|
119
|
+
}>>;
|
|
109
120
|
execute(context: EndpointCommandContext): unknown;
|
|
110
121
|
}
|
|
111
122
|
export interface EndpointCommands<TCommand = EndpointCommandDefinition> {
|
|
@@ -120,8 +131,8 @@ export declare function formatEndpointList(spec: Pick<EndpointCommandsSpec, 'ada
|
|
|
120
131
|
readonly footer?: string;
|
|
121
132
|
}): string;
|
|
122
133
|
/** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
|
|
123
|
-
export declare function addEndpointFromKeyValues(spec: EndpointCommandsSpec,
|
|
134
|
+
export declare function addEndpointFromKeyValues(spec: EndpointCommandsSpec, id: string, args: readonly string[], projectRoot?: string): string;
|
|
124
135
|
/** remove 的完整业务逻辑:从 yaml 移除;返回回复文本。 */
|
|
125
|
-
export declare function
|
|
136
|
+
export declare function removeEndpointById(spec: Pick<EndpointCommandsSpec, 'adapterKey'>, id: string, projectRoot?: string): string;
|
|
126
137
|
/** 生成 `<adapter> endpoint` 的 list / add / remove 三个命令定义(见文件头接入步骤)。 */
|
|
127
138
|
export declare function createEndpointCommands<TCommand>(spec: EndpointCommandsSpec, defineCommand: (definition: EndpointCommandDefinition) => TCommand): EndpointCommands<TCommand>;
|
package/lib/endpoint-commands.js
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
* 接入步骤(以 telegram 为例):
|
|
17
17
|
* 1. plugin.ts setup 里 `context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState())`,
|
|
18
18
|
* token 由 `defineEndpointRuntimeStateToken('telegram')` 创建。
|
|
19
|
-
* 2. adapters/telegram.ts create() 里 `context.use(token).endpoints.set(config.
|
|
19
|
+
* 2. adapters/telegram.ts create() 里 `context.use(token).endpoints.set(config.id, { id, mode })`。
|
|
20
20
|
* 3. src 下 `export const telegramEndpointCommands = createEndpointCommands({ adapterKey: 'telegram', ... }, defineCommand)`
|
|
21
21
|
* (defineCommand 由调用方从 @zhin.js/command 传入——provider 包之间禁止互相 import,
|
|
22
22
|
* 见 scripts/check-architecture-layers.mjs,故 defineCommand 走依赖注入)。
|
|
23
|
-
* 4. commands/endpoint/{list.ts, add/[name
|
|
23
|
+
* 4. commands/endpoint/{list.ts, add/[name].ts, remove/[name].ts} 分别
|
|
24
24
|
* `export default telegramEndpointCommands.list|add|remove`。
|
|
25
25
|
*
|
|
26
26
|
* 注意:adapterKey 即实例 key(zhin.config.yml 的 plugins.<key>);多实例自定义 key 时
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
|
-
import { createToken } from '@zhin.js/plugin-runtime';
|
|
31
|
+
import { createToken, outboundHostToken, } from '@zhin.js/plugin-runtime';
|
|
32
32
|
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
// 权限:master 判定
|
|
@@ -55,8 +55,9 @@ export function isEndpointOperator(config, input) {
|
|
|
55
55
|
}
|
|
56
56
|
if (masters.size === 0)
|
|
57
57
|
return true;
|
|
58
|
-
const
|
|
59
|
-
|
|
58
|
+
const senderRaw = input?.sender;
|
|
59
|
+
const senderId = (typeof senderRaw === 'object' && senderRaw?.id) ? senderRaw.id.trim() : '';
|
|
60
|
+
return !!senderId && masters.has(senderId);
|
|
60
61
|
}
|
|
61
62
|
/** add/remove 的拒绝文案(list 只读,不校验)。 */
|
|
62
63
|
export function endpointCommandForbidden(adapterDisplayName) {
|
|
@@ -72,6 +73,56 @@ export function extractEndpointCommandReply(input) {
|
|
|
72
73
|
}
|
|
73
74
|
return async () => undefined;
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* bindFlow 后续状态推送:优先走 OutboundHost(不受 inbound Message reply scope 限制)。
|
|
78
|
+
* 扫码绑定等长流程会在命令结果已送达、`$reply` 已冻结后继续 notify,必须用 durable 出站。
|
|
79
|
+
*/
|
|
80
|
+
export function createDurableEndpointCommandReply(input, use) {
|
|
81
|
+
const scoped = extractEndpointCommandReply(input);
|
|
82
|
+
const target = readOutboundSendTarget(input);
|
|
83
|
+
if (!target)
|
|
84
|
+
return scoped;
|
|
85
|
+
return async (text) => {
|
|
86
|
+
let outbound;
|
|
87
|
+
try {
|
|
88
|
+
outbound = use(outboundHostToken);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
outbound = undefined;
|
|
92
|
+
}
|
|
93
|
+
if (outbound) {
|
|
94
|
+
await outbound.send({ ...target, content: text });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
await scoped(text);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function readOutboundSendTarget(input) {
|
|
101
|
+
const message = input;
|
|
102
|
+
const conversation = message?.conversation;
|
|
103
|
+
const endpointKeyRaw = conversation?.endpoint?.id;
|
|
104
|
+
const adapterRaw = conversation?.endpoint?.adapter;
|
|
105
|
+
const kind = conversation?.kind;
|
|
106
|
+
const id = conversation?.id;
|
|
107
|
+
if (endpointKeyRaw == null
|
|
108
|
+
|| adapterRaw == null
|
|
109
|
+
|| (kind !== 'private' && kind !== 'group' && kind !== 'channel')
|
|
110
|
+
|| typeof id !== 'string'
|
|
111
|
+
|| !id) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const live = String(message?.metadata?.endpoint ?? message?.metadata?.endpointKey ?? '').trim();
|
|
115
|
+
return {
|
|
116
|
+
adapter: String(adapterRaw),
|
|
117
|
+
endpointKey: live || String(endpointKeyRaw),
|
|
118
|
+
conversation: {
|
|
119
|
+
kind,
|
|
120
|
+
id,
|
|
121
|
+
parent: conversation.parent,
|
|
122
|
+
threadId: typeof conversation.threadId === 'string' ? conversation.threadId : undefined,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
75
126
|
export function createEndpointRuntimeState() {
|
|
76
127
|
return { endpoints: new Map() };
|
|
77
128
|
}
|
|
@@ -94,8 +145,8 @@ function envSlug(text) {
|
|
|
94
145
|
.toUpperCase();
|
|
95
146
|
}
|
|
96
147
|
/** 派生 endpoint 凭据的 env 键:`${ADAPTER}_${NAME}_${FIELD}`(如 `TELEGRAM_MY_BOT_TOKEN`) */
|
|
97
|
-
export function buildEndpointEnvKey(adapterKey,
|
|
98
|
-
return `${envSlug(adapterKey)}_${envSlug(
|
|
148
|
+
export function buildEndpointEnvKey(adapterKey, endpointId, fieldKey) {
|
|
149
|
+
return `${envSlug(adapterKey)}_${envSlug(endpointId)}_${envSlug(fieldKey)}`;
|
|
99
150
|
}
|
|
100
151
|
function escapeRegExp(text) {
|
|
101
152
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -162,13 +213,13 @@ export function listConfiguredEndpoints(adapterKey, projectRoot) {
|
|
|
162
213
|
const endpoints = plugins[adapterKey]?.endpoints;
|
|
163
214
|
if (!Array.isArray(endpoints))
|
|
164
215
|
return [];
|
|
165
|
-
return endpoints.filter((entry) => !!entry && typeof entry === 'object' && typeof entry.
|
|
216
|
+
return endpoints.filter((entry) => !!entry && typeof entry === 'object' && typeof entry.id === 'string');
|
|
166
217
|
}
|
|
167
|
-
function
|
|
218
|
+
function entryId(item) {
|
|
168
219
|
if (!isMap(item))
|
|
169
220
|
return undefined;
|
|
170
|
-
const
|
|
171
|
-
return typeof
|
|
221
|
+
const id = item.get('id');
|
|
222
|
+
return typeof id === 'string' && id ? id : undefined;
|
|
172
223
|
}
|
|
173
224
|
/**
|
|
174
225
|
* 确保 plugins.<adapterKey>.endpoints 存在并返回其 YAMLSeq(节点级操作,保留既有条目与注释)。
|
|
@@ -202,22 +253,22 @@ function ensureEndpointsSeq(doc, adapterKey) {
|
|
|
202
253
|
}
|
|
203
254
|
return doc.getIn(['plugins', adapterKey, 'endpoints']);
|
|
204
255
|
}
|
|
205
|
-
/** 追加 endpoint 到 plugins.<adapterKey>.endpoints;
|
|
256
|
+
/** 追加 endpoint 到 plugins.<adapterKey>.endpoints;id 已存在时报错 */
|
|
206
257
|
export function addEndpointToConfig(adapterKey, entry, projectRoot) {
|
|
207
258
|
const document = readConfigDocument(adapterKey, projectRoot);
|
|
208
259
|
const seq = ensureEndpointsSeq(document.doc, adapterKey);
|
|
209
|
-
if (seq.items.some((item) =>
|
|
210
|
-
throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.
|
|
260
|
+
if (seq.items.some((item) => entryId(item) === entry.id)) {
|
|
261
|
+
throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.id}」,可先 ${adapterKey}.endpoint remove ${entry.id} 再重新添加`);
|
|
211
262
|
}
|
|
212
263
|
seq.items.push(document.doc.createNode(entry));
|
|
213
264
|
writeConfigDocument(document);
|
|
214
265
|
return document.filePath;
|
|
215
266
|
}
|
|
216
|
-
/** 按
|
|
217
|
-
export function removeEndpointFromConfig(adapterKey,
|
|
267
|
+
/** 按 id 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
|
|
268
|
+
export function removeEndpointFromConfig(adapterKey, id, projectRoot) {
|
|
218
269
|
const document = readConfigDocument(adapterKey, projectRoot);
|
|
219
270
|
const seq = ensureEndpointsSeq(document.doc, adapterKey);
|
|
220
|
-
const next = seq.items.filter((item) =>
|
|
271
|
+
const next = seq.items.filter((item) => entryId(item) !== id);
|
|
221
272
|
if (next.length === seq.items.length) {
|
|
222
273
|
return { removed: false, filePath: document.filePath };
|
|
223
274
|
}
|
|
@@ -225,9 +276,9 @@ export function removeEndpointFromConfig(adapterKey, name, projectRoot) {
|
|
|
225
276
|
writeConfigDocument(document);
|
|
226
277
|
return { removed: true, filePath: document.filePath };
|
|
227
278
|
}
|
|
228
|
-
function
|
|
229
|
-
const
|
|
230
|
-
return typeof
|
|
279
|
+
function endpointIdParam(params) {
|
|
280
|
+
const id = params.id;
|
|
281
|
+
return typeof id === 'string' && id.trim() ? id.trim() : undefined;
|
|
231
282
|
}
|
|
232
283
|
/** list 文案:运行中 + 配置中两段,footer 可选。 */
|
|
233
284
|
export function formatEndpointList(spec, source) {
|
|
@@ -239,7 +290,7 @@ export function formatEndpointList(spec, source) {
|
|
|
239
290
|
}
|
|
240
291
|
else {
|
|
241
292
|
for (const endpoint of running) {
|
|
242
|
-
lines.push(endpoint.mode ? ` - ${endpoint.
|
|
293
|
+
lines.push(endpoint.mode ? ` - ${endpoint.id}(${endpoint.mode})` : ` - ${endpoint.id}`);
|
|
243
294
|
}
|
|
244
295
|
}
|
|
245
296
|
lines.push(`【配置中的 ${spec.adapterDisplayName} endpoints】(zhin.config.yml → plugins.${spec.adapterKey}.endpoints)`);
|
|
@@ -249,7 +300,7 @@ export function formatEndpointList(spec, source) {
|
|
|
249
300
|
else {
|
|
250
301
|
for (const entry of source.configured) {
|
|
251
302
|
const detail = spec.describeEntry?.(entry);
|
|
252
|
-
lines.push(detail ? ` - ${entry.
|
|
303
|
+
lines.push(detail ? ` - ${entry.id}(${detail})` : ` - ${entry.id}`);
|
|
253
304
|
}
|
|
254
305
|
}
|
|
255
306
|
if (source.footer)
|
|
@@ -268,10 +319,10 @@ function addUsage(spec) {
|
|
|
268
319
|
].filter(Boolean).join(',');
|
|
269
320
|
return marks ? `${field.key}(${marks})` : field.key;
|
|
270
321
|
}).join('、')}`;
|
|
271
|
-
return `用法:${spec.adapterKey}.endpoint add <
|
|
322
|
+
return `用法:${spec.adapterKey}.endpoint add <id> <key=value...>${fieldText}`;
|
|
272
323
|
}
|
|
273
324
|
/** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
|
|
274
|
-
export function addEndpointFromKeyValues(spec,
|
|
325
|
+
export function addEndpointFromKeyValues(spec, id, args, projectRoot) {
|
|
275
326
|
const fields = spec.fields ?? [];
|
|
276
327
|
const known = new Map(fields.map((field) => [field.key, field]));
|
|
277
328
|
const values = new Map();
|
|
@@ -293,14 +344,14 @@ export function addEndpointFromKeyValues(spec, name, args, projectRoot) {
|
|
|
293
344
|
if (missing.length > 0) {
|
|
294
345
|
return `缺少必填字段:${missing.map((field) => field.key).join('、')}。${addUsage(spec)}`;
|
|
295
346
|
}
|
|
296
|
-
const entry = {
|
|
347
|
+
const entry = { id };
|
|
297
348
|
const envValues = {};
|
|
298
349
|
for (const field of fields) {
|
|
299
350
|
const value = values.get(field.key);
|
|
300
351
|
if (value === undefined)
|
|
301
352
|
continue;
|
|
302
353
|
if (field.env) {
|
|
303
|
-
const envKey = buildEndpointEnvKey(spec.adapterKey,
|
|
354
|
+
const envKey = buildEndpointEnvKey(spec.adapterKey, id, field.key);
|
|
304
355
|
envValues[envKey] = value;
|
|
305
356
|
entry[field.key] = `\${${envKey}}`;
|
|
306
357
|
}
|
|
@@ -309,11 +360,10 @@ export function addEndpointFromKeyValues(spec, name, args, projectRoot) {
|
|
|
309
360
|
}
|
|
310
361
|
}
|
|
311
362
|
try {
|
|
312
|
-
// 先写配置(重名等校验失败时不留孤儿 .env 键),再落 .env 凭据
|
|
313
363
|
const filePath = addEndpointToConfig(spec.adapterKey, entry, projectRoot);
|
|
314
364
|
if (Object.keys(envValues).length > 0)
|
|
315
365
|
persistEndpointEnvValues(envValues, projectRoot);
|
|
316
|
-
return (`✅ endpoint「${
|
|
366
|
+
return (`✅ endpoint「${id}」已追加到 ${filePath} 的 plugins.${spec.adapterKey}.endpoints` +
|
|
317
367
|
`${Object.keys(envValues).length > 0 ? '(凭据已写入 .env)' : ''}。\n` +
|
|
318
368
|
'⚠️ 需重启 zhin 后新 endpoint 才会生效。');
|
|
319
369
|
}
|
|
@@ -322,10 +372,10 @@ export function addEndpointFromKeyValues(spec, name, args, projectRoot) {
|
|
|
322
372
|
}
|
|
323
373
|
}
|
|
324
374
|
/** remove 的完整业务逻辑:从 yaml 移除;返回回复文本。 */
|
|
325
|
-
export function
|
|
326
|
-
const trimmed =
|
|
375
|
+
export function removeEndpointById(spec, id, projectRoot) {
|
|
376
|
+
const trimmed = id.trim();
|
|
327
377
|
if (!trimmed)
|
|
328
|
-
return `用法:${spec.adapterKey}.endpoint remove <
|
|
378
|
+
return `用法:${spec.adapterKey}.endpoint remove <id>`;
|
|
329
379
|
try {
|
|
330
380
|
const { removed, filePath } = removeEndpointFromConfig(spec.adapterKey, trimmed, projectRoot);
|
|
331
381
|
if (!removed) {
|
|
@@ -355,30 +405,32 @@ export function createEndpointCommands(spec, defineCommand) {
|
|
|
355
405
|
add: defineCommand({
|
|
356
406
|
description: spec.addDescription
|
|
357
407
|
?? `手动添加 ${spec.adapterDisplayName} endpoint(凭据写入 .env 并追加到 zhin.config.yml,重启生效)`,
|
|
408
|
+
params: { id: { type: 'string', description: 'endpoint ID' } },
|
|
358
409
|
execute({ config, input, params, args, use }) {
|
|
359
410
|
if (!isEndpointOperator(config, input))
|
|
360
411
|
return forbidden;
|
|
361
|
-
const
|
|
412
|
+
const id = endpointIdParam(params);
|
|
362
413
|
if (spec.bindFlow) {
|
|
363
414
|
return spec.bindFlow({
|
|
364
|
-
|
|
365
|
-
reply:
|
|
415
|
+
id,
|
|
416
|
+
reply: createDurableEndpointCommandReply(input, use),
|
|
366
417
|
config,
|
|
367
418
|
input,
|
|
368
419
|
use,
|
|
369
420
|
});
|
|
370
421
|
}
|
|
371
|
-
if (!
|
|
422
|
+
if (!id)
|
|
372
423
|
return addUsage(spec);
|
|
373
|
-
return addEndpointFromKeyValues(spec,
|
|
424
|
+
return addEndpointFromKeyValues(spec, id, args);
|
|
374
425
|
},
|
|
375
426
|
}),
|
|
376
427
|
remove: defineCommand({
|
|
377
428
|
description: `从 zhin.config.yml 的 plugins.${spec.adapterKey}.endpoints 移除指定 endpoint(重启生效)`,
|
|
429
|
+
params: { id: { type: 'string', description: 'endpoint ID' } },
|
|
378
430
|
execute({ config, input, params }) {
|
|
379
431
|
if (!isEndpointOperator(config, input))
|
|
380
432
|
return forbidden;
|
|
381
|
-
return
|
|
433
|
+
return removeEndpointById(spec, String(params.id ?? ''));
|
|
382
434
|
},
|
|
383
435
|
}),
|
|
384
436
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ConversationTarget, type MessageTarget } from '@zhin.js/im-contract';
|
|
2
|
+
export type { LegacyEndpointControlSurface } from '@zhin.js/im-contract';
|
|
2
3
|
/**
|
|
3
4
|
* Transport-neutral control plane for a live endpoint.
|
|
4
5
|
*
|
|
@@ -21,8 +22,11 @@ export interface EndpointWithControl {
|
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
23
24
|
* Resolves the public control port. The legacy branch is deliberately kept in
|
|
24
|
-
* Adapter only: it is a migration bridge for existing protocol
|
|
25
|
-
*
|
|
25
|
+
* Adapter only: it is a migration bridge for existing classic protocol
|
|
26
|
+
* endpoints (`LegacyEndpointControlSurface` lives in `@zhin.js/im-contract`),
|
|
27
|
+
* not an IM Core extension point. New adapters must expose `control` directly.
|
|
28
|
+
* 下线条件:classic Plugin 轨下线后,legacy 分支与 LegacyEndpointControlSurface
|
|
29
|
+
* 一并删除。
|
|
26
30
|
*/
|
|
27
31
|
export declare function resolveEndpointControl(endpoint: unknown): EndpointControl | undefined;
|
|
28
32
|
/** Checks only an Endpoint's explicit `control` port, never the legacy bridge. */
|
package/lib/endpoint-control.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { formatLegacyConversationRef, formatLegacyMessageRef, } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* Resolves the public control port. The legacy branch is deliberately kept in
|
|
4
|
-
* Adapter only: it is a migration bridge for existing protocol
|
|
5
|
-
*
|
|
4
|
+
* Adapter only: it is a migration bridge for existing classic protocol
|
|
5
|
+
* endpoints (`LegacyEndpointControlSurface` lives in `@zhin.js/im-contract`),
|
|
6
|
+
* not an IM Core extension point. New adapters must expose `control` directly.
|
|
7
|
+
* 下线条件:classic Plugin 轨下线后,legacy 分支与 LegacyEndpointControlSurface
|
|
8
|
+
* 一并删除。
|
|
6
9
|
*/
|
|
7
10
|
export function resolveEndpointControl(endpoint) {
|
|
8
11
|
if (!endpoint || typeof endpoint !== 'object')
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* 迁移指引(以 napcat/milky/onebot WS endpoint 为例):
|
|
21
21
|
* 1. 删除 #started / #stopping / #reconnectTimer / #heartbeatTimer / opened 旗标,
|
|
22
|
-
* 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.
|
|
22
|
+
* 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.id, reconnect, heartbeat })`。
|
|
23
23
|
* 2. `start()` 改为:
|
|
24
24
|
* ```ts
|
|
25
25
|
* this.#unregisterAgent = registerXxxAgentEndpoint(name, this); // agent 注册仍在适配器侧
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"yaml": "^2.9.0",
|
|
21
|
-
"@zhin.js/feature-kit": "1.0.
|
|
22
|
-
"@zhin.js/im-contract": "1.0.
|
|
23
|
-
"@zhin.js/logger": "1.0.
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
21
|
+
"@zhin.js/feature-kit": "1.0.8",
|
|
22
|
+
"@zhin.js/im-contract": "1.0.3",
|
|
23
|
+
"@zhin.js/logger": "1.0.76",
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
29
|
-
"@zhin.js/command": "1.0.
|
|
29
|
+
"@zhin.js/command": "1.0.9"
|
|
30
30
|
},
|
|
31
31
|
"zhin": {
|
|
32
32
|
"protocol": 1,
|
package/src/adapter-index.ts
CHANGED
|
@@ -89,13 +89,13 @@ export class AdapterIndex {
|
|
|
89
89
|
for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
90
90
|
for (const expansion of expandEndpointConfigs(slot, snapshot)) {
|
|
91
91
|
const endpoint = await createEndpointSoft(slot, snapshot, expansion);
|
|
92
|
-
if (endpoint.unconfigured) unconfigured.push(expansion.
|
|
92
|
+
if (endpoint.unconfigured) unconfigured.push(expansion.endpointId);
|
|
93
93
|
records.push({
|
|
94
94
|
id: expansion.id,
|
|
95
95
|
owner: slot.owner,
|
|
96
|
-
// 展开模式下 record name 即 endpoint
|
|
97
|
-
// 保证 Console 展示与 resolve/instance 按 entry
|
|
98
|
-
name: expansion.
|
|
96
|
+
// 展开模式下 record name 即 endpoint id(entry.id),
|
|
97
|
+
// 保证 Console 展示与 resolve/instance 按 entry id 命中唯一 record
|
|
98
|
+
name: expansion.endpointId,
|
|
99
99
|
source: slot.source,
|
|
100
100
|
capabilities: slot.definition.capabilities,
|
|
101
101
|
endpoint: endpoint.instance,
|
|
@@ -155,21 +155,21 @@ export class AdapterIndex {
|
|
|
155
155
|
* Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
|
|
156
156
|
* Matches local name, capability id, or owner path segments.
|
|
157
157
|
*/
|
|
158
|
-
resolve(adapter: string,
|
|
158
|
+
resolve(adapter: string, endpointKey: string): CapabilityId | undefined {
|
|
159
159
|
const matches = this.#order.filter((record) =>
|
|
160
|
-
matchesEndpoint(record, adapter,
|
|
160
|
+
matchesEndpoint(record, adapter, endpointKey));
|
|
161
161
|
if (matches.length === 1) return matches[0]?.id;
|
|
162
162
|
if (matches.length === 0) return undefined;
|
|
163
|
-
// Prefer exact localName ===
|
|
164
|
-
const exact = matches.find((record) => record.name ===
|
|
163
|
+
// Prefer exact localName === endpointKey when ambiguous.
|
|
164
|
+
const exact = matches.find((record) => record.name === endpointKey);
|
|
165
165
|
return exact?.id ?? matches[0]?.id;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
169
|
* Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
|
|
170
170
|
*/
|
|
171
|
-
instance(adapter: string,
|
|
172
|
-
const id = this.resolve(adapter,
|
|
171
|
+
instance(adapter: string, endpointKey: string): EndpointInstance | undefined {
|
|
172
|
+
const id = this.resolve(adapter, endpointKey);
|
|
173
173
|
if (!id) return undefined;
|
|
174
174
|
return this.#records.get(id)?.endpoint;
|
|
175
175
|
}
|
|
@@ -341,7 +341,7 @@ export function isAdapterIndex(value: unknown): value is AdapterIndex {
|
|
|
341
341
|
function matchesEndpoint(
|
|
342
342
|
record: AdapterRecord,
|
|
343
343
|
adapter: string,
|
|
344
|
-
|
|
344
|
+
endpointKey: string,
|
|
345
345
|
): boolean {
|
|
346
346
|
// 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
|
|
347
347
|
// `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
|
|
@@ -357,10 +357,10 @@ function matchesEndpoint(
|
|
|
357
357
|
// activity-feedback resolve with that id; slot.localName alone is not enough
|
|
358
358
|
// when multiple plugin instances share localName "icqq".
|
|
359
359
|
const liveName = endpointLiveName(record.endpoint);
|
|
360
|
-
const endpointOk = record.name ===
|
|
361
|
-
|| record.id ===
|
|
362
|
-
|| record.id.endsWith(`/${
|
|
363
|
-
|| (liveName !== undefined && liveName ===
|
|
360
|
+
const endpointOk = record.name === endpointKey
|
|
361
|
+
|| record.id === endpointKey
|
|
362
|
+
|| record.id.endsWith(`/${endpointKey}`)
|
|
363
|
+
|| (liveName !== undefined && liveName === endpointKey);
|
|
364
364
|
return adapterOk && endpointOk;
|
|
365
365
|
}
|
|
366
366
|
|
|
@@ -397,12 +397,12 @@ function isUnconfiguredError(error: unknown): boolean {
|
|
|
397
397
|
/** 单个实例配置展开的 endpoint 描述(多账号适配器经 `endpoints` 数组声明)。 */
|
|
398
398
|
interface EndpointExpansion {
|
|
399
399
|
readonly id: CapabilityId;
|
|
400
|
-
readonly
|
|
400
|
+
readonly endpointId: string;
|
|
401
401
|
readonly config?: Readonly<Record<string, unknown>>;
|
|
402
402
|
}
|
|
403
403
|
|
|
404
404
|
/**
|
|
405
|
-
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{
|
|
405
|
+
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{id, ...覆盖}]` 时
|
|
406
406
|
* 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
|
|
407
407
|
* 否则按实例 config 创建单个 endpoint(历史行为)。
|
|
408
408
|
*/
|
|
@@ -415,55 +415,55 @@ function expandEndpointConfigs(
|
|
|
415
415
|
| undefined;
|
|
416
416
|
const raw = config?.endpoints;
|
|
417
417
|
const entries = Array.isArray(raw)
|
|
418
|
-
? raw.filter((entry): entry is Record<string, unknown> & {
|
|
418
|
+
? raw.filter((entry): entry is Record<string, unknown> & { id: string } =>
|
|
419
419
|
!!entry && typeof entry === 'object'
|
|
420
|
-
&& typeof (entry as {
|
|
421
|
-
&& (entry as {
|
|
420
|
+
&& typeof (entry as { id?: unknown }).id === 'string'
|
|
421
|
+
&& (entry as { id: string }).id.length > 0)
|
|
422
422
|
: [];
|
|
423
423
|
if (entries.length === 0) {
|
|
424
424
|
if (Array.isArray(raw) && raw.length > 0) {
|
|
425
425
|
logger.warn(formatCompact({
|
|
426
426
|
op: 'adapter_endpoints_entries_dropped',
|
|
427
427
|
id: slot.id,
|
|
428
|
-
reason: 'every endpoints entry is missing a non-empty string
|
|
428
|
+
reason: 'every endpoints entry is missing a non-empty string id',
|
|
429
429
|
}));
|
|
430
430
|
}
|
|
431
|
-
return Object.freeze([{ id: slot.id,
|
|
431
|
+
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
432
432
|
}
|
|
433
433
|
// `~` 是 record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析
|
|
434
434
|
const valid = entries.filter((entry) => {
|
|
435
|
-
if (/[~\0]/u.test(entry.
|
|
435
|
+
if (/[~\0]/u.test(entry.id)) {
|
|
436
436
|
logger.warn(formatCompact({
|
|
437
|
-
op: '
|
|
437
|
+
op: 'adapter_endpoint_id_invalid',
|
|
438
438
|
id: slot.id,
|
|
439
|
-
|
|
439
|
+
endpointId: entry.id,
|
|
440
440
|
}));
|
|
441
441
|
return false;
|
|
442
442
|
}
|
|
443
443
|
return true;
|
|
444
444
|
});
|
|
445
|
-
//
|
|
445
|
+
// 重 id 会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
|
|
446
446
|
const seen = new Set<string>();
|
|
447
447
|
const deduped = valid.filter((entry) => {
|
|
448
|
-
if (seen.has(entry.
|
|
448
|
+
if (seen.has(entry.id)) {
|
|
449
449
|
logger.warn(formatCompact({
|
|
450
|
-
op: '
|
|
450
|
+
op: 'adapter_endpoint_id_duplicate',
|
|
451
451
|
id: slot.id,
|
|
452
|
-
|
|
452
|
+
endpointId: entry.id,
|
|
453
453
|
}));
|
|
454
454
|
return false;
|
|
455
455
|
}
|
|
456
|
-
seen.add(entry.
|
|
456
|
+
seen.add(entry.id);
|
|
457
457
|
return true;
|
|
458
458
|
});
|
|
459
459
|
if (deduped.length === 0) {
|
|
460
|
-
return Object.freeze([{ id: slot.id,
|
|
460
|
+
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
461
461
|
}
|
|
462
462
|
const { endpoints: _drop, ...base } = (config ?? {}) as Record<string, unknown>;
|
|
463
463
|
return Object.freeze(deduped.map((entry) => Object.freeze({
|
|
464
|
-
id: `${slot.id}~${entry.
|
|
465
|
-
|
|
466
|
-
config: Object.freeze({ ...base, ...entry,
|
|
464
|
+
id: `${slot.id}~${entry.id}` as CapabilityId,
|
|
465
|
+
endpointId: entry.id,
|
|
466
|
+
config: Object.freeze({ ...base, ...entry, id: entry.id }),
|
|
467
467
|
})));
|
|
468
468
|
}
|
|
469
469
|
|
|
@@ -492,7 +492,7 @@ async function createEndpointSoft(
|
|
|
492
492
|
log(formatCompact({
|
|
493
493
|
op: 'adapter_create_soft_fail',
|
|
494
494
|
id: expansion?.id ?? slot.id,
|
|
495
|
-
name: expansion?.
|
|
495
|
+
name: expansion?.endpointId ?? slot.localName,
|
|
496
496
|
error: message,
|
|
497
497
|
}));
|
|
498
498
|
return {
|
package/src/definition.ts
CHANGED
|
@@ -22,15 +22,9 @@ export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
|
|
|
22
22
|
export type AdapterInteractiveMode = 'native' | 'text';
|
|
23
23
|
|
|
24
24
|
export interface EndpointSendRequest {
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
* platform adapter has migrated its native boundary codec.
|
|
28
|
-
*/
|
|
29
|
-
readonly conversation?: ConversationRef;
|
|
30
|
-
/** @deprecated Use conversation for framework-facing code. */
|
|
31
|
-
readonly target: string;
|
|
25
|
+
/** 结构化会话寻址;端点在平台边界自行派生原生 target。 */
|
|
26
|
+
readonly conversation: ConversationRef;
|
|
32
27
|
readonly payload: unknown;
|
|
33
|
-
readonly parent?: { readonly type?: string; readonly id?: string; readonly name?: string };
|
|
34
28
|
}
|
|
35
29
|
|
|
36
30
|
export interface EndpointInstance<TResult = unknown> {
|
package/src/endpoint-commands.ts
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
* 接入步骤(以 telegram 为例):
|
|
17
17
|
* 1. plugin.ts setup 里 `context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState())`,
|
|
18
18
|
* token 由 `defineEndpointRuntimeStateToken('telegram')` 创建。
|
|
19
|
-
* 2. adapters/telegram.ts create() 里 `context.use(token).endpoints.set(config.
|
|
19
|
+
* 2. adapters/telegram.ts create() 里 `context.use(token).endpoints.set(config.id, { id, mode })`。
|
|
20
20
|
* 3. src 下 `export const telegramEndpointCommands = createEndpointCommands({ adapterKey: 'telegram', ... }, defineCommand)`
|
|
21
21
|
* (defineCommand 由调用方从 @zhin.js/command 传入——provider 包之间禁止互相 import,
|
|
22
22
|
* 见 scripts/check-architecture-layers.mjs,故 defineCommand 走依赖注入)。
|
|
23
|
-
* 4. commands/endpoint/{list.ts, add/[name
|
|
23
|
+
* 4. commands/endpoint/{list.ts, add/[name].ts, remove/[name].ts} 分别
|
|
24
24
|
* `export default telegramEndpointCommands.list|add|remove`。
|
|
25
25
|
*
|
|
26
26
|
* 注意:adapterKey 即实例 key(zhin.config.yml 的 plugins.<key>);多实例自定义 key 时
|
|
@@ -28,7 +28,13 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
createToken,
|
|
33
|
+
outboundHostToken,
|
|
34
|
+
type OutboundHost,
|
|
35
|
+
type OutboundSendInput,
|
|
36
|
+
type Token,
|
|
37
|
+
} from '@zhin.js/plugin-runtime';
|
|
32
38
|
import { isMap, isSeq, parseDocument, type YAMLSeq } from 'yaml';
|
|
33
39
|
|
|
34
40
|
// ---------------------------------------------------------------------------
|
|
@@ -54,8 +60,9 @@ export function isEndpointOperator(config: unknown, input: unknown): boolean {
|
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
if (masters.size === 0) return true;
|
|
57
|
-
const
|
|
58
|
-
|
|
63
|
+
const senderRaw = (input as { sender?: { id?: string } | null } | null | undefined)?.sender;
|
|
64
|
+
const senderId = (typeof senderRaw === 'object' && senderRaw?.id) ? senderRaw.id.trim() : '';
|
|
65
|
+
return !!senderId && masters.has(senderId);
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/** add/remove 的拒绝文案(list 只读,不校验)。 */
|
|
@@ -80,18 +87,83 @@ export function extractEndpointCommandReply(input: unknown): EndpointCommandRepl
|
|
|
80
87
|
return async () => undefined;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* bindFlow 后续状态推送:优先走 OutboundHost(不受 inbound Message reply scope 限制)。
|
|
92
|
+
* 扫码绑定等长流程会在命令结果已送达、`$reply` 已冻结后继续 notify,必须用 durable 出站。
|
|
93
|
+
*/
|
|
94
|
+
export function createDurableEndpointCommandReply(
|
|
95
|
+
input: unknown,
|
|
96
|
+
use: EndpointCommandUse,
|
|
97
|
+
): EndpointCommandReply {
|
|
98
|
+
const scoped = extractEndpointCommandReply(input);
|
|
99
|
+
const target = readOutboundSendTarget(input);
|
|
100
|
+
if (!target) return scoped;
|
|
101
|
+
|
|
102
|
+
return async (text) => {
|
|
103
|
+
let outbound: OutboundHost | undefined;
|
|
104
|
+
try {
|
|
105
|
+
outbound = use(outboundHostToken);
|
|
106
|
+
} catch {
|
|
107
|
+
outbound = undefined;
|
|
108
|
+
}
|
|
109
|
+
if (outbound) {
|
|
110
|
+
await outbound.send({ ...target, content: text });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await scoped(text);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readOutboundSendTarget(input: unknown): Omit<OutboundSendInput, 'content'> | undefined {
|
|
118
|
+
const message = input as {
|
|
119
|
+
conversation?: {
|
|
120
|
+
endpoint?: { id?: unknown; adapter?: unknown };
|
|
121
|
+
kind?: unknown;
|
|
122
|
+
id?: unknown;
|
|
123
|
+
parent?: OutboundSendInput['conversation']['parent'];
|
|
124
|
+
threadId?: unknown;
|
|
125
|
+
};
|
|
126
|
+
metadata?: Readonly<Record<string, unknown>>;
|
|
127
|
+
} | null | undefined;
|
|
128
|
+
const conversation = message?.conversation;
|
|
129
|
+
const endpointKeyRaw = conversation?.endpoint?.id;
|
|
130
|
+
const adapterRaw = conversation?.endpoint?.adapter;
|
|
131
|
+
const kind = conversation?.kind;
|
|
132
|
+
const id = conversation?.id;
|
|
133
|
+
if (
|
|
134
|
+
endpointKeyRaw == null
|
|
135
|
+
|| adapterRaw == null
|
|
136
|
+
|| (kind !== 'private' && kind !== 'group' && kind !== 'channel')
|
|
137
|
+
|| typeof id !== 'string'
|
|
138
|
+
|| !id
|
|
139
|
+
) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const live = String(message?.metadata?.endpoint ?? message?.metadata?.endpointKey ?? '').trim();
|
|
143
|
+
return {
|
|
144
|
+
adapter: String(adapterRaw),
|
|
145
|
+
endpointKey: live || String(endpointKeyRaw),
|
|
146
|
+
conversation: {
|
|
147
|
+
kind,
|
|
148
|
+
id,
|
|
149
|
+
parent: conversation.parent,
|
|
150
|
+
threadId: typeof conversation.threadId === 'string' ? conversation.threadId : undefined,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
83
155
|
// ---------------------------------------------------------------------------
|
|
84
156
|
// 运行时状态:adapter create() 注册的 running endpoints
|
|
85
157
|
// ---------------------------------------------------------------------------
|
|
86
158
|
|
|
87
159
|
export interface EndpointRunningInfo {
|
|
88
|
-
readonly
|
|
160
|
+
readonly id: string;
|
|
89
161
|
/** 连接模式(ws / wss / polling / socket-mode …),仅用于 list 展示。 */
|
|
90
162
|
readonly mode?: string;
|
|
91
163
|
}
|
|
92
164
|
|
|
93
165
|
export interface EndpointRuntimeState {
|
|
94
|
-
/** 当前 generation 已成功创建的 endpoint(
|
|
166
|
+
/** 当前 generation 已成功创建的 endpoint(id → 描述) */
|
|
95
167
|
readonly endpoints: Map<string, EndpointRunningInfo>;
|
|
96
168
|
}
|
|
97
169
|
|
|
@@ -127,10 +199,10 @@ function envSlug(text: string): string {
|
|
|
127
199
|
/** 派生 endpoint 凭据的 env 键:`${ADAPTER}_${NAME}_${FIELD}`(如 `TELEGRAM_MY_BOT_TOKEN`) */
|
|
128
200
|
export function buildEndpointEnvKey(
|
|
129
201
|
adapterKey: string,
|
|
130
|
-
|
|
202
|
+
endpointId: string,
|
|
131
203
|
fieldKey: string,
|
|
132
204
|
): string {
|
|
133
|
-
return `${envSlug(adapterKey)}_${envSlug(
|
|
205
|
+
return `${envSlug(adapterKey)}_${envSlug(endpointId)}_${envSlug(fieldKey)}`;
|
|
134
206
|
}
|
|
135
207
|
|
|
136
208
|
function escapeRegExp(text: string): string {
|
|
@@ -170,7 +242,7 @@ export function persistEndpointEnvValues(
|
|
|
170
242
|
// ---------------------------------------------------------------------------
|
|
171
243
|
|
|
172
244
|
export interface ConfiguredEndpointEntry {
|
|
173
|
-
|
|
245
|
+
id: string;
|
|
174
246
|
[key: string]: unknown;
|
|
175
247
|
}
|
|
176
248
|
|
|
@@ -223,14 +295,14 @@ export function listConfiguredEndpoints(
|
|
|
223
295
|
if (!Array.isArray(endpoints)) return [];
|
|
224
296
|
return endpoints.filter(
|
|
225
297
|
(entry): entry is ConfiguredEndpointEntry =>
|
|
226
|
-
!!entry && typeof entry === 'object' && typeof (entry as {
|
|
298
|
+
!!entry && typeof entry === 'object' && typeof (entry as { id?: unknown }).id === 'string',
|
|
227
299
|
);
|
|
228
300
|
}
|
|
229
301
|
|
|
230
|
-
function
|
|
302
|
+
function entryId(item: unknown): string | undefined {
|
|
231
303
|
if (!isMap(item)) return undefined;
|
|
232
|
-
const
|
|
233
|
-
return typeof
|
|
304
|
+
const id = item.get('id');
|
|
305
|
+
return typeof id === 'string' && id ? id : undefined;
|
|
234
306
|
}
|
|
235
307
|
|
|
236
308
|
/**
|
|
@@ -269,7 +341,7 @@ function ensureEndpointsSeq(
|
|
|
269
341
|
return doc.getIn(['plugins', adapterKey, 'endpoints']) as YAMLSeq;
|
|
270
342
|
}
|
|
271
343
|
|
|
272
|
-
/** 追加 endpoint 到 plugins.<adapterKey>.endpoints;
|
|
344
|
+
/** 追加 endpoint 到 plugins.<adapterKey>.endpoints;id 已存在时报错 */
|
|
273
345
|
export function addEndpointToConfig(
|
|
274
346
|
adapterKey: string,
|
|
275
347
|
entry: ConfiguredEndpointEntry,
|
|
@@ -277,23 +349,23 @@ export function addEndpointToConfig(
|
|
|
277
349
|
): string {
|
|
278
350
|
const document = readConfigDocument(adapterKey, projectRoot);
|
|
279
351
|
const seq = ensureEndpointsSeq(document.doc, adapterKey);
|
|
280
|
-
if (seq.items.some((item) =>
|
|
281
|
-
throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.
|
|
352
|
+
if (seq.items.some((item) => entryId(item) === entry.id)) {
|
|
353
|
+
throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.id}」,可先 ${adapterKey}.endpoint remove ${entry.id} 再重新添加`);
|
|
282
354
|
}
|
|
283
355
|
seq.items.push(document.doc.createNode(entry));
|
|
284
356
|
writeConfigDocument(document);
|
|
285
357
|
return document.filePath;
|
|
286
358
|
}
|
|
287
359
|
|
|
288
|
-
/** 按
|
|
360
|
+
/** 按 id 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
|
|
289
361
|
export function removeEndpointFromConfig(
|
|
290
362
|
adapterKey: string,
|
|
291
|
-
|
|
363
|
+
id: string,
|
|
292
364
|
projectRoot?: string,
|
|
293
365
|
): { removed: boolean; filePath: string } {
|
|
294
366
|
const document = readConfigDocument(adapterKey, projectRoot);
|
|
295
367
|
const seq = ensureEndpointsSeq(document.doc, adapterKey);
|
|
296
|
-
const next = seq.items.filter((item) =>
|
|
368
|
+
const next = seq.items.filter((item) => entryId(item) !== id);
|
|
297
369
|
if (next.length === seq.items.length) {
|
|
298
370
|
return { removed: false, filePath: document.filePath };
|
|
299
371
|
}
|
|
@@ -322,9 +394,9 @@ export type EndpointCommandUse = <T>(token: Token<T>) => T;
|
|
|
322
394
|
|
|
323
395
|
/** bindFlow 钩子上下文:接管 add 命令的自定义绑定流程(如 QQ 扫码)。 */
|
|
324
396
|
export interface EndpointBindFlowContext {
|
|
325
|
-
/** 命令参数
|
|
326
|
-
readonly
|
|
327
|
-
/** 向当前会话推送后续状态(二维码刷新 / 成功 /
|
|
397
|
+
/** 命令参数 id(未指定时为 undefined,流程可自行决定终名) */
|
|
398
|
+
readonly id?: string;
|
|
399
|
+
/** 向当前会话推送后续状态(二维码刷新 / 成功 / 失败;走 durable OutboundHost,可在命令 reply scope 结束后调用) */
|
|
328
400
|
readonly reply: EndpointCommandReply;
|
|
329
401
|
readonly config: unknown;
|
|
330
402
|
readonly input: unknown;
|
|
@@ -355,8 +427,9 @@ export interface EndpointCommandsSpec {
|
|
|
355
427
|
* provider 层不允许 import @zhin.js/command,故 defineCommand 由调用方注入,
|
|
356
428
|
* 这里只描述结构;适配器侧传入 defineCommand 后 TCommand 即 Readonly<CommandDefinition>。
|
|
357
429
|
*
|
|
358
|
-
* `params` 值域须与 CommandParameterValue 对齐(含 null /
|
|
359
|
-
*
|
|
430
|
+
* `params` 值域须与 CommandParameterValue 对齐(含 null / 结构化对象 / rest 段的
|
|
431
|
+
* `ReadonlyArray<string | number | boolean>`),否则注入的 defineCommand 会因
|
|
432
|
+
* TS 逆变检查失败(TS2345)。
|
|
360
433
|
*/
|
|
361
434
|
export interface EndpointCommandContext {
|
|
362
435
|
readonly config: unknown;
|
|
@@ -369,13 +442,33 @@ export interface EndpointCommandContext {
|
|
|
369
442
|
readonly args: readonly string[];
|
|
370
443
|
readonly params: Readonly<Record<
|
|
371
444
|
string,
|
|
372
|
-
|
|
445
|
+
| string
|
|
446
|
+
| number
|
|
447
|
+
| boolean
|
|
448
|
+
| ReadonlyArray<string | number | boolean>
|
|
449
|
+
| Readonly<Record<string, unknown>>
|
|
450
|
+
| null
|
|
373
451
|
>>;
|
|
374
452
|
readonly use: EndpointCommandUse;
|
|
375
453
|
}
|
|
376
454
|
|
|
377
455
|
export interface EndpointCommandDefinition {
|
|
378
456
|
readonly description?: string;
|
|
457
|
+
// 与 @zhin.js/command 的 CommandParamSchema 结构对齐(provider 层不能 import
|
|
458
|
+
// command,字面量联合在此镜像声明;新增参数类型须两侧同步)。
|
|
459
|
+
readonly params?: Readonly<Record<string, {
|
|
460
|
+
readonly type:
|
|
461
|
+
| 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'word' | 'text'
|
|
462
|
+
| 'mention' | 'image' | 'face' | 'reply' | 'forward' | 'dice' | 'rps';
|
|
463
|
+
readonly default?:
|
|
464
|
+
| string
|
|
465
|
+
| number
|
|
466
|
+
| boolean
|
|
467
|
+
| ReadonlyArray<string | number | boolean>
|
|
468
|
+
| Readonly<Record<string, unknown>>
|
|
469
|
+
| null;
|
|
470
|
+
readonly description?: string;
|
|
471
|
+
}>>;
|
|
379
472
|
execute(context: EndpointCommandContext): unknown;
|
|
380
473
|
}
|
|
381
474
|
|
|
@@ -385,9 +478,9 @@ export interface EndpointCommands<TCommand = EndpointCommandDefinition> {
|
|
|
385
478
|
readonly remove: TCommand;
|
|
386
479
|
}
|
|
387
480
|
|
|
388
|
-
function
|
|
389
|
-
const
|
|
390
|
-
return typeof
|
|
481
|
+
function endpointIdParam(params: Readonly<Record<string, unknown>>): string | undefined {
|
|
482
|
+
const id = params.id;
|
|
483
|
+
return typeof id === 'string' && id.trim() ? id.trim() : undefined;
|
|
391
484
|
}
|
|
392
485
|
|
|
393
486
|
/** list 文案:运行中 + 配置中两段,footer 可选。 */
|
|
@@ -406,7 +499,7 @@ export function formatEndpointList(
|
|
|
406
499
|
lines.push(' (无)');
|
|
407
500
|
} else {
|
|
408
501
|
for (const endpoint of running) {
|
|
409
|
-
lines.push(endpoint.mode ? ` - ${endpoint.
|
|
502
|
+
lines.push(endpoint.mode ? ` - ${endpoint.id}(${endpoint.mode})` : ` - ${endpoint.id}`);
|
|
410
503
|
}
|
|
411
504
|
}
|
|
412
505
|
lines.push(`【配置中的 ${spec.adapterDisplayName} endpoints】(zhin.config.yml → plugins.${spec.adapterKey}.endpoints)`);
|
|
@@ -415,7 +508,7 @@ export function formatEndpointList(
|
|
|
415
508
|
} else {
|
|
416
509
|
for (const entry of source.configured) {
|
|
417
510
|
const detail = spec.describeEntry?.(entry);
|
|
418
|
-
lines.push(detail ? ` - ${entry.
|
|
511
|
+
lines.push(detail ? ` - ${entry.id}(${detail})` : ` - ${entry.id}`);
|
|
419
512
|
}
|
|
420
513
|
}
|
|
421
514
|
if (source.footer) lines.push(source.footer);
|
|
@@ -434,13 +527,13 @@ function addUsage(spec: EndpointCommandsSpec): string {
|
|
|
434
527
|
].filter(Boolean).join(',');
|
|
435
528
|
return marks ? `${field.key}(${marks})` : field.key;
|
|
436
529
|
}).join('、')}`;
|
|
437
|
-
return `用法:${spec.adapterKey}.endpoint add <
|
|
530
|
+
return `用法:${spec.adapterKey}.endpoint add <id> <key=value...>${fieldText}`;
|
|
438
531
|
}
|
|
439
532
|
|
|
440
533
|
/** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
|
|
441
534
|
export function addEndpointFromKeyValues(
|
|
442
535
|
spec: EndpointCommandsSpec,
|
|
443
|
-
|
|
536
|
+
id: string,
|
|
444
537
|
args: readonly string[],
|
|
445
538
|
projectRoot?: string,
|
|
446
539
|
): string {
|
|
@@ -463,13 +556,13 @@ export function addEndpointFromKeyValues(
|
|
|
463
556
|
if (missing.length > 0) {
|
|
464
557
|
return `缺少必填字段:${missing.map((field) => field.key).join('、')}。${addUsage(spec)}`;
|
|
465
558
|
}
|
|
466
|
-
const entry: ConfiguredEndpointEntry = {
|
|
559
|
+
const entry: ConfiguredEndpointEntry = { id };
|
|
467
560
|
const envValues: Record<string, string> = {};
|
|
468
561
|
for (const field of fields) {
|
|
469
562
|
const value = values.get(field.key);
|
|
470
563
|
if (value === undefined) continue;
|
|
471
564
|
if (field.env) {
|
|
472
|
-
const envKey = buildEndpointEnvKey(spec.adapterKey,
|
|
565
|
+
const envKey = buildEndpointEnvKey(spec.adapterKey, id, field.key);
|
|
473
566
|
envValues[envKey] = value;
|
|
474
567
|
entry[field.key] = `\${${envKey}}`;
|
|
475
568
|
} else {
|
|
@@ -477,11 +570,10 @@ export function addEndpointFromKeyValues(
|
|
|
477
570
|
}
|
|
478
571
|
}
|
|
479
572
|
try {
|
|
480
|
-
// 先写配置(重名等校验失败时不留孤儿 .env 键),再落 .env 凭据
|
|
481
573
|
const filePath = addEndpointToConfig(spec.adapterKey, entry, projectRoot);
|
|
482
574
|
if (Object.keys(envValues).length > 0) persistEndpointEnvValues(envValues, projectRoot);
|
|
483
575
|
return (
|
|
484
|
-
`✅ endpoint「${
|
|
576
|
+
`✅ endpoint「${id}」已追加到 ${filePath} 的 plugins.${spec.adapterKey}.endpoints` +
|
|
485
577
|
`${Object.keys(envValues).length > 0 ? '(凭据已写入 .env)' : ''}。\n` +
|
|
486
578
|
'⚠️ 需重启 zhin 后新 endpoint 才会生效。'
|
|
487
579
|
);
|
|
@@ -491,13 +583,13 @@ export function addEndpointFromKeyValues(
|
|
|
491
583
|
}
|
|
492
584
|
|
|
493
585
|
/** remove 的完整业务逻辑:从 yaml 移除;返回回复文本。 */
|
|
494
|
-
export function
|
|
586
|
+
export function removeEndpointById(
|
|
495
587
|
spec: Pick<EndpointCommandsSpec, 'adapterKey'>,
|
|
496
|
-
|
|
588
|
+
id: string,
|
|
497
589
|
projectRoot?: string,
|
|
498
590
|
): string {
|
|
499
|
-
const trimmed =
|
|
500
|
-
if (!trimmed) return `用法:${spec.adapterKey}.endpoint remove <
|
|
591
|
+
const trimmed = id.trim();
|
|
592
|
+
if (!trimmed) return `用法:${spec.adapterKey}.endpoint remove <id>`;
|
|
501
593
|
try {
|
|
502
594
|
const { removed, filePath } = removeEndpointFromConfig(spec.adapterKey, trimmed, projectRoot);
|
|
503
595
|
if (!removed) {
|
|
@@ -532,27 +624,29 @@ export function createEndpointCommands<TCommand>(
|
|
|
532
624
|
add: defineCommand({
|
|
533
625
|
description: spec.addDescription
|
|
534
626
|
?? `手动添加 ${spec.adapterDisplayName} endpoint(凭据写入 .env 并追加到 zhin.config.yml,重启生效)`,
|
|
627
|
+
params: { id: { type: 'string', description: 'endpoint ID' } },
|
|
535
628
|
execute({ config, input, params, args, use }) {
|
|
536
629
|
if (!isEndpointOperator(config, input)) return forbidden;
|
|
537
|
-
const
|
|
630
|
+
const id = endpointIdParam(params);
|
|
538
631
|
if (spec.bindFlow) {
|
|
539
632
|
return spec.bindFlow({
|
|
540
|
-
|
|
541
|
-
reply:
|
|
633
|
+
id,
|
|
634
|
+
reply: createDurableEndpointCommandReply(input, use),
|
|
542
635
|
config,
|
|
543
636
|
input,
|
|
544
637
|
use,
|
|
545
638
|
});
|
|
546
639
|
}
|
|
547
|
-
if (!
|
|
548
|
-
return addEndpointFromKeyValues(spec,
|
|
640
|
+
if (!id) return addUsage(spec);
|
|
641
|
+
return addEndpointFromKeyValues(spec, id, args);
|
|
549
642
|
},
|
|
550
643
|
}),
|
|
551
644
|
remove: defineCommand({
|
|
552
645
|
description: `从 zhin.config.yml 的 plugins.${spec.adapterKey}.endpoints 移除指定 endpoint(重启生效)`,
|
|
646
|
+
params: { id: { type: 'string', description: 'endpoint ID' } },
|
|
553
647
|
execute({ config, input, params }) {
|
|
554
648
|
if (!isEndpointOperator(config, input)) return forbidden;
|
|
555
|
-
return
|
|
649
|
+
return removeEndpointById(spec, String(params.id ?? ''));
|
|
556
650
|
},
|
|
557
651
|
}),
|
|
558
652
|
});
|
package/src/endpoint-control.ts
CHANGED
|
@@ -2,9 +2,12 @@ import {
|
|
|
2
2
|
formatLegacyConversationRef,
|
|
3
3
|
formatLegacyMessageRef,
|
|
4
4
|
type ConversationTarget,
|
|
5
|
+
type LegacyEndpointControlSurface,
|
|
5
6
|
type MessageTarget,
|
|
6
7
|
} from '@zhin.js/im-contract';
|
|
7
8
|
|
|
9
|
+
export type { LegacyEndpointControlSurface } from '@zhin.js/im-contract';
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
12
|
* Transport-neutral control plane for a live endpoint.
|
|
10
13
|
*
|
|
@@ -28,31 +31,13 @@ export interface EndpointWithControl {
|
|
|
28
31
|
readonly control?: EndpointControl;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
interface LegacyEndpointControlSurface {
|
|
32
|
-
recallMessage?(messageId: string): Promise<void>;
|
|
33
|
-
$recallMessage?(messageId: string): Promise<void>;
|
|
34
|
-
editMessage?(messageId: string, content: unknown): Promise<string | null>;
|
|
35
|
-
$editMessage?(messageId: string, content: unknown): Promise<string | null>;
|
|
36
|
-
addReaction?(
|
|
37
|
-
messageId: string,
|
|
38
|
-
emoji: string,
|
|
39
|
-
hint?: { readonly sceneType?: string; readonly channelId?: string },
|
|
40
|
-
): Promise<string | null>;
|
|
41
|
-
$addReaction?(
|
|
42
|
-
messageId: string,
|
|
43
|
-
emoji: string,
|
|
44
|
-
hint?: { readonly sceneType?: string; readonly channelId?: string },
|
|
45
|
-
): Promise<string | null>;
|
|
46
|
-
removeReaction?(messageId: string, reactionId: string): Promise<void>;
|
|
47
|
-
$removeReaction?(messageId: string, reactionId: string): Promise<void>;
|
|
48
|
-
typing?(target: string, active?: boolean): Promise<void>;
|
|
49
|
-
$typing?(target: string, active?: boolean): Promise<void>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
34
|
/**
|
|
53
35
|
* Resolves the public control port. The legacy branch is deliberately kept in
|
|
54
|
-
* Adapter only: it is a migration bridge for existing protocol
|
|
55
|
-
*
|
|
36
|
+
* Adapter only: it is a migration bridge for existing classic protocol
|
|
37
|
+
* endpoints (`LegacyEndpointControlSurface` lives in `@zhin.js/im-contract`),
|
|
38
|
+
* not an IM Core extension point. New adapters must expose `control` directly.
|
|
39
|
+
* 下线条件:classic Plugin 轨下线后,legacy 分支与 LegacyEndpointControlSurface
|
|
40
|
+
* 一并删除。
|
|
56
41
|
*/
|
|
57
42
|
export function resolveEndpointControl(endpoint: unknown): EndpointControl | undefined {
|
|
58
43
|
if (!endpoint || typeof endpoint !== 'object') return undefined;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* 迁移指引(以 napcat/milky/onebot WS endpoint 为例):
|
|
21
21
|
* 1. 删除 #started / #stopping / #reconnectTimer / #heartbeatTimer / opened 旗标,
|
|
22
|
-
* 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.
|
|
22
|
+
* 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.id, reconnect, heartbeat })`。
|
|
23
23
|
* 2. `start()` 改为:
|
|
24
24
|
* ```ts
|
|
25
25
|
* this.#unregisterAgent = registerXxxAgentEndpoint(name, this); // agent 注册仍在适配器侧
|