@zhin.js/adapter 1.1.2 → 1.1.4
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.js +2 -0
- package/lib/definition.d.ts +20 -0
- package/lib/definition.js +22 -0
- package/lib/endpoint-commands.js +7 -7
- package/lib/endpoint-control.d.ts +31 -0
- package/lib/endpoint-control.js +77 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/package.json +6 -5
- package/src/adapter-index.ts +6 -0
- package/src/definition.ts +56 -0
- package/src/endpoint-commands.ts +7 -7
- package/src/endpoint-control.ts +145 -0
- package/src/index.ts +1 -0
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`
|
package/lib/adapter-index.js
CHANGED
|
@@ -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) {
|
package/lib/definition.d.ts
CHANGED
|
@@ -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() {
|
package/lib/endpoint-commands.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* createEndpointCommands — 适配器 endpoint 管理命令套件(list / add / remove)。
|
|
3
3
|
*
|
|
4
|
-
* 把 QQ 适配器独有的 `qq
|
|
4
|
+
* 把 QQ 适配器独有的 `qq.endpoint` 命令族泛化为任意适配器可复用的套件:
|
|
5
5
|
*
|
|
6
|
-
* - `<adapter
|
|
6
|
+
* - `<adapter>.endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
|
|
7
7
|
* + zhin.config.yml 配置里的 `plugins.<adapterKey>.endpoints`。
|
|
8
|
-
* - `<adapter
|
|
8
|
+
* - `<adapter>.endpoint add <name> <key=value...>`:手动录入字段,
|
|
9
9
|
* 凭据类字段(env: true)值写入 .env(`<ADAPTER>_<NAME>_<FIELD>` 大写键),
|
|
10
10
|
* yaml 中保存 `${REF}` 引用;其余字段内联写入。yaml 用 Document 节点级操作保留注释。
|
|
11
|
-
* - `<adapter
|
|
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}
|
|
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}
|
|
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}
|
|
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.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,14 +18,15 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"yaml": "^2.9.0",
|
|
21
|
-
"@zhin.js/feature-kit": "1.0.
|
|
21
|
+
"@zhin.js/feature-kit": "1.0.5",
|
|
22
|
+
"@zhin.js/im-contract": "1.0.0",
|
|
22
23
|
"@zhin.js/logger": "1.0.75",
|
|
23
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.2"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
|
-
"@types/node": "^26.1.
|
|
27
|
+
"@types/node": "^26.1.2",
|
|
27
28
|
"typescript": "^6.0.3",
|
|
28
|
-
"@zhin.js/command": "1.0.
|
|
29
|
+
"@zhin.js/command": "1.0.6"
|
|
29
30
|
},
|
|
30
31
|
"zhin": {
|
|
31
32
|
"protocol": 1,
|
package/src/adapter-index.ts
CHANGED
|
@@ -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
|
|
package/src/endpoint-commands.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* createEndpointCommands — 适配器 endpoint 管理命令套件(list / add / remove)。
|
|
3
3
|
*
|
|
4
|
-
* 把 QQ 适配器独有的 `qq
|
|
4
|
+
* 把 QQ 适配器独有的 `qq.endpoint` 命令族泛化为任意适配器可复用的套件:
|
|
5
5
|
*
|
|
6
|
-
* - `<adapter
|
|
6
|
+
* - `<adapter>.endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
|
|
7
7
|
* + zhin.config.yml 配置里的 `plugins.<adapterKey>.endpoints`。
|
|
8
|
-
* - `<adapter
|
|
8
|
+
* - `<adapter>.endpoint add <name> <key=value...>`:手动录入字段,
|
|
9
9
|
* 凭据类字段(env: true)值写入 .env(`<ADAPTER>_<NAME>_<FIELD>` 大写键),
|
|
10
10
|
* yaml 中保存 `${REF}` 引用;其余字段内联写入。yaml 用 Document 节点级操作保留注释。
|
|
11
|
-
* - `<adapter
|
|
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}
|
|
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}
|
|
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}
|
|
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';
|