@zhin.js/adapter 1.0.1 → 1.1.0

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 CHANGED
@@ -16,6 +16,30 @@ export default defineAdapter({
16
16
  本包只依赖 Kernel 与 Feature Kit,不包含具体平台 SDK。生产 manifest 指向
17
17
  `lib/provider.js`;开发时可通过 conditional export 读取源码。
18
18
 
19
+ ## Adapter ↔ Endpoint:固定 1 对多
20
+
21
+ 一个 adapter 插件实例固定对应一到多个 endpoint:
22
+
23
+ - `plugins.<adapter>` 配置该 adapter 所有 endpoint 的**通用配置**(如凭据共享字段、
24
+ `master`、`intents`)。
25
+ - `plugins.<adapter>.endpoints[index]` 配置单个 endpoint 的**特殊配置**,逐项覆盖通用
26
+ 配置,`name` 必填。
27
+ - 不写 `endpoints` 时退化为单 endpoint(历史行为),实例 config 原样传给 `create()`。
28
+
29
+ 展开由 `expandEndpointConfigs`(`src/adapter-index.ts`)完成:endpoint record id 为
30
+ `<slotId>~<name>`,合并顺序 `{...通用, ...项}`(项优先),`endpoints` 键不下传给适配器。
31
+ record name 即 entry.name——Console 展示、`resolve`/`instance` 查找、inbox 落库都按它命中
32
+ 唯一 endpoint(适配器实例的 live name 如 icqq uin 优先于它展示)。entry.name 不得含
33
+ `~`/`\0`(会破坏 id 结构),重名/缺名的 entry 会被丢弃并 warn。
34
+ 多账号示例见 `plugins/adapters/icqq` / `plugins/adapters/qq` 的 README 与 schema。
35
+
36
+ ## 命令前缀(commandPrefix)
37
+
38
+ 适配器实例 config 支持 `commandPrefix`(默认 `''`):`''` 表示任意文本都按命令匹配;
39
+ `'/'` 则要求消息以 `/` 开头才进命令分发。`endpoints[i].commandPrefix` 可逐项覆盖。
40
+ 解析在 `@zhin.js/core` 的 `MessageDispatcher`(`defaultCommandPrefixResolver`);
41
+ `ImRuntime({ commandPrefix })` 可设全局静态前缀覆盖该行为。
42
+
19
43
  验证:`pnpm --filter @zhin.js/adapter test && pnpm --filter @zhin.js/adapter build`。
20
44
 
21
45
  架构说明见 [Plugin Monorepo 与 Feature Provider](../../../docs/architecture/target-implementation/plugin-monorepo-and-features.md)。
@@ -1,5 +1,6 @@
1
1
  import { type CapabilityId, type CapabilitySlot, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
2
  import type { AdapterCapability, AdapterDefinition, EndpointInstance, EndpointSendRequest } from './definition.js';
3
+ import { type EndpointManagementCapability } from './endpoint-management.js';
3
4
  export interface AdapterDescriptor {
4
5
  readonly id: CapabilityId;
5
6
  readonly owner: PluginId;
@@ -12,6 +13,7 @@ export interface AdapterEndpointSummary extends AdapterDescriptor {
12
13
  readonly connected: boolean;
13
14
  readonly status: 'online' | 'offline';
14
15
  readonly phase: AdapterEndpointPhase;
16
+ readonly managementCapabilities: readonly EndpointManagementCapability[];
15
17
  }
16
18
  export type AdapterEndpointPhase = 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
17
19
  export declare class AdapterIndex {
@@ -1,6 +1,7 @@
1
1
  import { DisposeStack, } from '@zhin.js/plugin-runtime';
2
2
  import { createCapabilityContext } from '@zhin.js/feature-kit';
3
3
  import { formatCompact, getLogger } from '@zhin.js/logger';
4
+ import { listEndpointManagementCapabilities, } from './endpoint-management.js';
4
5
  const logger = getLogger('Adapter');
5
6
  export class AdapterIndex {
6
7
  $projection = 'zhin.adapter-index/1';
@@ -23,24 +24,28 @@ export class AdapterIndex {
23
24
  const unconfigured = [];
24
25
  try {
25
26
  for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
26
- const endpoint = await createEndpointSoft(slot, snapshot);
27
- if (endpoint.unconfigured)
28
- unconfigured.push(slot.localName);
29
- records.push({
30
- id: slot.id,
31
- owner: slot.owner,
32
- name: slot.localName,
33
- source: slot.source,
34
- capabilities: slot.definition.capabilities,
35
- endpoint: endpoint.instance,
36
- unconfigured: endpoint.unconfigured,
37
- started: false,
38
- open: false,
39
- failed: false,
40
- startAttempted: false,
41
- // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
42
- stopped: endpoint.unconfigured,
43
- });
27
+ for (const expansion of expandEndpointConfigs(slot, snapshot)) {
28
+ const endpoint = await createEndpointSoft(slot, snapshot, expansion);
29
+ if (endpoint.unconfigured)
30
+ unconfigured.push(expansion.name);
31
+ records.push({
32
+ id: expansion.id,
33
+ owner: slot.owner,
34
+ // 展开模式下 record name 即 endpoint 名(entry.name),
35
+ // 保证 Console 展示与 resolve/instance 按 entry name 命中唯一 record
36
+ name: expansion.name,
37
+ source: slot.source,
38
+ capabilities: slot.definition.capabilities,
39
+ endpoint: endpoint.instance,
40
+ unconfigured: endpoint.unconfigured,
41
+ started: false,
42
+ open: false,
43
+ failed: false,
44
+ startAttempted: false,
45
+ // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
46
+ stopped: endpoint.unconfigured,
47
+ });
48
+ }
44
49
  }
45
50
  if (unconfigured.length > 0) {
46
51
  logger.info(formatCompact({
@@ -71,6 +76,7 @@ export class AdapterIndex {
71
76
  connected: record.open && !record.stopped,
72
77
  status: record.open && !record.stopped ? 'online' : 'offline',
73
78
  phase: endpointPhase(record),
79
+ managementCapabilities: listEndpointManagementCapabilities(record.endpoint),
74
80
  })));
75
81
  }
76
82
  /**
@@ -252,8 +258,13 @@ export function isAdapterIndex(value) {
252
258
  && value.$projection === 'zhin.adapter-index/1';
253
259
  }
254
260
  function matchesEndpoint(record, adapter, endpointId) {
261
+ // 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
262
+ // `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
263
+ // 不能用 `/` 去 endsWith,否则永远匹配不上(endpoint not found)。
264
+ const localName = record.id.split('\0').pop() ?? record.id;
255
265
  const adapterOk = record.name === adapter
256
266
  || record.id === adapter
267
+ || localName === adapter
257
268
  || record.id.endsWith(`/${adapter}`)
258
269
  || record.owner === adapter
259
270
  || record.owner.endsWith(`/${adapter}`);
@@ -295,12 +306,72 @@ function isUnconfiguredError(error) {
295
306
  return (error instanceof TypeError
296
307
  && /requires|not configured|missing|未配置|缺少/i.test(error.message));
297
308
  }
298
- async function createEndpointSoft(slot, snapshot) {
309
+ /**
310
+ * 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{name, ...覆盖}]` 时
311
+ * 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
312
+ * 否则按实例 config 创建单个 endpoint(历史行为)。
313
+ */
314
+ function expandEndpointConfigs(slot, snapshot) {
315
+ const config = snapshot.config.get(slot.owner);
316
+ const raw = config?.endpoints;
317
+ const entries = Array.isArray(raw)
318
+ ? raw.filter((entry) => !!entry && typeof entry === 'object'
319
+ && typeof entry.name === 'string'
320
+ && entry.name.length > 0)
321
+ : [];
322
+ if (entries.length === 0) {
323
+ if (Array.isArray(raw) && raw.length > 0) {
324
+ logger.warn(formatCompact({
325
+ op: 'adapter_endpoints_entries_dropped',
326
+ id: slot.id,
327
+ reason: 'every endpoints entry is missing a non-empty string name',
328
+ }));
329
+ }
330
+ return Object.freeze([{ id: slot.id, name: slot.localName }]);
331
+ }
332
+ // `~` 是 record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析
333
+ const valid = entries.filter((entry) => {
334
+ if (/[~\0]/u.test(entry.name)) {
335
+ logger.warn(formatCompact({
336
+ op: 'adapter_endpoint_name_invalid',
337
+ id: slot.id,
338
+ name: entry.name,
339
+ }));
340
+ return false;
341
+ }
342
+ return true;
343
+ });
344
+ // 重名会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
345
+ const seen = new Set();
346
+ const deduped = valid.filter((entry) => {
347
+ if (seen.has(entry.name)) {
348
+ logger.warn(formatCompact({
349
+ op: 'adapter_endpoint_name_duplicate',
350
+ id: slot.id,
351
+ name: entry.name,
352
+ }));
353
+ return false;
354
+ }
355
+ seen.add(entry.name);
356
+ return true;
357
+ });
358
+ if (deduped.length === 0) {
359
+ return Object.freeze([{ id: slot.id, name: slot.localName }]);
360
+ }
361
+ const { endpoints: _drop, ...base } = (config ?? {});
362
+ return Object.freeze(deduped.map((entry) => Object.freeze({
363
+ id: `${slot.id}~${entry.name}`,
364
+ name: entry.name,
365
+ config: Object.freeze({ ...base, ...entry, name: entry.name }),
366
+ })));
367
+ }
368
+ async function createEndpointSoft(slot, snapshot, expansion) {
299
369
  let endpoint;
300
370
  try {
301
371
  endpoint = await slot.definition.create(Object.freeze({
302
372
  ...createCapabilityContext(snapshot, slot.owner),
303
- id: slot.id,
373
+ ...(expansion?.config ? { config: expansion.config } : {}),
374
+ id: expansion?.id ?? slot.id,
304
375
  name: slot.localName,
305
376
  }));
306
377
  }
@@ -313,8 +384,8 @@ async function createEndpointSoft(slot, snapshot) {
313
384
  const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
314
385
  log(formatCompact({
315
386
  op: 'adapter_create_soft_fail',
316
- id: slot.id,
317
- name: slot.localName,
387
+ id: expansion?.id ?? slot.id,
388
+ name: expansion?.name ?? slot.localName,
318
389
  error: message,
319
390
  }));
320
391
  return {
@@ -325,7 +396,7 @@ async function createEndpointSoft(slot, snapshot) {
325
396
  // Programming errors (create() did not return an Endpoint) must surface:
326
397
  // they propagate to AdapterIndex.create's catch, which disposes the records
327
398
  // created so far instead of hiding the bug behind an unconfigured stub.
328
- assertEndpoint(endpoint, slot.id);
399
+ assertEndpoint(endpoint, expansion?.id ?? slot.id);
329
400
  return { instance: endpoint, unconfigured: false };
330
401
  }
331
402
  function createUnconfiguredEndpoint(reason) {
@@ -1,5 +1,6 @@
1
1
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
2
  import type { CapabilityContext } from '@zhin.js/feature-kit';
3
+ import type { EndpointManagement } from './endpoint-management.js';
3
4
  declare const adapterBrand: "zhin.adapter/1";
4
5
  export type AdapterCapability = 'inbound' | 'outbound';
5
6
  export interface EndpointSendRequest {
@@ -12,6 +13,8 @@ export interface EndpointSendRequest {
12
13
  };
13
14
  }
14
15
  export interface EndpointInstance<TResult = unknown> {
16
+ /** Optional platform-neutral Console/Host management surface. */
17
+ readonly management?: EndpointManagement;
15
18
  /** Allocates transport resources but must not admit inbound events yet. */
16
19
  start?(): void | Promise<void>;
17
20
  /** Opens admission after the candidate generation has committed. */
@@ -0,0 +1,51 @@
1
+ export interface EndpointFriend {
2
+ readonly user_id: number;
3
+ readonly nickname: string;
4
+ readonly remark: string;
5
+ }
6
+ export interface EndpointGroup {
7
+ readonly group_id: number;
8
+ readonly name: string;
9
+ }
10
+ export interface EndpointChannelParent {
11
+ readonly type: string;
12
+ readonly id: string;
13
+ readonly name?: string;
14
+ }
15
+ export interface EndpointChannel {
16
+ readonly id: string;
17
+ readonly name?: string;
18
+ readonly parent?: EndpointChannelParent;
19
+ }
20
+ /**
21
+ * Optional, platform-neutral management surface exposed by an Endpoint.
22
+ *
23
+ * Platform adapters own SDK aliases, identifier coercion, and response
24
+ * normalization. Hosts consume this interface without inspecting adapter
25
+ * names or transport-specific fields.
26
+ */
27
+ export interface EndpointManagement {
28
+ listFriends?(): Promise<readonly EndpointFriend[]>;
29
+ listGroups?(): Promise<readonly EndpointGroup[]>;
30
+ listChannels?(): Promise<readonly EndpointChannel[]>;
31
+ listGroupMembers?(groupId: string): Promise<readonly unknown[]>;
32
+ approveRequest?(requestId: string, remark?: string): Promise<void>;
33
+ rejectRequest?(requestId: string, reason?: string): Promise<void>;
34
+ kickGroupMember?(groupId: string, userId: string): Promise<void>;
35
+ muteGroupMember?(groupId: string, userId: string, durationSeconds: number): Promise<void>;
36
+ setGroupAdmin?(groupId: string, userId: string, enabled: boolean): Promise<void>;
37
+ deleteFriend?(userId: string): Promise<void>;
38
+ }
39
+ export interface EndpointWithManagement {
40
+ readonly management?: EndpointManagement;
41
+ }
42
+ /**
43
+ * Stable, transport-neutral capability ids exposed to Host/Console clients.
44
+ * Values intentionally mirror EndpointManagement method names so adapters only
45
+ * need to implement the semantic port; no second capability declaration exists.
46
+ */
47
+ export declare const endpointManagementCapabilityIds: readonly ["listFriends", "listGroups", "listChannels", "listGroupMembers", "approveRequest", "rejectRequest", "kickGroupMember", "muteGroupMember", "setGroupAdmin", "deleteFriend"];
48
+ export type EndpointManagementCapability = (typeof endpointManagementCapabilityIds)[number];
49
+ export declare function resolveEndpointManagement(endpoint: unknown): EndpointManagement | undefined;
50
+ /** Derive advertised capabilities from the live semantic port implementation. */
51
+ export declare function listEndpointManagementCapabilities(endpoint: unknown): readonly EndpointManagementCapability[];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Stable, transport-neutral capability ids exposed to Host/Console clients.
3
+ * Values intentionally mirror EndpointManagement method names so adapters only
4
+ * need to implement the semantic port; no second capability declaration exists.
5
+ */
6
+ export const endpointManagementCapabilityIds = [
7
+ 'listFriends',
8
+ 'listGroups',
9
+ 'listChannels',
10
+ 'listGroupMembers',
11
+ 'approveRequest',
12
+ 'rejectRequest',
13
+ 'kickGroupMember',
14
+ 'muteGroupMember',
15
+ 'setGroupAdmin',
16
+ 'deleteFriend',
17
+ ];
18
+ export function resolveEndpointManagement(endpoint) {
19
+ if (!endpoint || typeof endpoint !== 'object')
20
+ return undefined;
21
+ const management = endpoint.management;
22
+ return management && typeof management === 'object' ? management : undefined;
23
+ }
24
+ /** Derive advertised capabilities from the live semantic port implementation. */
25
+ export function listEndpointManagementCapabilities(endpoint) {
26
+ const management = resolveEndpointManagement(endpoint);
27
+ if (!management)
28
+ return Object.freeze([]);
29
+ return Object.freeze(endpointManagementCapabilityIds.filter((capability) => typeof management[capability] === 'function'));
30
+ }
package/lib/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './adapter-index.js';
2
2
  export * from './credentials.js';
3
3
  export * from './definition.js';
4
+ export * from './endpoint-management.js';
4
5
  export * from './provider.js';
5
6
  export { default } from './provider.js';
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './adapter-index.js';
2
2
  export * from './credentials.js';
3
3
  export * from './definition.js';
4
+ export * from './endpoint-management.js';
4
5
  export * from './provider.js';
5
6
  export { default } from './provider.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -17,9 +17,9 @@
17
17
  "src"
18
18
  ],
19
19
  "dependencies": {
20
- "@zhin.js/feature-kit": "1.0.1",
21
- "@zhin.js/plugin-runtime": "1.0.1",
22
- "@zhin.js/logger": "1.0.75"
20
+ "@zhin.js/feature-kit": "1.0.2",
21
+ "@zhin.js/logger": "1.0.75",
22
+ "@zhin.js/plugin-runtime": "1.1.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^26.1.0",
@@ -13,6 +13,10 @@ import type {
13
13
  EndpointInstance,
14
14
  EndpointSendRequest,
15
15
  } from './definition.js';
16
+ import {
17
+ listEndpointManagementCapabilities,
18
+ type EndpointManagementCapability,
19
+ } from './endpoint-management.js';
16
20
 
17
21
  const logger = getLogger('Adapter');
18
22
 
@@ -29,6 +33,7 @@ export interface AdapterEndpointSummary extends AdapterDescriptor {
29
33
  readonly connected: boolean;
30
34
  readonly status: 'online' | 'offline';
31
35
  readonly phase: AdapterEndpointPhase;
36
+ readonly managementCapabilities: readonly EndpointManagementCapability[];
32
37
  }
33
38
 
34
39
  export type AdapterEndpointPhase =
@@ -79,23 +84,27 @@ export class AdapterIndex {
79
84
  const unconfigured: string[] = [];
80
85
  try {
81
86
  for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
82
- const endpoint = await createEndpointSoft(slot, snapshot);
83
- if (endpoint.unconfigured) unconfigured.push(slot.localName);
84
- records.push({
85
- id: slot.id,
86
- owner: slot.owner,
87
- name: slot.localName,
88
- source: slot.source,
89
- capabilities: slot.definition.capabilities,
90
- endpoint: endpoint.instance,
91
- unconfigured: endpoint.unconfigured,
92
- started: false,
93
- open: false,
94
- failed: false,
95
- startAttempted: false,
96
- // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
97
- stopped: endpoint.unconfigured,
98
- });
87
+ for (const expansion of expandEndpointConfigs(slot, snapshot)) {
88
+ const endpoint = await createEndpointSoft(slot, snapshot, expansion);
89
+ if (endpoint.unconfigured) unconfigured.push(expansion.name);
90
+ records.push({
91
+ id: expansion.id,
92
+ owner: slot.owner,
93
+ // 展开模式下 record name 即 endpoint 名(entry.name),
94
+ // 保证 Console 展示与 resolve/instance 按 entry name 命中唯一 record
95
+ name: expansion.name,
96
+ source: slot.source,
97
+ capabilities: slot.definition.capabilities,
98
+ endpoint: endpoint.instance,
99
+ unconfigured: endpoint.unconfigured,
100
+ started: false,
101
+ open: false,
102
+ failed: false,
103
+ startAttempted: false,
104
+ // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
105
+ stopped: endpoint.unconfigured,
106
+ });
107
+ }
99
108
  }
100
109
  if (unconfigured.length > 0) {
101
110
  logger.info(formatCompact({
@@ -133,6 +142,7 @@ export class AdapterIndex {
133
142
  connected: record.open && !record.stopped,
134
143
  status: record.open && !record.stopped ? 'online' as const : 'offline' as const,
135
144
  phase: endpointPhase(record),
145
+ managementCapabilities: listEndpointManagementCapabilities(record.endpoint),
136
146
  })));
137
147
  }
138
148
 
@@ -320,8 +330,13 @@ function matchesEndpoint(
320
330
  adapter: string,
321
331
  endpointId: string,
322
332
  ): boolean {
333
+ // 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
334
+ // `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
335
+ // 不能用 `/` 去 endsWith,否则永远匹配不上(endpoint not found)。
336
+ const localName = record.id.split('\0').pop() ?? record.id;
323
337
  const adapterOk = record.name === adapter
324
338
  || record.id === adapter
339
+ || localName === adapter
325
340
  || record.id.endsWith(`/${adapter}`)
326
341
  || record.owner === adapter
327
342
  || record.owner.endsWith(`/${adapter}`);
@@ -366,16 +381,91 @@ function isUnconfiguredError(error: unknown): boolean {
366
381
  );
367
382
  }
368
383
 
384
+ /** 单个实例配置展开的 endpoint 描述(多账号适配器经 `endpoints` 数组声明)。 */
385
+ interface EndpointExpansion {
386
+ readonly id: CapabilityId;
387
+ readonly name: string;
388
+ readonly config?: Readonly<Record<string, unknown>>;
389
+ }
390
+
391
+ /**
392
+ * 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{name, ...覆盖}]` 时
393
+ * 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
394
+ * 否则按实例 config 创建单个 endpoint(历史行为)。
395
+ */
396
+ function expandEndpointConfigs(
397
+ slot: Readonly<CapabilitySlot<AdapterDefinition>>,
398
+ snapshot: RuntimeSnapshot,
399
+ ): readonly EndpointExpansion[] {
400
+ const config = snapshot.config.get(slot.owner) as
401
+ | { endpoints?: unknown }
402
+ | undefined;
403
+ const raw = config?.endpoints;
404
+ const entries = Array.isArray(raw)
405
+ ? raw.filter((entry): entry is Record<string, unknown> & { name: string } =>
406
+ !!entry && typeof entry === 'object'
407
+ && typeof (entry as { name?: unknown }).name === 'string'
408
+ && (entry as { name: string }).name.length > 0)
409
+ : [];
410
+ if (entries.length === 0) {
411
+ if (Array.isArray(raw) && raw.length > 0) {
412
+ logger.warn(formatCompact({
413
+ op: 'adapter_endpoints_entries_dropped',
414
+ id: slot.id,
415
+ reason: 'every endpoints entry is missing a non-empty string name',
416
+ }));
417
+ }
418
+ return Object.freeze([{ id: slot.id, name: slot.localName }]);
419
+ }
420
+ // `~` 是 record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析
421
+ const valid = entries.filter((entry) => {
422
+ if (/[~\0]/u.test(entry.name)) {
423
+ logger.warn(formatCompact({
424
+ op: 'adapter_endpoint_name_invalid',
425
+ id: slot.id,
426
+ name: entry.name,
427
+ }));
428
+ return false;
429
+ }
430
+ return true;
431
+ });
432
+ // 重名会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
433
+ const seen = new Set<string>();
434
+ const deduped = valid.filter((entry) => {
435
+ if (seen.has(entry.name)) {
436
+ logger.warn(formatCompact({
437
+ op: 'adapter_endpoint_name_duplicate',
438
+ id: slot.id,
439
+ name: entry.name,
440
+ }));
441
+ return false;
442
+ }
443
+ seen.add(entry.name);
444
+ return true;
445
+ });
446
+ if (deduped.length === 0) {
447
+ return Object.freeze([{ id: slot.id, name: slot.localName }]);
448
+ }
449
+ const { endpoints: _drop, ...base } = (config ?? {}) as Record<string, unknown>;
450
+ return Object.freeze(deduped.map((entry) => Object.freeze({
451
+ id: `${slot.id}~${entry.name}` as CapabilityId,
452
+ name: entry.name,
453
+ config: Object.freeze({ ...base, ...entry, name: entry.name }),
454
+ })));
455
+ }
456
+
369
457
  async function createEndpointSoft(
370
458
  slot: Readonly<CapabilitySlot<AdapterDefinition>>,
371
459
  snapshot: RuntimeSnapshot,
460
+ expansion?: EndpointExpansion,
372
461
  ): Promise<{ readonly instance: EndpointInstance; readonly unconfigured: boolean }> {
373
462
  let endpoint: unknown;
374
463
  try {
375
464
  endpoint = await slot.definition.create(
376
465
  Object.freeze({
377
466
  ...createCapabilityContext(snapshot, slot.owner),
378
- id: slot.id,
467
+ ...(expansion?.config ? { config: expansion.config } : {}),
468
+ id: expansion?.id ?? slot.id,
379
469
  name: slot.localName,
380
470
  }),
381
471
  );
@@ -388,8 +478,8 @@ async function createEndpointSoft(
388
478
  const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
389
479
  log(formatCompact({
390
480
  op: 'adapter_create_soft_fail',
391
- id: slot.id,
392
- name: slot.localName,
481
+ id: expansion?.id ?? slot.id,
482
+ name: expansion?.name ?? slot.localName,
393
483
  error: message,
394
484
  }));
395
485
  return {
@@ -400,7 +490,7 @@ async function createEndpointSoft(
400
490
  // Programming errors (create() did not return an Endpoint) must surface:
401
491
  // they propagate to AdapterIndex.create's catch, which disposes the records
402
492
  // created so far instead of hiding the bug behind an unconfigured stub.
403
- assertEndpoint(endpoint, slot.id);
493
+ assertEndpoint(endpoint, expansion?.id ?? slot.id);
404
494
  return { instance: endpoint, unconfigured: false };
405
495
  }
406
496
 
package/src/definition.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
2
  import type { CapabilityContext } from '@zhin.js/feature-kit';
3
+ import type { EndpointManagement } from './endpoint-management.js';
3
4
 
4
5
  const adapterBrand = 'zhin.adapter/1' as const;
5
6
 
@@ -12,6 +13,8 @@ export interface EndpointSendRequest {
12
13
  }
13
14
 
14
15
  export interface EndpointInstance<TResult = unknown> {
16
+ /** Optional platform-neutral Console/Host management surface. */
17
+ readonly management?: EndpointManagement;
15
18
  /** Allocates transport resources but must not admit inbound events yet. */
16
19
  start?(): void | Promise<void>;
17
20
  /** Opens admission after the candidate generation has committed. */
@@ -0,0 +1,84 @@
1
+ export interface EndpointFriend {
2
+ readonly user_id: number;
3
+ readonly nickname: string;
4
+ readonly remark: string;
5
+ }
6
+
7
+ export interface EndpointGroup {
8
+ readonly group_id: number;
9
+ readonly name: string;
10
+ }
11
+
12
+ export interface EndpointChannelParent {
13
+ readonly type: string;
14
+ readonly id: string;
15
+ readonly name?: string;
16
+ }
17
+
18
+ export interface EndpointChannel {
19
+ readonly id: string;
20
+ readonly name?: string;
21
+ readonly parent?: EndpointChannelParent;
22
+ }
23
+
24
+ /**
25
+ * Optional, platform-neutral management surface exposed by an Endpoint.
26
+ *
27
+ * Platform adapters own SDK aliases, identifier coercion, and response
28
+ * normalization. Hosts consume this interface without inspecting adapter
29
+ * names or transport-specific fields.
30
+ */
31
+ export interface EndpointManagement {
32
+ listFriends?(): Promise<readonly EndpointFriend[]>;
33
+ listGroups?(): Promise<readonly EndpointGroup[]>;
34
+ listChannels?(): Promise<readonly EndpointChannel[]>;
35
+ listGroupMembers?(groupId: string): Promise<readonly unknown[]>;
36
+ approveRequest?(requestId: string, remark?: string): Promise<void>;
37
+ rejectRequest?(requestId: string, reason?: string): Promise<void>;
38
+ kickGroupMember?(groupId: string, userId: string): Promise<void>;
39
+ muteGroupMember?(groupId: string, userId: string, durationSeconds: number): Promise<void>;
40
+ setGroupAdmin?(groupId: string, userId: string, enabled: boolean): Promise<void>;
41
+ deleteFriend?(userId: string): Promise<void>;
42
+ }
43
+
44
+ export interface EndpointWithManagement {
45
+ readonly management?: EndpointManagement;
46
+ }
47
+
48
+ /**
49
+ * Stable, transport-neutral capability ids exposed to Host/Console clients.
50
+ * Values intentionally mirror EndpointManagement method names so adapters only
51
+ * need to implement the semantic port; no second capability declaration exists.
52
+ */
53
+ export const endpointManagementCapabilityIds = [
54
+ 'listFriends',
55
+ 'listGroups',
56
+ 'listChannels',
57
+ 'listGroupMembers',
58
+ 'approveRequest',
59
+ 'rejectRequest',
60
+ 'kickGroupMember',
61
+ 'muteGroupMember',
62
+ 'setGroupAdmin',
63
+ 'deleteFriend',
64
+ ] as const;
65
+
66
+ export type EndpointManagementCapability =
67
+ (typeof endpointManagementCapabilityIds)[number];
68
+
69
+ export function resolveEndpointManagement(endpoint: unknown): EndpointManagement | undefined {
70
+ if (!endpoint || typeof endpoint !== 'object') return undefined;
71
+ const management = (endpoint as EndpointWithManagement).management;
72
+ return management && typeof management === 'object' ? management : undefined;
73
+ }
74
+
75
+ /** Derive advertised capabilities from the live semantic port implementation. */
76
+ export function listEndpointManagementCapabilities(
77
+ endpoint: unknown,
78
+ ): readonly EndpointManagementCapability[] {
79
+ const management = resolveEndpointManagement(endpoint);
80
+ if (!management) return Object.freeze([]);
81
+ return Object.freeze(endpointManagementCapabilityIds.filter(
82
+ (capability) => typeof management[capability] === 'function',
83
+ ));
84
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './adapter-index.js';
2
2
  export * from './credentials.js';
3
3
  export * from './definition.js';
4
+ export * from './endpoint-management.js';
4
5
  export * from './provider.js';
5
6
  export { default } from './provider.js';