@zhin.js/adapter 1.1.5 → 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.
@@ -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, endpointId: string): CapabilityId | undefined;
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, endpointId: string): EndpointInstance | undefined;
38
+ instance(adapter: string, endpointKey: string): EndpointInstance | undefined;
39
39
  owner(id: CapabilityId): PluginId;
40
40
  /**
41
41
  * Endpoint 的消息段能力声明(出站协商降级依据);
@@ -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.name);
31
+ unconfigured.push(expansion.endpointId);
32
32
  records.push({
33
33
  id: expansion.id,
34
34
  owner: slot.owner,
35
- // 展开模式下 record name 即 endpoint 名(entry.name),
36
- // 保证 Console 展示与 resolve/instance 按 entry name 命中唯一 record
37
- name: expansion.name,
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, endpointId) {
89
- const matches = this.#order.filter((record) => matchesEndpoint(record, adapter, endpointId));
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 === endpointId when ambiguous.
95
- const exact = matches.find((record) => record.name === endpointId);
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, endpointId) {
102
- const id = this.resolve(adapter, endpointId);
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, endpointId) {
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 === endpointId
285
- || record.id === endpointId
286
- || record.id.endsWith(`/${endpointId}`)
287
- || (liveName !== undefined && liveName === endpointId);
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: [{name, ...覆盖}]` 时
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.name === 'string'
329
- && entry.name.length > 0)
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 name',
336
+ reason: 'every endpoints entry is missing a non-empty string id',
337
337
  }));
338
338
  }
339
- return Object.freeze([{ id: slot.id, name: slot.localName }]);
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.name)) {
343
+ if (/[~\0]/u.test(entry.id)) {
344
344
  logger.warn(formatCompact({
345
- op: 'adapter_endpoint_name_invalid',
345
+ op: 'adapter_endpoint_id_invalid',
346
346
  id: slot.id,
347
- name: entry.name,
347
+ endpointId: entry.id,
348
348
  }));
349
349
  return false;
350
350
  }
351
351
  return true;
352
352
  });
353
- // 重名会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
353
+ // id 会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
354
354
  const seen = new Set();
355
355
  const deduped = valid.filter((entry) => {
356
- if (seen.has(entry.name)) {
356
+ if (seen.has(entry.id)) {
357
357
  logger.warn(formatCompact({
358
- op: 'adapter_endpoint_name_duplicate',
358
+ op: 'adapter_endpoint_id_duplicate',
359
359
  id: slot.id,
360
- name: entry.name,
360
+ endpointId: entry.id,
361
361
  }));
362
362
  return false;
363
363
  }
364
- seen.add(entry.name);
364
+ seen.add(entry.id);
365
365
  return true;
366
366
  });
367
367
  if (deduped.length === 0) {
368
- return Object.freeze([{ id: slot.id, name: slot.localName }]);
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.name}`,
373
- name: entry.name,
374
- config: Object.freeze({ ...base, ...entry, name: entry.name }),
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?.name ?? slot.localName,
397
+ name: expansion?.endpointId ?? slot.localName,
398
398
  error: message,
399
399
  }));
400
400
  return {
@@ -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 name: string;
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(name → 描述) */
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, endpointName: string, fieldKey: string): 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
- name: string;
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;name 已存在时报错 */
45
+ /** 追加 endpoint 到 plugins.<adapterKey>.endpoints;id 已存在时报错 */
41
46
  export declare function addEndpointToConfig(adapterKey: string, entry: ConfiguredEndpointEntry, projectRoot?: string): string;
42
- /** 按 name 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
43
- export declare function removeEndpointFromConfig(adapterKey: string, name: string, projectRoot?: 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
- /** 命令参数 name(未指定时为 undefined,流程可自行决定终名) */
62
- readonly name?: string;
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;
@@ -126,8 +131,8 @@ export declare function formatEndpointList(spec: Pick<EndpointCommandsSpec, 'ada
126
131
  readonly footer?: string;
127
132
  }): string;
128
133
  /** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
129
- export declare function addEndpointFromKeyValues(spec: EndpointCommandsSpec, name: string, args: readonly string[], projectRoot?: string): string;
134
+ export declare function addEndpointFromKeyValues(spec: EndpointCommandsSpec, id: string, args: readonly string[], projectRoot?: string): string;
130
135
  /** remove 的完整业务逻辑:从 yaml 移除;返回回复文本。 */
