@zhin.js/adapter 1.0.0 → 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 +24 -0
- package/lib/adapter-index.d.ts +2 -0
- package/lib/adapter-index.js +94 -23
- package/lib/definition.d.ts +3 -0
- package/lib/endpoint-management.d.ts +51 -0
- package/lib/endpoint-management.js +30 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/package.json +4 -4
- package/src/adapter-index.ts +111 -21
- package/src/definition.ts +3 -0
- package/src/endpoint-management.ts +84 -0
- package/src/index.ts +1 -0
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)。
|
package/lib/adapter-index.d.ts
CHANGED
|
@@ -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 {
|
package/lib/adapter-index.js
CHANGED
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/definition.d.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
|
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
package/lib/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter",
|
|
3
|
-
"version": "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/
|
|
21
|
-
"@zhin.js/
|
|
22
|
-
"@zhin.js/
|
|
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",
|
package/src/adapter-index.ts
CHANGED
|
@@ -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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
+
}
|