@zhin.js/adapter 1.1.2 → 1.1.3

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
@@ -19,6 +19,30 @@ export default defineAdapter({
19
19
  单文件插件可用 `setup({ addAdapter })` 注册 `defineAdapter(...)`;Endpoint 仍由同一个
20
20
  AdapterIndex 和 generation handoff 管理。
21
21
 
22
+ ## Transport Contract
23
+
24
+ Adapter definitions declare `capabilities` for inbound/outbound admission and
25
+ `operations` for optional actions such as `recall`, `edit`, `reaction`, and
26
+ `typing`. Runtime callers should query the resulting `EndpointCapabilities`
27
+ instead of probing optional endpoint methods. The zero-dependency types live in
28
+ [`@zhin.js/im-contract`](../im-contract/README.md).
29
+
30
+ New framework-facing outbound code should carry a structured `ConversationRef`.
31
+ `EndpointSendRequest.target` remains temporarily for platform codecs that still
32
+ need their legacy target string; it is not a general-purpose message identity.
33
+
34
+ ## Endpoint Control Port
35
+
36
+ `EndpointInstance.control` owns actions addressed to an existing message:
37
+ `recall`, `addReaction`, and `removeReaction`. IM Core consumes only this port;
38
+ adapter-specific method names and compound message ids stay at the protocol
39
+ boundary.
40
+
41
+ New adapters should provide `control` directly and declare matching
42
+ `operations`. During the 4.x migration, `resolveEndpointControl()` can adapt
43
+ legacy `recallMessage` / `$recallMessage` / reaction methods, but that bridge
44
+ exists only in this package and is not a public extension pattern.
45
+
22
46
  ## Endpoint 生命周期基座(createEndpointLifecycle)
23
47
 
24
48
  WS/SSE 类端点的 start/stop/重连/心跳统一走 `createEndpointLifecycle`
@@ -2,6 +2,7 @@ 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
4
  import { listEndpointManagementCapabilities, } from './endpoint-management.js';
5
+ import { assertDeclaredEndpointOperations } from './endpoint-control.js';
5
6
  const logger = getLogger('Adapter');
6
7
  export class AdapterIndex {
7
8
  $projection = 'zhin.adapter-index/1';
@@ -405,6 +406,7 @@ async function createEndpointSoft(slot, snapshot, expansion) {
405
406
  // they propagate to AdapterIndex.create's catch, which disposes the records
406
407
  // created so far instead of hiding the bug behind an unconfigured stub.
407
408
  assertEndpoint(endpoint, expansion?.id ?? slot.id);
409
+ assertDeclaredEndpointOperations(endpoint, slot.definition.operations, String(expansion?.id ?? slot.id));
408
410
  return { instance: endpoint, unconfigured: false };
409
411
  }
410
412
  function createUnconfiguredEndpoint(reason) {
@@ -1,13 +1,23 @@
1
1
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
2
  import type { CapabilityContext } from '@zhin.js/feature-kit';
3
+ import type { ConversationRef, EndpointCapabilities, EndpointOperation } from '@zhin.js/im-contract';
3
4
  import type { EndpointManagement } from './endpoint-management.js';
5
+ import type { EndpointControl } from './endpoint-control.js';
4
6
  declare const adapterBrand: "zhin.adapter/1";
5
7
  export type AdapterCapability = 'inbound' | 'outbound';
8
+ /** Operations beyond sending, declared by an Adapter definition. */
9
+ export type AdapterOperation = Exclude<EndpointOperation, 'send'>;
6
10
  /** 端点可消费的出站媒体来源形式。 */
7
11
  export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
8
12
  /** 交互段(卡片/按钮等富交互)的端点消费方式。 */
9
13
  export type AdapterInteractiveMode = 'native' | 'text';
10
14
  export interface EndpointSendRequest {
15
+ /**
16
+ * Structured identity for new callers. The target remains until every
17
+ * platform adapter has migrated its native boundary codec.
18
+ */
19
+ readonly conversation?: ConversationRef;
20
+ /** @deprecated Use conversation for framework-facing code. */
11
21
  readonly target: string;
12
22
  readonly payload: unknown;
13
23
  readonly parent?: {
@@ -19,6 +29,8 @@ export interface EndpointSendRequest {
19
29
  export interface EndpointInstance<TResult = unknown> {
20
30
  /** Optional platform-neutral Console/Host management surface. */
21
31
  readonly management?: EndpointManagement;
32
+ /** Optional platform-neutral control surface for existing messages. */
33
+ readonly control?: EndpointControl;
22
34
  /** Allocates transport resources but must not admit inbound events yet. */
23
35
  start?(): void | Promise<void>;
24
36
  /** Opens admission after the candidate generation has committed. */
@@ -65,6 +77,12 @@ export interface AdapterSegmentPolicy {
65
77
  export interface AdapterDefinition<TConfig = unknown, TResult = unknown> {
66
78
  readonly $feature: typeof adapterBrand;
67
79
  readonly capabilities: readonly AdapterCapability[];
80
+ /**
81
+ * Explicit support for operations other than send. `send` is derived from
82
+ * `capabilities: ['outbound']`; a method existing on an endpoint is not a
83
+ * capability declaration.
84
+ */
85
+ readonly operations?: readonly AdapterOperation[];
68
86
  /** 可选:端点消息段能力声明(出站协商降级挂载点)。 */
69
87
  readonly segments?: AdapterSegmentPolicy;
70
88
  create(context: AdapterContext<TConfig>): EndpointInstance<TResult> | Promise<EndpointInstance<TResult>>;
@@ -75,5 +93,7 @@ declare module '@zhin.js/plugin-runtime' {
75
93
  }
76
94
  }
77
95
  export declare function defineAdapter<TConfig = unknown, TResult = unknown>(definition: Omit<AdapterDefinition<TConfig, TResult>, '$feature'>): Readonly<AdapterDefinition<TConfig, TResult>>;
96
+ /** Converts the definition's compact authoring form into the public contract. */
97
+ export declare function endpointCapabilitiesOf(definition: Pick<AdapterDefinition, 'capabilities' | 'operations'>): EndpointCapabilities;
78
98
  export declare function parseAdapterDefinition(value: unknown): AdapterDefinition;
79
99
  export {};
package/lib/definition.js CHANGED
@@ -3,6 +3,7 @@ const HTML_OUTBOUND_MODES = ['direct', 'image', 'text'];
3
3
  const OUTBOUND_MEDIA_FORMS = [
4
4
  'url', 'path', 'base64', 'upload',
5
5
  ];
6
+ const ADAPTER_OPERATIONS = ['recall', 'edit', 'reaction', 'typing'];
6
7
  export function defineAdapter(definition) {
7
8
  if (typeof definition.create !== 'function') {
8
9
  throw new TypeError('Adapter create must be a function');
@@ -13,13 +14,33 @@ export function defineAdapter(definition) {
13
14
  throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
14
15
  }
15
16
  const segments = normalizeSegmentPolicy(definition.segments);
17
+ const operations = normalizeOperations(definition.operations);
16
18
  return Object.freeze({
17
19
  ...definition,
18
20
  $feature: adapterBrand,
19
21
  capabilities: Object.freeze(capabilities),
22
+ ...(operations ? { operations } : {}),
20
23
  ...(segments ? { segments } : {}),
21
24
  });
22
25
  }
26
+ /** Converts the definition's compact authoring form into the public contract. */
27
+ export function endpointCapabilitiesOf(definition) {
28
+ const operations = definition.operations?.reduce((result, operation) => ({ ...result, [operation]: true }), {});
29
+ return Object.freeze({
30
+ inbound: definition.capabilities.includes('inbound'),
31
+ outbound: definition.capabilities.includes('outbound'),
32
+ ...(operations && Object.keys(operations).length > 0 ? { operations: Object.freeze(operations) } : {}),
33
+ });
34
+ }
35
+ function normalizeOperations(operations) {
36
+ if (operations === undefined)
37
+ return undefined;
38
+ if (!Array.isArray(operations)
39
+ || operations.some((operation) => !ADAPTER_OPERATIONS.includes(operation))) {
40
+ throw new TypeError('Adapter operations must be recall, edit, reaction and/or typing');
41
+ }
42
+ return Object.freeze([...new Set(operations)]);
43
+ }
23
44
  function normalizeSegmentPolicy(policy) {
24
45
  if (policy === undefined)
25
46
  return undefined;
@@ -66,6 +87,7 @@ export function parseAdapterDefinition(value) {
66
87
  throw invalidAdapter();
67
88
  // defineAdapter 已校验过形状;外部手工构造的 definition 也在此兜底
68
89
  normalizeSegmentPolicy(definition.segments);
90
+ normalizeOperations(definition.operations);
69
91
  return definition;
70
92
  }
71
93
  function invalidAdapter() {
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * createEndpointCommands — 适配器 endpoint 管理命令套件(list / add / remove)。
3
3
  *
4
- * 把 QQ 适配器独有的 `qq endpoint` 命令族泛化为任意适配器可复用的套件:
4
+ * 把 QQ 适配器独有的 `qq.endpoint` 命令族泛化为任意适配器可复用的套件:
5
5
  *
6
- * - `<adapter> endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
6
+ * - `<adapter>.endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
7
7
  * + zhin.config.yml 配置里的 `plugins.<adapterKey>.endpoints`。
8
- * - `<adapter> endpoint add <name> <key=value...>`:手动录入字段,
8
+ * - `<adapter>.endpoint add <name> <key=value...>`:手动录入字段,
9
9
  * 凭据类字段(env: true)值写入 .env(`<ADAPTER>_<NAME>_<FIELD>` 大写键),
10
10
  * yaml 中保存 `${REF}` 引用;其余字段内联写入。yaml 用 Document 节点级操作保留注释。
11
- * - `<adapter> endpoint remove <name>`:从 `plugins.<adapterKey>.endpoints` 移除(重启生效)。
11
+ * - `<adapter>.endpoint remove <name>`:从 `plugins.<adapterKey>.endpoints` 移除(重启生效)。
12
12
  * - 权限:实例 config 声明了 master(顶层或 endpoints[i])时仅 master 可用 add/remove,
13
13
  * 未配置放行(isEndpointOperator)。
14
14
  * - 特殊 add 流程(如 QQ 扫码绑定)经 spec.bindFlow 钩子接管 add 命令。
@@ -207,7 +207,7 @@ export function addEndpointToConfig(adapterKey, entry, projectRoot) {
207
207
  const document = readConfigDocument(adapterKey, projectRoot);
208
208
  const seq = ensureEndpointsSeq(document.doc, adapterKey);
209
209
  if (seq.items.some((item) => entryName(item) === entry.name)) {
210
- throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey} endpoint remove ${entry.name} 再重新添加`);
210
+ throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey}.endpoint remove ${entry.name} 再重新添加`);
211
211
  }
212
212
  seq.items.push(document.doc.createNode(entry));
213
213
  writeConfigDocument(document);
@@ -268,7 +268,7 @@ function addUsage(spec) {
268
268
  ].filter(Boolean).join(',');
269
269
  return marks ? `${field.key}(${marks})` : field.key;
270
270
  }).join('、')}`;
271
- return `用法:${spec.adapterKey} endpoint add <name> <key=value...>${fieldText}`;
271
+ return `用法:${spec.adapterKey}.endpoint add <name> <key=value...>${fieldText}`;
272
272
  }
273
273
  /** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
274
274
  export function addEndpointFromKeyValues(spec, name, args, projectRoot) {
@@ -325,7 +325,7 @@ export function addEndpointFromKeyValues(spec, name, args, projectRoot) {
325
325
  export function removeEndpointByName(spec, name, projectRoot) {
326
326
  const trimmed = name.trim();
327
327
  if (!trimmed)
328
- return `用法:${spec.adapterKey} endpoint remove <name>`;
328
+ return `用法:${spec.adapterKey}.endpoint remove <name>`;
329
329
  try {
330
330
  const { removed, filePath } = removeEndpointFromConfig(spec.adapterKey, trimmed, projectRoot);
331
331
  if (!removed) {
@@ -0,0 +1,31 @@
1
+ import { type ConversationTarget, type MessageTarget } from '@zhin.js/im-contract';
2
+ /**
3
+ * Transport-neutral control plane for a live endpoint.
4
+ *
5
+ * Sending belongs to EndpointInstance.send(). This port intentionally owns
6
+ * operations that address an existing platform message, so Core never needs
7
+ * to know a protocol's method names or identifier layout.
8
+ */
9
+ export interface EndpointControl {
10
+ recall?(message: MessageTarget): Promise<void>;
11
+ edit?(message: MessageTarget, content: unknown): Promise<string | null>;
12
+ addReaction?(message: MessageTarget, emoji: string, hint?: {
13
+ readonly sceneType?: string;
14
+ readonly channelId?: string;
15
+ }): Promise<string | null>;
16
+ removeReaction?(message: MessageTarget, reactionId: string): Promise<void>;
17
+ typing?(conversation: ConversationTarget, active?: boolean): Promise<void>;
18
+ }
19
+ export interface EndpointWithControl {
20
+ readonly control?: EndpointControl;
21
+ }
22
+ /**
23
+ * 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.
26
+ */
27
+ export declare function resolveEndpointControl(endpoint: unknown): EndpointControl | undefined;
28
+ /** Checks only an Endpoint's explicit `control` port, never the legacy bridge. */
29
+ export declare function hasExplicitEndpointOperation(endpoint: unknown, operation: 'recall' | 'edit' | 'reaction' | 'typing'): boolean;
30
+ /** Rejects a declaration that cannot be fulfilled by the explicit control port. */
31
+ export declare function assertDeclaredEndpointOperations(endpoint: unknown, operations: readonly ('recall' | 'edit' | 'reaction' | 'typing')[] | undefined, id: string): void;
@@ -0,0 +1,77 @@
1
+ import { formatLegacyConversationRef, formatLegacyMessageRef, } from '@zhin.js/im-contract';
2
+ /**
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.
6
+ */
7
+ export function resolveEndpointControl(endpoint) {
8
+ if (!endpoint || typeof endpoint !== 'object')
9
+ return undefined;
10
+ const explicit = endpoint.control;
11
+ if (explicit && typeof explicit === 'object')
12
+ return explicit;
13
+ const legacy = endpoint;
14
+ const recall = legacy.recallMessage ?? legacy.$recallMessage;
15
+ const edit = legacy.editMessage ?? legacy.$editMessage;
16
+ const addReaction = legacy.addReaction ?? legacy.$addReaction;
17
+ const removeReaction = legacy.removeReaction ?? legacy.$removeReaction;
18
+ const typing = legacy.typing ?? legacy.$typing;
19
+ if (!recall && !edit && !addReaction && !removeReaction && !typing)
20
+ return undefined;
21
+ return Object.freeze({
22
+ ...(recall
23
+ ? { recall: (message) => recall.call(endpoint, legacyMessageId(message)) }
24
+ : {}),
25
+ ...(edit
26
+ ? {
27
+ edit: (message, content) => edit.call(endpoint, legacyMessageId(message), content),
28
+ }
29
+ : {}),
30
+ ...(addReaction
31
+ ? {
32
+ addReaction: (message, emoji, hint) => addReaction.call(endpoint, legacyMessageId(message), emoji, hint),
33
+ }
34
+ : {}),
35
+ ...(removeReaction
36
+ ? {
37
+ removeReaction: (message, reactionId) => removeReaction.call(endpoint, legacyMessageId(message), reactionId),
38
+ }
39
+ : {}),
40
+ ...(typing
41
+ ? {
42
+ typing: (conversation, active) => typing.call(endpoint, legacyConversationTarget(conversation), active),
43
+ }
44
+ : {}),
45
+ });
46
+ }
47
+ function legacyMessageId(message) {
48
+ return typeof message === 'string' ? message : formatLegacyMessageRef(message);
49
+ }
50
+ function legacyConversationTarget(conversation) {
51
+ return typeof conversation === 'string' ? conversation : formatLegacyConversationRef(conversation);
52
+ }
53
+ /** Checks only an Endpoint's explicit `control` port, never the legacy bridge. */
54
+ export function hasExplicitEndpointOperation(endpoint, operation) {
55
+ if (!endpoint || typeof endpoint !== 'object')
56
+ return false;
57
+ const control = endpoint.control;
58
+ if (!control || typeof control !== 'object')
59
+ return false;
60
+ switch (operation) {
61
+ case 'recall': return typeof control.recall === 'function';
62
+ case 'edit': return typeof control.edit === 'function';
63
+ case 'reaction': return typeof control.addReaction === 'function';
64
+ case 'typing': return typeof control.typing === 'function';
65
+ }
66
+ }
67
+ /** Rejects a declaration that cannot be fulfilled by the explicit control port. */
68
+ export function assertDeclaredEndpointOperations(endpoint, operations, id) {
69
+ for (const operation of operations ?? []) {
70
+ if (!hasExplicitEndpointOperation(endpoint, operation)) {
71
+ throw new TypeError(`Adapter Endpoint ${id} declares ${operation} but control.${controlMethodName(operation)} is missing`);
72
+ }
73
+ }
74
+ }
75
+ function controlMethodName(operation) {
76
+ return operation === 'reaction' ? 'addReaction' : operation;
77
+ }
package/lib/index.d.ts CHANGED
@@ -6,5 +6,6 @@ export * from './definition.js';
6
6
  export * from './endpoint-commands.js';
7
7
  export * from './endpoint-lifecycle.js';
8
8
  export * from './endpoint-management.js';
9
+ export * from './endpoint-control.js';
9
10
  export * from './provider.js';
10
11
  export { default } from './provider.js';
package/lib/index.js CHANGED
@@ -6,5 +6,6 @@ export * from './definition.js';
6
6
  export * from './endpoint-commands.js';
7
7
  export * from './endpoint-lifecycle.js';
8
8
  export * from './endpoint-management.js';
9
+ export * from './endpoint-control.js';
9
10
  export * from './provider.js';
10
11
  export { default } from './provider.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -19,13 +19,14 @@
19
19
  "dependencies": {
20
20
  "yaml": "^2.9.0",
21
21
  "@zhin.js/feature-kit": "1.0.4",
22
+ "@zhin.js/im-contract": "1.0.0",
22
23
  "@zhin.js/logger": "1.0.75",
23
24
  "@zhin.js/plugin-runtime": "1.1.1"
24
25
  },
25
26
  "devDependencies": {
26
- "@types/node": "^26.1.0",
27
+ "@types/node": "^26.1.2",
27
28
  "typescript": "^6.0.3",
28
- "@zhin.js/command": "1.0.4"
29
+ "@zhin.js/command": "1.0.5"
29
30
  },
30
31
  "zhin": {
31
32
  "protocol": 1,
@@ -18,6 +18,7 @@ import {
18
18
  listEndpointManagementCapabilities,
19
19
  type EndpointManagementCapability,
20
20
  } from './endpoint-management.js';
21
+ import { assertDeclaredEndpointOperations } from './endpoint-control.js';
21
22
 
22
23
  const logger = getLogger('Adapter');
23
24
 
@@ -503,6 +504,11 @@ async function createEndpointSoft(
503
504
  // they propagate to AdapterIndex.create's catch, which disposes the records
504
505
  // created so far instead of hiding the bug behind an unconfigured stub.
505
506
  assertEndpoint(endpoint, expansion?.id ?? slot.id);
507
+ assertDeclaredEndpointOperations(
508
+ endpoint,
509
+ slot.definition.operations,
510
+ String(expansion?.id ?? slot.id),
511
+ );
506
512
  return { instance: endpoint, unconfigured: false };
507
513
  }
508
514
 
package/src/definition.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
2
  import type { CapabilityContext } from '@zhin.js/feature-kit';
3
+ import type {
4
+ ConversationRef,
5
+ EndpointCapabilities,
6
+ EndpointOperation,
7
+ } from '@zhin.js/im-contract';
3
8
  import type { EndpointManagement } from './endpoint-management.js';
9
+ import type { EndpointControl } from './endpoint-control.js';
4
10
 
5
11
  const adapterBrand = 'zhin.adapter/1' as const;
6
12
 
7
13
  export type AdapterCapability = 'inbound' | 'outbound';
8
14
 
15
+ /** Operations beyond sending, declared by an Adapter definition. */
16
+ export type AdapterOperation = Exclude<EndpointOperation, 'send'>;
17
+
9
18
  /** 端点可消费的出站媒体来源形式。 */
10
19
  export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
11
20
 
@@ -13,6 +22,12 @@ export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
13
22
  export type AdapterInteractiveMode = 'native' | 'text';
14
23
 
15
24
  export interface EndpointSendRequest {
25
+ /**
26
+ * Structured identity for new callers. The target remains until every
27
+ * platform adapter has migrated its native boundary codec.
28
+ */
29
+ readonly conversation?: ConversationRef;
30
+ /** @deprecated Use conversation for framework-facing code. */
16
31
  readonly target: string;
17
32
  readonly payload: unknown;
18
33
  readonly parent?: { readonly type?: string; readonly id?: string; readonly name?: string };
@@ -21,6 +36,8 @@ export interface EndpointSendRequest {
21
36
  export interface EndpointInstance<TResult = unknown> {
22
37
  /** Optional platform-neutral Console/Host management surface. */
23
38
  readonly management?: EndpointManagement;
39
+ /** Optional platform-neutral control surface for existing messages. */
40
+ readonly control?: EndpointControl;
24
41
  /** Allocates transport resources but must not admit inbound events yet. */
25
42
  start?(): void | Promise<void>;
26
43
  /** Opens admission after the candidate generation has committed. */
@@ -74,9 +91,17 @@ const OUTBOUND_MEDIA_FORMS: readonly AdapterOutboundMedia[] = [
74
91
  'url', 'path', 'base64', 'upload',
75
92
  ];
76
93
 
94
+ const ADAPTER_OPERATIONS: readonly AdapterOperation[] = ['recall', 'edit', 'reaction', 'typing'];
95
+
77
96
  export interface AdapterDefinition<TConfig = unknown, TResult = unknown> {
78
97
  readonly $feature: typeof adapterBrand;
79
98
  readonly capabilities: readonly AdapterCapability[];
99
+ /**
100
+ * Explicit support for operations other than send. `send` is derived from
101
+ * `capabilities: ['outbound']`; a method existing on an endpoint is not a
102
+ * capability declaration.
103
+ */
104
+ readonly operations?: readonly AdapterOperation[];
80
105
  /** 可选:端点消息段能力声明(出站协商降级挂载点)。 */
81
106
  readonly segments?: AdapterSegmentPolicy;
82
107
  create(
@@ -107,14 +132,44 @@ export function defineAdapter<TConfig = unknown, TResult = unknown>(
107
132
  throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
108
133
  }
109
134
  const segments = normalizeSegmentPolicy(definition.segments);
135
+ const operations = normalizeOperations(definition.operations);
110
136
  return Object.freeze({
111
137
  ...definition,
112
138
  $feature: adapterBrand,
113
139
  capabilities: Object.freeze(capabilities),
140
+ ...(operations ? { operations } : {}),
114
141
  ...(segments ? { segments } : {}),
115
142
  });
116
143
  }
117
144
 
145
+ /** Converts the definition's compact authoring form into the public contract. */
146
+ export function endpointCapabilitiesOf(
147
+ definition: Pick<AdapterDefinition, 'capabilities' | 'operations'>,
148
+ ): EndpointCapabilities {
149
+ const operations = definition.operations?.reduce<Partial<Record<AdapterOperation, true>>>(
150
+ (result, operation) => ({ ...result, [operation]: true }),
151
+ {},
152
+ );
153
+ return Object.freeze({
154
+ inbound: definition.capabilities.includes('inbound'),
155
+ outbound: definition.capabilities.includes('outbound'),
156
+ ...(operations && Object.keys(operations).length > 0 ? { operations: Object.freeze(operations) } : {}),
157
+ });
158
+ }
159
+
160
+ function normalizeOperations(
161
+ operations: readonly AdapterOperation[] | undefined,
162
+ ): readonly AdapterOperation[] | undefined {
163
+ if (operations === undefined) return undefined;
164
+ if (
165
+ !Array.isArray(operations)
166
+ || operations.some((operation) => !ADAPTER_OPERATIONS.includes(operation))
167
+ ) {
168
+ throw new TypeError('Adapter operations must be recall, edit, reaction and/or typing');
169
+ }
170
+ return Object.freeze([...new Set(operations)]);
171
+ }
172
+
118
173
  function normalizeSegmentPolicy(
119
174
  policy: AdapterSegmentPolicy | undefined,
120
175
  ): AdapterSegmentPolicy | undefined {
@@ -175,6 +230,7 @@ export function parseAdapterDefinition(value: unknown): AdapterDefinition {
175
230
  ) throw invalidAdapter();
176
231
  // defineAdapter 已校验过形状;外部手工构造的 definition 也在此兜底
177
232
  normalizeSegmentPolicy(definition.segments);
233
+ normalizeOperations(definition.operations);
178
234
  return definition as AdapterDefinition;
179
235
  }
180
236
 
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * createEndpointCommands — 适配器 endpoint 管理命令套件(list / add / remove)。
3
3
  *
4
- * 把 QQ 适配器独有的 `qq endpoint` 命令族泛化为任意适配器可复用的套件:
4
+ * 把 QQ 适配器独有的 `qq.endpoint` 命令族泛化为任意适配器可复用的套件:
5
5
  *
6
- * - `<adapter> endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
6
+ * - `<adapter>.endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
7
7
  * + zhin.config.yml 配置里的 `plugins.<adapterKey>.endpoints`。
8
- * - `<adapter> endpoint add <name> <key=value...>`:手动录入字段,
8
+ * - `<adapter>.endpoint add <name> <key=value...>`:手动录入字段,
9
9
  * 凭据类字段(env: true)值写入 .env(`<ADAPTER>_<NAME>_<FIELD>` 大写键),
10
10
  * yaml 中保存 `${REF}` 引用;其余字段内联写入。yaml 用 Document 节点级操作保留注释。
11
- * - `<adapter> endpoint remove <name>`:从 `plugins.<adapterKey>.endpoints` 移除(重启生效)。
11
+ * - `<adapter>.endpoint remove <name>`:从 `plugins.<adapterKey>.endpoints` 移除(重启生效)。
12
12
  * - 权限:实例 config 声明了 master(顶层或 endpoints[i])时仅 master 可用 add/remove,
13
13
  * 未配置放行(isEndpointOperator)。
14
14
  * - 特殊 add 流程(如 QQ 扫码绑定)经 spec.bindFlow 钩子接管 add 命令。
@@ -278,7 +278,7 @@ export function addEndpointToConfig(
278
278
  const document = readConfigDocument(adapterKey, projectRoot);
279
279
  const seq = ensureEndpointsSeq(document.doc, adapterKey);
280
280
  if (seq.items.some((item) => entryName(item) === entry.name)) {
281
- throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey} endpoint remove ${entry.name} 再重新添加`);
281
+ throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey}.endpoint remove ${entry.name} 再重新添加`);
282
282
  }
283
283
  seq.items.push(document.doc.createNode(entry));
284
284
  writeConfigDocument(document);
@@ -434,7 +434,7 @@ function addUsage(spec: EndpointCommandsSpec): string {
434
434
  ].filter(Boolean).join(',');
435
435
  return marks ? `${field.key}(${marks})` : field.key;
436
436
  }).join('、')}`;
437
- return `用法:${spec.adapterKey} endpoint add <name> <key=value...>${fieldText}`;
437
+ return `用法:${spec.adapterKey}.endpoint add <name> <key=value...>${fieldText}`;
438
438
  }
439
439
 
440
440
  /** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
@@ -497,7 +497,7 @@ export function removeEndpointByName(
497
497
  projectRoot?: string,
498
498
  ): string {
499
499
  const trimmed = name.trim();
500
- if (!trimmed) return `用法:${spec.adapterKey} endpoint remove <name>`;
500
+ if (!trimmed) return `用法:${spec.adapterKey}.endpoint remove <name>`;
501
501
  try {
502
502
  const { removed, filePath } = removeEndpointFromConfig(spec.adapterKey, trimmed, projectRoot);
503
503
  if (!removed) {
@@ -0,0 +1,145 @@
1
+ import {
2
+ formatLegacyConversationRef,
3
+ formatLegacyMessageRef,
4
+ type ConversationTarget,
5
+ type MessageTarget,
6
+ } from '@zhin.js/im-contract';
7
+
8
+ /**
9
+ * Transport-neutral control plane for a live endpoint.
10
+ *
11
+ * Sending belongs to EndpointInstance.send(). This port intentionally owns
12
+ * operations that address an existing platform message, so Core never needs
13
+ * to know a protocol's method names or identifier layout.
14
+ */
15
+ export interface EndpointControl {
16
+ recall?(message: MessageTarget): Promise<void>;
17
+ edit?(message: MessageTarget, content: unknown): Promise<string | null>;
18
+ addReaction?(
19
+ message: MessageTarget,
20
+ emoji: string,
21
+ hint?: { readonly sceneType?: string; readonly channelId?: string },
22
+ ): Promise<string | null>;
23
+ removeReaction?(message: MessageTarget, reactionId: string): Promise<void>;
24
+ typing?(conversation: ConversationTarget, active?: boolean): Promise<void>;
25
+ }
26
+
27
+ export interface EndpointWithControl {
28
+ readonly control?: EndpointControl;
29
+ }
30
+
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
+ /**
53
+ * 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.
56
+ */
57
+ export function resolveEndpointControl(endpoint: unknown): EndpointControl | undefined {
58
+ if (!endpoint || typeof endpoint !== 'object') return undefined;
59
+ const explicit = (endpoint as EndpointWithControl).control;
60
+ if (explicit && typeof explicit === 'object') return explicit;
61
+
62
+ const legacy = endpoint as LegacyEndpointControlSurface;
63
+ const recall = legacy.recallMessage ?? legacy.$recallMessage;
64
+ const edit = legacy.editMessage ?? legacy.$editMessage;
65
+ const addReaction = legacy.addReaction ?? legacy.$addReaction;
66
+ const removeReaction = legacy.removeReaction ?? legacy.$removeReaction;
67
+ const typing = legacy.typing ?? legacy.$typing;
68
+ if (!recall && !edit && !addReaction && !removeReaction && !typing) return undefined;
69
+
70
+ return Object.freeze({
71
+ ...(recall
72
+ ? { recall: (message: MessageTarget) => recall.call(endpoint, legacyMessageId(message)) }
73
+ : {}),
74
+ ...(edit
75
+ ? {
76
+ edit: (message: MessageTarget, content: unknown) =>
77
+ edit.call(endpoint, legacyMessageId(message), content),
78
+ }
79
+ : {}),
80
+ ...(addReaction
81
+ ? {
82
+ addReaction: (
83
+ message: MessageTarget,
84
+ emoji: string,
85
+ hint?: { readonly sceneType?: string; readonly channelId?: string },
86
+ ) => addReaction.call(endpoint, legacyMessageId(message), emoji, hint),
87
+ }
88
+ : {}),
89
+ ...(removeReaction
90
+ ? {
91
+ removeReaction: (message: MessageTarget, reactionId: string) =>
92
+ removeReaction.call(endpoint, legacyMessageId(message), reactionId),
93
+ }
94
+ : {}),
95
+ ...(typing
96
+ ? {
97
+ typing: (conversation: ConversationTarget, active?: boolean) =>
98
+ typing.call(endpoint, legacyConversationTarget(conversation), active),
99
+ }
100
+ : {}),
101
+ });
102
+ }
103
+
104
+ function legacyMessageId(message: MessageTarget): string {
105
+ return typeof message === 'string' ? message : formatLegacyMessageRef(message);
106
+ }
107
+
108
+ function legacyConversationTarget(conversation: ConversationTarget): string {
109
+ return typeof conversation === 'string' ? conversation : formatLegacyConversationRef(conversation);
110
+ }
111
+
112
+ /** Checks only an Endpoint's explicit `control` port, never the legacy bridge. */
113
+ export function hasExplicitEndpointOperation(
114
+ endpoint: unknown,
115
+ operation: 'recall' | 'edit' | 'reaction' | 'typing',
116
+ ): boolean {
117
+ if (!endpoint || typeof endpoint !== 'object') return false;
118
+ const control = (endpoint as EndpointWithControl).control;
119
+ if (!control || typeof control !== 'object') return false;
120
+ switch (operation) {
121
+ case 'recall': return typeof control.recall === 'function';
122
+ case 'edit': return typeof control.edit === 'function';
123
+ case 'reaction': return typeof control.addReaction === 'function';
124
+ case 'typing': return typeof control.typing === 'function';
125
+ }
126
+ }
127
+
128
+ /** Rejects a declaration that cannot be fulfilled by the explicit control port. */
129
+ export function assertDeclaredEndpointOperations(
130
+ endpoint: unknown,
131
+ operations: readonly ('recall' | 'edit' | 'reaction' | 'typing')[] | undefined,
132
+ id: string,
133
+ ): void {
134
+ for (const operation of operations ?? []) {
135
+ if (!hasExplicitEndpointOperation(endpoint, operation)) {
136
+ throw new TypeError(
137
+ `Adapter Endpoint ${id} declares ${operation} but control.${controlMethodName(operation)} is missing`,
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ function controlMethodName(operation: 'recall' | 'edit' | 'reaction' | 'typing'): string {
144
+ return operation === 'reaction' ? 'addReaction' : operation;
145
+ }
package/src/index.ts CHANGED
@@ -6,5 +6,6 @@ export * from './definition.js';
6
6
  export * from './endpoint-commands.js';
7
7
  export * from './endpoint-lifecycle.js';
8
8
  export * from './endpoint-management.js';
9
+ export * from './endpoint-control.js';
9
10
  export * from './provider.js';
10
11
  export { default } from './provider.js';