131
- export declare function removeEndpointByName(spec: Pick<EndpointCommandsSpec, 'adapterKey'>, name: string, projectRoot?: string): string;
136
+ export declare function removeEndpointById(spec: Pick<EndpointCommandsSpec, 'adapterKey'>, id: string, projectRoot?: string): string;
132
137
  /** 生成 `<adapter> endpoint` 的 list / add / remove 三个命令定义(见文件头接入步骤)。 */
133
138
  export declare function createEndpointCommands<TCommand>(spec: EndpointCommandsSpec, defineCommand: (definition: EndpointCommandDefinition) => TCommand): EndpointCommands<TCommand>;
@@ -16,7 +16,7 @@
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.name, { name, mode })`。
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 走依赖注入)。
@@ -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 sender = String(input?.sender ?? '').trim();
59
- return !!sender && masters.has(sender);
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, endpointName, fieldKey) {
98
- return `${envSlug(adapterKey)}_${envSlug(endpointName)}_${envSlug(fieldKey)}`;
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.name === 'string');
216
+ return endpoints.filter((entry) => !!entry && typeof entry === 'object' && typeof entry.id === 'string');
166
217
  }
167
- function entryName(item) {
218
+ function entryId(item) {
168
219
  if (!isMap(item))
169
220
  return undefined;
170
- const name = item.get('name');
171
- return typeof name === 'string' && name ? name : undefined;
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;name 已存在时报错 */
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) => entryName(item) === entry.name)) {
210
- throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey}.endpoint remove ${entry.name} 再重新添加`);
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
- /** 按 name 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
217
- export function removeEndpointFromConfig(adapterKey, name, projectRoot) {
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) => entryName(item) !== name);
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 endpointNameParam(params) {
229
- const name = params.name;
230
- return typeof name === 'string' && name.trim() ? name.trim() : undefined;
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.name}(${endpoint.mode})` : ` - ${endpoint.name}`);
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.name}(${detail})` : ` - ${entry.name}`);
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 <name> <key=value...>${fieldText}`;
322
+ return `用法:${spec.adapterKey}.endpoint add <id> <key=value...>${fieldText}`;
272
323
  }
273
324
  /** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
274
- export function addEndpointFromKeyValues(spec, name, args, projectRoot) {
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 = { name };
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, name, field.key);
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「${name}」已追加到 ${filePath} 的 plugins.${spec.adapterKey}.endpoints` +
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 removeEndpointByName(spec, name, projectRoot) {
326
- const trimmed = name.trim();
375
+ export function removeEndpointById(spec, id, projectRoot) {
376
+ const trimmed = id.trim();
327
377
  if (!trimmed)
328
- return `用法:${spec.adapterKey}.endpoint remove <name>`;
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,32 +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,重启生效)`,
358
- params: { name: { type: 'string', description: 'endpoint 名称' } },
408
+ params: { id: { type: 'string', description: 'endpoint ID' } },
359
409
  execute({ config, input, params, args, use }) {
360
410
  if (!isEndpointOperator(config, input))
361
411
  return forbidden;
362
- const name = endpointNameParam(params);
412
+ const id = endpointIdParam(params);
363
413
  if (spec.bindFlow) {
364
414
  return spec.bindFlow({
365
- name,
366
- reply: extractEndpointCommandReply(input),
415
+ id,
416
+ reply: createDurableEndpointCommandReply(input, use),
367
417
  config,
368
418
  input,
369
419
  use,
370
420
  });
371
421
  }
372
- if (!name)
422
+ if (!id)
373
423
  return addUsage(spec);
374
- return addEndpointFromKeyValues(spec, name, args);
424
+ return addEndpointFromKeyValues(spec, id, args);
375
425
  },
376
426
  }),
377
427
  remove: defineCommand({
378
428
  description: `从 zhin.config.yml 的 plugins.${spec.adapterKey}.endpoints 移除指定 endpoint(重启生效)`,
379
- params: { name: { type: 'string', description: 'endpoint 名称' } },
429
+ params: { id: { type: 'string', description: 'endpoint ID' } },
380
430
  execute({ config, input, params }) {
381
431
  if (!isEndpointOperator(config, input))
382
432
  return forbidden;
383
- return removeEndpointByName(spec, String(params.name ?? ''));
433
+ return removeEndpointById(spec, String(params.id ?? ''));
384
434
  },
385
435
  }),
386
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 endpoints, not
25
- * an IM Core extension point. New adapters must expose `control` directly.
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. */
@@ -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 endpoints, not
5
- * an IM Core extension point. New adapters must expose `control` directly.
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.name, reconnect, heartbeat })`。
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.5",
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.6",
22
- "@zhin.js/im-contract": "1.0.1",
23
- "@zhin.js/logger": "1.0.75",
24
- "@zhin.js/plugin-runtime": "1.1.3"
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.7"
29
+ "@zhin.js/command": "1.0.9"
30
30
  },
31
31
  "zhin": {
32
32
  "protocol": 1,
@@ -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.name);
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 名(entry.name),
97
- // 保证 Console 展示与 resolve/instance 按 entry name 命中唯一 record
98
- name: expansion.name,
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, endpointId: string): CapabilityId | undefined {
158
+ resolve(adapter: string, endpointKey: string): CapabilityId | undefined {
159
159
  const matches = this.#order.filter((record) =>
160
- matchesEndpoint(record, adapter, endpointId));
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 === endpointId when ambiguous.
164
- const exact = matches.find((record) => record.name === endpointId);
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, endpointId: string): EndpointInstance | undefined {
172
- const id = this.resolve(adapter, endpointId);
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
- endpointId: string,
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 === endpointId
361
- || record.id === endpointId
362
- || record.id.endsWith(`/${endpointId}`)
363
- || (liveName !== undefined && liveName === endpointId);
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 name: string;
400
+ readonly endpointId: string;
401
401
  readonly config?: Readonly<Record<string, unknown>>;
402
402
  }
403
403
 
404
404
  /**
405
- * 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{name, ...覆盖}]` 时
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> & { name: string } =>
418
+ ? raw.filter((entry): entry is Record<string, unknown> & { id: string } =>
419
419
  !!entry && typeof entry === 'object'
420
- && typeof (entry as { name?: unknown }).name === 'string'
421
- && (entry as { name: string }).name.length > 0)
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 name',
428
+ reason: 'every endpoints entry is missing a non-empty string id',
429
429
  }));
