@zhin.js/adapter 1.0.1 → 1.1.1

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.
@@ -10,9 +10,14 @@ import { formatCompact, getLogger } from '@zhin.js/logger';
10
10
  import type {
11
11
  AdapterCapability,
12
12
  AdapterDefinition,
13
+ AdapterSegmentPolicy,
13
14
  EndpointInstance,
14
15
  EndpointSendRequest,
15
16
  } from './definition.js';
17
+ import {
18
+ listEndpointManagementCapabilities,
19
+ type EndpointManagementCapability,
20
+ } from './endpoint-management.js';
16
21
 
17
22
  const logger = getLogger('Adapter');
18
23
 
@@ -29,6 +34,7 @@ export interface AdapterEndpointSummary extends AdapterDescriptor {
29
34
  readonly connected: boolean;
30
35
  readonly status: 'online' | 'offline';
31
36
  readonly phase: AdapterEndpointPhase;
37
+ readonly managementCapabilities: readonly EndpointManagementCapability[];
32
38
  }
33
39
 
34
40
  export type AdapterEndpointPhase =
@@ -36,6 +42,7 @@ export type AdapterEndpointPhase =
36
42
 
37
43
  interface AdapterRecord extends AdapterDescriptor {
38
44
  readonly endpoint: EndpointInstance;
45
+ readonly segments?: AdapterSegmentPolicy;
39
46
  readonly unconfigured: boolean;
40
47
  started: boolean;
41
48
  open: boolean;
@@ -79,23 +86,28 @@ export class AdapterIndex {
79
86
  const unconfigured: string[] = [];
80
87
  try {
81
88
  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
- });
89
+ for (const expansion of expandEndpointConfigs(slot, snapshot)) {
90
+ const endpoint = await createEndpointSoft(slot, snapshot, expansion);
91
+ if (endpoint.unconfigured) unconfigured.push(expansion.name);
92
+ records.push({
93
+ id: expansion.id,
94
+ owner: slot.owner,
95
+ // 展开模式下 record name 即 endpoint 名(entry.name),
96
+ // 保证 Console 展示与 resolve/instance 按 entry name 命中唯一 record
97
+ name: expansion.name,
98
+ source: slot.source,
99
+ capabilities: slot.definition.capabilities,
100
+ endpoint: endpoint.instance,
101
+ ...(slot.definition.segments ? { segments: slot.definition.segments } : {}),
102
+ unconfigured: endpoint.unconfigured,
103
+ started: false,
104
+ open: false,
105
+ failed: false,
106
+ startAttempted: false,
107
+ // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
108
+ stopped: endpoint.unconfigured,
109
+ });
110
+ }
99
111
  }
100
112
  if (unconfigured.length > 0) {
101
113
  logger.info(formatCompact({
@@ -118,7 +130,8 @@ export class AdapterIndex {
118
130
  list(): readonly AdapterDescriptor[] {
119
131
  return this.#order.map(({ endpoint: _endpoint, unconfigured: _unconfigured,
120
132
  started: _started, open: _open, stopped: _stopped, failed: _failed,
121
- startAttempted: _startAttempted, ...descriptor }) => Object.freeze(descriptor));
133
+ startAttempted: _startAttempted, segments: _segments,
134
+ ...descriptor }) => Object.freeze(descriptor));
122
135
  }
123
136
 
124
137
  /** Endpoint rows for Console `endpoint.list` / `endpoint.info`. */
@@ -133,6 +146,7 @@ export class AdapterIndex {
133
146
  connected: record.open && !record.stopped,
134
147
  status: record.open && !record.stopped ? 'online' as const : 'offline' as const,
135
148
  phase: endpointPhase(record),
149
+ managementCapabilities: listEndpointManagementCapabilities(record.endpoint),
136
150
  })));
137
151
  }
138
152
 
@@ -165,6 +179,14 @@ export class AdapterIndex {
165
179
  return record.owner;
166
180
  }
167
181
 
