@zhin.js/adapter-kook 2.0.11 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +99 -7
- package/lib/bot.d.ts +33 -2
- package/lib/bot.d.ts.map +1 -1
- package/lib/bot.js +157 -20
- package/lib/bot.js.map +1 -1
- package/lib/kook-asset-upload.d.ts +12 -0
- package/lib/kook-asset-upload.d.ts.map +1 -0
- package/lib/kook-asset-upload.js +46 -0
- package/lib/kook-asset-upload.js.map +1 -0
- package/lib/kook-inbound.d.ts +6 -0
- package/lib/kook-inbound.d.ts.map +1 -0
- package/lib/kook-inbound.js +22 -0
- package/lib/kook-inbound.js.map +1 -0
- package/lib/kook-msg-route.d.ts +34 -0
- package/lib/kook-msg-route.d.ts.map +1 -0
- package/lib/kook-msg-route.js +106 -0
- package/lib/kook-msg-route.js.map +1 -0
- package/lib/kook-side-events.d.ts +43 -0
- package/lib/kook-side-events.d.ts.map +1 -0
- package/lib/kook-side-events.js +147 -0
- package/lib/kook-side-events.js.map +1 -0
- package/lib/outbound-media.d.ts +10 -0
- package/lib/outbound-media.d.ts.map +1 -0
- package/lib/outbound-media.js +86 -0
- package/lib/outbound-media.js.map +1 -0
- package/lib/outbound-sendable.d.ts +11 -0
- package/lib/outbound-sendable.d.ts.map +1 -0
- package/lib/outbound-sendable.js +55 -0
- package/lib/outbound-sendable.js.map +1 -0
- package/lib/types.d.ts +19 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/bot.ts +233 -20
- package/src/kook-asset-upload.ts +55 -0
- package/src/kook-inbound.ts +22 -0
- package/src/kook-msg-route.ts +122 -0
- package/src/kook-side-events.ts +202 -0
- package/src/outbound-media.ts +111 -0
- package/src/outbound-sendable.ts +61 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK Gateway 非聊天入站:系统消息 (type=255) / BROADCAST → Notice
|
|
3
|
+
* @see https://developer.kookapp.cn/doc/event/event-introduction
|
|
4
|
+
*/
|
|
5
|
+
import { Notice, formatCompact, type NoticeType } from "zhin.js";
|
|
6
|
+
|
|
7
|
+
/** KOOK WebSocket s=0 时 d 字段(kook-client Receiver 入参) */
|
|
8
|
+
export interface KookGatewayEvent {
|
|
9
|
+
channel_type?: string;
|
|
10
|
+
type?: number;
|
|
11
|
+
target_id?: string;
|
|
12
|
+
author_id?: string;
|
|
13
|
+
content?: string;
|
|
14
|
+
msg_id?: string;
|
|
15
|
+
msg_timestamp?: number;
|
|
16
|
+
nonce?: string;
|
|
17
|
+
extra?: KookGatewayExtra;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KookGatewayExtra {
|
|
22
|
+
type?: string;
|
|
23
|
+
body?: Record<string, unknown>;
|
|
24
|
+
guild_id?: string;
|
|
25
|
+
channel_id?: string;
|
|
26
|
+
channel_name?: string;
|
|
27
|
+
author?: { id?: string; username?: string; nickname?: string };
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SYSTEM_MESSAGE_TYPE = 255;
|
|
32
|
+
|
|
33
|
+
/** 是否应由适配器作为 notice 处理(kook-client 对 notice 的 transform 为空实现) */
|
|
34
|
+
export function isKookNoticeGatewayEvent(data: unknown): data is KookGatewayEvent {
|
|
35
|
+
if (!data || typeof data !== "object") return false;
|
|
36
|
+
const ev = data as KookGatewayEvent;
|
|
37
|
+
if (ev.type === SYSTEM_MESSAGE_TYPE) return true;
|
|
38
|
+
return ev.channel_type === "BROADCAST";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveKookSideEventDedupeKey(
|
|
42
|
+
event: KookGatewayEvent,
|
|
43
|
+
kind: "notice" | "gateway",
|
|
44
|
+
): string {
|
|
45
|
+
if (event.msg_id) return `${kind}:${event.msg_id}`;
|
|
46
|
+
const extra = event.extra ?? {};
|
|
47
|
+
const noticeType = extra.type ?? String(event.type ?? "");
|
|
48
|
+
const scope = event.target_id ?? "";
|
|
49
|
+
const ts = event.msg_timestamp ?? 0;
|
|
50
|
+
return `${kind}:${ts}_${noticeType}_${scope}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NOTICE_TYPE_MAP: Record<string, NoticeType> = {
|
|
54
|
+
joined_guild: "group_member_increase",
|
|
55
|
+
exited_guild: "group_member_decrease",
|
|
56
|
+
deleted_message: "group_recall",
|
|
57
|
+
deleted_private_message: "friend_recall",
|
|
58
|
+
added_reaction: "group_emoji_reaction",
|
|
59
|
+
deleted_reaction: "group_emoji_reaction",
|
|
60
|
+
private_added_reaction: "group_emoji_reaction",
|
|
61
|
+
private_deleted_reaction: "group_emoji_reaction",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function resolveNoticeZhinType(rawType: string): NoticeType {
|
|
65
|
+
return NOTICE_TYPE_MAP[rawType] ?? rawType;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function senderFromId(id: unknown, name?: string) {
|
|
69
|
+
if (id == null || id === "") return undefined;
|
|
70
|
+
const s = String(id);
|
|
71
|
+
return { id: s, name: name ?? s };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readString(obj: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
75
|
+
const v = obj?.[key];
|
|
76
|
+
if (v == null || v === "") return undefined;
|
|
77
|
+
return String(v);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 解析 notice 发生的频道 / 群 / 私聊 */
|
|
81
|
+
export function resolveKookNoticeChannel(
|
|
82
|
+
event: KookGatewayEvent,
|
|
83
|
+
): { id: string; type: "group" | "private" | "channel" } {
|
|
84
|
+
const extra = event.extra ?? {};
|
|
85
|
+
const body = extra.body ?? {};
|
|
86
|
+
const rawType = String(extra.type ?? "");
|
|
87
|
+
|
|
88
|
+
if (event.channel_type === "BROADCAST") {
|
|
89
|
+
const guildId =
|
|
90
|
+
readString(body, "guild_id")
|
|
91
|
+
?? extra.guild_id
|
|
92
|
+
?? event.target_id
|
|
93
|
+
?? "";
|
|
94
|
+
return { id: guildId, type: "group" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (event.type === SYSTEM_MESSAGE_TYPE && event.channel_type === "GROUP") {
|
|
98
|
+
const guildId =
|
|
99
|
+
readString(body, "guild_id")
|
|
100
|
+
?? extra.guild_id
|
|
101
|
+
?? event.target_id
|
|
102
|
+
?? "";
|
|
103
|
+
if (
|
|
104
|
+
rawType === "joined_guild"
|
|
105
|
+
|| rawType === "exited_guild"
|
|
106
|
+
|| rawType === "updated_guild"
|
|
107
|
+
|| rawType === "deleted_guild"
|
|
108
|
+
|| rawType.startsWith("added_role")
|
|
109
|
+
|| rawType.startsWith("deleted_role")
|
|
110
|
+
|| rawType.startsWith("updated_role")
|
|
111
|
+
|| rawType.startsWith("added_block")
|
|
112
|
+
|| rawType.startsWith("deleted_block")
|
|
113
|
+
|| rawType === "self_joined_guild"
|
|
114
|
+
|| rawType === "self_exited_guild"
|
|
115
|
+
) {
|
|
116
|
+
return { id: guildId, type: "group" };
|
|
117
|
+
}
|
|
118
|
+
const channelId =
|
|
119
|
+
readString(body, "channel_id")
|
|
120
|
+
?? extra.channel_id
|
|
121
|
+
?? event.target_id
|
|
122
|
+
?? "";
|
|
123
|
+
return { id: channelId, type: "channel" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
rawType.startsWith("private_")
|
|
128
|
+
|| rawType.includes("private_message")
|
|
129
|
+
|| event.channel_type === "PERSON"
|
|
130
|
+
) {
|
|
131
|
+
const userId =
|
|
132
|
+
readString(body, "user_id")
|
|
133
|
+
?? readString(body, "target_id")
|
|
134
|
+
?? event.target_id
|
|
135
|
+
?? event.author_id
|
|
136
|
+
?? "";
|
|
137
|
+
return { id: userId, type: "private" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const channelId =
|
|
141
|
+
readString(body, "channel_id")
|
|
142
|
+
?? extra.channel_id
|
|
143
|
+
?? event.target_id
|
|
144
|
+
?? "";
|
|
145
|
+
return { id: channelId, type: "channel" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function enrichKookGatewayForPlugins(event: KookGatewayEvent): KookGatewayEvent {
|
|
149
|
+
const extra = event.extra ?? {};
|
|
150
|
+
const noticeType = extra.type;
|
|
151
|
+
if (!isKookNoticeGatewayEvent(event)) {
|
|
152
|
+
return { ...(event as object), post_type: "message" } as KookGatewayEvent;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
...(event as object),
|
|
156
|
+
post_type: "notice",
|
|
157
|
+
notice_type: noticeType ?? "system",
|
|
158
|
+
} as KookGatewayEvent;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function formatKookNotice(
|
|
162
|
+
event: KookGatewayEvent,
|
|
163
|
+
botName: string,
|
|
164
|
+
): ReturnType<typeof Notice.from<KookGatewayEvent>> {
|
|
165
|
+
const extra = event.extra ?? {};
|
|
166
|
+
const body = extra.body ?? {};
|
|
167
|
+
const rawType = String(extra.type ?? "unknown");
|
|
168
|
+
const $type = resolveNoticeZhinType(rawType);
|
|
169
|
+
const channel = resolveKookNoticeChannel(event);
|
|
170
|
+
const ts = event.msg_timestamp ?? Date.now();
|
|
171
|
+
|
|
172
|
+
const targetId =
|
|
173
|
+
readString(body, "user_id")
|
|
174
|
+
?? readString(body, "msg_id")
|
|
175
|
+
?? readString(body, "target_id");
|
|
176
|
+
const operatorId =
|
|
177
|
+
readString(body, "operator_id")
|
|
178
|
+
?? (event.author_id !== "1" ? event.author_id : undefined);
|
|
179
|
+
|
|
180
|
+
const mapped = NOTICE_TYPE_MAP[rawType] != null;
|
|
181
|
+
|
|
182
|
+
return Notice.from(event, {
|
|
183
|
+
$id: resolveKookSideEventDedupeKey(event, "notice"),
|
|
184
|
+
$adapter: "kook",
|
|
185
|
+
$bot: botName,
|
|
186
|
+
$type,
|
|
187
|
+
$subType: mapped ? rawType : undefined,
|
|
188
|
+
$channel: channel,
|
|
189
|
+
$operator: senderFromId(operatorId),
|
|
190
|
+
$target: senderFromId(targetId),
|
|
191
|
+
$timestamp: ts,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function formatKookNoticeLog(notice: ReturnType<typeof formatKookNotice>): string {
|
|
196
|
+
return formatCompact({
|
|
197
|
+
notice: notice.$type,
|
|
198
|
+
kook_type: notice.$subType,
|
|
199
|
+
channel: `${notice.$channel.type}(${notice.$channel.id})`,
|
|
200
|
+
bot: notice.$bot,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK 出站媒体:base64 / 本地文件须先走 /v3/asset/create 上传,再嵌入 KMarkdown。
|
|
3
|
+
*/
|
|
4
|
+
import type { MessageElement, MessageSegment, SendContent } from "zhin.js";
|
|
5
|
+
|
|
6
|
+
const MEDIA_TYPES = new Set(["image", "audio", "video", "file"]);
|
|
7
|
+
|
|
8
|
+
export interface KookMediaUploader {
|
|
9
|
+
uploadMedia(data: string | Buffer): Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isMessageSegment(seg: MessageElement): seg is MessageSegment {
|
|
13
|
+
return typeof seg.type === "string";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRemoteUrl(value: string): boolean {
|
|
17
|
+
return /^https?:\/\//i.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function asSegments(content: SendContent): MessageElement[] {
|
|
21
|
+
if (typeof content === "string") {
|
|
22
|
+
return [{ type: "text", data: { text: content } }];
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(content)) {
|
|
25
|
+
return [content];
|
|
26
|
+
}
|
|
27
|
+
return content.map((item) =>
|
|
28
|
+
typeof item === "string" ? { type: "text", data: { text: item } } : item,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractBase64Buffer(data: Record<string, unknown>): Buffer | undefined {
|
|
33
|
+
const url = typeof data.url === "string" ? data.url : undefined;
|
|
34
|
+
const file = typeof data.file === "string" ? data.file : undefined;
|
|
35
|
+
const base64 = typeof data.base64 === "string" ? data.base64 : undefined;
|
|
36
|
+
|
|
37
|
+
if (url?.startsWith("base64://")) {
|
|
38
|
+
return Buffer.from(url.slice(9), "base64");
|
|
39
|
+
}
|
|
40
|
+
if (url && /^data:[^/]+\/[^;]+;base64,/i.test(url)) {
|
|
41
|
+
return Buffer.from(url.replace(/^data:[^/]+\/[^;]+;base64,/, ""), "base64");
|
|
42
|
+
}
|
|
43
|
+
if (file?.startsWith("base64://")) {
|
|
44
|
+
return Buffer.from(file.slice(9), "base64");
|
|
45
|
+
}
|
|
46
|
+
if (base64) {
|
|
47
|
+
const raw = base64.startsWith("base64://") ? base64.slice(9) : base64;
|
|
48
|
+
return Buffer.from(raw, "base64");
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveLocalFile(data: Record<string, unknown>): string | undefined {
|
|
54
|
+
const url = typeof data.url === "string" ? data.url : undefined;
|
|
55
|
+
const file = typeof data.file === "string" ? data.file : undefined;
|
|
56
|
+
const candidate = file ?? url;
|
|
57
|
+
if (!candidate || isRemoteUrl(candidate)) return undefined;
|
|
58
|
+
if (candidate.startsWith("base64://") || /^data:/.test(candidate)) return undefined;
|
|
59
|
+
if (candidate.startsWith("file://") || candidate.startsWith("/") || !candidate.includes("://")) {
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function withUploadedUrl(seg: MessageSegment, uploadedUrl: string): MessageSegment {
|
|
66
|
+
const data = { ...seg.data } as Record<string, unknown>;
|
|
67
|
+
data.url = uploadedUrl;
|
|
68
|
+
data.file = uploadedUrl;
|
|
69
|
+
delete data.base64;
|
|
70
|
+
return { type: seg.type, data } as MessageSegment;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveMediaSegment(
|
|
74
|
+
uploader: KookMediaUploader,
|
|
75
|
+
seg: MessageSegment,
|
|
76
|
+
): Promise<MessageSegment> {
|
|
77
|
+
if (!MEDIA_TYPES.has(seg.type)) return seg;
|
|
78
|
+
|
|
79
|
+
const data = seg.data as Record<string, unknown>;
|
|
80
|
+
const url = typeof data.url === "string" ? data.url : undefined;
|
|
81
|
+
const file = typeof data.file === "string" ? data.file : undefined;
|
|
82
|
+
if ((url && isRemoteUrl(url)) || (file && isRemoteUrl(file))) {
|
|
83
|
+
return seg;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const buffer = extractBase64Buffer(data);
|
|
87
|
+
if (buffer) {
|
|
88
|
+
const uploaded = await uploader.uploadMedia(buffer);
|
|
89
|
+
return withUploadedUrl(seg, uploaded);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const local = resolveLocalFile(data);
|
|
93
|
+
if (local) {
|
|
94
|
+
const uploaded = await uploader.uploadMedia(local);
|
|
95
|
+
return withUploadedUrl(seg, uploaded);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return seg;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 将 base64 / 本地媒体上传为 KOOK 可访问 URL */
|
|
102
|
+
export async function materializeOutboundMedia(
|
|
103
|
+
uploader: KookMediaUploader,
|
|
104
|
+
content: SendContent,
|
|
105
|
+
): Promise<MessageElement[]> {
|
|
106
|
+
const segments = asSegments(content);
|
|
107
|
+
return Promise.all(segments.map(async (item) => {
|
|
108
|
+
if (!isMessageSegment(item)) return item;
|
|
109
|
+
return resolveMediaSegment(uploader, item);
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zhin 消息段 → kook-client Sendable。
|
|
3
|
+
* 图片/音视频须用 segment 发送对应 msg_type,不能写成 Markdown 。
|
|
4
|
+
*/
|
|
5
|
+
import { segment, type MessageSegment as KookMessageSegment } from "kook-client";
|
|
6
|
+
import type { MessageElement } from "zhin.js";
|
|
7
|
+
|
|
8
|
+
function mediaUrl(data: Record<string, unknown>): string {
|
|
9
|
+
return String(data.url ?? data.file ?? "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 单张图片(可带 reply)→ kook image 消息段(type=2)
|
|
14
|
+
*/
|
|
15
|
+
export function convertToKookSendable(
|
|
16
|
+
elements: MessageElement[],
|
|
17
|
+
formatText: (content: MessageElement[]) => string,
|
|
18
|
+
): string | KookMessageSegment[] {
|
|
19
|
+
const replies: string[] = [];
|
|
20
|
+
const images: string[] = [];
|
|
21
|
+
const videos: string[] = [];
|
|
22
|
+
const audios: string[] = [];
|
|
23
|
+
const rest: MessageElement[] = [];
|
|
24
|
+
|
|
25
|
+
for (const el of elements) {
|
|
26
|
+
if (el.type === "reply" && el.data.id) {
|
|
27
|
+
replies.push(String(el.data.id));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (el.type === "image") {
|
|
31
|
+
const url = mediaUrl(el.data as Record<string, unknown>);
|
|
32
|
+
if (url) images.push(url);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (el.type === "video") {
|
|
36
|
+
const url = mediaUrl(el.data as Record<string, unknown>);
|
|
37
|
+
if (url) videos.push(url);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (el.type === "audio") {
|
|
41
|
+
const url = mediaUrl(el.data as Record<string, unknown>);
|
|
42
|
+
if (url) audios.push(url);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
rest.push(el);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const replySegs = replies.map((id) => segment.reply(id));
|
|
49
|
+
|
|
50
|
+
if (images.length === 1 && videos.length === 0 && audios.length === 0 && rest.length === 0) {
|
|
51
|
+
return [...replySegs, segment.image(images[0])];
|
|
52
|
+
}
|
|
53
|
+
if (videos.length === 1 && images.length === 0 && audios.length === 0 && rest.length === 0) {
|
|
54
|
+
return [...replySegs, segment.video(videos[0])];
|
|
55
|
+
}
|
|
56
|
+
if (audios.length === 1 && images.length === 0 && videos.length === 0 && rest.length === 0) {
|
|
57
|
+
return [...replySegs, segment.audio(audios[0])];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return formatText(elements);
|
|
61
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,24 @@ export interface KookSenderInfo {
|
|
|
22
22
|
isAdmin?: boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface KookTypingIndicatorConfig {
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
defaultEmoji?: string;
|
|
28
|
+
autoRemove?: boolean;
|
|
29
|
+
removeDelay?: number;
|
|
30
|
+
privateConfig?: {
|
|
31
|
+
type?: 'reaction' | 'message' | 'typing' | 'none';
|
|
32
|
+
emoji?: string;
|
|
33
|
+
message?: string;
|
|
34
|
+
};
|
|
35
|
+
/** 频道/群聊(KOOK channel 走 groupConfig 合并逻辑) */
|
|
36
|
+
groupConfig?: {
|
|
37
|
+
type?: 'reaction' | 'message' | 'typing' | 'none';
|
|
38
|
+
emoji?: string;
|
|
39
|
+
message?: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
export interface KookBotConfig {
|
|
26
44
|
context: "kook";
|
|
27
45
|
name: string;
|
|
@@ -31,6 +49,8 @@ export interface KookBotConfig {
|
|
|
31
49
|
max_retry?: number;
|
|
32
50
|
ignore?: "bot" | "self";
|
|
33
51
|
logLevel?: LogLevel;
|
|
52
|
+
/** AI 处理中提示(reaction 推荐:不打断会话) */
|
|
53
|
+
typingIndicator?: KookTypingIndicatorConfig;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
export type KookRawMessage = PrivateMessageEvent | ChannelMessageEvent;
|