430
430
  }
431
- return Object.freeze([{ id: slot.id, name: slot.localName }]);
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.name)) {
435
+ if (/[~\0]/u.test(entry.id)) {
436
436
  logger.warn(formatCompact({
437
- op: 'adapter_endpoint_name_invalid',
437
+ op: 'adapter_endpoint_id_invalid',
438
438
  id: slot.id,
439
- name: entry.name,
439
+ endpointId: entry.id,
440
440
  }));
441
441
  return false;
442
442
  }
443
443
  return true;
444
444
  });
445
- // 重名会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
445
+ // id 会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
446
446
  const seen = new Set<string>();
447
447
  const deduped = valid.filter((entry) => {
448
- if (seen.has(entry.name)) {
448
+ if (seen.has(entry.id)) {
449
449
  logger.warn(formatCompact({
450
- op: 'adapter_endpoint_name_duplicate',
450
+ op: 'adapter_endpoint_id_duplicate',
451
451
  id: slot.id,
452
- name: entry.name,
452
+ endpointId: entry.id,
453
453
  }));
454
454
  return false;
455
455
  }
456
- seen.add(entry.name);
456
+ seen.add(entry.id);
457
457
  return true;
458
458
  });
459
459
  if (deduped.length === 0) {
460
- return Object.freeze([{ id: slot.id, name: slot.localName }]);
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.name}` as CapabilityId,
465
- name: entry.name,
466
- config: Object.freeze({ ...base, ...entry, name: entry.name }),
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?.name ?? slot.localName,
495
+ name: expansion?.endpointId ?? slot.localName,
496
496
  error: message,
497
497
  }));
498
498
  return {
@@ -16,7 +16,7 @@
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.name, { name, mode })`。
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 走依赖注入)。
@@ -28,7 +28,13 @@
28
28
  */
29
29
  import fs from 'node:fs';
30
30
  import path from 'node:path';
31
- import { createToken, type Token } from '@zhin.js/plugin-runtime';
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 sender = String((input as { sender?: unknown } | null | undefined)?.sender ?? '').trim();
58
- return !!sender && masters.has(sender);
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 name: string;
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(name → 描述) */
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
- endpointName: string,
202
+ endpointId: string,
131
203
  fieldKey: string,