182
+ /**
183
+ * Endpoint 的消息段能力声明(出站协商降级依据);
184
+ * 未声明或未知 id 返回 undefined(调用方按历史行为处理)。
185
+ */
186
+ segmentPolicy(id: CapabilityId): AdapterSegmentPolicy | undefined {
187
+ return this.#records.get(id)?.segments;
188
+ }
189
+
168
190
  async start(): Promise<void> {
169
191
  // Soft-start in parallel with a short wait so kitchen-sink Roots do not
170
192
  // stall generation. Configured platforms that need longer (QQ auth, Slack
@@ -320,8 +342,13 @@ function matchesEndpoint(
320
342
  adapter: string,
321
343
  endpointId: string,
322
344
  ): boolean {
345
+ // 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
346
+ // `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
347
+ // 不能用 `/` 去 endsWith,否则永远匹配不上(endpoint not found)。
348
+ const localName = record.id.split('\0').pop() ?? record.id;
323
349
  const adapterOk = record.name === adapter
324
350
  || record.id === adapter
351
+ || localName === adapter
325
352
  || record.id.endsWith(`/${adapter}`)
326
353
  || record.owner === adapter
327
354
  || record.owner.endsWith(`/${adapter}`);
@@ -366,16 +393,91 @@ function isUnconfiguredError(error: unknown): boolean {
366
393
  );
367
394
  }
368
395
 
396
+ /** 单个实例配置展开的 endpoint 描述(多账号适配器经 `endpoints` 数组声明)。 */
397
+ interface EndpointExpansion {
398
+ readonly id: CapabilityId;
399
+ readonly name: string;
400
+ readonly config?: Readonly<Record<string, unknown>>;
401
+ }
402
+
403
+ /**
404
+ * 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{name, ...覆盖}]` 时
405
+ * 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
406
+ * 否则按实例 config 创建单个 endpoint(历史行为)。
407
+ */
408
+ function expandEndpointConfigs(
409
+ slot: Readonly<CapabilitySlot<AdapterDefinition>>,
410
+ snapshot: RuntimeSnapshot,
411
+ ): readonly EndpointExpansion[] {
412
+ const config = snapshot.config.get(slot.owner) as
413
+ | { endpoints?: unknown }
414
+ | undefined;
415
+ const raw = config?.endpoints;
416
+ const entries = Array.isArray(raw)
417
+ ? raw.filter((entry): entry is Record<string, unknown> & { name: string } =>
418
+ !!entry && typeof entry === 'object'
419
+ && typeof (entry as { name?: unknown }).name === 'string'
420
+ && (entry as { name: string }).name.length > 0)
421
+ : [];
422
+ if (entries.length === 0) {
423
+ if (Array.isArray(raw) && raw.length > 0) {
424
+ logger.warn(formatCompact({
425
+ op: 'adapter_endpoints_entries_dropped',
426
+ id: slot.id,
427
+ reason: 'every endpoints entry is missing a non-empty string name',
428
+ }));
429
+ }
430
+ return Object.freeze([{ id: slot.id, name: slot.localName }]);
431
+ }
432
+ // `~` 是 record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析
433
+ const valid = entries.filter((entry) => {
434
+ if (/[~\0]/u.test(entry.name)) {
435
+ logger.warn(formatCompact({
436
+ op: 'adapter_endpoint_name_invalid',
437
+ id: slot.id,
438
+ name: entry.name,
439
+ }));
440
+ return false;
441
+ }
442
+ return true;
443
+ });
444
+ // 重名会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
445
+ const seen = new Set<string>();
446
+ const deduped = valid.filter((entry) => {
447
+ if (seen.has(entry.name)) {
448
+ logger.warn(formatCompact({
449
+ op: 'adapter_endpoint_name_duplicate',
450
+ id: slot.id,
451
+ name: entry.name,
452
+ }));
453
+ return false;
454
+ }
455
+ seen.add(entry.name);
456
+ return true;
457
+ });
458
+ if (deduped.length === 0) {
459
+ return Object.freeze([{ id: slot.id, name: slot.localName }]);
460
+ }
461
+ const { endpoints: _drop, ...base } = (config ?? {}) as Record<string, unknown>;
462
+ return Object.freeze(deduped.map((entry) => Object.freeze({
463
+ id: `${slot.id}~${entry.name}` as CapabilityId,
464
+ name: entry.name,
465
+ config: Object.freeze({ ...base, ...entry, name: entry.name }),
466
+ })));
467
+ }
468
+
369
469
  async function createEndpointSoft(
370
470
  slot: Readonly<CapabilitySlot<AdapterDefinition>>,
371
471
  snapshot: RuntimeSnapshot,
472
+ expansion?: EndpointExpansion,
372
473
  ): Promise<{ readonly instance: EndpointInstance; readonly unconfigured: boolean }> {
373
474
  let endpoint: unknown;
374
475
  try {
375
476
  endpoint = await slot.definition.create(
376
477
  Object.freeze({
377
478
  ...createCapabilityContext(snapshot, slot.owner),
378
- id: slot.id,
479
+ ...(expansion?.config ? { config: expansion.config } : {}),
480
+ id: expansion?.id ?? slot.id,
379
481
  name: slot.localName,
380
482
  }),
381
483
  );
@@ -388,8 +490,8 @@ async function createEndpointSoft(
388
490
  const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
389
491
  log(formatCompact({
390
492
  op: 'adapter_create_soft_fail',
391
- id: slot.id,
392
- name: slot.localName,
493
+ id: expansion?.id ?? slot.id,
494
+ name: expansion?.name ?? slot.localName,
393
495
  error: message,
394
496
  }));
395
497
  return {
@@ -400,7 +502,7 @@ async function createEndpointSoft(
400
502
  // Programming errors (create() did not return an Endpoint) must surface:
401
503
  // they propagate to AdapterIndex.create's catch, which disposes the records
402
504
  // created so far instead of hiding the bug behind an unconfigured stub.
403
- assertEndpoint(endpoint, slot.id);
505
+ assertEndpoint(endpoint, expansion?.id ?? slot.id);
404
506
  return { instance: endpoint, unconfigured: false };
405
507
  }
406
508
 
package/src/definition.ts CHANGED
@@ -1,10 +1,17 @@
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
 
6
7
  export type AdapterCapability = 'inbound' | 'outbound';
7
8
 
9
+ /** 端点可消费的出站媒体来源形式。 */
10
+ export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
11
+
12
+ /** 交互段(卡片/按钮等富交互)的端点消费方式。 */
13
+ export type AdapterInteractiveMode = 'native' | 'text';
14
+
8
15
  export interface EndpointSendRequest {
9
16
  readonly target: string;
10
17
  readonly payload: unknown;
@@ -12,6 +19,8 @@ export interface EndpointSendRequest {
12
19
  }
13
20
 
14
21
  export interface EndpointInstance<TResult = unknown> {
22
+ /** Optional platform-neutral Console/Host management surface. */
23
+ readonly management?: EndpointManagement;
15
24
  /** Allocates transport resources but must not admit inbound events yet. */
16
25
  start?(): void | Promise<void>;
17
26
  /** Opens admission after the candidate generation has committed. */
@@ -28,14 +37,62 @@ export interface AdapterContext<TConfig = unknown> extends CapabilityContext<TCo
28
37
  readonly name: string;
29
38
  }
30
39
 
40
+ /**
41
+ * html 段出站策略:
42
+ * - `direct`:端点直接消费 html 段(Console UI 类端点),核心不做任何转换;
43
+ * - `image`:经 html-renderer 渲染成 image 段,无渲染器时降级 text(缺省);
44
+ * - `text`:直接降级为 text 段。
45
+ */
46
+ export type HtmlOutboundMode = 'direct' | 'image' | 'text';
47
+
48
+ /**
49
+ * 端点消息段能力声明(出站协商降级的依据)。
50
+ * 缺省(未声明 `segments`)保持历史行为:仅 html 段按 image/text 处理,
51
+ * 其余段原样透传给端点。
52
+ */
53
+ export interface AdapterSegmentPolicy {
54
+ /**
55
+ * 端点原生可消费的 wire 段类型(如 `['text', 'image', 'at']`)。
56
+ * 声明后,未列出的段由核心按 `formatSegmentPreview` 降级为 text 段;
57
+ * 不声明则不过滤(全部透传)。
58
+ */
59
+ readonly supported?: readonly string[];
60
+ /** html 段处理策略,缺省 `image`。 */
61
+ readonly html?: HtmlOutboundMode;
62
+ /** 端点可消费的媒体来源形式;缺省表示不做媒体来源协商。 */
63
+ readonly outboundMedia?: readonly AdapterOutboundMedia[];
64
+ /**
65
+ * 交互段(卡片/按钮等富交互)消费方式:`native` 原生渲染 / `text` 降级纯文本。
66
+ * 目前仅为声明(供出站协商与门禁消费),`text` 的降级执行随 Wave 2 落地。
67
+ */
68
+ readonly interactive?: AdapterInteractiveMode;
69
+ }
70
+
71
+ const HTML_OUTBOUND_MODES: readonly HtmlOutboundMode[] = ['direct', 'image', 'text'];
72
+
73
+ const OUTBOUND_MEDIA_FORMS: readonly AdapterOutboundMedia[] = [
74
+ 'url', 'path', 'base64', 'upload',
75
+ ];
76
+
31
77
  export interface AdapterDefinition<TConfig = unknown, TResult = unknown> {
32
78
  readonly $feature: typeof adapterBrand;
33
79
  readonly capabilities: readonly AdapterCapability[];
80
+ /** 可选:端点消息段能力声明(出站协商降级挂载点)。 */
81
+ readonly segments?: AdapterSegmentPolicy;
34
82
  create(
35
83
  context: AdapterContext<TConfig>,
36
84
  ): EndpointInstance<TResult> | Promise<EndpointInstance<TResult>>;
37
85
  }
38
86
 
87
+ declare module '@zhin.js/plugin-runtime' {
88
+ interface PluginSetupContext<TConfig> {
89
+ addAdapter<TResult = unknown>(
90
+ localName: string,
91
+ definition: AdapterDefinition<TConfig, TResult>,
92
+ ): void;
93
+ }
94
+ }
95
+
39
96
  export function defineAdapter<TConfig = unknown, TResult = unknown>(
40
97
  definition: Omit<AdapterDefinition<TConfig, TResult>, '$feature'>,
41
98
  ): Readonly<AdapterDefinition<TConfig, TResult>> {
@@ -49,10 +106,58 @@ export function defineAdapter<TConfig = unknown, TResult = unknown>(
49
106
  ) {
50
107
  throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
51
108
  }
109
+ const segments = normalizeSegmentPolicy(definition.segments);
52
110
  return Object.freeze({
53
111
  ...definition,
54
112
  $feature: adapterBrand,
55
113
  capabilities: Object.freeze(capabilities),
114
+ ...(segments ? { segments } : {}),
115
+ });
116
+ }
117
+
118
+ function normalizeSegmentPolicy(
119
+ policy: AdapterSegmentPolicy | undefined,
120
+ ): AdapterSegmentPolicy | undefined {
121
+ if (policy === undefined) return undefined;
122
+ if (!policy || typeof policy !== 'object') {
123
+ throw new TypeError('Adapter segments policy must be an object');
124
+ }
125
+ if (
126
+ policy.supported !== undefined
127
+ && (!Array.isArray(policy.supported)
128
+ || policy.supported.some((type) => typeof type !== 'string' || !type))
129
+ ) {
130
+ throw new TypeError('Adapter segments.supported must be an array of segment type names');
131
+ }
132
+ if (policy.html !== undefined && !HTML_OUTBOUND_MODES.includes(policy.html)) {
133
+ throw new TypeError('Adapter segments.html must be direct, image or text');
134
+ }
135
+ if (
136
+ policy.outboundMedia !== undefined
137
+ && (!Array.isArray(policy.outboundMedia)
138
+ || policy.outboundMedia.length === 0
139
+ || policy.outboundMedia.some(
140
+ (form) => !OUTBOUND_MEDIA_FORMS.includes(form as AdapterOutboundMedia),
141
+ ))
142
+ ) {
143
+ throw new TypeError(
144
+ "Adapter segments.outboundMedia must be a non-empty array of 'url' | 'path' | 'base64' | 'upload'",
145
+ );
146
+ }
147
+ if (
148
+ policy.interactive !== undefined
149
+ && policy.interactive !== 'native'
150
+ && policy.interactive !== 'text'
151
+ ) {
152
+ throw new TypeError("Adapter segments.interactive must be 'native' or 'text'");
153
+ }
154
+ return Object.freeze({
155
+ ...(policy.supported ? { supported: Object.freeze([...new Set(policy.supported)]) } : {}),
156
+ ...(policy.html ? { html: policy.html } : {}),
157
+ ...(policy.outboundMedia
158
+ ? { outboundMedia: Object.freeze([...new Set(policy.outboundMedia)]) }
159
+ : {}),
160
+ ...(policy.interactive ? { interactive: policy.interactive } : {}),
56
161
  });
57
162
  }
58
163
 
@@ -68,6 +173,8 @@ export function parseAdapterDefinition(value: unknown): AdapterDefinition {
68
173
  (capability) => capability !== 'inbound' && capability !== 'outbound',
69
174
  )
70
175
  ) throw invalidAdapter();
176
+ // defineAdapter 已校验过形状;外部手工构造的 definition 也在此兜底
177
+ normalizeSegmentPolicy(definition.segments);
71
178
  return definition as AdapterDefinition;
72
179
  }
73
180