@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
package/lib/types.d.ts
CHANGED
|
@@ -18,6 +18,23 @@ export interface KookSenderInfo {
|
|
|
18
18
|
isGuildOwner?: boolean;
|
|
19
19
|
isAdmin?: boolean;
|
|
20
20
|
}
|
|
21
|
+
export interface KookTypingIndicatorConfig {
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
defaultEmoji?: string;
|
|
24
|
+
autoRemove?: boolean;
|
|
25
|
+
removeDelay?: number;
|
|
26
|
+
privateConfig?: {
|
|
27
|
+
type?: 'reaction' | 'message' | 'typing' | 'none';
|
|
28
|
+
emoji?: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
};
|
|
31
|
+
/** 频道/群聊(KOOK channel 走 groupConfig 合并逻辑) */
|
|
32
|
+
groupConfig?: {
|
|
33
|
+
type?: 'reaction' | 'message' | 'typing' | 'none';
|
|
34
|
+
emoji?: string;
|
|
35
|
+
message?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
21
38
|
export interface KookBotConfig {
|
|
22
39
|
context: "kook";
|
|
23
40
|
name: string;
|
|
@@ -27,6 +44,8 @@ export interface KookBotConfig {
|
|
|
27
44
|
max_retry?: number;
|
|
28
45
|
ignore?: "bot" | "self";
|
|
29
46
|
logLevel?: LogLevel;
|
|
47
|
+
/** AI 处理中提示(reaction 推荐:不打断会话) */
|
|
48
|
+
typingIndicator?: KookTypingIndicatorConfig;
|
|
30
49
|
}
|
|
31
50
|
export type KookRawMessage = PrivateMessageEvent | ChannelMessageEvent;
|
|
32
51
|
//# sourceMappingURL=types.d.ts.map
|
package/lib/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,YAAY,EAAE,QAAQ,EAAE,CAAC;AAEzB,oBAAY,cAAc;IACxB,MAAM,IAAI;IACV,KAAK,IAAI;IACT,KAAK,IAAI;IACT,YAAY,IAAI;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,YAAY,EAAE,QAAQ,EAAE,CAAC;AAEzB,oBAAY,cAAc;IACxB,MAAM,IAAI;IACV,KAAK,IAAI;IACT,KAAK,IAAI;IACT,YAAY,IAAI;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE;QACd,IAAI,CAAC,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;QAClD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,6CAA6C;IAC7C,WAAW,CAAC,EAAE;QACZ,IAAI,CAAC,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;QAClD,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;CACH;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,kCAAkC;IAClC,eAAe,CAAC,EAAE,yBAAyB,CAAC;CAC7C;AAED,MAAM,MAAM,cAAc,GAAG,mBAAmB,GAAG,mBAAmB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-kook",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Zhin.js adapter for KOOK (开黑啦)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -41,17 +41,17 @@
|
|
|
41
41
|
"lucide-react": "^1.17.0",
|
|
42
42
|
"typescript": "^6.0.3",
|
|
43
43
|
"@zhin.js/cli": "1.0.88",
|
|
44
|
-
"@zhin.js/host-api": "0.0
|
|
44
|
+
"@zhin.js/host-api": "1.0.0",
|
|
45
45
|
"@zhin.js/contract": "1.0.1",
|
|
46
|
-
"zhin.js": "
|
|
46
|
+
"zhin.js": "2.0.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@zhin.js/client": "2.0.2",
|
|
50
|
-
"@zhin.js/host-api": "0.0
|
|
50
|
+
"@zhin.js/host-api": "1.0.0",
|
|
51
51
|
"@zhin.js/contract": "1.0.1",
|
|
52
|
-
"@zhin.js/host-router": "0.0
|
|
52
|
+
"@zhin.js/host-router": "1.0.0",
|
|
53
53
|
"@zhin.js/logger": "0.1.70",
|
|
54
|
-
"zhin.js": "
|
|
54
|
+
"zhin.js": "2.0.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@zhin.js/client": {
|
package/src/bot.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
GuildMember,
|
|
12
12
|
parseGroupId,
|
|
13
13
|
} from "kook-client";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
14
15
|
import path from "path";
|
|
15
16
|
import {
|
|
16
17
|
Bot,
|
|
@@ -24,10 +25,45 @@ import {
|
|
|
24
25
|
import type { KookBotConfig, KookSenderInfo, KookRawMessage } from "./types.js";
|
|
25
26
|
import { KookPermission } from "./types.js";
|
|
26
27
|
import type { KookAdapter } from "./adapter.js";
|
|
28
|
+
import { InboundMessageDeduper } from "./kook-inbound.js";
|
|
29
|
+
import {
|
|
30
|
+
enrichKookGatewayForPlugins,
|
|
31
|
+
formatKookNotice,
|
|
32
|
+
formatKookNoticeLog,
|
|
33
|
+
isKookNoticeGatewayEvent,
|
|
34
|
+
resolveKookSideEventDedupeKey,
|
|
35
|
+
type KookGatewayEvent,
|
|
36
|
+
} from "./kook-side-events.js";
|
|
37
|
+
import {
|
|
38
|
+
encodeKookMsgRef,
|
|
39
|
+
encodeKookReactionId,
|
|
40
|
+
isKookApiGoneResult,
|
|
41
|
+
isKookApiSuccess,
|
|
42
|
+
isKookMsgGoneError,
|
|
43
|
+
shouldStopDeleteAfterResponse,
|
|
44
|
+
kookDeleteApiPath,
|
|
45
|
+
kookReactionApiPath,
|
|
46
|
+
parseKookMsgRef,
|
|
47
|
+
parseKookReactionId,
|
|
48
|
+
plainKookMsgId,
|
|
49
|
+
resolveKookRoutes,
|
|
50
|
+
routeFromSceneType,
|
|
51
|
+
routeFromSendType,
|
|
52
|
+
type KookMsgRoute,
|
|
53
|
+
} from "./kook-msg-route.js";
|
|
54
|
+
import { materializeOutboundMedia } from "./outbound-media.js";
|
|
55
|
+
import { uploadKookAsset } from "./kook-asset-upload.js";
|
|
56
|
+
import { convertToKookSendable } from "./outbound-sendable.js";
|
|
27
57
|
|
|
28
58
|
export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage> {
|
|
29
59
|
$connected: boolean = false;
|
|
60
|
+
/** KOOK 平台 user_id,用于 @ 触发匹配(resolveBotAtIds) */
|
|
61
|
+
$platformUserId?: string;
|
|
30
62
|
adapter: KookAdapter;
|
|
63
|
+
private readonly inboundDeduper = new InboundMessageDeduper();
|
|
64
|
+
private readonly onGatewayEvent = (raw: KookGatewayEvent) => {
|
|
65
|
+
this.handleGatewayEvent(raw);
|
|
66
|
+
};
|
|
31
67
|
|
|
32
68
|
get $id(): string {
|
|
33
69
|
return this.$config.name;
|
|
@@ -49,6 +85,41 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
49
85
|
});
|
|
50
86
|
this.adapter = adapter;
|
|
51
87
|
this.setupEventListeners();
|
|
88
|
+
this.hookGatewayReceiver();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 在 kook-client Receiver transform 之前拦截原始 gateway 事件(SDK 未实现 notice transform)
|
|
93
|
+
*/
|
|
94
|
+
private hookGatewayReceiver(): void {
|
|
95
|
+
const receiver = this.receiver as import("node:events").EventEmitter;
|
|
96
|
+
receiver.prependListener("event", this.onGatewayEvent);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private handleGatewayEvent(raw: KookGatewayEvent): void {
|
|
100
|
+
try {
|
|
101
|
+
const enriched = enrichKookGatewayForPlugins(raw);
|
|
102
|
+
EventEmitter.prototype.emit.call(this.adapter, "kook.gateway", enriched);
|
|
103
|
+
|
|
104
|
+
if (!isKookNoticeGatewayEvent(raw)) return;
|
|
105
|
+
|
|
106
|
+
const dedupeKey = resolveKookSideEventDedupeKey(raw, "notice");
|
|
107
|
+
if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
|
|
108
|
+
|
|
109
|
+
const notice = formatKookNotice(raw, this.$config.name);
|
|
110
|
+
this.emitSideEvent("notice.receive", notice);
|
|
111
|
+
this.pluginLogger.info(formatKookNoticeLog(notice));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.pluginLogger.error("处理 KOOK gateway 事件失败:", error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private emitSideEvent(
|
|
118
|
+
event: "notice.receive" | "request.receive",
|
|
119
|
+
payload: unknown,
|
|
120
|
+
): void {
|
|
121
|
+
this.adapter.emit(event, payload as never);
|
|
122
|
+
void this.adapter.plugin.dispatch(event, payload as never);
|
|
52
123
|
}
|
|
53
124
|
|
|
54
125
|
/**
|
|
@@ -59,6 +130,14 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
59
130
|
this.on("message", (msg: KookRawMessage) => {
|
|
60
131
|
try {
|
|
61
132
|
const message = this.$formatMessage(msg);
|
|
133
|
+
const atSegs = message.$content.filter((s) => s.type === 'at');
|
|
134
|
+
if (atSegs.length > 0) {
|
|
135
|
+
this.pluginLogger.debug(
|
|
136
|
+
`KOOK @解析: self_id=${this.$platformUserId ?? (this as { self_id?: string }).self_id ?? '?'}`
|
|
137
|
+
+ ` at=${JSON.stringify(atSegs.map((s) => s.data))}`
|
|
138
|
+
+ ` preview=${segment.raw(message.$content)}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
62
141
|
this.pluginLogger.debug(`KOOK 格式化消息: $content=${JSON.stringify(message.$content)}, $raw=${message.$raw}`);
|
|
63
142
|
this.adapter.emit("message.receive", message);
|
|
64
143
|
|
|
@@ -139,7 +218,9 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
139
218
|
$timestamp: msg.timestamp,
|
|
140
219
|
|
|
141
220
|
$recall: async () => {
|
|
142
|
-
await this.$recallMessage(message.$id
|
|
221
|
+
await this.$recallMessage(message.$id, {
|
|
222
|
+
route: msg.message_type === 'channel' ? 'channel' : 'direct',
|
|
223
|
+
});
|
|
143
224
|
},
|
|
144
225
|
|
|
145
226
|
$reply: async (content: SendContent, quote?: string | boolean): Promise<string> => {
|
|
@@ -389,6 +470,16 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
389
470
|
throw error;
|
|
390
471
|
}
|
|
391
472
|
}
|
|
473
|
+
private buildAtElement(userId: string | number): MessageElement {
|
|
474
|
+
const id = String(userId);
|
|
475
|
+
return { type: 'at', data: { id, user_id: id, qq: id } };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private resolveAtUserId(data: Record<string, unknown>): string {
|
|
479
|
+
const raw = data.user_id ?? data.qq ?? data.id;
|
|
480
|
+
return raw == null ? '' : String(raw);
|
|
481
|
+
}
|
|
482
|
+
|
|
392
483
|
private parseMarkdown(content: string): MessageElement[] {
|
|
393
484
|
const elements: MessageElement[] = [];
|
|
394
485
|
|
|
@@ -420,7 +511,7 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
420
511
|
matches.push({
|
|
421
512
|
index: match.index,
|
|
422
513
|
length: match[0].length,
|
|
423
|
-
element:
|
|
514
|
+
element: this.buildAtElement(userId),
|
|
424
515
|
});
|
|
425
516
|
}
|
|
426
517
|
|
|
@@ -497,7 +588,7 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
497
588
|
break;
|
|
498
589
|
|
|
499
590
|
case "at":
|
|
500
|
-
elements.push(
|
|
591
|
+
elements.push(this.buildAtElement(segment.user_id));
|
|
501
592
|
break;
|
|
502
593
|
|
|
503
594
|
case "image":
|
|
@@ -554,7 +645,14 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
554
645
|
try {
|
|
555
646
|
await this.connect();
|
|
556
647
|
this.$connected = true;
|
|
557
|
-
this
|
|
648
|
+
const selfId = (this as { self_id?: string | number }).self_id;
|
|
649
|
+
if (selfId != null) {
|
|
650
|
+
this.$platformUserId = String(selfId);
|
|
651
|
+
}
|
|
652
|
+
this.pluginLogger.info(
|
|
653
|
+
`KOOK Bot ${this.$id} 连接成功`
|
|
654
|
+
+ (this.$platformUserId ? ` (platform_user_id=${this.$platformUserId})` : ''),
|
|
655
|
+
);
|
|
558
656
|
} catch (error) {
|
|
559
657
|
this.pluginLogger.error(`KOOK Bot ${this.$id} 连接失败:`, error);
|
|
560
658
|
throw error;
|
|
@@ -566,6 +664,9 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
566
664
|
*/
|
|
567
665
|
async $disconnect(): Promise<void> {
|
|
568
666
|
try {
|
|
667
|
+
const receiver = this.receiver as import("node:events").EventEmitter;
|
|
668
|
+
receiver.off("event", this.onGatewayEvent);
|
|
669
|
+
this.inboundDeduper.clear();
|
|
569
670
|
(this as unknown as import('node:events').EventEmitter).removeAllListeners();
|
|
570
671
|
await this.disconnect();
|
|
571
672
|
this.$connected = false;
|
|
@@ -576,6 +677,13 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
576
677
|
}
|
|
577
678
|
}
|
|
578
679
|
|
|
680
|
+
/**
|
|
681
|
+
* 上传媒体(覆盖 kook-client:其 FormData.append 不接受 Node Buffer)
|
|
682
|
+
*/
|
|
683
|
+
override async uploadMedia(data: string | Buffer): Promise<string> {
|
|
684
|
+
return uploadKookAsset(this.request, data);
|
|
685
|
+
}
|
|
686
|
+
|
|
579
687
|
/**
|
|
580
688
|
* 发送消息
|
|
581
689
|
*/
|
|
@@ -583,12 +691,11 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
583
691
|
try {
|
|
584
692
|
const { id, type, content } = options;
|
|
585
693
|
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const kookContent = this.convertToKookFormat(elements);
|
|
694
|
+
const elements = await materializeOutboundMedia(this, content);
|
|
695
|
+
const kookContent = convertToKookSendable(
|
|
696
|
+
elements,
|
|
697
|
+
(els) => this.convertToKookFormat(els),
|
|
698
|
+
);
|
|
592
699
|
|
|
593
700
|
// 根据消息类型发送
|
|
594
701
|
let result: any;
|
|
@@ -598,7 +705,8 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
598
705
|
result = await (this as any).sendChannelMsg(id, kookContent);
|
|
599
706
|
}
|
|
600
707
|
|
|
601
|
-
|
|
708
|
+
const route = routeFromSendType(type);
|
|
709
|
+
return encodeKookMsgRef(route, String(result?.msg_id ?? ''));
|
|
602
710
|
} catch (error) {
|
|
603
711
|
this.pluginLogger.error(`KOOK Bot ${this.$id} 发送消息失败:`, error);
|
|
604
712
|
throw error;
|
|
@@ -606,18 +714,122 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
606
714
|
}
|
|
607
715
|
|
|
608
716
|
/**
|
|
609
|
-
|
|
717
|
+
* 撤回消息。支持 `kook:channel:msgId` 出站 ref,或入站 plain msgId + route/sceneType 提示。
|
|
610
718
|
*/
|
|
611
|
-
async $recallMessage(
|
|
719
|
+
async $recallMessage(
|
|
720
|
+
messageIdOrRef: string,
|
|
721
|
+
hint?: { route?: KookMsgRoute; sceneType?: 'private' | 'group' | 'channel' },
|
|
722
|
+
): Promise<void> {
|
|
723
|
+
const { route: encodedRoute, msgId } = parseKookMsgRef(messageIdOrRef);
|
|
724
|
+
const routeHint = encodedRoute ?? hint?.route ?? routeFromSceneType(hint?.sceneType);
|
|
612
725
|
try {
|
|
613
|
-
await
|
|
614
|
-
this.pluginLogger.debug(
|
|
726
|
+
await this.deleteKookMsg(msgId, routeHint);
|
|
727
|
+
this.pluginLogger.debug(
|
|
728
|
+
`KOOK Bot ${this.$id} 撤回消息 (${routeHint ?? 'auto'}): ${msgId}`,
|
|
729
|
+
);
|
|
615
730
|
} catch (error) {
|
|
616
731
|
this.pluginLogger.error(`KOOK Bot ${this.$id} 撤回消息失败:`, error);
|
|
617
732
|
throw error;
|
|
618
733
|
}
|
|
619
734
|
}
|
|
620
735
|
|
|
736
|
+
private async deleteKookMsg(msgId: string, routeHint?: KookMsgRoute): Promise<void> {
|
|
737
|
+
const routes = resolveKookRoutes('delete', routeHint);
|
|
738
|
+
let lastError: unknown;
|
|
739
|
+
|
|
740
|
+
for (const route of routes) {
|
|
741
|
+
try {
|
|
742
|
+
const result = await this.request.post(
|
|
743
|
+
kookDeleteApiPath(route),
|
|
744
|
+
{ msg_id: msgId },
|
|
745
|
+
) as { code?: number };
|
|
746
|
+
if (shouldStopDeleteAfterResponse(result, routes.length)) return;
|
|
747
|
+
} catch (err) {
|
|
748
|
+
if (isKookMsgGoneError(err)) return;
|
|
749
|
+
lastError = err;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* 为消息添加表情回应(TypingIndicator reaction 模式)
|
|
758
|
+
* @returns reactionId(含 channel/direct 路由),供 $removeReaction 使用
|
|
759
|
+
*/
|
|
760
|
+
async $addReaction(
|
|
761
|
+
messageId: string,
|
|
762
|
+
emoji: string,
|
|
763
|
+
hint?: { sceneType?: 'private' | 'group' | 'channel' },
|
|
764
|
+
): Promise<string> {
|
|
765
|
+
const msgId = plainKookMsgId(messageId);
|
|
766
|
+
const route = await this.mutateMsgReaction(
|
|
767
|
+
msgId,
|
|
768
|
+
emoji,
|
|
769
|
+
'add',
|
|
770
|
+
routeFromSceneType(hint?.sceneType),
|
|
771
|
+
);
|
|
772
|
+
return encodeKookReactionId(route, msgId, emoji);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** 移除本 Bot 在消息上的表情回应 */
|
|
776
|
+
async $removeReaction(messageId: string, reactionId: string): Promise<void> {
|
|
777
|
+
const { route, emoji } = parseKookReactionId(reactionId);
|
|
778
|
+
await this.mutateMsgReaction(plainKookMsgId(messageId), emoji, 'delete', route);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private async mutateMsgReaction(
|
|
782
|
+
msgId: string,
|
|
783
|
+
emoji: string,
|
|
784
|
+
action: 'add' | 'delete',
|
|
785
|
+
routeHint?: KookMsgRoute,
|
|
786
|
+
): Promise<KookMsgRoute> {
|
|
787
|
+
const routes = resolveKookRoutes(action, routeHint);
|
|
788
|
+
const body = { msg_id: msgId, emoji };
|
|
789
|
+
let lastError: unknown;
|
|
790
|
+
|
|
791
|
+
for (const route of routes) {
|
|
792
|
+
try {
|
|
793
|
+
const result = await this.request.post(
|
|
794
|
+
kookReactionApiPath(route, action),
|
|
795
|
+
body,
|
|
796
|
+
) as { code?: number };
|
|
797
|
+
if (isKookApiSuccess(result)) {
|
|
798
|
+
this.pluginLogger.debug(
|
|
799
|
+
`KOOK Bot ${this.$id} ${action} reaction (${route}) on ${msgId}`,
|
|
800
|
+
);
|
|
801
|
+
return route;
|
|
802
|
+
}
|
|
803
|
+
if (action === 'delete' && shouldStopDeleteAfterResponse(result, routes.length)) {
|
|
804
|
+
const label = isKookApiGoneResult(result) ? 'already gone' : 'done';
|
|
805
|
+
this.pluginLogger.debug(
|
|
806
|
+
`KOOK Bot ${this.$id} delete reaction ${label} (${route}) on ${msgId}`,
|
|
807
|
+
);
|
|
808
|
+
return route;
|
|
809
|
+
}
|
|
810
|
+
} catch (err) {
|
|
811
|
+
if (action === 'delete' && isKookMsgGoneError(err)) {
|
|
812
|
+
this.pluginLogger.debug(
|
|
813
|
+
`KOOK Bot ${this.$id} delete reaction already gone (${route}) on ${msgId}`,
|
|
814
|
+
);
|
|
815
|
+
return route;
|
|
816
|
+
}
|
|
817
|
+
lastError = err;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (action === 'delete') {
|
|
822
|
+
this.pluginLogger.debug(
|
|
823
|
+
`KOOK Bot ${this.$id} delete reaction noop on ${msgId}`,
|
|
824
|
+
);
|
|
825
|
+
return routeHint ?? 'channel';
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
throw lastError instanceof Error
|
|
829
|
+
? lastError
|
|
830
|
+
: new Error(`KOOK ${action} reaction failed (msg_id=${msgId})`);
|
|
831
|
+
}
|
|
832
|
+
|
|
621
833
|
/**
|
|
622
834
|
* 将消息段转换为 KOOK KMarkdown 格式
|
|
623
835
|
* 支持:文本、图片、@提及、表情、引用等
|
|
@@ -634,12 +846,13 @@ export class KookBot extends Client implements Bot<KookBotConfig, KookRawMessage
|
|
|
634
846
|
// 图片:
|
|
635
847
|
return ``;
|
|
636
848
|
|
|
637
|
-
case "at":
|
|
638
|
-
|
|
639
|
-
if (
|
|
640
|
-
return
|
|
849
|
+
case "at": {
|
|
850
|
+
const atId = this.resolveAtUserId(el.data as Record<string, unknown>);
|
|
851
|
+
if (atId === 'all') {
|
|
852
|
+
return '(met)all(met)';
|
|
641
853
|
}
|
|
642
|
-
return `(met)${
|
|
854
|
+
return `(met)${atId}(met)`;
|
|
855
|
+
}
|
|
643
856
|
|
|
644
857
|
case "face":
|
|
645
858
|
// 表情:(emj)表情名(emj)[表情ID]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK /v3/asset/create 上传(formdata-node 要求 Blob,不能直传 Node Buffer)。
|
|
3
|
+
*/
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
export interface KookAssetRequest {
|
|
7
|
+
post(
|
|
8
|
+
url: string,
|
|
9
|
+
data: FormData,
|
|
10
|
+
config?: { headers?: Record<string, string> },
|
|
11
|
+
): Promise<{ data?: { url?: string }; message?: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function guessFilename(data: string | Buffer): string {
|
|
15
|
+
if (Buffer.isBuffer(data)) return "upload.png";
|
|
16
|
+
if (/^data:image\/png/i.test(data)) return "upload.png";
|
|
17
|
+
if (/^data:image\/jpe?g/i.test(data)) return "upload.jpg";
|
|
18
|
+
if (/^data:image\/gif/i.test(data)) return "upload.gif";
|
|
19
|
+
if (/^data:image\/webp/i.test(data)) return "upload.webp";
|
|
20
|
+
const base = data.replace(/^file:\/\//, "").split("/").pop();
|
|
21
|
+
return base?.includes(".") ? base : "upload.png";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function toBuffer(data: string | Buffer): Promise<Buffer> {
|
|
25
|
+
if (Buffer.isBuffer(data)) return data;
|
|
26
|
+
if (data.startsWith("base64://")) {
|
|
27
|
+
return Buffer.from(data.slice(9), "base64");
|
|
28
|
+
}
|
|
29
|
+
if (/^data:[^/]+\/[^;]+;base64,/i.test(data)) {
|
|
30
|
+
return Buffer.from(data.replace(/^data:[^/]+\/[^;]+;base64,/, ""), "base64");
|
|
31
|
+
}
|
|
32
|
+
return readFile(data.replace(/^file:\/\//, ""));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function uploadKookAsset(
|
|
36
|
+
request: KookAssetRequest,
|
|
37
|
+
data: string | Buffer,
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
if (typeof data === "string" && /^https?:\/\//i.test(data)) {
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
const buffer = await toBuffer(data);
|
|
43
|
+
const form = new globalThis.FormData();
|
|
44
|
+
form.append("file", new globalThis.Blob([new Uint8Array(buffer)]), guessFilename(data));
|
|
45
|
+
|
|
46
|
+
const response = await request.post("/v3/asset/create", form, {
|
|
47
|
+
headers: { Accept: "application/json" },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const url = response.data?.url;
|
|
51
|
+
if (!url) {
|
|
52
|
+
throw new Error(`KOOK asset upload failed: ${response.message ?? "missing url"}`);
|
|
53
|
+
}
|
|
54
|
+
return url;
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK 入站侧事件去重(gateway notice / 系统消息)
|
|
3
|
+
*/
|
|
4
|
+
const DEDUPE_TTL_MS = 120_000;
|
|
5
|
+
|
|
6
|
+
export class InboundMessageDeduper {
|
|
7
|
+
private readonly seen = new Map<string, number>();
|
|
8
|
+
|
|
9
|
+
shouldProcess(key: string): boolean {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
for (const [id, t] of this.seen) {
|
|
12
|
+
if (now - t > DEDUPE_TTL_MS) this.seen.delete(id);
|
|
13
|
+
}
|
|
14
|
+
if (this.seen.has(key)) return false;
|
|
15
|
+
this.seen.set(key, now);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clear(): void {
|
|
20
|
+
this.seen.clear();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KOOK msg_id 路由:频道 / 私聊 API 路径不同,出站编码路由、入站/删除按路由命中。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type KookMsgRoute = 'channel' | 'direct';
|
|
6
|
+
|
|
7
|
+
export function routeFromSceneType(
|
|
8
|
+
sceneType?: 'private' | 'group' | 'channel',
|
|
9
|
+
): KookMsgRoute | undefined {
|
|
10
|
+
if (sceneType === 'private') return 'direct';
|
|
11
|
+
if (sceneType === 'group' || sceneType === 'channel') return 'channel';
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function routeFromSendType(type: 'private' | 'group' | 'channel'): KookMsgRoute {
|
|
16
|
+
return type === 'private' ? 'direct' : 'channel';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 出站 send 返回值,供 recall / typing 删除时走路由 */
|
|
20
|
+
export function encodeKookMsgRef(route: KookMsgRoute, msgId: string): string {
|
|
21
|
+
return `kook:${route}:${msgId}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseKookMsgRef(ref: string): { route?: KookMsgRoute; msgId: string } {
|
|
25
|
+
const parts = ref.split(':');
|
|
26
|
+
if (parts[0] === 'kook' && (parts[1] === 'channel' || parts[1] === 'direct') && parts.length >= 3) {
|
|
27
|
+
return { route: parts[1], msgId: parts.slice(2).join(':') };
|
|
28
|
+
}
|
|
29
|
+
return { msgId: ref };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function plainKookMsgId(ref: string): string {
|
|
33
|
+
return parseKookMsgRef(ref).msgId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveKookRoutes(
|
|
37
|
+
action: 'add' | 'delete',
|
|
38
|
+
routeHint?: KookMsgRoute,
|
|
39
|
+
): KookMsgRoute[] {
|
|
40
|
+
if (action === 'delete' && routeHint) return [routeHint];
|
|
41
|
+
if (routeHint) return [routeHint, routeHint === 'channel' ? 'direct' : 'channel'];
|
|
42
|
+
return ['channel', 'direct'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isKookGoneText(text: string): boolean {
|
|
46
|
+
return text.includes('404')
|
|
47
|
+
|| text.includes('该数据不存在')
|
|
48
|
+
|| text.includes('没有权限操作')
|
|
49
|
+
|| text.toLowerCase().includes('not found');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isKookMsgGoneError(err: unknown): boolean {
|
|
53
|
+
return isKookGoneText(err instanceof Error ? err.message : String(err));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** KOOK 标准成功:code=0;少数接口 HTTP 200 省略 code */
|
|
57
|
+
export function isKookApiSuccess(result: unknown): boolean {
|
|
58
|
+
if (result == null || typeof result !== 'object') return false;
|
|
59
|
+
const { code } = result as { code?: number };
|
|
60
|
+
if (code === 0) return true;
|
|
61
|
+
if (code === undefined) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 明确的业务失败(可尝试另一路由) */
|
|
66
|
+
export function isKookExplicitError(result: unknown): boolean {
|
|
67
|
+
if (result == null || typeof result !== 'object') return false;
|
|
68
|
+
const { code } = result as { code?: number };
|
|
69
|
+
return typeof code === 'number' && code !== 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* delete 请求已返回且未抛错:是否停止(不再打第二条路由)。
|
|
74
|
+
* 仅当双路由且响应为明确错误码时,才继续尝试下一路由。
|
|
75
|
+
*/
|
|
76
|
+
export function shouldStopDeleteAfterResponse(
|
|
77
|
+
result: unknown,
|
|
78
|
+
routeCount: number,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (isKookApiSuccess(result) || isKookApiGoneResult(result)) return true;
|
|
81
|
+
if (routeCount === 1) return true;
|
|
82
|
+
return !isKookExplicitError(result);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** KOOK 有时用 HTTP 200 + 非 0 code 表示目标已不存在 */
|
|
86
|
+
export function isKookApiGoneResult(result: unknown): boolean {
|
|
87
|
+
if (!result || typeof result !== 'object') return false;
|
|
88
|
+
const { code, message } = result as { code?: number; message?: string };
|
|
89
|
+
if (code === 404) return true;
|
|
90
|
+
return isKookGoneText(message ?? '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function kookDeleteApiPath(route: KookMsgRoute): string {
|
|
94
|
+
return route === 'channel' ? '/v3/message/delete' : '/v3/direct-message/delete';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function kookReactionApiPath(route: KookMsgRoute, action: 'add' | 'delete'): string {
|
|
98
|
+
const prefix = route === 'channel' ? '/v3/message' : '/v3/direct-message';
|
|
99
|
+
const suffix = action === 'add' ? 'add-reaction' : 'delete-reaction';
|
|
100
|
+
return `${prefix}/${suffix}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function encodeKookReactionId(route: KookMsgRoute, msgId: string, emoji: string): string {
|
|
104
|
+
return `reaction:${route}:${msgId}:${emoji}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseKookReactionId(reactionId: string): {
|
|
108
|
+
route?: KookMsgRoute;
|
|
109
|
+
emoji: string;
|
|
110
|
+
} {
|
|
111
|
+
const parts = reactionId.split(':');
|
|
112
|
+
if (parts[0] === 'reaction' && (parts[1] === 'channel' || parts[1] === 'direct')) {
|
|
113
|
+
return {
|
|
114
|
+
route: parts[1],
|
|
115
|
+
emoji: parts.slice(3).join(':') || '⏳',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (parts[0] === 'reaction' && parts.length >= 3) {
|
|
119
|
+
return { emoji: parts.slice(2).join(':') || '⏳' };
|
|
120
|
+
}
|
|
121
|
+
return { emoji: parts[parts.length - 1] ?? '⏳' };
|
|
122
|
+
}
|