132
204
  ): string {
133
- return `${envSlug(adapterKey)}_${envSlug(endpointName)}_${envSlug(fieldKey)}`;
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
- name: string;
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 { name?: unknown }).name === 'string',
298
+ !!entry && typeof entry === 'object' && typeof (entry as { id?: unknown }).id === 'string',
227
299
  );
228
300
  }
229
301
 
230
- function entryName(item: unknown): string | undefined {
302
+ function entryId(item: unknown): string | undefined {
231
303
  if (!isMap(item)) return undefined;
232
- const name = item.get('name');
233
- return typeof name === 'string' && name ? name : undefined;
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;name 已存在时报错 */
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) => entryName(item) === entry.name)) {
281
- throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey}.endpoint remove ${entry.name} 再重新添加`);
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
- /** 按 name 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
360
+ /** 按 id 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
289
361
  export function removeEndpointFromConfig(
290
362
  adapterKey: string,
291
- name: string,
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) => entryName(item) !== name);
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
- /** 命令参数 name(未指定时为 undefined,流程可自行决定终名) */
326
- readonly name?: string;
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;
@@ -406,9 +478,9 @@ export interface EndpointCommands<TCommand = EndpointCommandDefinition> {
406
478
  readonly remove: TCommand;
407
479
  }
408
480
 
409
- function endpointNameParam(params: Readonly<Record<string, unknown>>): string | undefined {
410
- const name = params.name;
411
- return typeof name === 'string' && name.trim() ? name.trim() : undefined;
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;
412
484
  }
413
485
 
414
486
  /** list 文案:运行中 + 配置中两段,footer 可选。 */
@@ -427,7 +499,7 @@ export function formatEndpointList(
427
499
  lines.push(' (无)');
428
500
  } else {
429
501
  for (const endpoint of running) {
430
- lines.push(endpoint.mode ? ` - ${endpoint.name}(${endpoint.mode})` : ` - ${endpoint.name}`);
502
+ lines.push(endpoint.mode ? ` - ${endpoint.id}(${endpoint.mode})` : ` - ${endpoint.id}`);
431
503
  }
432
504
  }
433
505
  lines.push(`【配置中的 ${spec.adapterDisplayName} endpoints】(zhin.config.yml → plugins.${spec.adapterKey}.endpoints)`);
@@ -436,7 +508,7 @@ export function formatEndpointList(
436
508
  } else {
437
509
  for (const entry of source.configured) {
438
510
  const detail = spec.describeEntry?.(entry);
439
- lines.push(detail ? ` - ${entry.name}(${detail})` : ` - ${entry.name}`);
511
+ lines.push(detail ? ` - ${entry.id}(${detail})` : ` - ${entry.id}`);
440
512
  }
441
513
  }
442
514
  if (source.footer) lines.push(source.footer);
@@ -455,13 +527,13 @@ function addUsage(spec: EndpointCommandsSpec): string {
455
527
  ].filter(Boolean).join(',');
456
528
  return marks ? `${field.key}(${marks})` : field.key;
457
529
  }).join('、')}`;
458
- return `用法:${spec.adapterKey}.endpoint add <name> <key=value...>${fieldText}`;
530
+ return `用法:${spec.adapterKey}.endpoint add <id> <key=value...>${fieldText}`;
459
531
  }
460
532
 
