@zhin.js/adapter-kook 1.1.4 → 1.1.6
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/CHANGELOG.md +49 -0
- package/README.md +30 -1
- package/adapters/kook/index.js +4 -4
- package/adapters/kook/index.ts +4 -4
- package/lib/api-proxy.d.ts +6 -0
- package/lib/api-proxy.js +18 -0
- package/lib/endpoint.d.ts +8 -5
- package/lib/endpoint.js +68 -26
- package/lib/outbound.d.ts +7 -0
- package/lib/outbound.js +141 -0
- package/lib/protocol.d.ts +16 -0
- package/lib/protocol.js +32 -1
- package/lib/stream-proxy.d.ts +7 -0
- package/lib/stream-proxy.js +18 -0
- package/lib/webhook.d.ts +2 -1
- package/lib/webhook.js +14 -5
- package/lib/ws.d.ts +24 -3
- package/lib/ws.js +162 -10
- package/package.json +17 -17
- package/schema.json +36 -0
- package/src/api-proxy.ts +21 -0
- package/src/endpoint.ts +67 -24
- package/src/outbound.ts +109 -0
- package/src/protocol.ts +33 -1
- package/src/stream-proxy.ts +20 -0
- package/src/webhook.ts +14 -12
- package/src/ws.ts +147 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# @zhin.js/adapter-kook
|
|
2
2
|
|
|
3
|
+
## 1.1.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2cdb181: Upgrade kook-client to ^1.0.5 and qq-official-bot to ^1.3.1, which include the upstream lifecycle and upload fixes. Remove local SDK patches and validate the published SDK directly in regression fixtures.
|
|
8
|
+
- Updated dependencies [badcc1f]
|
|
9
|
+
- @zhin.js/core@1.1.41
|
|
10
|
+
- @zhin.js/feature-kit@1.1.3
|
|
11
|
+
- @zhin.js/adapter@1.1.17
|
|
12
|
+
- @zhin.js/agent-feature@1.1.4
|
|
13
|
+
- @zhin.js/skill@1.1.4
|
|
14
|
+
- @zhin.js/tool@1.1.4
|
|
15
|
+
- @zhin.js/host-http@1.1.4
|
|
16
|
+
- @zhin.js/permission@1.1.4
|
|
17
|
+
|
|
18
|
+
## 1.1.5
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- b9b3953: Add an opt-in fixed-loopback JSON API fault proxy through the actual SDK Axios HTTPS agent. Preserve the canonical www.kookapp.cn identity and TLS validation, refuse other destinations and redirects, and keep media fetch uploads outside this proxy scope.
|
|
23
|
+
- b9b3953: canonical reply 映射原生 quote。图片经原生二进制 multipart 上传,图文混排用卡片保持顺序,修复仅显示 [image] 或丢失文本;上传失败不发文字替代,缺真实消息 ID 不确认成功,日志错误不携带原始平台响应。
|
|
24
|
+
|
|
25
|
+
撤回使用完整会话路由调用真实频道/私聊删除接口,修复不存在 recallMsg 方法被 optional no-op 当作成功的问题;false 回执和缺失方法明确失败。
|
|
26
|
+
|
|
27
|
+
- b9b3953: Expose the actual KOOK SDK WebSocket receiver state to adapter health, including open, reconnecting and closed transitions, rather than leaving connected bots unobserved.
|
|
28
|
+
- b9b3953: 兼容 API 错误规范化后的黑名单预加载权限拒绝。仅在启动预加载的黑名单接口明确返回 403 时跳过该可选资源;其他鉴权、网络失败和运行期调用继续报错。
|
|
29
|
+
- b9b3953: Preserve inbound message identity and routing when kook-client user or channel cache enrichment getters throw. Missing cached display names and roles no longer discard incoming messages.
|
|
30
|
+
|
|
31
|
+
Allow message transport startup when optional blacklist cache preloading is explicitly denied with HTTP 403. Explicit blacklist management requests and other startup failures still propagate.
|
|
32
|
+
|
|
33
|
+
- Updated dependencies [b9b3953]
|
|
34
|
+
- Updated dependencies [b9b3953]
|
|
35
|
+
- Updated dependencies [b9b3953]
|
|
36
|
+
- Updated dependencies [b9b3953]
|
|
37
|
+
- Updated dependencies [b9b3953]
|
|
38
|
+
- Updated dependencies [b9b3953]
|
|
39
|
+
- Updated dependencies [b9b3953]
|
|
40
|
+
- Updated dependencies [b9b3953]
|
|
41
|
+
- Updated dependencies [b9b3953]
|
|
42
|
+
- Updated dependencies [b9b3953]
|
|
43
|
+
- Updated dependencies [b9b3953]
|
|
44
|
+
- @zhin.js/adapter@1.1.16
|
|
45
|
+
- @zhin.js/im-contract@1.1.3
|
|
46
|
+
- @zhin.js/core@1.1.40
|
|
47
|
+
- @zhin.js/host-http@1.1.3
|
|
48
|
+
- @zhin.js/tool@1.1.3
|
|
49
|
+
- @zhin.js/skill@1.1.3
|
|
50
|
+
- @zhin.js/agent-feature@1.1.3
|
|
51
|
+
|
|
3
52
|
## 1.1.4
|
|
4
53
|
|
|
5
54
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -95,7 +95,11 @@ platform permit checker 由 `plugin.ts` 的 generation 生命周期注册;Capa
|
|
|
95
95
|
|
|
96
96
|
## 迁移后出站能力变化
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
出站统一经 `outboundMessageToken` 渲染。canonical `markdown` 保留 KMarkdown;图片上传后以原生卡片保留图文混排;canonical `reply` 通过 SDK quote 参数传递。视频、音频和文件 URL 表示为链接,本地这些文件的上传尚未接线。
|
|
99
|
+
|
|
100
|
+
canonical `keyboard` 映射为原生 Card `action-group`,每行 1–4 个按钮,`click: return-val` 将 payload 原样返回。WebSocket 和 Webhook 的 `message_btn_click` 事件进入可靠的 canonical `action` 消息段,携带实际点击人、会话和 `sourceMessageId`。按钮显示成功不能算交互通过,必须实际点击并核对回调。disabled 和 command 模式按钮当前明确 `unsupported_operation`。官方合同:[卡片按钮](https://developer.kookapp.cn/doc/cardmessage)、[按钮点击事件](https://developer.kookapp.cn/doc/event/user)。
|
|
101
|
+
|
|
102
|
+
canonical `share` 映射为含标题、描述/正文与“打开链接”按钮的 Card;只支持 HTTP(S) URL,不代表平台原生应用/音乐名片。share 的 image/audio/app metadata 尚未接线,明确拒绝。实机分享验收需核对标题、描述与点击后的目标 URL。
|
|
99
103
|
|
|
100
104
|
## 故障排查
|
|
101
105
|
|
|
@@ -109,3 +113,28 @@ platform permit checker 由 `plugin.ts` 的 generation 生命周期注册;Capa
|
|
|
109
113
|
## 许可证
|
|
110
114
|
|
|
111
115
|
MIT License
|
|
116
|
+
|
|
117
|
+
### 图片与引用
|
|
118
|
+
|
|
119
|
+
canonical reply 使用原生 `quote`,关联原消息 ID。canonical 图片(URL、本地路径、base64)先上传到 KOOK asset 接口,再用原生卡片发送;图片与文本混排按原顺序保留。上传失败不会用文字代替并确认成功,缺失真实消息 ID 为 unknown。文件/音视频暂仅支持既有 URL 链接,二进制上传会明确拒绝。
|
|
120
|
+
|
|
121
|
+
撤回通过 canonical control 的完整 MessageRef 区分频道与私聊,并调用对应正式删除接口。transport 未实现删除或平台返回 false 时明确失败,不允许静默成功;直接调用 `recallMessage` 须同时传入 ConversationRef。
|
|
122
|
+
# WSS 可控故障验收
|
|
123
|
+
|
|
124
|
+
`kook-client@1.0.5` 已在上游提供可选 `socketFactory`、`autoReconnect` 与 `handleProcessErrors`,无需本地 pnpm 补丁。Zhin 显式设置 `handleProcessErrors: false`,让框架管理进程错误,不扫描或删除其他全局监听。Zhin 关闭 SDK 内部重连/DNS 监视,由统一 Endpoint lifecycle 负责 start/stop/reconnect,SDK 保留协议心跳和事件转换。停止会取消 pending hello,迟到 discovery 不会再建连接。
|
|
125
|
+
|
|
126
|
+
可选 `streamProxy: { port: 18443, serverName: "实际网关域名" }` 仅改 TCP 路由到 `127.0.0.1`,真实 WSS URL、Host、SNI 和证书链/域名校验保留;网关变化拒绝并只记录安全 hostname,不输出私有 URL/query 或回退直连。验收项目支持 `KOOK_STREAM_PROXY_PORT` / `KOOK_STREAM_PROXY_SERVER_NAME` 临时进程覆盖,需同时提供,留空正常直连。先以占位域名获取安全日志中的实际 gateway hostname,随后启动固定上游 `tcp-fault-proxy.mjs --upstream-host 实际网关域名 --upstream-port 443 --port 18443 --control-port 18444` 并同步 serverName,不覆盖已有 `.env`。
|
|
127
|
+
|
|
128
|
+
正常入站后 POST `/cut`,观察 SDK断线和 lifecycle离开 open;POST `/recover` 后等待新连接 open,再发送唯一新 probe确认真实入站及可见回包。切断仅 WSS;HTTP discovery/出站仍直连,不能据此证明所有网络恢复、离线事件无损或发送重试。停止代理并移除临时覆盖后恢复常规运行。本地实际SDK/TLS回归与平台实机证据分开记录。
|
|
129
|
+
|
|
130
|
+
受控重连使用原会话 URL、已处理 SN 与 session_id 恢复。收到 Resume ACK 前缓存离线事件;确认后依 SN 连续顺序派发,重复事件不再次进入业务链。服务器要求新会话时清除旧 SN 和未派发数据,重新发现网关并初始化缓存。已初始化会话恢复成功时复用路由缓存,避免重复加载成员;停止会取消 Hello/Resume 等待和后续重连。上述本地 TLS 回归不替代真实平台 cut/recover 验收。
|
|
131
|
+
|
|
132
|
+
### 出站 HTTP 结果
|
|
133
|
+
|
|
134
|
+
HTTP 408 与所有 5xx 保持 unknown,即使正文包含拒绝码也不认定明确未发送;其他 4xx 和成功 HTTP 响应中的非零平台 code 为 rejected。Runtime 在 Axios 正式 transformResponse 阶段保留 HTTP 状态语义,避免 SDK 错误转换丢掉状态。真实已安装 SDK + loopback HTTP fixture 验证单次 POST、不自动重发。Stream 故障代理不影响出站 API;API 专用测试需另行配置 Axios httpsAgent 固定 loopback 转发,并保持实际域名/SNI/证书校验,当前验收配置尚未暴露。
|
|
135
|
+
|
|
136
|
+
### JSON API 故障验收代理
|
|
137
|
+
|
|
138
|
+
可选 `apiProxy: { port: 18580 }` 使用 SDK Axios 实例正式 `httpsAgent` 扩展,仅将 `www.kookapp.cn:443` 的 TCP 路由到 `127.0.0.1`;原 Host、SNI、证书链及域名验证保留,拒绝其他目标和重定向,无自动直连回退。默认关闭。它与 `streamProxy` 独立,覆盖 SDK JSON API(含网关查询、初始化、消息发送/撤回),不覆盖适配器的 fetch 图片上传及外部媒体下载。Axios 正式 agent 配置见 https://axios-http.com/docs/req_config 。
|
|
139
|
+
|
|
140
|
+
启动固定上游代理:`node scripts/platform-acceptance/tcp-fault-proxy.mjs --upstream-host www.kookapp.cn --upstream-port 443 --port 18580 --control-port 18581`。验收项目可用 `KOOK_API_PROXY_PORT=18580 pnpm --filter platform-acceptance-bot dev:kook` 临时覆盖;不改 `.env`。POST 已提交后断线只能记 unknown,不自动重发;恢复后使用新探针样本。
|
package/adapters/kook/index.js
CHANGED
|
@@ -12,11 +12,11 @@ export { KookWebhookEndpoint, KookWebsocketEndpoint, } from "../../lib/endpoint.
|
|
|
12
12
|
export default defineAdapter({
|
|
13
13
|
capabilities: ['inbound', 'outbound'],
|
|
14
14
|
operations: ['recall'],
|
|
15
|
-
// KOOK
|
|
16
|
-
//
|
|
15
|
+
// KOOK 图片上传后以原生卡片保留图文混排;KMarkdown 原生消费;
|
|
16
|
+
// 原生卡片按钮经 return-val 事件进入 canonical action 链路。
|
|
17
17
|
segments: {
|
|
18
|
-
outboundMedia: ['url'],
|
|
19
|
-
interactive: '
|
|
18
|
+
outboundMedia: ['url', 'upload'],
|
|
19
|
+
interactive: 'native',
|
|
20
20
|
markdown: 'native',
|
|
21
21
|
},
|
|
22
22
|
create(context) {
|
package/adapters/kook/index.ts
CHANGED
|
@@ -27,11 +27,11 @@ export type { CreateKookClient, KookClientTransport } from '../../src/ws.js';
|
|
|
27
27
|
export default defineAdapter<KookEndpointConfig>({
|
|
28
28
|
capabilities: ['inbound', 'outbound'],
|
|
29
29
|
operations: ['recall'],
|
|
30
|
-
// KOOK
|
|
31
|
-
//
|
|
30
|
+
// KOOK 图片上传后以原生卡片保留图文混排;KMarkdown 原生消费;
|
|
31
|
+
// 原生卡片按钮经 return-val 事件进入 canonical action 链路。
|
|
32
32
|
segments: {
|
|
33
|
-
outboundMedia: ['url'],
|
|
34
|
-
interactive: '
|
|
33
|
+
outboundMedia: ['url', 'upload'],
|
|
34
|
+
interactive: 'native',
|
|
35
35
|
markdown: 'native',
|
|
36
36
|
},
|
|
37
37
|
create(context) {
|
package/lib/api-proxy.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Agent } from 'node:https';
|
|
2
|
+
import tls from 'node:tls';
|
|
3
|
+
import { Socket } from 'node:net';
|
|
4
|
+
/** JSON Web API only: keep www.kookapp.cn identity, route TCP to a fixed loopback port. */
|
|
5
|
+
export function createKookApiAgent(proxy) {
|
|
6
|
+
const agent = new Agent({ keepAlive: false });
|
|
7
|
+
agent.createConnection = options => {
|
|
8
|
+
const hostname = String(options.hostname ?? options.host ?? '');
|
|
9
|
+
if (hostname !== 'www.kookapp.cn' || Number(options.port ?? 443) !== 443) {
|
|
10
|
+
const rejected = new Socket();
|
|
11
|
+
queueMicrotask(() => rejected.destroy(new Error('KOOK JSON API proxy rejected destination identity')));
|
|
12
|
+
return rejected;
|
|
13
|
+
}
|
|
14
|
+
return tls.connect({ ...options, path: undefined,
|
|
15
|
+
host: '127.0.0.1', port: proxy.port, servername: 'www.kookapp.cn', rejectUnauthorized: true });
|
|
16
|
+
};
|
|
17
|
+
return agent;
|
|
18
|
+
}
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
1
|
+
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
2
|
+
import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
|
|
3
3
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
4
4
|
import type { CapabilityId } from 'zhin.js';
|
|
5
5
|
import { type KookInboundMessage, type KookWebhookEventData, type ResolvedKookWebhookConfig, type ResolvedKookWebsocketConfig } from './protocol.js';
|
|
@@ -15,6 +15,7 @@ export declare class KookWebsocketEndpoint extends Endpoint<KookClientTransport>
|
|
|
15
15
|
readonly control: EndpointControl;
|
|
16
16
|
constructor(options: KookEndpointOptions);
|
|
17
17
|
get client(): KookClientTransport;
|
|
18
|
+
get transportState(): EndpointTransportState | undefined;
|
|
18
19
|
start(): Promise<void>;
|
|
19
20
|
open(): void;
|
|
20
21
|
close(): void;
|
|
@@ -22,7 +23,8 @@ export declare class KookWebsocketEndpoint extends Endpoint<KookClientTransport>
|
|
|
22
23
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
23
24
|
/** Test / internal: admit a normalized message when open. */
|
|
24
25
|
admit(msg: KookInboundMessage): void;
|
|
25
|
-
recallMessage(messageId: string): Promise<void>;
|
|
26
|
+
recallMessage(messageId: string, conversation?: ConversationRef): Promise<void>;
|
|
27
|
+
admitButton(raw: unknown): Promise<void>;
|
|
26
28
|
}
|
|
27
29
|
export interface KookWebhookEndpointOptions {
|
|
28
30
|
readonly id: CapabilityId;
|
|
@@ -45,11 +47,12 @@ export declare class KookWebhookEndpoint extends Endpoint<KookClientTransport> {
|
|
|
45
47
|
close(): void;
|
|
46
48
|
stop(): Promise<void>;
|
|
47
49
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
48
|
-
admitEvent(event: KookWebhookEventData): void
|
|
50
|
+
admitEvent(event: KookWebhookEventData): Promise<void>;
|
|
49
51
|
/** Test / internal: admit a message when open. */
|
|
50
52
|
admit(msg: KookInboundMessage): void;
|
|
51
|
-
recallMessage(messageId: string): Promise<void>;
|
|
53
|
+
recallMessage(messageId: string, conversation?: ConversationRef): Promise<void>;
|
|
52
54
|
checkAndRememberSn(sn: number): boolean;
|
|
55
|
+
forgetSn(sn: number): void;
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
55
58
|
* KOOK endpoint 的 EndpointManagement 语义端口(websocket / webhook 共用)。
|
package/lib/endpoint.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
|
+
import { Endpoint, } from 'zhin.js/adapter';
|
|
3
3
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
4
|
-
import { formatInboundContent,
|
|
4
|
+
import { formatInboundContent, isKookBotMentioned, kookInboundConversation, normalizeKookWebhookEvent, normalizeKookButtonEvent, senderDisplayName, } from './protocol.js';
|
|
5
5
|
import { registerKookWebhookRoutes } from './webhook.js';
|
|
6
6
|
import { defaultCreateClient, defaultCreateWebhookClient, normalizeKookMessage, } from './ws.js';
|
|
7
|
+
import { recallKookMessage, sendKookOutbound } from './outbound.js';
|
|
7
8
|
import { receiveKookSideEvent } from './side-event-dispatch.js';
|
|
8
9
|
export class KookWebsocketEndpoint extends Endpoint {
|
|
10
|
+
#sentConversations = new Map();
|
|
9
11
|
#logger;
|
|
10
12
|
#options;
|
|
11
13
|
#createClient;
|
|
@@ -13,7 +15,9 @@ export class KookWebsocketEndpoint extends Endpoint {
|
|
|
13
15
|
#open = false;
|
|
14
16
|
#started = false;
|
|
15
17
|
management = createKookEndpointManagement(() => this.#requireClient());
|
|
16
|
-
control =
|
|
18
|
+
control = Object.freeze({
|
|
19
|
+
recall: (message) => recallKookMessage(this.#requireClient(), message),
|
|
20
|
+
});
|
|
17
21
|
constructor(options) {
|
|
18
22
|
super();
|
|
19
23
|
this.#logger = getAdapterLogger('kook', options.config.id);
|
|
@@ -23,6 +27,11 @@ export class KookWebsocketEndpoint extends Endpoint {
|
|
|
23
27
|
get client() {
|
|
24
28
|
return this.#requireClient();
|
|
25
29
|
}
|
|
30
|
+
get transportState() {
|
|
31
|
+
if (!this.#started)
|
|
32
|
+
return 'stopped';
|
|
33
|
+
return this.#client?.getTransportState?.();
|
|
34
|
+
}
|
|
26
35
|
async start() {
|
|
27
36
|
if (this.#started)
|
|
28
37
|
return;
|
|
@@ -52,6 +61,7 @@ export class KookWebsocketEndpoint extends Endpoint {
|
|
|
52
61
|
}
|
|
53
62
|
async stop() {
|
|
54
63
|
this.#open = false;
|
|
64
|
+
this.#sentConversations.clear();
|
|
55
65
|
if (this.#client) {
|
|
56
66
|
try {
|
|
57
67
|
this.#client.removeAllListeners();
|
|
@@ -66,12 +76,8 @@ export class KookWebsocketEndpoint extends Endpoint {
|
|
|
66
76
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
67
77
|
}
|
|
68
78
|
async send({ conversation, payload }) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const result = conversation.kind === 'private'
|
|
72
|
-
? await client.sendPrivateMsg(conversation.id, body)
|
|
73
|
-
: await client.sendChannelMsg(conversation.id, body);
|
|
74
|
-
const messageId = result?.msg_id != null ? String(result.msg_id) : '';
|
|
79
|
+
const messageId = await sendKookOutbound(this.#requireClient(), { conversation, payload });
|
|
80
|
+
rememberKookConversation(this.#sentConversations, messageId, conversation);
|
|
75
81
|
this.#logger.debug(formatCompact({
|
|
76
82
|
op: 'kook_send',
|
|
77
83
|
endpoint: this.#options.config.id,
|
|
@@ -113,10 +119,10 @@ export class KookWebsocketEndpoint extends Endpoint {
|
|
|
113
119
|
}));
|
|
114
120
|
});
|
|
115
121
|
}
|
|
116
|
-
async recallMessage(messageId) {
|
|
117
|
-
if (!
|
|
118
|
-
|
|
119
|
-
await this.#requireClient()
|
|
122
|
+
async recallMessage(messageId, conversation) {
|
|
123
|
+
if (!conversation)
|
|
124
|
+
throw new EndpointDeliveryError('unsupported_operation', 'KOOK recall requires conversation routing', 'rejected');
|
|
125
|
+
await recallKookMessage(this.#requireClient(), { id: messageId, conversation });
|
|
120
126
|
}
|
|
121
127
|
#bindClient(client) {
|
|
122
128
|
client.on('message', (raw) => {
|
|
@@ -127,10 +133,24 @@ export class KookWebsocketEndpoint extends Endpoint {
|
|
|
127
133
|
});
|
|
128
134
|
const receiver = client.receiver;
|
|
129
135
|
receiver?.on('event', (raw) => {
|
|
136
|
+
if (this.#client !== client || !this.#open)
|
|
137
|
+
return;
|
|
130
138
|
this.#emitPlatformEvent('event', raw);
|
|
139
|
+
if (normalizeKookButtonEvent(raw)) {
|
|
140
|
+
void this.admitButton(raw).catch(error => this.#logger.warn(formatCompact({ op: 'kook_button_failed', error: error instanceof Error ? error.message : 'dispatch failed' })));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
131
143
|
receiveKookSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, raw, this.#logger);
|
|
132
144
|
});
|
|
133
145
|
}
|
|
146
|
+
async admitButton(raw) {
|
|
147
|
+
if (!this.#open)
|
|
148
|
+
throw new Error('KOOK endpoint is closed');
|
|
149
|
+
const event = normalizeKookButtonEvent(raw);
|
|
150
|
+
if (!event)
|
|
151
|
+
throw new Error('Invalid KOOK button event');
|
|
152
|
+
await this.emitAccepted('message.receive', kookButtonIngress(String(this.#options.id), this.#options.config.id, event, this.#sentConversations.get(event.sourceMessageId)));
|
|
153
|
+
}
|
|
134
154
|
#emitPlatformEvent(name, event) {
|
|
135
155
|
void this.emitPlatform(name, event).catch((error) => {
|
|
136
156
|
this.#logger.warn(formatCompact({
|
|
@@ -147,6 +167,7 @@ export class KookWebsocketEndpoint extends Endpoint {
|
|
|
147
167
|
}
|
|
148
168
|
}
|
|
149
169
|
export class KookWebhookEndpoint extends Endpoint {
|
|
170
|
+
#sentConversations = new Map();
|
|
150
171
|
#logger;
|
|
151
172
|
#options;
|
|
152
173
|
#createClient;
|
|
@@ -156,7 +177,9 @@ export class KookWebhookEndpoint extends Endpoint {
|
|
|
156
177
|
#open = false;
|
|
157
178
|
#started = false;
|
|
158
179
|
management = createKookEndpointManagement(() => this.#requireClient());
|
|
159
|
-
control =
|
|
180
|
+
control = Object.freeze({
|
|
181
|
+
recall: (message) => recallKookMessage(this.#requireClient(), message),
|
|
182
|
+
});
|
|
160
183
|
constructor(options) {
|
|
161
184
|
super();
|
|
162
185
|
this.#logger = getAdapterLogger('kook', options.config.id);
|
|
@@ -206,6 +229,7 @@ export class KookWebhookEndpoint extends Endpoint {
|
|
|
206
229
|
}
|
|
207
230
|
async stop() {
|
|
208
231
|
this.#open = false;
|
|
232
|
+
this.#sentConversations.clear();
|
|
209
233
|
for (const release of this.#routeReleases.splice(0))
|
|
210
234
|
release();
|
|
211
235
|
this.#processedSn.clear();
|
|
@@ -223,12 +247,8 @@ export class KookWebhookEndpoint extends Endpoint {
|
|
|
223
247
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
224
248
|
}
|
|
225
249
|
async send({ conversation, payload }) {
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
const result = conversation.kind === 'private'
|
|
229
|
-
? await client.sendPrivateMsg(conversation.id, body)
|
|
230
|
-
: await client.sendChannelMsg(conversation.id, body);
|
|
231
|
-
const messageId = result?.msg_id != null ? String(result.msg_id) : '';
|
|
250
|
+
const messageId = await sendKookOutbound(this.#requireClient(), { conversation, payload });
|
|
251
|
+
rememberKookConversation(this.#sentConversations, messageId, conversation);
|
|
232
252
|
this.#logger.debug(formatCompact({
|
|
233
253
|
op: 'kook_send',
|
|
234
254
|
endpoint: this.#options.config.id,
|
|
@@ -237,7 +257,14 @@ export class KookWebhookEndpoint extends Endpoint {
|
|
|
237
257
|
}));
|
|
238
258
|
return messageId;
|
|
239
259
|
}
|
|
240
|
-
admitEvent(event) {
|
|
260
|
+
async admitEvent(event) {
|
|
261
|
+
if (normalizeKookButtonEvent(event)) {
|
|
262
|
+
if (!this.#open)
|
|
263
|
+
throw new Error('KOOK endpoint is closed');
|
|
264
|
+
const button = normalizeKookButtonEvent(event);
|
|
265
|
+
await this.emitAccepted('message.receive', kookButtonIngress(String(this.#options.id), this.#options.config.id, button, this.#sentConversations.get(button.sourceMessageId)));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
241
268
|
void this.emitPlatform(String(event.type ?? 'event'), event).catch((error) => {
|
|
242
269
|
this.#logger.warn(formatCompact({
|
|
243
270
|
op: 'kook_platform_event_failed',
|
|
@@ -287,10 +314,10 @@ export class KookWebhookEndpoint extends Endpoint {
|
|
|
287
314
|
}));
|
|
288
315
|
});
|
|
289
316
|
}
|
|
290
|
-
async recallMessage(messageId) {
|
|
291
|
-
if (!
|
|
292
|
-
|
|
293
|
-
await this.#requireClient()
|
|
317
|
+
async recallMessage(messageId, conversation) {
|
|
318
|
+
if (!conversation)
|
|
319
|
+
throw new EndpointDeliveryError('unsupported_operation', 'KOOK recall requires conversation routing', 'rejected');
|
|
320
|
+
await recallKookMessage(this.#requireClient(), { id: messageId, conversation });
|
|
294
321
|
}
|
|
295
322
|
checkAndRememberSn(sn) {
|
|
296
323
|
if (this.#processedSn.has(sn))
|
|
@@ -303,6 +330,7 @@ export class KookWebhookEndpoint extends Endpoint {
|
|
|
303
330
|
}
|
|
304
331
|
return true;
|
|
305
332
|
}
|
|
333
|
+
forgetSn(sn) { this.#processedSn.delete(sn); }
|
|
306
334
|
#requireClient() {
|
|
307
335
|
if (!this.#client)
|
|
308
336
|
throw new Error('KOOK client not initialized');
|
|
@@ -319,6 +347,20 @@ function toGroupId(id) {
|
|
|
319
347
|
return id;
|
|
320
348
|
}
|
|
321
349
|
/** KOOK 文字频道标记:HTTP API type=1(2=语音),kook-client 消息侧用 'GROUP'。 */
|
|
350
|
+
function rememberKookConversation(cache, messageId, conversation) {
|
|
351
|
+
cache.set(messageId, conversation);
|
|
352
|
+
while (cache.size > 2048)
|
|
353
|
+
cache.delete(cache.keys().next().value);
|
|
354
|
+
}
|
|
355
|
+
function kookButtonIngress(endpointKey, endpointId, event, sentConversation) {
|
|
356
|
+
const conversation = { endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
357
|
+
kind: sentConversation?.kind ?? event.channelKind, id: sentConversation?.id ?? event.channelId,
|
|
358
|
+
...(sentConversation?.parent ? { parent: sentConversation.parent } : {}) };
|
|
359
|
+
return { conversation, message: { conversation, id: event.id }, content: `[action: ${event.payload}]`,
|
|
360
|
+
segments: [{ type: 'action', data: { id: event.id, payload: event.payload, sourceMessageId: event.sourceMessageId } }],
|
|
361
|
+
sender: { id: event.userId }, endpointId,
|
|
362
|
+
metadata: Object.freeze({ eventType: 'message_btn_click', payload: event.payload, sourceMessageId: event.sourceMessageId, timestamp: event.timestamp }) };
|
|
363
|
+
}
|
|
322
364
|
function isKookTextChannelType(type) {
|
|
323
365
|
return type === 1 || type === '1' || type === 'GROUP';
|
|
324
366
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type MessageRef } from '@zhin.js/im-contract';
|
|
2
|
+
import type { EndpointSendRequest } from 'zhin.js/adapter';
|
|
3
|
+
import type { KookClientTransport } from './ws.js';
|
|
4
|
+
/** Native cards preserve mixed text/images; SDK's ordinary segments stop at first media. */
|
|
5
|
+
export declare function sendKookOutbound(client: KookClientTransport, { conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
6
|
+
/** Deletion requires the canonical conversation: KOOK has separate channel/private APIs. */
|
|
7
|
+
export declare function recallKookMessage(client: KookClientTransport, message: MessageRef): Promise<void>;
|
package/lib/outbound.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
|
|
3
|
+
import { formatOutboundKmarkdown } from './protocol.js';
|
|
4
|
+
/** Native cards preserve mixed text/images; SDK's ordinary segments stop at first media. */
|
|
5
|
+
export async function sendKookOutbound(client, { conversation, payload }) {
|
|
6
|
+
try {
|
|
7
|
+
const items = Array.isArray(payload) ? payload : payload && typeof payload === 'object' ? [payload] : [payload];
|
|
8
|
+
let quote;
|
|
9
|
+
const modules = [];
|
|
10
|
+
let hasImage = false;
|
|
11
|
+
let hasCard = false;
|
|
12
|
+
for (const item of items) {
|
|
13
|
+
if (item && typeof item === 'object' && item.type === 'reply') {
|
|
14
|
+
if (typeof item.data?.message_id === 'string' && item.data.message_id.trim())
|
|
15
|
+
quote = { message_id: item.data.message_id };
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (item && typeof item === 'object' && item.type === 'keyboard') {
|
|
19
|
+
hasCard = true;
|
|
20
|
+
const rows = item.data?.rows;
|
|
21
|
+
if (!Array.isArray(rows) || !rows.length)
|
|
22
|
+
throw new EndpointDeliveryError('invalid_payload', 'KOOK keyboard requires button rows', 'not_sent');
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
if (!Array.isArray(row) || !row.length || row.length > 4)
|
|
25
|
+
throw new EndpointDeliveryError('invalid_payload', 'KOOK button rows require 1-4 buttons', 'not_sent');
|
|
26
|
+
const elements = row.map(button => {
|
|
27
|
+
if (!button || typeof button !== 'object' || typeof button.label !== 'string' || !button.label.trim() || typeof button.payload !== 'string' || !button.payload)
|
|
28
|
+
throw new EndpointDeliveryError('invalid_payload', 'KOOK buttons require label and payload', 'not_sent');
|
|
29
|
+
if (button.disabled || button.mode === 'command')
|
|
30
|
+
throw new EndpointDeliveryError('unsupported_operation', 'KOOK disabled/command buttons are not implemented', 'not_sent');
|
|
31
|
+
return { type: 'button', theme: button.style === 'danger' ? 'danger' : button.style === 'primary' ? 'primary' : 'secondary',
|
|
32
|
+
value: button.payload, click: 'return-val', text: { type: 'plain-text', content: button.label } };
|
|
33
|
+
});
|
|
34
|
+
modules.push({ type: 'action-group', elements });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (item && typeof item === 'object' && item.type === 'share') {
|
|
38
|
+
hasCard = true;
|
|
39
|
+
const data = item.data ?? {};
|
|
40
|
+
let url;
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(data.url);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new EndpointDeliveryError('invalid_payload', 'KOOK share requires an HTTP(S) URL', 'not_sent');
|
|
46
|
+
}
|
|
47
|
+
if (!['http:', 'https:'].includes(url.protocol) || typeof data.title !== 'string' || !data.title.trim())
|
|
48
|
+
throw new EndpointDeliveryError('invalid_payload', 'KOOK share requires URL and title', 'not_sent');
|
|
49
|
+
if (['image', 'audio', 'artist', 'duration', 'config'].some(key => data[key] !== undefined))
|
|
50
|
+
throw new EndpointDeliveryError('unsupported_operation', 'KOOK share media/app metadata is not implemented', 'not_sent');
|
|
51
|
+
modules.push({ type: 'section', mode: 'left', text: { type: 'plain-text', content: data.title } });
|
|
52
|
+
for (const value of [data.description, data.content])
|
|
53
|
+
if (typeof value === 'string' && value)
|
|
54
|
+
modules.push({ type: 'section', mode: 'left', text: { type: 'plain-text', content: value } });
|
|
55
|
+
modules.push({ type: 'action-group', elements: [{ type: 'button', theme: 'secondary', click: 'link', value: url.href, text: { type: 'plain-text', content: '打开链接' } }] });
|
|
56
|
+
}
|
|
57
|
+
else if (item && typeof item === 'object' && item.type === 'image') {
|
|
58
|
+
hasImage = true;
|
|
59
|
+
const media = item.data?.media;
|
|
60
|
+
if (!isMediaRef(media))
|
|
61
|
+
throw new EndpointDeliveryError('unsupported_media', 'KOOK image requires canonical media', 'rejected');
|
|
62
|
+
let url;
|
|
63
|
+
if (media.kind === 'file')
|
|
64
|
+
url = media.value;
|
|
65
|
+
else {
|
|
66
|
+
if (!client.uploadMedia)
|
|
67
|
+
throw new EndpointDeliveryError('unsupported_media', 'KOOK transport cannot upload images', 'rejected');
|
|
68
|
+
let bytes;
|
|
69
|
+
if (media.kind === 'base64')
|
|
70
|
+
bytes = Buffer.from(media.value.replace(/^data:[^,]*,/, ''), 'base64');
|
|
71
|
+
else if (media.kind === 'path')
|
|
72
|
+
bytes = await readFile(media.value);
|
|
73
|
+
else {
|
|
74
|
+
const response = await fetch(media.value, { signal: AbortSignal.timeout(30_000) });
|
|
75
|
+
if (!response.ok)
|
|
76
|
+
throw new EndpointDeliveryError('media_download_failed', 'KOOK media download failed', 'rejected');
|
|
77
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
78
|
+
}
|
|
79
|
+
url = await client.uploadMedia(bytes);
|
|
80
|
+
}
|
|
81
|
+
if (typeof url !== 'string' || !/^https:\/\//.test(url))
|
|
82
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK upload returned no media URL', 'unknown');
|
|
83
|
+
modules.push({ type: 'container', elements: [{ type: 'image', src: url, alt: String(item.data?.alt ?? 'image') }] });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (item && typeof item === 'object' && ['file', 'audio', 'video'].includes(item.type)
|
|
87
|
+
&& isMediaRef(item.data?.media) && !['url', 'file'].includes(item.data.media.kind)) {
|
|
88
|
+
throw new EndpointDeliveryError('unsupported_media', 'KOOK binary uploads currently support images only', 'rejected');
|
|
89
|
+
}
|
|
90
|
+
const text = formatOutboundKmarkdown([item]);
|
|
91
|
+
if (text)
|
|
92
|
+
modules.push({ type: 'section', mode: 'left', text: { type: 'kmarkdown', content: text } });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const body = hasImage || hasCard ? { __isCard: true, type: 'card', theme: 'secondary', size: 'lg', modules } : formatOutboundKmarkdown(payload);
|
|
96
|
+
const result = conversation.kind === 'private'
|
|
97
|
+
? await client.sendPrivateMsg(conversation.id, body, quote)
|
|
98
|
+
: await client.sendChannelMsg(conversation.id, body, quote);
|
|
99
|
+
const id = result?.msg_id != null ? String(result.msg_id) : '';
|
|
100
|
+
if (!id.trim())
|
|
101
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK did not return a real message ID', 'unknown');
|
|
102
|
+
return id;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof EndpointDeliveryError)
|
|
106
|
+
throw error;
|
|
107
|
+
// Installed SDK discards HTTP metadata; its exact numerical rejection prefix is safe to recover.
|
|
108
|
+
const code = error instanceof Error ? /error with code\((\d+)\):/.exec(error.message)?.[1] : undefined;
|
|
109
|
+
if (code && Number(code) !== 408 && !(Number(code) >= 500 && Number(code) < 600))
|
|
110
|
+
throw new EndpointDeliveryError('platform_rejected', `KOOK request rejected (code=${code})`, 'rejected');
|
|
111
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK outbound outcome is unknown', 'unknown');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Deletion requires the canonical conversation: KOOK has separate channel/private APIs. */
|
|
115
|
+
export async function recallKookMessage(client, message) {
|
|
116
|
+
if (!message.id.trim())
|
|
117
|
+
throw new EndpointDeliveryError('invalid_message_id', 'KOOK recall requires a message ID', 'rejected');
|
|
118
|
+
try {
|
|
119
|
+
let deleted;
|
|
120
|
+
if (message.conversation.kind === 'private') {
|
|
121
|
+
if (!client.recallPrivateMsg)
|
|
122
|
+
throw new EndpointDeliveryError('unsupported_operation', 'KOOK transport cannot delete private messages', 'rejected');
|
|
123
|
+
deleted = await client.recallPrivateMsg(message.conversation.id, message.id);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
if (!client.recallChannelMsg)
|
|
127
|
+
throw new EndpointDeliveryError('unsupported_operation', 'KOOK transport cannot delete channel messages', 'rejected');
|
|
128
|
+
deleted = await client.recallChannelMsg(message.conversation.id, message.id);
|
|
129
|
+
}
|
|
130
|
+
if (deleted !== true)
|
|
131
|
+
throw new EndpointDeliveryError('platform_rejected', 'KOOK did not confirm message deletion', 'rejected');
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof EndpointDeliveryError)
|
|
135
|
+
throw error;
|
|
136
|
+
const code = error instanceof Error ? /error with code\((\d+)\):/.exec(error.message)?.[1] : undefined;
|
|
137
|
+
if (code && Number(code) !== 408 && !(Number(code) >= 500 && Number(code) < 600))
|
|
138
|
+
throw new EndpointDeliveryError('platform_rejected', `KOOK deletion rejected (code=${code})`, 'rejected');
|
|
139
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'KOOK deletion outcome is unknown', 'unknown');
|
|
140
|
+
}
|
|
141
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare enum KookPermission {
|
|
|
10
10
|
}
|
|
11
11
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
12
12
|
export interface KookEndpointConfig {
|
|
13
|
+
readonly apiProxy?: import('./api-proxy.js').KookApiProxy;
|
|
14
|
+
readonly streamProxy?: import('./stream-proxy.js').KookStreamProxy;
|
|
13
15
|
readonly id: string;
|
|
14
16
|
readonly token: string;
|
|
15
17
|
/** Default `websocket`. `webhook` requires httpHostToken + verify_token. */
|
|
@@ -24,6 +26,8 @@ export interface KookEndpointConfig {
|
|
|
24
26
|
readonly logLevel?: LogLevel;
|
|
25
27
|
}
|
|
26
28
|
export interface ResolvedKookWebsocketConfig {
|
|
29
|
+
readonly apiProxy?: import('./api-proxy.js').KookApiProxy;
|
|
30
|
+
readonly streamProxy?: import('./stream-proxy.js').KookStreamProxy;
|
|
27
31
|
readonly context: 'kook';
|
|
28
32
|
readonly connection: 'websocket';
|
|
29
33
|
readonly id: string;
|
|
@@ -35,6 +39,7 @@ export interface ResolvedKookWebsocketConfig {
|
|
|
35
39
|
readonly logLevel: LogLevel;
|
|
36
40
|
}
|
|
37
41
|
export interface ResolvedKookWebhookConfig {
|
|
42
|
+
readonly apiProxy?: import('./api-proxy.js').KookApiProxy;
|
|
38
43
|
readonly context: 'kook';
|
|
39
44
|
readonly connection: 'webhook';
|
|
40
45
|
readonly id: string;
|
|
@@ -64,6 +69,7 @@ export interface KookWebhookEventData {
|
|
|
64
69
|
readonly author_id?: string;
|
|
65
70
|
readonly target_id?: string;
|
|
66
71
|
readonly chat_code?: string;
|
|
72
|
+
readonly value?: string;
|
|
67
73
|
readonly emoji?: {
|
|
68
74
|
readonly id?: string;
|
|
69
75
|
readonly name?: string;
|
|
@@ -137,3 +143,13 @@ export declare function normalizeKookWebhookEvent(event: KookWebhookEventData, o
|
|
|
137
143
|
readonly ignore?: 'bot' | 'self';
|
|
138
144
|
readonly selfId?: string;
|
|
139
145
|
}): KookInboundMessage | null;
|
|
146
|
+
/** Native Card return-val events; routing and actor must come from the click event. */
|
|
147
|
+
export declare function normalizeKookButtonEvent(raw: unknown): {
|
|
148
|
+
id: string;
|
|
149
|
+
sourceMessageId: string;
|
|
150
|
+
payload: string;
|
|
151
|
+
userId: string;
|
|
152
|
+
channelKind: 'private' | 'channel';
|
|
153
|
+
channelId: string;
|
|
154
|
+
timestamp: number;
|
|
155
|
+
} | null;
|