@zhin.js/adapter-kook 2.0.10 → 2.0.12

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +99 -7
  3. package/lib/bot.d.ts +33 -2
  4. package/lib/bot.d.ts.map +1 -1
  5. package/lib/bot.js +157 -20
  6. package/lib/bot.js.map +1 -1
  7. package/lib/kook-asset-upload.d.ts +12 -0
  8. package/lib/kook-asset-upload.d.ts.map +1 -0
  9. package/lib/kook-asset-upload.js +46 -0
  10. package/lib/kook-asset-upload.js.map +1 -0
  11. package/lib/kook-inbound.d.ts +6 -0
  12. package/lib/kook-inbound.d.ts.map +1 -0
  13. package/lib/kook-inbound.js +22 -0
  14. package/lib/kook-inbound.js.map +1 -0
  15. package/lib/kook-msg-route.d.ts +34 -0
  16. package/lib/kook-msg-route.d.ts.map +1 -0
  17. package/lib/kook-msg-route.js +106 -0
  18. package/lib/kook-msg-route.js.map +1 -0
  19. package/lib/kook-side-events.d.ts +43 -0
  20. package/lib/kook-side-events.d.ts.map +1 -0
  21. package/lib/kook-side-events.js +147 -0
  22. package/lib/kook-side-events.js.map +1 -0
  23. package/lib/outbound-media.d.ts +10 -0
  24. package/lib/outbound-media.d.ts.map +1 -0
  25. package/lib/outbound-media.js +86 -0
  26. package/lib/outbound-media.js.map +1 -0
  27. package/lib/outbound-sendable.d.ts +11 -0
  28. package/lib/outbound-sendable.d.ts.map +1 -0
  29. package/lib/outbound-sendable.js +55 -0
  30. package/lib/outbound-sendable.js.map +1 -0
  31. package/lib/types.d.ts +19 -0
  32. package/lib/types.d.ts.map +1 -1
  33. package/package.json +5 -5
  34. package/src/bot.ts +233 -20
  35. package/src/kook-asset-upload.ts +55 -0
  36. package/src/kook-inbound.ts +22 -0
  37. package/src/kook-msg-route.ts +122 -0
  38. package/src/kook-side-events.ts +202 -0
  39. package/src/outbound-media.ts +111 -0
  40. package/src/outbound-sendable.ts +61 -0
  41. package/src/types.ts +20 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbound-sendable.js","sourceRoot":"","sources":["../src/outbound-sendable.ts"],"names":[],"mappings":"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"}
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
@@ -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;CACrB;AAED,MAAM,MAAM,cAAc,GAAG,mBAAmB,GAAG,mBAAmB,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": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "description": "Zhin.js adapter for KOOK (开黑啦)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -40,10 +40,10 @@
40
40
  "@types/react-dom": "^19.2.3",
41
41
  "lucide-react": "^1.17.0",
42
42
  "typescript": "^6.0.3",
43
- "@zhin.js/cli": "1.0.87",
44
- "@zhin.js/host-api": "0.0.4",
43
+ "@zhin.js/cli": "1.0.88",
45
44
  "@zhin.js/contract": "1.0.1",
46
- "zhin.js": "1.0.91"
45
+ "zhin.js": "1.0.93",
46
+ "@zhin.js/host-api": "0.0.4"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@zhin.js/client": "2.0.2",
@@ -51,7 +51,7 @@
51
51
  "@zhin.js/contract": "1.0.1",
52
52
  "@zhin.js/host-router": "0.0.3",
53
53
  "@zhin.js/logger": "0.1.70",
54
- "zhin.js": "1.0.91"
54
+ "zhin.js": "1.0.93"
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: { type: "at", data: { id: userId } }
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({ type: "at", data: { id: segment.user_id } });
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.pluginLogger.info(`KOOK Bot ${this.$id} 连接成功`);
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
- // 将消息段转换为 KOOK 格式
587
- const elements: MessageElement[] = Array.isArray(content)
588
- ? content.map(el => typeof el === 'string' ? { type: 'text' as const, data: { text: el } } : el)
589
- : [typeof content === 'string' ? { type: 'text' as const, data: { text: content } } : content];
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
- return result?.msg_id || "";
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(messageId: string): Promise<void> {
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 (this as any).deleteMsg(messageId);
614
- this.pluginLogger.debug(`KOOK Bot ${this.$id} 撤回消息: ${messageId}`);
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
  // 图片:![alt](url)
635
847
  return `![${el.data.alt || '图片'}](${el.data.url || el.data.file})`;
636
848
 
637
- case "at":
638
- // @提及:(met)userId(met) @all
639
- if (el.data.id === "all") {
640
- return "(met)all(met)";
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)${el.data.id}(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
+ }