461
533
  /** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
462
534
  export function addEndpointFromKeyValues(
463
535
  spec: EndpointCommandsSpec,
464
- name: string,
536
+ id: string,
465
537
  args: readonly string[],
466
538
  projectRoot?: string,
467
539
  ): string {
@@ -484,13 +556,13 @@ export function addEndpointFromKeyValues(
484
556
  if (missing.length > 0) {
485
557
  return `缺少必填字段:${missing.map((field) => field.key).join('、')}。${addUsage(spec)}`;
486
558
  }
487
- const entry: ConfiguredEndpointEntry = { name };
559
+ const entry: ConfiguredEndpointEntry = { id };
488
560
  const envValues: Record<string, string> = {};
489
561
  for (const field of fields) {
490
562
  const value = values.get(field.key);
491
563
  if (value === undefined) continue;
492
564
  if (field.env) {
493
- const envKey = buildEndpointEnvKey(spec.adapterKey, name, field.key);
565
+ const envKey = buildEndpointEnvKey(spec.adapterKey, id, field.key);
494
566
  envValues[envKey] = value;
495
567
  entry[field.key] = `\${${envKey}}`;
496
568
  } else {
@@ -498,11 +570,10 @@ export function addEndpointFromKeyValues(
498
570
  }
499
571
  }
500
572
  try {
501
- // 先写配置(重名等校验失败时不留孤儿 .env 键),再落 .env 凭据
502
573
  const filePath = addEndpointToConfig(spec.adapterKey, entry, projectRoot);
503
574
  if (Object.keys(envValues).length > 0) persistEndpointEnvValues(envValues, projectRoot);
504
575
  return (
505
- `✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.${spec.adapterKey}.endpoints` +
576
+ `✅ endpoint「${id}」已追加到 ${filePath} 的 plugins.${spec.adapterKey}.endpoints` +
506
577
  `${Object.keys(envValues).length > 0 ? '(凭据已写入 .env)' : ''}。\n` +
507
578
  '⚠️ 需重启 zhin 后新 endpoint 才会生效。'
508
579
  );
@@ -512,13 +583,13 @@ export function addEndpointFromKeyValues(
512
583
  }
513
584
 
514
585
  /** remove 的完整业务逻辑:从 yaml 移除;返回回复文本。 */
515
- export function removeEndpointByName(
586
+ export function removeEndpointById(
516
587
  spec: Pick<EndpointCommandsSpec, 'adapterKey'>,
517
- name: string,
588
+ id: string,
518
589
  projectRoot?: string,
519
590
  ): string {
520
- const trimmed = name.trim();
521
- if (!trimmed) return `用法:${spec.adapterKey}.endpoint remove <name>`;
591
+ const trimmed = id.trim();
592
+ if (!trimmed) return `用法:${spec.adapterKey}.endpoint remove <id>`;
522
593
  try {
523
594
  const { removed, filePath } = removeEndpointFromConfig(spec.adapterKey, trimmed, projectRoot);
524
595
  if (!removed) {
@@ -553,29 +624,29 @@ export function createEndpointCommands<TCommand>(
553
624
  add: defineCommand({
554
625
  description: spec.addDescription
555
626
  ?? `手动添加 ${spec.adapterDisplayName} endpoint(凭据写入 .env 并追加到 zhin.config.yml,重启生效)`,
556
- params: { name: { type: 'string', description: 'endpoint 名称' } },
627
+ params: { id: { type: 'string', description: 'endpoint ID' } },
557
628
  execute({ config, input, params, args, use }) {
558
629
  if (!isEndpointOperator(config, input)) return forbidden;
559
- const name = endpointNameParam(params);
630
+ const id = endpointIdParam(params);
560
631
  if (spec.bindFlow) {
561
632
  return spec.bindFlow({
562
- name,
563
- reply: extractEndpointCommandReply(input),
633
+ id,
634
+ reply: createDurableEndpointCommandReply(input, use),
564
635
  config,
565
636
  input,
566
637
  use,
567
638
  });
568
639
  }
569
- if (!name) return addUsage(spec);
570
- return addEndpointFromKeyValues(spec, name, args);
640
+ if (!id) return addUsage(spec);
641
+ return addEndpointFromKeyValues(spec, id, args);
571
642
  },
572
643
  }),
573
644
  remove: defineCommand({
574
645
  description: `从 zhin.config.yml 的 plugins.${spec.adapterKey}.endpoints 移除指定 endpoint(重启生效)`,
575
- params: { name: { type: 'string', description: 'endpoint 名称' } },
646
+ params: { id: { type: 'string', description: 'endpoint ID' } },
576
647
  execute({ config, input, params }) {
577
648
  if (!isEndpointOperator(config, input)) return forbidden;
578
- return removeEndpointByName(spec, String(params.name ?? ''));
649
+ return removeEndpointById(spec, String(params.id ?? ''));
579
650
  },
580
651
  }),
581
652
  });
@@ -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 endpoints, not
55
- * an IM Core extension point. New adapters must expose `control` directly.
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.name, reconnect, heartbeat })`。
22
+ * 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.id, reconnect, heartbeat })`。
23
23
  * 2. `start()` 改为:
24
24
  * ```ts
25
25
  * this.#unregisterAgent = registerXxxAgentEndpoint(name, this); // agent 注册仍在适配器侧