koishi-plugin-chat-patch 3.0.2 → 5.0.1

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 (237) hide show
  1. package/client/vue/index.vue +60 -634
  2. package/client/web/dist/assets/AppIcon-DIku6-h0.png +0 -0
  3. package/client/web/dist/assets/AppIconDark-BYymN71t.png +0 -0
  4. package/client/web/dist/assets/CapacitorAppIcon-CSZwNhvi.png +0 -0
  5. package/client/web/dist/assets/CapacitorAppIconDark-BMvMOzsg.png +0 -0
  6. package/client/web/dist/assets/Chat-CTdyeaX2.css +1 -0
  7. package/client/web/dist/assets/Chat-DkZvD7d8.js +28 -0
  8. package/client/web/dist/assets/MealHungryPan-Beos3lRI.js +1 -0
  9. package/client/web/dist/assets/MsgBody-aZ4_aija.js +1 -0
  10. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CPZjRLp6.js +67 -0
  11. package/client/web/dist/assets/NapcatAppIcon-C7VOAkW9.png +0 -0
  12. package/client/web/dist/assets/Vue-lEhHouxB.png +0 -0
  13. package/client/web/dist/assets/append_darwin-CqBNU62J.css +1 -0
  14. package/client/web/dist/assets/append_full_vibrancy-j0D2LM9m.css +1 -0
  15. package/client/web/dist/assets/append_linux-uhxeBqeC.css +1 -0
  16. package/client/web/dist/assets/append_linux_vibrancy-COiZq8MS.css +1 -0
  17. package/client/web/dist/assets/append_napcat-DV8CAyAd.css +1 -0
  18. package/client/web/dist/assets/append_new-407Lo5_V.css +1 -0
  19. package/client/web/dist/assets/append_vibrancy-6FHctkOY.css +1 -0
  20. package/client/web/dist/assets/append_win32-srmD-78y.css +1 -0
  21. package/client/web/dist/assets/cakes-CxTVKdnK.svg +189 -0
  22. package/client/web/dist/assets/core-DhEqZVGG.js +1 -0
  23. package/client/web/dist/assets/event-Cadp6m6J.js +1 -0
  24. package/client/web/dist/assets/index-CPTXiemn.js +4045 -0
  25. package/client/web/dist/assets/index-Cg_esKB0.js +1 -0
  26. package/client/web/dist/assets/index-Cln012sP.css +1 -0
  27. package/client/web/dist/assets//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223-CJKIKBn3.ttf +0 -0
  28. package/client/web/dist/assets//346/234/237/345/276/205-C90_OY60.png +0 -0
  29. package/client/web/dist/bcui/css/color-dark.css +35 -0
  30. package/client/web/dist/bcui/css/color-light.css +35 -0
  31. package/client/web/dist/bcui/css/style.css +468 -0
  32. package/client/web/dist/bcui/js/main.js +193 -0
  33. package/client/web/dist/css/append-dark.css +13 -0
  34. package/client/web/dist/css/append-light.css +13 -0
  35. package/client/web/dist/css/append-napcat-dark.css +14 -0
  36. package/client/web/dist/css/append-napcat-light.css +14 -0
  37. package/client/web/dist/favicon.ico +0 -0
  38. package/client/web/dist/img/icons/icon-for-fuck-apple.png +0 -0
  39. package/client/web/dist/img/icons/icon-maskable.png +0 -0
  40. package/client/web/dist/img/icons/icon.png +0 -0
  41. package/client/web/dist/img/icons/icon.svg +10 -0
  42. package/client/web/dist/index.html +22 -0
  43. package/client/web/dist/robots.txt +2 -0
  44. package/client/web/dist/sw.js +58 -0
  45. package/client/web/env.d.ts +56 -0
  46. package/client/web/index.html +21 -0
  47. package/client/web/package-lock.json +3419 -0
  48. package/client/web/package.json +62 -0
  49. package/client/web/public/bcui/css/color-dark.css +35 -0
  50. package/client/web/public/bcui/css/color-light.css +35 -0
  51. package/client/web/public/bcui/css/style.css +468 -0
  52. package/client/web/public/bcui/js/main.js +193 -0
  53. package/client/web/public/css/append-dark.css +13 -0
  54. package/client/web/public/css/append-light.css +13 -0
  55. package/client/web/public/css/append-napcat-dark.css +14 -0
  56. package/client/web/public/css/append-napcat-light.css +14 -0
  57. package/client/web/public/favicon.ico +0 -0
  58. package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
  59. package/client/web/public/img/icons/icon-maskable.png +0 -0
  60. package/client/web/public/img/icons/icon.png +0 -0
  61. package/client/web/public/img/icons/icon.svg +10 -0
  62. package/client/web/public/robots.txt +2 -0
  63. package/client/web/public/sw.js +58 -0
  64. package/client/web/scripts/copy-bcui.mjs +17 -0
  65. package/client/web/src/App.vue +1127 -0
  66. package/client/web/src/assets/css/append/append_darwin.css +68 -0
  67. package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
  68. package/client/web/src/assets/css/append/append_linux.css +125 -0
  69. package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
  70. package/client/web/src/assets/css/append/append_napcat.css +35 -0
  71. package/client/web/src/assets/css/append/append_new.css +843 -0
  72. package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
  73. package/client/web/src/assets/css/append/append_win32.css +109 -0
  74. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
  75. package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
  76. package/client/web/src/assets/css/chat.css +2104 -0
  77. package/client/web/src/assets/css/msg.css +941 -0
  78. package/client/web/src/assets/css/options.css +811 -0
  79. package/client/web/src/assets/css/sys_notice.css +62 -0
  80. package/client/web/src/assets/css/view.css +2105 -0
  81. package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
  82. package/client/web/src/assets/img/Vue.png +0 -0
  83. package/client/web/src/assets/img/cakes.svg +189 -0
  84. package/client/web/src/assets/img/icons/AppIcon.png +0 -0
  85. package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
  86. package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
  87. package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
  88. package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
  89. package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
  90. package/client/web/src/assets/img/icons/exit.png +0 -0
  91. package/client/web/src/assets/img/icons/pen.png +0 -0
  92. package/client/web/src/assets/img/stars.svg +89 -0
  93. package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
  94. package/client/web/src/assets/l10n/_About I10n.md +35 -0
  95. package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
  96. package/client/web/src/assets/l10n/zh-CN.po +1941 -0
  97. package/client/web/src/assets/logo.png +0 -0
  98. package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
  99. package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
  100. package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
  101. package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
  102. package/client/web/src/assets/qed.txt +1 -0
  103. package/client/web/src/components/AboutPan.vue +197 -0
  104. package/client/web/src/components/BulletinBody.vue +129 -0
  105. package/client/web/src/components/DepPan.vue +310 -0
  106. package/client/web/src/components/EmojiFace.vue +32 -0
  107. package/client/web/src/components/FacePan.vue +461 -0
  108. package/client/web/src/components/FileBody.vue +129 -0
  109. package/client/web/src/components/FileManager.vue +532 -0
  110. package/client/web/src/components/FriendBody.vue +54 -0
  111. package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
  112. package/client/web/src/components/History.vue +49 -0
  113. package/client/web/src/components/LazyLottie.vue +82 -0
  114. package/client/web/src/components/MergePan.vue +127 -0
  115. package/client/web/src/components/MsgBody.vue +1478 -0
  116. package/client/web/src/components/MusicPlayer.vue +549 -0
  117. package/client/web/src/components/NoticeBody.vue +168 -0
  118. package/client/web/src/components/QzonePermissionPan.vue +150 -0
  119. package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
  120. package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
  121. package/client/web/src/components/TinySessionBody.vue +51 -0
  122. package/client/web/src/components/UmamiInfoPan.vue +1549 -0
  123. package/client/web/src/components/UpdatePan.vue +263 -0
  124. package/client/web/src/components/ViewerCom.vue +1360 -0
  125. package/client/web/src/components/VoiceMsg.vue +626 -0
  126. package/client/web/src/components/WelPan.vue +894 -0
  127. package/client/web/src/components/msg-body/.gitkeep +0 -0
  128. package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
  129. package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
  130. package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
  131. package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
  132. package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
  133. package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
  134. package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
  135. package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
  136. package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
  137. package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
  138. package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
  139. package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
  140. package/client/web/src/components/notice-component/.gitkeep +0 -0
  141. package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
  142. package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
  143. package/client/web/src/components/tooltip/Tooltip.vue +50 -0
  144. package/client/web/src/components/tooltip/Tooltips.vue +14 -0
  145. package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
  146. package/client/web/src/function/base.ts +238 -0
  147. package/client/web/src/function/connect.ts +2042 -0
  148. package/client/web/src/function/debug.ts +10 -0
  149. package/client/web/src/function/elements/information.ts +141 -0
  150. package/client/web/src/function/elements/system.ts +101 -0
  151. package/client/web/src/function/elements/vueComp.ts +62 -0
  152. package/client/web/src/function/favicon.ts +76 -0
  153. package/client/web/src/function/input.ts +69 -0
  154. package/client/web/src/function/model/emoji.ts +127 -0
  155. package/client/web/src/function/model/img.ts +150 -0
  156. package/client/web/src/function/model/msg-body.ts +51 -0
  157. package/client/web/src/function/msg.ts +2674 -0
  158. package/client/web/src/function/notify.ts +225 -0
  159. package/client/web/src/function/option.ts +749 -0
  160. package/client/web/src/function/satori-model.ts +818 -0
  161. package/client/web/src/function/satori.ts +325 -0
  162. package/client/web/src/function/sender.ts +178 -0
  163. package/client/web/src/function/tooltip.ts +49 -0
  164. package/client/web/src/function/utils/appUtil.ts +2288 -0
  165. package/client/web/src/function/utils/avatarUtil.ts +31 -0
  166. package/client/web/src/function/utils/backgroundUtil.ts +110 -0
  167. package/client/web/src/function/utils/linkViewUtil.ts +101 -0
  168. package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
  169. package/client/web/src/function/utils/msgUtil.ts +1242 -0
  170. package/client/web/src/function/utils/pinyin.ts +141 -0
  171. package/client/web/src/function/utils/recordUtil.ts +187 -0
  172. package/client/web/src/function/utils/sessionUtil.ts +114 -0
  173. package/client/web/src/function/utils/systemUtil.ts +664 -0
  174. package/client/web/src/main.ts +91 -0
  175. package/client/web/src/pages/Chat.vue +3453 -0
  176. package/client/web/src/pages/Friends.vue +636 -0
  177. package/client/web/src/pages/Info.vue +717 -0
  178. package/client/web/src/pages/Messages.vue +875 -0
  179. package/client/web/src/pages/Options.vue +82 -0
  180. package/client/web/src/pages/Qzone.vue +1178 -0
  181. package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
  182. package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
  183. package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
  184. package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
  185. package/client/web/src/pages/options/OptAccount.vue +107 -0
  186. package/client/web/src/pages/options/OptAddon.vue +185 -0
  187. package/client/web/src/pages/options/OptDev.vue +704 -0
  188. package/client/web/src/pages/options/OptFunction.vue +403 -0
  189. package/client/web/src/pages/options/OptInfo.vue +165 -0
  190. package/client/web/src/pages/options/OptView.vue +846 -0
  191. package/client/web/src/runtime/backend.ts +310 -0
  192. package/client/web/src/state/auth.ts +14 -0
  193. package/client/web/src/state/chat.ts +36 -0
  194. package/client/web/src/state/connection.ts +20 -0
  195. package/client/web/src/state/contact.ts +37 -0
  196. package/client/web/src/state/qzone.ts +18 -0
  197. package/client/web/src/state/sessionHistory.ts +25 -0
  198. package/client/web/src/state/settings.ts +18 -0
  199. package/client/web/src/state/sticker.ts +10 -0
  200. package/client/web/src/state/ui.ts +56 -0
  201. package/client/web/tsconfig.json +27 -0
  202. package/client/web/tsconfig.tsbuildinfo +1 -0
  203. package/client/web/vite.config.ts +33 -0
  204. package/dist/index.js +1 -12
  205. package/dist/style.css +1 -1
  206. package/lib/index.js +2070 -2077
  207. package/package.json +15 -2
  208. package/readme.md +0 -22
  209. package/src/bootstrap.ts +71 -0
  210. package/src/cache.ts +319 -0
  211. package/src/config.ts +23 -35
  212. package/src/database.ts +650 -0
  213. package/src/index.ts +27 -59
  214. package/src/media.ts +302 -0
  215. package/src/recorder.ts +175 -0
  216. package/src/self-message.ts +456 -0
  217. package/src/server.d.ts +26 -0
  218. package/src/types.ts +108 -48
  219. package/src/web.ts +712 -0
  220. package/client/vue/chat-logic.ts +0 -683
  221. package/client/vue/composables/useChatActions.ts +0 -61
  222. package/client/vue/composables/useChatData.ts +0 -185
  223. package/client/vue/composables/useImageCache.ts +0 -281
  224. package/client/vue/composables/useVideoCache.ts +0 -125
  225. package/client/vue/types.ts +0 -72
  226. package/lib/api-handlers.d.ts +0 -25
  227. package/lib/config.d.ts +0 -14
  228. package/lib/file-manager.d.ts +0 -124
  229. package/lib/index.d.ts +0 -17
  230. package/lib/logger.d.ts +0 -9
  231. package/lib/message-handler.d.ts +0 -35
  232. package/lib/types.d.ts +0 -57
  233. package/lib/utils.d.ts +0 -18
  234. package/src/api-handlers.ts +0 -646
  235. package/src/file-manager.ts +0 -1436
  236. package/src/message-handler.ts +0 -507
  237. package/src/utils.ts +0 -189
package/lib/index.js CHANGED
@@ -39,2303 +39,2296 @@ __export(src_exports, {
39
39
  usage: () => usage
40
40
  });
41
41
  module.exports = __toCommonJS(src_exports);
42
- var import_node_path5 = __toESM(require("node:path"));
42
+ var import_node_path4 = __toESM(require("node:path"));
43
43
 
44
- // src/utils.ts
45
- var import_node_path = require("node:path");
46
- var import_node_crypto = require("node:crypto");
47
- var import_node_url = require("node:url");
48
- var import_node_fs = require("node:fs");
49
- var _Utils = class _Utils {
50
- constructor(config, ctx) {
51
- this.config = config;
52
- this.ctx = ctx;
53
- this.persistImageWriteCount = 0;
54
- this.pendingTasks = /* @__PURE__ */ new Set();
55
- }
56
- isPlatformBlocked(platform) {
57
- if (!this.config.blockedPlatforms || this.config.blockedPlatforms.length === 0) {
58
- return false;
59
- }
60
- for (const blockedPlatform of this.config.blockedPlatforms) {
61
- if (blockedPlatform.exactMatch) {
62
- if (platform === blockedPlatform.platformName) {
63
- return true;
64
- }
65
- } else {
66
- if (platform.includes(blockedPlatform.platformName)) {
67
- return true;
68
- }
69
- }
70
- }
71
- return false;
72
- }
73
- extractTextContent(elements) {
74
- let text = "";
75
- for (const element of elements) {
76
- if (element.type === "text") {
77
- text += element.attrs?.content || "";
78
- } else if (element.type === "p") {
79
- if (element.children && element.children.length > 0) {
80
- text += this.extractTextContent(element.children) + "\n";
81
- }
82
- } else if (element.children && element.children.length > 0) {
83
- text += this.extractTextContent(element.children);
44
+ // src/logger.ts
45
+ function createPluginLogger(logger, config) {
46
+ return {
47
+ logInfo(...args) {
48
+ if (config.loggerinfo) {
49
+ Reflect.apply(logger.info, logger, args);
84
50
  }
51
+ },
52
+ info(...args) {
53
+ Reflect.apply(logger.info, logger, args);
54
+ },
55
+ warn(...args) {
56
+ Reflect.apply(logger.warn, logger, args);
57
+ },
58
+ error(...args) {
59
+ Reflect.apply(logger.error, logger, args);
85
60
  }
86
- return text;
87
- }
88
- isBase64(str) {
89
- if (!str || typeof str !== "string") return false;
90
- if (str.startsWith("data:")) {
91
- return str.includes("base64,");
92
- }
93
- if (str.length > 100) {
94
- const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
95
- return base64Regex.test(str);
96
- }
97
- return false;
98
- }
99
- async persistBase64ImageAsync(base64Data) {
100
- if (!this.ctx || !base64Data.startsWith("data:image/")) return base64Data;
101
- try {
102
- const dir = (0, import_node_path.join)(this.ctx.baseDir, "data", "chat-patch", "persist-images");
103
- await import_node_fs.promises.mkdir(dir, { recursive: true });
104
- const hash = (0, import_node_crypto.createHash)("md5").update(base64Data).digest("hex");
105
- const ext = base64Data.split(";")[0].split("/")[1] || "png";
106
- const filename = `${hash}.${ext}`;
107
- const filePath = (0, import_node_path.join)(dir, filename);
108
- if (!await this.fileExists(filePath)) {
109
- const base64Content = base64Data.split(",")[1];
110
- await import_node_fs.promises.writeFile(filePath, Buffer.from(base64Content, "base64"));
111
- }
112
- this.persistImageWriteCount += 1;
113
- if (this.persistImageWriteCount % 20 === 0) {
114
- this.trackTask(this.cleanupPersistImagesAsync(dir));
61
+ };
62
+ }
63
+ __name(createPluginLogger, "createPluginLogger");
64
+
65
+ // src/cache.ts
66
+ function getString(value) {
67
+ return typeof value === "string" ? value : "";
68
+ }
69
+ __name(getString, "getString");
70
+ function isUsableName(value, id) {
71
+ return Boolean(value) && value !== id && !/unknown user|unknown guild|unknown channel/i.test(value);
72
+ }
73
+ __name(isUsableName, "isUsableName");
74
+ function normalizeGroupId(value) {
75
+ const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, "").trim();
76
+ const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/);
77
+ return wrapped ? wrapped[1] : raw || value;
78
+ }
79
+ __name(normalizeGroupId, "normalizeGroupId");
80
+ function isPrivateChannelType(value) {
81
+ const num = Number(value);
82
+ if (Number.isFinite(num)) return num === 1;
83
+ const text = String(value ?? "").toLowerCase();
84
+ return text === "direct" || text === "private";
85
+ }
86
+ __name(isPrivateChannelType, "isPrivateChannelType");
87
+ function normalizeCacheType(type) {
88
+ if (type === "user" || type === "friend") return "friend";
89
+ return "group";
90
+ }
91
+ __name(normalizeCacheType, "normalizeCacheType");
92
+ function encodeKeyPart(value) {
93
+ return encodeURIComponent(value);
94
+ }
95
+ __name(encodeKeyPart, "encodeKeyPart");
96
+ var _ContactCacheService = class _ContactCacheService {
97
+ constructor(ctx, database, logger) {
98
+ this.ctx = ctx;
99
+ this.database = database;
100
+ this.logger = logger;
101
+ this.inflight = /* @__PURE__ */ new Map();
102
+ }
103
+ // 查询单用户缓存;未命中时由后端调用 bot.getUser,并写入数据库
104
+ async getUser(platform, selfId, userId, guildId, channelId, fallbackName, fallbackAvatar) {
105
+ if (!userId || userId === "0") return null;
106
+ const guild = guildId && guildId !== "0" ? guildId : void 0;
107
+ const channel = channelId && channelId !== "0" ? channelId : void 0;
108
+ const item = await this.resolve(platform, selfId, "user", userId, async (bot) => {
109
+ let user = null;
110
+ let member = null;
111
+ try {
112
+ user = await bot.getUser(userId, guild);
113
+ } catch (error) {
114
+ this.logger.logInfo("用户身份回源失败:", userId, error);
115
115
  }
116
- return (0, import_node_url.pathToFileURL)(filePath).href;
117
- } catch {
118
- return base64Data;
119
- }
120
- }
121
- async cleanupPersistImagesAsync(dir) {
122
- try {
123
- const fileNames = await import_node_fs.promises.readdir(dir);
124
- const files = await Promise.all(fileNames.map(async (name2) => {
125
- const filePath = (0, import_node_path.join)(dir, name2);
126
- const stats = await import_node_fs.promises.stat(filePath);
127
- return { path: filePath, mtime: stats.mtimeMs };
128
- }));
129
- files.sort((a, b) => b.mtime - a.mtime);
130
- for (const file of files.slice(this.config.maxPersistImages)) {
116
+ let name2 = getString(user?.name) || getString(user?.nick) || getString(user?.username) || getString(user?.nickname);
117
+ let avatar = getString(user?.avatar);
118
+ if (guild && (!name2 || !avatar)) {
131
119
  try {
132
- await import_node_fs.promises.unlink(file.path);
133
- } catch {
134
- continue;
120
+ member = await bot.getGuildMember(guild, userId);
121
+ } catch (error) {
122
+ this.logger.logInfo("群成员身份回源失败:", userId, error);
135
123
  }
124
+ name2 = name2 || getString(member?.name) || getString(member?.nick) || getString(member?.user?.name) || getString(member?.user?.nick);
125
+ avatar = avatar || getString(member?.avatar) || getString(member?.user?.avatar);
136
126
  }
137
- } catch {
138
- }
139
- }
140
- async cleanBase64ContentAsync(obj, isBotMessage = false) {
141
- if (obj === null || obj === void 0) {
142
- return obj;
143
- }
144
- if (typeof obj === "string") {
145
- if (this.isBase64(obj)) {
146
- if (isBotMessage && !obj.startsWith("data:image/")) {
147
- return "[富媒体内容已省略]";
148
- }
149
- return await this.persistBase64ImageAsync(obj);
127
+ const eventName = getString(fallbackName);
128
+ const eventAvatar = getString(fallbackAvatar);
129
+ if (!isUsableName(name2, userId) && isUsableName(eventName, userId)) {
130
+ name2 = eventName;
150
131
  }
151
- return obj;
152
- }
153
- if (Array.isArray(obj)) {
154
- const cleanedItems = await Promise.all(obj.map((item) => this.cleanBase64ContentAsync(item, isBotMessage)));
155
- return cleanedItems;
156
- }
157
- if (typeof obj === "object") {
158
- const cleaned = {};
159
- const source = obj;
160
- for (const [key, value] of Object.entries(source)) {
161
- const type = typeof source.type === "string" ? source.type : void 0;
162
- if (isBotMessage && type && !["text", "image", "img"].includes(type)) {
163
- if (typeof value === "string" && (key === "src" || key === "url" || key === "file") && this.isBase64(value)) {
164
- cleaned[key] = "[富媒体内容已省略]";
165
- continue;
166
- }
132
+ if (!avatar && eventAvatar) avatar = eventAvatar;
133
+ if (!name2 && !avatar) return null;
134
+ this.logger.logInfo("用户身份已回源并写入缓存:", userId, name2);
135
+ return {
136
+ id: userId,
137
+ name: name2 || userId,
138
+ avatar: avatar || void 0,
139
+ raw: {
140
+ id: userId,
141
+ user_id: userId,
142
+ name: name2,
143
+ nickname: name2,
144
+ avatar: avatar || void 0,
145
+ channel_id: channel || void 0,
146
+ guild_id: guild || void 0
167
147
  }
168
- if (typeof value === "string" && (key === "src" || key === "url" || key === "file" || key === "data" || key === "content") && this.isBase64(value)) {
169
- if (isBotMessage && !value.startsWith("data:image/")) {
170
- cleaned[key] = "[富媒体内容已省略]";
171
- } else {
172
- cleaned[key] = await this.persistBase64ImageAsync(value);
173
- }
174
- } else {
175
- cleaned[key] = await this.cleanBase64ContentAsync(value, isBotMessage);
148
+ };
149
+ }, {
150
+ name: fallbackName,
151
+ avatar: fallbackAvatar
152
+ });
153
+ if (!item) return null;
154
+ const needChannel = Boolean(channel && item.channelId !== channel);
155
+ const needGuild = Boolean(guild && item.guildId !== guild);
156
+ if (channel) item.channelId = channel;
157
+ if (guild) item.guildId = guild;
158
+ if (needChannel || needGuild) {
159
+ if (item.raw && typeof item.raw === "object") {
160
+ const raw = item.raw;
161
+ if (channel) raw.channel_id = channel;
162
+ if (guild) raw.guild_id = guild;
163
+ }
164
+ await this.database.appendContact(platform, selfId, "friend", item);
165
+ }
166
+ return item;
167
+ }
168
+ // 查询群/频道缓存;未命中时由后端调用 guild.get/channel.get,并写入数据库
169
+ async getGroup(platform, selfId, id, guildId, channelId, fallbackName, fallbackAvatar, channelType) {
170
+ const guild = guildId && guildId !== "0" ? guildId : void 0;
171
+ const channel = channelId && channelId !== "0" ? channelId : void 0;
172
+ if (isPrivateChannelType(channelType)) return null;
173
+ if (channelType === void 0 && (/^(?:private|direct):/i.test(id) || /^(?:private|direct):/i.test(channel || ""))) return null;
174
+ const rawId = id && id !== "0" ? normalizeGroupId(id) : "";
175
+ const cacheId = rawId || guild || normalizeGroupId(channel || "") || "";
176
+ if (!cacheId) return null;
177
+ const item = await this.resolve(platform, selfId, "group", cacheId, async (bot) => {
178
+ const [guildData, channelData] = await Promise.all([
179
+ guild ? this.safeGet(() => bot.getGuild(guild)) : cacheId ? this.safeGet(() => bot.getGuild(cacheId)) : Promise.resolve(null),
180
+ channel ? this.safeGet(() => bot.getChannel(channel, guild)) : Promise.resolve(null)
181
+ ]);
182
+ let name2 = getString(guildData?.name) || getString(channelData?.name);
183
+ let avatar = getString(guildData?.avatar) || getString(channelData?.avatar);
184
+ const eventName = getString(fallbackName);
185
+ const eventAvatar = getString(fallbackAvatar);
186
+ if (!isUsableName(name2, cacheId) && isUsableName(eventName, cacheId)) {
187
+ name2 = eventName;
188
+ }
189
+ if (!avatar && eventAvatar) avatar = eventAvatar;
190
+ if (!name2 && !avatar) return null;
191
+ this.logger.logInfo("群组身份已回源并写入缓存:", cacheId, name2);
192
+ return {
193
+ id: cacheId,
194
+ name: name2 || cacheId,
195
+ avatar: avatar || void 0,
196
+ raw: {
197
+ id: cacheId,
198
+ group_id: cacheId,
199
+ name: name2,
200
+ group_name: name2,
201
+ avatar: avatar || void 0,
202
+ channel_id: channel || void 0,
203
+ channel_type: channelType ?? void 0,
204
+ guild_id: guild || void 0
176
205
  }
177
- }
178
- return cleaned;
179
- }
180
- return obj;
181
- }
182
- async dispose() {
183
- await Promise.allSettled([...this.pendingTasks]);
184
- }
185
- trackTask(task) {
186
- this.pendingTasks.add(task);
187
- void task.finally(() => {
188
- this.pendingTasks.delete(task);
206
+ };
207
+ }, {
208
+ name: fallbackName,
209
+ avatar: fallbackAvatar
189
210
  });
211
+ if (!item) return null;
212
+ const needChannel = Boolean(channel && item.channelId !== channel);
213
+ const needGuild = Boolean(guild && item.guildId !== guild);
214
+ if (channel) item.channelId = channel;
215
+ if (guild) item.guildId = guild;
216
+ if (needChannel || needGuild) {
217
+ if (item.raw && typeof item.raw === "object") {
218
+ const raw = item.raw;
219
+ if (channel) raw.channel_id = channel;
220
+ if (guild) raw.guild_id = guild;
221
+ }
222
+ await this.database.appendContact(platform, selfId, "group", item);
223
+ }
224
+ return item;
225
+ }
226
+ // 收到群消息时更新该群对应的群友表
227
+ async recordGroupMember(platform, selfId, groupId, member) {
228
+ if (!platform || !selfId || !groupId || !member.id) return;
229
+ await this.database.appendGroupMember(platform, selfId, groupId, member);
230
+ }
231
+ // 同一身份的并发请求合并,避免首条消息同时触发多个回源
232
+ resolve(platform, selfId, type, id, fetcher, fallback) {
233
+ const canonical = normalizeCacheType(type);
234
+ const key = [platform, selfId, canonical, id].map(encodeKeyPart).join(":");
235
+ const existing = this.inflight.get(key);
236
+ if (existing) return existing;
237
+ const task = this.resolveUncached(platform, selfId, canonical, id, fetcher, fallback).finally(() => this.inflight.delete(key));
238
+ this.inflight.set(key, task);
239
+ return task;
240
+ }
241
+ async resolveUncached(platform, selfId, type, id, fetcher, fallback) {
242
+ const cached = await this.database.getContact(platform, selfId, type, id);
243
+ if (cached && !this.shouldRefreshWithFallback(cached, id, fallback)) {
244
+ return this.publicContact(cached);
245
+ }
246
+ const bot = this.ctx.bots.find((item2) => {
247
+ return item2.platform === platform && item2.selfId === selfId;
248
+ });
249
+ if (!bot) {
250
+ const fallbackItem = this.toFallbackContact(id, fallback);
251
+ if (fallbackItem) {
252
+ await this.database.appendContact(platform, selfId, type, fallbackItem);
253
+ return this.publicContact(fallbackItem);
254
+ }
255
+ return null;
256
+ }
257
+ const item = await fetcher(bot);
258
+ if (!item) return null;
259
+ await this.database.appendContact(platform, selfId, type, item);
260
+ return this.publicContact(item);
261
+ }
262
+ shouldRefreshWithFallback(cached, id, fallback) {
263
+ if (!fallback) return false;
264
+ const cachedNameUsable = isUsableName(cached.name, id);
265
+ const fallbackNameUsable = isUsableName(getString(fallback.name), id);
266
+ const hasCachedAvatar = Boolean(cached.avatar);
267
+ const hasFallbackAvatar = Boolean(getString(fallback.avatar));
268
+ return !cachedNameUsable && fallbackNameUsable || !hasCachedAvatar && hasFallbackAvatar;
269
+ }
270
+ toFallbackContact(id, fallback) {
271
+ const name2 = isUsableName(getString(fallback?.name), id) ? getString(fallback.name) : "";
272
+ const avatar = getString(fallback?.avatar);
273
+ if (!name2 && !avatar) return null;
274
+ return {
275
+ id,
276
+ name: name2 || id,
277
+ avatar: avatar || void 0
278
+ };
190
279
  }
191
- async fileExists(filePath) {
280
+ async safeGet(getter) {
192
281
  try {
193
- await import_node_fs.promises.access(filePath);
194
- return true;
195
- } catch {
196
- return false;
282
+ return await getter();
283
+ } catch (error) {
284
+ this.logger.logInfo("群组身份回源失败:", error);
285
+ return null;
197
286
  }
198
287
  }
288
+ publicContact(item) {
289
+ return {
290
+ id: item.id,
291
+ name: item.name,
292
+ avatar: item.avatar || void 0,
293
+ channelId: item.channelId || void 0,
294
+ guildId: item.guildId || void 0,
295
+ raw: item.raw ?? void 0
296
+ };
297
+ }
199
298
  };
200
- __name(_Utils, "Utils");
201
- var Utils = _Utils;
299
+ __name(_ContactCacheService, "ContactCacheService");
300
+ var ContactCacheService = _ContactCacheService;
202
301
 
203
- // src/message-handler.ts
204
- var import_node_fs2 = require("node:fs");
205
- var import_node_path2 = __toESM(require("node:path"));
206
- var import_node_crypto2 = require("node:crypto");
207
- var _MessageHandler = class _MessageHandler {
208
- constructor(ctx, config, fileManager, logger) {
302
+ // src/database.ts
303
+ var import_level = require("level");
304
+ var import_node_path = __toESM(require("node:path"));
305
+ var import_node_crypto = require("node:crypto");
306
+ function encodeKeyPart2(value) {
307
+ return encodeURIComponent(value);
308
+ }
309
+ __name(encodeKeyPart2, "encodeKeyPart");
310
+ function decodeKeyPart(value) {
311
+ try {
312
+ return decodeURIComponent(value);
313
+ } catch {
314
+ return value;
315
+ }
316
+ }
317
+ __name(decodeKeyPart, "decodeKeyPart");
318
+ function messagePrefix(platform, selfId, channelId) {
319
+ return `m:${encodeKeyPart2(platform)}:${encodeKeyPart2(selfId)}:${encodeKeyPart2(channelId)}:`;
320
+ }
321
+ __name(messagePrefix, "messagePrefix");
322
+ function legacyMessagePrefix(platform, selfId, channelId) {
323
+ return `m:${platform}:${selfId}:${channelId}:`;
324
+ }
325
+ __name(legacyMessagePrefix, "legacyMessagePrefix");
326
+ function messageKey(record) {
327
+ const time = String(record.timestamp).padStart(16, "0");
328
+ return `${messagePrefix(record.platform, record.selfId, record.channelId || "")}${time}:${encodeKeyPart2(record.id || "unknown")}`;
329
+ }
330
+ __name(messageKey, "messageKey");
331
+ function selfMessagePrefix(platform, selfId, channelId) {
332
+ return `sm:${encodeKeyPart2(platform)}:${encodeKeyPart2(selfId)}:${encodeKeyPart2(channelId)}:`;
333
+ }
334
+ __name(selfMessagePrefix, "selfMessagePrefix");
335
+ function selfMessageKey(record) {
336
+ const time = String(record.sentAt).padStart(16, "0");
337
+ return `${selfMessagePrefix(record.platform, record.selfId, record.channelId)}${time}:${encodeKeyPart2(record.id || "unknown")}`;
338
+ }
339
+ __name(selfMessageKey, "selfMessageKey");
340
+ function contactKey(platform, selfId, type) {
341
+ return `c:${encodeKeyPart2(platform)}:${encodeKeyPart2(selfId)}:${encodeKeyPart2(type)}`;
342
+ }
343
+ __name(contactKey, "contactKey");
344
+ function legacyContactKey(platform, selfId, type) {
345
+ return `c:${platform}:${selfId}:${type}`;
346
+ }
347
+ __name(legacyContactKey, "legacyContactKey");
348
+ function groupMemberKey(platform, selfId, groupId) {
349
+ return `gm:${encodeKeyPart2(platform)}:${encodeKeyPart2(selfId)}:${encodeKeyPart2(groupId)}`;
350
+ }
351
+ __name(groupMemberKey, "groupMemberKey");
352
+ function legacyGroupMemberKey(platform, selfId, groupId) {
353
+ return `gm:${platform}:${selfId}:${groupId}`;
354
+ }
355
+ __name(legacyGroupMemberKey, "legacyGroupMemberKey");
356
+ function isPrivateChannelId(value) {
357
+ return typeof value === "string" && /^(?:private|direct):/i.test(value);
358
+ }
359
+ __name(isPrivateChannelId, "isPrivateChannelId");
360
+ function isPrivateChannelType2(value) {
361
+ const num = Number(value);
362
+ if (Number.isFinite(num)) return num === 1;
363
+ const text = String(value ?? "").toLowerCase();
364
+ return text === "direct" || text === "private";
365
+ }
366
+ __name(isPrivateChannelType2, "isPrivateChannelType");
367
+ function isUsableGroupContact(item) {
368
+ if (isPrivateChannelId(item.channelId)) return false;
369
+ if (typeof item.raw === "object" && item.raw !== null) {
370
+ const raw = item.raw;
371
+ if (isPrivateChannelType2(raw.channel_type) || isPrivateChannelId(raw.channel_id) || isPrivateChannelId(raw.channelId)) return false;
372
+ }
373
+ return true;
374
+ }
375
+ __name(isUsableGroupContact, "isUsableGroupContact");
376
+ var _ChatDatabase = class _ChatDatabase {
377
+ constructor(ctx, config, logger) {
209
378
  this.ctx = ctx;
210
379
  this.config = config;
211
- this.fileManager = fileManager;
212
380
  this.logger = logger;
213
- this.correctChannelIds = /* @__PURE__ */ new Map();
214
- this.scheduledTasks = /* @__PURE__ */ new Set();
215
- this.channelRefreshInFlight = /* @__PURE__ */ new Set();
216
- this.lastChannelRefreshAt = /* @__PURE__ */ new Map();
217
- this.CHANNEL_REFRESH_TTL_MS = 10 * 60 * 1e3;
218
- this.utils = new Utils(config);
219
- }
220
- recordUserMessage(session, timestamp) {
221
- this.scheduleTask("记录用户消息", async () => {
222
- await this.processUserMessage(session, timestamp);
223
- });
381
+ this.opened = false;
382
+ const dir = import_node_path.default.resolve(ctx.baseDir, "data", "chat-patch", "db");
383
+ this.db = new import_level.Level(dir, { keyEncoding: "utf8", valueEncoding: "utf8" });
224
384
  }
225
- recordBotMessage(session, timestamp) {
226
- this.scheduleTask("记录机器人消息", async () => {
227
- await this.processBotMessage(session, timestamp);
228
- });
229
- }
230
- setCorrectChannelId(selfId, channelId) {
231
- this.correctChannelIds.set(selfId, channelId);
232
- this.logger.logInfo("设置正确的 channelId:", { selfId, channelId });
233
- }
234
- getCorrectChannelId(selfId) {
235
- return this.correctChannelIds.get(selfId);
236
- }
237
- updateBotInfoToFile(session) {
238
- this.scheduleTask("更新机器人信息", async () => {
239
- const botInfo = {
240
- selfId: session.selfId,
241
- platform: session.platform || "unknown",
242
- username: session.bot.user?.name || `Bot-${session.selfId}`,
243
- avatar: session.bot.user?.avatar,
244
- status: "online"
245
- };
246
- await this.fileManager.upsertBotInfo(botInfo);
247
- this.logger.logInfo("更新机器人信息到文件:", botInfo.username);
248
- });
385
+ async initialize() {
386
+ await this.db.open();
387
+ this.opened = true;
388
+ this.logger.logInfo("LevelDB 已打开:", this.db.location);
249
389
  }
250
- updateChannelInfoToFile(session) {
251
- const isDirect = session.isDirect || session.channelId?.includes("private");
252
- const directUserName = session.username || session.event?.user?.name || session.userId;
253
- const existingChannel = this.fileManager.getCachedChannelInfo(session.selfId, session.channelId);
254
- let immediateName = session.channelId;
255
- if (isDirect) {
256
- if (directUserName && directUserName !== session.userId) {
257
- immediateName = `私聊(${directUserName})`;
258
- } else if (existingChannel?.name && !existingChannel.name.includes("未知")) {
259
- immediateName = existingChannel.name;
260
- } else if (session.platform && session.platform.toLowerCase().includes("sandbox")) {
261
- immediateName = `私聊(${session.userId})`;
262
- } else {
263
- immediateName = "私聊(未知用户)";
264
- }
265
- } else if (existingChannel?.guildName) {
266
- immediateName = existingChannel.guildName;
267
- }
268
- const channelKey = `${session.selfId}:${session.channelId}`;
269
- if (this.shouldRefreshChannelInfo(channelKey, existingChannel, isDirect, session.channelId, directUserName)) {
270
- this.scheduleTask("更新频道信息", async () => {
271
- await this.refreshChannelInfo(session, existingChannel, isDirect, directUserName);
272
- });
390
+ async dispose() {
391
+ if (!this.opened) return;
392
+ await this.db.close();
393
+ this.opened = false;
394
+ this.logger.logInfo("LevelDB closed");
395
+ }
396
+ async appendMessage(record) {
397
+ await this.db.put(messageKey(record), JSON.stringify(record));
398
+ await this.trimMessages(record.platform, record.selfId, record.channelId || "");
399
+ }
400
+ async upsertSelfMessage(record) {
401
+ if (record.messageId) {
402
+ await this.removeSelfMessageByMessageId(
403
+ record.platform,
404
+ record.selfId,
405
+ record.channelId,
406
+ record.messageId,
407
+ record.id
408
+ );
409
+ }
410
+ await this.db.put(selfMessageKey(record), JSON.stringify(record));
411
+ await this.trimSelfMessages(record.platform, record.selfId, record.channelId);
412
+ }
413
+ async listSelfMessages(platform, selfId, channelId, limit = this.config.historyPageSize) {
414
+ const result = [];
415
+ const prefix = selfMessagePrefix(platform, selfId, channelId);
416
+ for await (const [, value] of this.db.iterator({
417
+ gte: prefix,
418
+ lte: `${prefix}￿`,
419
+ reverse: true,
420
+ limit
421
+ })) {
422
+ const parsed = this.parseSelfMessage(value);
423
+ if (parsed) result.push(parsed);
273
424
  }
274
- return immediateName;
425
+ return result;
275
426
  }
276
- async downloadAndCacheMedia(url, type) {
277
- try {
278
- if (!url || url.startsWith("data:")) return url;
279
- if (url.includes("/vite/@fs/")) return url;
280
- let folder = "media";
281
- if (type === "image") folder = "images";
282
- else if (type === "avatar") folder = "avatars";
283
- const dir = import_node_path2.default.join(this.ctx.baseDir, "data", "chat-patch", "persist-media", folder);
284
- await import_node_fs2.promises.mkdir(dir, { recursive: true });
285
- const hash = (0, import_node_crypto2.createHash)("md5").update(url).digest("hex");
286
- const ext = import_node_path2.default.extname(new URL(url).pathname) || (type === "image" ? ".jpg" : ".mp4");
287
- const filename = `${hash}${ext}`;
288
- const filePath = import_node_path2.default.join(dir, filename);
289
- if (!await this.fileExists(filePath)) {
290
- const buffer = await this.ctx.http.get(url, { responseType: "arraybuffer" });
291
- await import_node_fs2.promises.writeFile(filePath, Buffer.from(buffer));
292
- }
293
- const normalizedPath = filePath.replace(/\\/g, "/");
294
- return `/vite/@fs/${normalizedPath}`;
295
- } catch (e) {
296
- this.logger.warn("下载并缓存媒体失败:", e);
297
- return url;
427
+ async listSelfMessagesBefore(platform, selfId, channelId, beforeTime, limit = this.config.historyPageSize) {
428
+ const result = [];
429
+ const prefix = selfMessagePrefix(platform, selfId, channelId);
430
+ const before = `${prefix}${String(beforeTime).padStart(16, "0")}`;
431
+ for await (const [, value] of this.db.iterator({
432
+ gte: prefix,
433
+ lt: before,
434
+ reverse: true,
435
+ limit
436
+ })) {
437
+ const parsed = this.parseSelfMessage(value);
438
+ if (parsed) result.push(parsed);
298
439
  }
440
+ return result;
299
441
  }
300
- processMediaElementsAsync(elements, isUserMessage = true) {
301
- if (!elements) return;
302
- this.scheduleTask("处理媒体元素", async () => {
303
- await this.processMediaElements(elements, isUserMessage);
304
- });
305
- }
306
- async processUserMessage(session, timestamp) {
307
- try {
308
- if (!timestamp) timestamp = Date.now();
309
- this.updateBotInfoToFile(session);
310
- const guildName = this.updateChannelInfoToFile(session);
311
- const isDirect = session.isDirect || session.channelId?.includes("private");
312
- if (session.elements) {
313
- this.processMediaElementsAsync(session.elements, true);
314
- }
315
- if (session.quote?.elements) {
316
- this.processMediaElementsAsync(session.quote.elements, true);
317
- }
318
- let quoteInfo = void 0;
319
- if (session.quote) {
320
- quoteInfo = {
321
- messageId: session.quote.messageId || session.quote.id,
322
- id: session.quote.id,
323
- content: session.quote.content || "",
324
- elements: session.quote.elements,
325
- user: {
326
- id: session.quote.user?.id || session.quote.user?.userId || "unknown",
327
- name: session.quote.user?.name || session.quote.user?.username || "unknown",
328
- userId: session.quote.user?.userId || session.quote.user?.id || "unknown",
329
- avatar: session.quote.user?.avatar,
330
- username: session.quote.user?.username || session.quote.user?.name || "unknown"
331
- },
332
- timestamp: session.quote.timestamp || Date.now()
333
- };
334
- }
335
- let content = "";
336
- let elements = [];
337
- if (session.content) {
338
- content = session.content;
339
- } else if (session.stripped?.content) {
340
- content = session.stripped.content;
341
- }
342
- if (session.elements) {
343
- elements = session.elements;
344
- if (!content) {
345
- content = session.elements.filter((element) => element.type === "text").map((element) => element.attrs?.content || "").join("");
346
- }
347
- }
348
- const messageInfo = {
349
- id: session.event?.message?.id || `msg-${timestamp}`,
350
- content: content || session.content || "",
351
- userId: session.userId || session.event?.user?.id || "unknown",
352
- username: session.username || session.event?.user?.name || session.userId || "unknown",
353
- avatar: session.event?.user?.avatar,
354
- timestamp,
355
- channelId: session.channelId,
356
- selfId: session.selfId,
357
- elements,
358
- type: "user",
359
- guildName,
360
- platform: session.platform || "unknown",
361
- quote: quoteInfo,
362
- isDirect: !!isDirect
442
+ async updateSelfMessageByMessageId(platform, selfId, _channelId, messageId, patch) {
443
+ const prefix = `sm:${encodeKeyPart2(platform)}:${encodeKeyPart2(selfId)}:`;
444
+ for await (const [key, value] of this.db.iterator({
445
+ gte: prefix,
446
+ lte: `${prefix}￿`
447
+ })) {
448
+ const parsed = this.parseSelfMessage(value);
449
+ if (!parsed || parsed.messageId !== messageId) continue;
450
+ const next = {
451
+ ...parsed,
452
+ ...patch,
453
+ id: parsed.id,
454
+ sentAt: parsed.sentAt
363
455
  };
364
- messageInfo.elements = await this.utils.cleanBase64ContentAsync(messageInfo.elements, false);
365
- messageInfo.quote = messageInfo.quote ? await this.utils.cleanBase64ContentAsync(messageInfo.quote, false) : void 0;
366
- await this.fileManager.addMessageToFile(messageInfo);
367
- const eventElements = await this.utils.cleanBase64ContentAsync(elements, false);
368
- const eventQuote = quoteInfo ? await this.utils.cleanBase64ContentAsync(quoteInfo, false) : void 0;
369
- const messageEvent = {
370
- type: "message",
371
- selfId: session.selfId,
372
- platform: session.platform || "unknown",
373
- channelId: session.channelId,
374
- messageId: session.event?.message?.id || `msg-${timestamp}`,
375
- content: content || session.content || "",
376
- userId: session.userId || session.event?.user?.id || "unknown",
377
- username: session.username || session.event?.user?.name || session.userId || "unknown",
378
- avatar: session.event?.user?.avatar,
379
- timestamp,
380
- guildName,
381
- channelType: session.type || 0,
382
- elements: eventElements,
383
- quote: eventQuote,
384
- isDirect: session.isDirect,
385
- bot: {
386
- avatar: session.bot.user?.avatar,
387
- name: session.bot.user?.name
388
- }
389
- };
390
- this.ctx.console.broadcast("chat-message-event", messageEvent);
391
- } catch (error) {
392
- this.logger.error("处理用户消息失败:", error);
456
+ await this.db.put(key, JSON.stringify(next));
457
+ return true;
393
458
  }
459
+ return false;
394
460
  }
395
- async processBotMessage(session, timestamp) {
396
- try {
397
- if (!timestamp) timestamp = Date.now();
398
- const correctChannelId = this.getCorrectChannelId(session.selfId);
399
- const finalChannelId = correctChannelId || session.channelId;
400
- this.updateBotInfoToFile(session);
401
- const guildName = this.updateChannelInfoToFile(session);
402
- const isDirect = session.isDirect || finalChannelId?.includes("private");
403
- let content = session.content || "";
404
- if (!content && session.event?.message?.elements) {
405
- content = this.utils.extractTextContent(session.event.message.elements).trim();
406
- }
407
- let quoteInfo = void 0;
408
- const quoteMatch = content.match(/<quote id="([^"]+)"\/>/);
409
- if (quoteMatch) {
410
- const quoteId = quoteMatch[1];
411
- const quotedMsg = await this.fileManager.findChannelMessageById(session.selfId, finalChannelId, quoteId);
412
- if (quotedMsg) {
413
- const realId = quotedMsg.id.startsWith("bot-msg-") ? quotedMsg.realId : quotedMsg.id;
414
- quoteInfo = {
415
- messageId: realId || quotedMsg.id,
416
- id: realId || quotedMsg.id,
417
- content: quotedMsg.content,
418
- elements: quotedMsg.elements,
419
- user: {
420
- id: quotedMsg.userId,
421
- name: quotedMsg.username,
422
- userId: quotedMsg.userId,
423
- avatar: quotedMsg.avatar,
424
- username: quotedMsg.username
425
- },
426
- timestamp: quotedMsg.timestamp
427
- };
428
- content = content.replace(/<quote id="[^"]+"\/>\s*/, "");
429
- if (realId) {
430
- session.content = session.content.replace(/id="[^"]+"/, `id="${realId}"`);
461
+ async findSelfForwardContent(platform, selfId, channelId, id) {
462
+ const prefixes = channelId ? [selfMessagePrefix(platform, selfId, channelId)] : [`sm:${encodeKeyPart2(platform)}:${encodeKeyPart2(selfId)}:`];
463
+ for (const prefix of prefixes) {
464
+ for await (const [, value] of this.db.iterator({
465
+ gte: prefix,
466
+ lte: `${prefix}￿`
467
+ })) {
468
+ const parsed = this.parseSelfMessage(value);
469
+ if (!parsed) continue;
470
+ if (parsed.forwardId === id || parsed.messageId === id) {
471
+ if (Array.isArray(parsed.forwardContent) && parsed.forwardContent.length > 0) {
472
+ return parsed.forwardContent;
431
473
  }
432
474
  }
433
475
  }
434
- const messageInfo = {
435
- id: `bot-msg-${timestamp}`,
436
- content,
437
- userId: session.selfId,
438
- username: session.bot.user?.name || `Bot-${session.selfId}`,
439
- avatar: session.bot.user?.avatar,
440
- timestamp,
441
- channelId: finalChannelId,
442
- selfId: session.selfId,
443
- elements: await this.utils.cleanBase64ContentAsync(session.event?.message?.elements, true),
444
- type: "bot",
445
- guildName,
446
- platform: session.platform || "unknown",
447
- quote: quoteInfo,
448
- isDirect: !!isDirect,
449
- sending: true
450
- // 标记为正在发送
451
- };
452
- await this.fileManager.addMessageToFile(messageInfo);
453
- const eventElements = await this.utils.cleanBase64ContentAsync(session.event?.message?.elements, true);
454
- const messageEvent = {
455
- type: "bot-message",
456
- selfId: session.selfId,
457
- platform: session.platform || "unknown",
458
- channelId: finalChannelId,
459
- messageId: `bot-msg-${timestamp}`,
460
- content,
461
- userId: session.selfId,
462
- username: session.bot.user?.name || `Bot-${session.selfId}`,
463
- avatar: session.bot.user?.avatar,
464
- timestamp,
465
- guildName,
466
- channelType: session.event?.channel?.type || session.type || 0,
467
- elements: eventElements,
468
- quote: quoteInfo,
469
- isDirect: !!isDirect,
470
- sending: true,
471
- bot: {
472
- avatar: session.bot.user?.avatar,
473
- name: session.bot.user?.name
474
- }
475
- };
476
- this.ctx.console.broadcast("chat-bot-message-event", messageEvent);
477
- } catch (error) {
478
- this.logger.error("处理机器人消息失败:", error);
479
- }
480
- }
481
- dispose() {
482
- void this.utils.dispose();
483
- for (const dispose of this.scheduledTasks) {
484
- dispose();
485
476
  }
486
- this.scheduledTasks.clear();
487
- }
488
- scheduleTask(label, task) {
489
- const dispose = this.ctx.setTimeout(() => {
490
- this.scheduledTasks.delete(dispose);
491
- void task().catch((error) => {
492
- this.logger.error(`${label}失败:`, error);
493
- });
494
- }, 0);
495
- this.scheduledTasks.add(dispose);
477
+ return null;
496
478
  }
497
- shouldRefreshChannelInfo(channelKey, existingChannel, isDirect, channelId, directUserName) {
498
- if (this.channelRefreshInFlight.has(channelKey)) {
499
- return false;
500
- }
501
- const lastRefresh = this.lastChannelRefreshAt.get(channelKey) || 0;
502
- if (Date.now() - lastRefresh < this.CHANNEL_REFRESH_TTL_MS) {
503
- return false;
479
+ async removeSelfMessageByMessageId(platform, selfId, _channelId, messageId, exceptId) {
480
+ const prefix = `sm:${encodeKeyPart2(platform)}:${encodeKeyPart2(selfId)}:`;
481
+ const toDelete = [];
482
+ for await (const [key, value] of this.db.iterator({
483
+ gte: prefix,
484
+ lte: `${prefix}￿`
485
+ })) {
486
+ const parsed = this.parseSelfMessage(value);
487
+ if (parsed && parsed.messageId === messageId && parsed.id !== exceptId) {
488
+ toDelete.push(key);
489
+ }
504
490
  }
505
- if (isDirect) {
506
- return !directUserName && (!existingChannel || existingChannel.name.includes("未知"));
491
+ if (toDelete.length) {
492
+ await this.db.batch(toDelete.map((key) => ({ type: "del", key })));
507
493
  }
508
- return !existingChannel?.guildName || existingChannel.guildName === channelId;
509
494
  }
510
- async refreshChannelInfo(session, existingChannel, isDirect, directUserName) {
511
- const channelKey = `${session.selfId}:${session.channelId}`;
512
- this.channelRefreshInFlight.add(channelKey);
495
+ parseSelfMessage(value) {
513
496
  try {
514
- let guildName = existingChannel?.guildName || session.channelId;
515
- if (!isDirect) {
516
- guildName = await this.resolveGuildName(session);
497
+ const parsed = JSON.parse(value);
498
+ if (typeof parsed === "object" && parsed !== null) {
499
+ const record = parsed;
500
+ if (typeof record.id === "string" && typeof record.platform === "string" && typeof record.selfId === "string" && typeof record.channelId === "string" && typeof record.sentAt === "number") {
501
+ return record;
502
+ }
517
503
  }
518
- const finalName = this.buildChannelName(session, existingChannel, isDirect, directUserName, guildName);
519
- const channelInfo = {
520
- id: session.channelId,
521
- name: finalName,
522
- type: session.type || 0,
523
- channelId: session.channelId,
524
- guildName,
525
- isDirect: !!isDirect
526
- };
527
- await this.fileManager.upsertChannelInfo(session.selfId, session.channelId, channelInfo);
528
- this.lastChannelRefreshAt.set(channelKey, Date.now());
529
- this.logger.logInfo("更新频道信息到文件:", channelInfo.name);
530
- } finally {
531
- this.channelRefreshInFlight.delete(channelKey);
504
+ } catch {
505
+ this.logger.warn("机器人消息解析失败:", value.slice(0, 120));
532
506
  }
507
+ return null;
533
508
  }
534
- async resolveGuildName(session) {
535
- try {
536
- if (session.guildId && session.bot.getGuild && typeof session.bot.getGuild === "function") {
537
- const guild = await session.bot.getGuild(session.guildId);
538
- return guild?.name || session.channelId;
539
- }
540
- if (session.guildId && session.bot.getChannel && typeof session.bot.getChannel === "function") {
541
- const channel = await session.bot.getChannel(session.guildId);
542
- return channel?.name || session.channelId;
509
+ async listMessages(platform, selfId, channelId, limit = this.config.historyPageSize) {
510
+ const result = [];
511
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
512
+ for await (const [, value] of this.db.iterator({
513
+ gte: prefix,
514
+ lte: `${prefix}￿`,
515
+ reverse: true,
516
+ limit: limit - result.length
517
+ })) {
518
+ try {
519
+ const parsed = JSON.parse(value);
520
+ if (typeof parsed === "object" && parsed !== null) {
521
+ result.push(parsed);
522
+ }
523
+ } catch {
524
+ this.logger.warn("历史消息解析失败:", value.slice(0, 120));
525
+ }
543
526
  }
544
- } catch (error) {
545
- this.logger.logInfo("获取频道信息失败,使用频道ID作为备用:", error);
527
+ if (result.length >= limit) break;
546
528
  }
547
- return session.channelId;
529
+ return result;
548
530
  }
549
- buildChannelName(session, existingChannel, isDirect, directUserName, guildName) {
550
- if (isDirect) {
551
- if (directUserName && directUserName !== session.userId) {
552
- return `私聊(${directUserName})`;
553
- }
554
- if (existingChannel?.name && !existingChannel.name.includes("未知")) {
555
- return existingChannel.name;
556
- }
557
- if (session.platform && session.platform.toLowerCase().includes("sandbox")) {
558
- return `私聊(${session.userId})`;
559
- }
560
- return "私聊(未知用户)";
561
- }
562
- return guildName || session.channelId;
563
- }
564
- async processMediaElements(elements, isUserMessage) {
565
- for (const el of elements) {
566
- if (["image", "img", "mface"].includes(el.type)) {
567
- const src = el.attrs.src || el.attrs.url || el.attrs.file;
568
- if (src && isUserMessage) {
569
- try {
570
- await this.downloadAndCacheMedia(src, "image");
571
- } catch (error) {
572
- this.logger.warn("缓存图片失败:", error);
573
- }
574
- }
575
- } else if (el.type === "audio") {
576
- const src = el.attrs.src || el.attrs.url || el.attrs.file;
577
- if (src && isUserMessage) {
578
- try {
579
- await this.downloadAndCacheMedia(src, "media");
580
- } catch (error) {
581
- this.logger.warn("缓存语音失败:", error);
531
+ async listMessagesBefore(platform, selfId, channelId, beforeTime, limit = this.config.historyPageSize) {
532
+ const result = [];
533
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
534
+ const before = `${prefix}${String(beforeTime).padStart(16, "0")}`;
535
+ for await (const [, value] of this.db.iterator({
536
+ gte: prefix,
537
+ lt: before,
538
+ reverse: true,
539
+ limit: limit - result.length
540
+ })) {
541
+ try {
542
+ const parsed = JSON.parse(value);
543
+ if (typeof parsed === "object" && parsed !== null) {
544
+ result.push(parsed);
582
545
  }
546
+ } catch {
547
+ this.logger.warn("历史消息解析失败:", value.slice(0, 120));
583
548
  }
584
549
  }
585
- if (el.children?.length) {
586
- await this.processMediaElements(el.children, isUserMessage);
550
+ if (result.length >= limit) break;
551
+ }
552
+ return result;
553
+ }
554
+ async clearChannel(platform, selfId, channelId) {
555
+ const operations = [];
556
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
557
+ for await (const [key] of this.db.iterator({
558
+ gte: prefix,
559
+ lte: `${prefix}￿`
560
+ })) {
561
+ operations.push({ type: "del", key });
587
562
  }
588
563
  }
564
+ const selfPrefix = selfMessagePrefix(platform, selfId, channelId);
565
+ for await (const [key] of this.db.iterator({
566
+ gte: selfPrefix,
567
+ lte: `${selfPrefix}￿`
568
+ })) {
569
+ operations.push({ type: "del", key });
570
+ }
571
+ if (operations.length) await this.db.batch(operations);
589
572
  }
590
- async fileExists(filePath) {
573
+ async getPinned() {
591
574
  try {
592
- await import_node_fs2.promises.access(filePath);
593
- return true;
575
+ const value = await this.db.get("meta:pinned");
576
+ const parsed = JSON.parse(value);
577
+ if (typeof parsed === "object" && parsed !== null) {
578
+ return parsed;
579
+ }
594
580
  } catch {
595
- return false;
596
581
  }
582
+ return { bots: [], channels: [] };
597
583
  }
598
- };
599
- __name(_MessageHandler, "MessageHandler");
600
- var MessageHandler = _MessageHandler;
601
-
602
- // src/file-manager.ts
603
- var import_node_path3 = __toESM(require("node:path"));
604
- var import_node_fs3 = require("node:fs");
605
- var _FileManager = class _FileManager {
606
- constructor(ctx, config, logger) {
607
- this.ctx = ctx;
608
- this.config = config;
609
- this.logger = logger;
610
- this.memoryCache = this.createEmptyChatData();
611
- this.channelMessagesCache = /* @__PURE__ */ new Map();
612
- this.recentMessageIdsCache = /* @__PURE__ */ new Map();
613
- this.pendingBotMessageIds = /* @__PURE__ */ new Map();
614
- this.messageChunkLocationCache = /* @__PURE__ */ new Map();
615
- this.dirtyChannelKeys = /* @__PURE__ */ new Set();
616
- this.pendingMessages = /* @__PURE__ */ new Map();
617
- // 按channelKey分组的待写入消息
618
- this.writeTimers = /* @__PURE__ */ new Map();
619
- // 每个频道独立的写入定时器
620
- this.metadataLoadPromise = null;
621
- this.channelLoadPromises = /* @__PURE__ */ new Map();
622
- this.writeQueue = Promise.resolve();
623
- this.disposed = false;
624
- this.WRITE_DEBOUNCE_MS = 1e3;
625
- this.RECENT_MESSAGE_ID_CACHE_SIZE = 200;
626
- const baseDir = import_node_path3.default.resolve(ctx.baseDir, "data", "chat-patch");
627
- this.storageBaseDir = import_node_path3.default.join(baseDir, "v2");
628
- this.chatHistoryDir = import_node_path3.default.join(this.storageBaseDir, "chat-history");
629
- this.metadataFilePath = import_node_path3.default.join(this.storageBaseDir, "metadata.json");
630
- this.utils = new Utils(config, ctx);
631
- this.ctx.setTimeout(() => {
632
- void this.cleanupLegacyStorage(baseDir);
633
- }, 0);
634
- }
635
- async initialize() {
636
- await this.ensureMetadataLoaded();
637
- }
638
- readChatDataFromFile() {
639
- this.memoryCache.messages = this.getChannelMessagesCacheSnapshot();
640
- return this.memoryCache;
641
- }
642
- getCachedChannelInfo(selfId, channelId) {
643
- return this.memoryCache.channels[selfId]?.[channelId];
644
- }
645
- async readMetadataOnly() {
646
- await this.ensureMetadataLoaded();
647
- const { messages, ...metadata } = this.memoryCache;
648
- return { ...metadata };
649
- }
650
- async upsertBotInfo(botInfo) {
651
- await this.ensureMetadataLoaded();
652
- const current = this.memoryCache.bots[botInfo.selfId];
653
- if (current && this.isSameBotInfo(current, botInfo)) {
654
- return;
655
- }
656
- this.memoryCache.bots[botInfo.selfId] = botInfo;
657
- this.scheduleMetadataWrite();
584
+ async setPinned(state) {
585
+ await this.db.put("meta:pinned", JSON.stringify(state));
658
586
  }
659
- async upsertChannelInfo(selfId, channelId, channelInfo) {
660
- await this.ensureMetadataLoaded();
661
- if (!this.memoryCache.channels[selfId]) {
662
- this.memoryCache.channels[selfId] = {};
663
- }
664
- const current = this.memoryCache.channels[selfId][channelId];
665
- if (current && this.isSameChannelInfo(current, channelInfo)) {
666
- return;
587
+ async getMeta(key) {
588
+ try {
589
+ return JSON.parse(await this.db.get(`meta:${key}`));
590
+ } catch {
591
+ return void 0;
667
592
  }
668
- this.memoryCache.channels[selfId][channelId] = channelInfo;
669
- this.scheduleMetadataWrite();
670
593
  }
671
- async setPinnedBots(pinnedBots) {
672
- await this.ensureMetadataLoaded();
673
- this.memoryCache.pinnedBots = [...pinnedBots];
674
- this.scheduleMetadataWrite();
594
+ async setMeta(key, value) {
595
+ await this.db.put(`meta:${key}`, JSON.stringify(value));
675
596
  }
676
- async setPinnedChannels(pinnedChannels) {
677
- await this.ensureMetadataLoaded();
678
- this.memoryCache.pinnedChannels = [...pinnedChannels];
679
- this.scheduleMetadataWrite();
597
+ async recordMedia(url, filePath, channelId = "") {
598
+ const key = `media:${(0, import_node_crypto.createHash)("md5").update(url).digest("hex")}`;
599
+ await this.db.put(key, JSON.stringify({ filePath, channelId }));
680
600
  }
681
- // 清理旧版本数据目录
682
- async cleanupLegacyStorage(baseDir) {
683
- const oldFiles = ["chat-data.json", "data.json", "messages.json"];
684
- for (const fileName of oldFiles) {
685
- const filePath = import_node_path3.default.join(baseDir, fileName);
601
+ async getMediaPath(url) {
602
+ try {
603
+ const key = `media:${(0, import_node_crypto.createHash)("md5").update(url).digest("hex")}`;
604
+ const value = await this.db.get(key);
686
605
  try {
687
- await import_node_fs3.promises.unlink(filePath);
688
- this.logger.logInfo(`已删除旧版本数据文件: ${fileName}`);
689
- } catch (error) {
690
- if (!this.isFileMissingError(error)) {
691
- this.logger.warn(`删除旧版本数据文件失败: ${fileName}`, error);
606
+ const parsed = JSON.parse(value);
607
+ if (typeof parsed === "object" && parsed !== null) {
608
+ const item = parsed;
609
+ return typeof item.filePath === "string" ? item.filePath : value;
692
610
  }
611
+ } catch {
693
612
  }
613
+ return value;
614
+ } catch {
615
+ return void 0;
694
616
  }
695
- const legacyPaths = [
696
- import_node_path3.default.join(baseDir, "metadata.json"),
697
- import_node_path3.default.join(baseDir, "chat-history")
698
- ];
699
- for (const legacyPath of legacyPaths) {
617
+ }
618
+ async getAllMedia() {
619
+ const result = [];
620
+ for await (const [, value] of this.db.iterator({
621
+ gte: "media:",
622
+ lte: "media:￿"
623
+ })) {
700
624
  try {
701
- await import_node_fs3.promises.rm(legacyPath, { recursive: true, force: true });
702
- this.logger.logInfo(`已清理旧版数据路径: ${legacyPath}`);
703
- } catch (error) {
704
- this.logger.warn(`清理旧版数据路径失败: ${legacyPath}`, error);
625
+ const parsed = JSON.parse(value);
626
+ if (typeof parsed === "object" && parsed !== null) {
627
+ const item = parsed;
628
+ if (typeof item.filePath === "string") {
629
+ result.push({
630
+ filePath: item.filePath,
631
+ channelId: typeof item.channelId === "string" ? item.channelId : ""
632
+ });
633
+ }
634
+ } else if (typeof parsed === "string") {
635
+ result.push({ filePath: parsed, channelId: "" });
636
+ }
637
+ } catch {
638
+ result.push({ filePath: value, channelId: "" });
705
639
  }
706
640
  }
641
+ return result;
707
642
  }
708
- // 确保目录存在
709
- async ensureDir(dirPath) {
710
- await import_node_fs3.promises.mkdir(dirPath, { recursive: true });
711
- }
712
- // 读取单个频道的消息(公共方法)
713
- async readChannelMessages(selfId, channelId) {
714
- await this.ensureMetadataLoaded();
715
- const channelKey = `${selfId}:${channelId}`;
716
- const cached = this.getCachedChannelMessages(channelKey);
717
- if (cached) {
718
- return cached;
719
- }
720
- const existingPromise = this.channelLoadPromises.get(channelKey);
721
- if (existingPromise) {
722
- return existingPromise;
643
+ async removeMediaByPath(filePath) {
644
+ const normalized = import_node_path.default.normalize(filePath);
645
+ const toDelete = [];
646
+ for await (const [key, value] of this.db.iterator({
647
+ gte: "media:",
648
+ lte: "media:￿"
649
+ })) {
650
+ let stored = value;
651
+ try {
652
+ const parsed = JSON.parse(value);
653
+ if (typeof parsed === "object" && parsed !== null) {
654
+ const item = parsed;
655
+ stored = typeof item.filePath === "string" ? item.filePath : value;
656
+ }
657
+ } catch {
658
+ }
659
+ if (import_node_path.default.normalize(stored) === normalized) toDelete.push(key);
723
660
  }
724
- const loadPromise = this.loadChannelMessages(selfId, channelId);
725
- this.channelLoadPromises.set(channelKey, loadPromise);
726
- try {
727
- return await loadPromise;
728
- } finally {
729
- this.channelLoadPromises.delete(channelKey);
661
+ if (toDelete.length) {
662
+ await this.db.batch(toDelete.map((key) => ({ type: "del", key })));
730
663
  }
731
664
  }
732
- async readChannelMessagesPage(selfId, channelId, limit, offset = 0) {
733
- await this.ensureMetadataLoaded();
734
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
735
- if (limit <= 0 || offset >= indexData.totalMessages) {
736
- return {
737
- messages: [],
738
- total: indexData.totalMessages
739
- };
740
- }
741
- const entry = this.createStoredChannelEntry(selfId, channelId);
742
- const collected = [];
743
- let skipped = 0;
744
- for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
745
- const chunk = indexData.chunks[chunkIndex];
746
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
747
- for (let messageIndex = chunkMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
748
- if (skipped < offset) {
749
- skipped += 1;
750
- continue;
751
- }
752
- collected.push(chunkMessages[messageIndex]);
753
- if (collected.length >= limit) {
754
- return {
755
- messages: collected.reverse(),
756
- total: indexData.totalMessages
757
- };
665
+ async getContacts(platform, selfId, type) {
666
+ for (const key of [contactKey(platform, selfId, type), legacyContactKey(platform, selfId, type)]) {
667
+ try {
668
+ const value = await this.db.get(key);
669
+ const parsed = JSON.parse(value);
670
+ if (Array.isArray(parsed)) {
671
+ const contacts = parsed;
672
+ return type === "group" ? contacts.filter(isUsableGroupContact) : contacts;
758
673
  }
674
+ } catch {
759
675
  }
760
676
  }
761
- return {
762
- messages: collected.reverse(),
763
- total: indexData.totalMessages
764
- };
677
+ return [];
765
678
  }
766
- async findChannelMessageById(selfId, channelId, messageId) {
767
- await this.ensureMetadataLoaded();
768
- const channelKey = `${selfId}:${channelId}`;
769
- const cachedMessages = this.peekCachedChannelMessages(channelKey);
770
- const cachedMatched = cachedMessages?.find((message) => message.id === messageId);
771
- if (cachedMatched) {
772
- return cachedMatched;
773
- }
774
- const entry = this.createStoredChannelEntry(selfId, channelId);
775
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
776
- for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
777
- const chunk = indexData.chunks[chunkIndex];
778
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
779
- const matched = messages.find((message) => message.id === messageId);
780
- if (matched) {
781
- return matched;
782
- }
783
- }
784
- return null;
679
+ async getContact(platform, selfId, type, id) {
680
+ const contacts = await this.getContacts(platform, selfId, type);
681
+ return contacts.find((item) => item.id === id) ?? null;
785
682
  }
786
- // 读取元数据(bots、channels、pinned等)
787
- async readMetadata() {
788
- try {
789
- const jsonData = await import_node_fs3.promises.readFile(this.metadataFilePath, "utf8");
790
- const data = JSON.parse(jsonData);
791
- return {
792
- bots: data.bots || {},
793
- channels: data.channels || {},
794
- pinnedBots: data.pinnedBots || [],
795
- pinnedChannels: data.pinnedChannels || [],
796
- lastSaveTime: data.lastSaveTime
797
- };
798
- } catch (error) {
799
- if (this.isFileMissingError(error)) {
800
- return {
801
- bots: {},
802
- channels: {},
803
- pinnedBots: [],
804
- pinnedChannels: []
805
- };
806
- }
807
- this.logger.error("读取元数据失败:", error);
808
- return {
809
- bots: {},
810
- channels: {},
811
- pinnedBots: [],
812
- pinnedChannels: []
813
- };
814
- }
683
+ async setContacts(platform, selfId, type, contacts) {
684
+ const next = type === "group" ? contacts.filter(isUsableGroupContact) : contacts;
685
+ await this.db.put(contactKey(platform, selfId, type), JSON.stringify(next));
686
+ }
687
+ async appendContact(platform, selfId, type, contact) {
688
+ if (type === "group" && !isUsableGroupContact(contact)) return;
689
+ const contacts = await this.getContacts(platform, selfId, type);
690
+ const next = contacts.filter((item) => item.id !== contact.id);
691
+ next.push(contact);
692
+ await this.setContacts(platform, selfId, type, next);
815
693
  }
816
- // 写入元数据
817
- async writeMetadata(metadata) {
694
+ async getContactsLegacy(platform, selfId, type) {
818
695
  try {
819
- await this.ensureDir(import_node_path3.default.dirname(this.metadataFilePath));
820
- const dataToWrite = {
821
- ...metadata,
822
- lastSaveTime: Date.now()
823
- };
824
- const jsonData = JSON.stringify(dataToWrite, null, 2);
825
- await this.atomicWriteTextFile(this.metadataFilePath, jsonData);
826
- } catch (error) {
827
- this.logger.error("写入元数据失败:", error);
828
- }
829
- }
830
- // 为特定频道安排写入
831
- scheduleWrite(channelKey) {
832
- const existingTimer = this.writeTimers.get(channelKey);
833
- if (existingTimer) {
834
- existingTimer();
835
- }
836
- const timer = this.ctx.setTimeout(() => {
837
- this.writeTimers.delete(channelKey);
838
- void this.flushPendingMessages(channelKey);
839
- }, this.WRITE_DEBOUNCE_MS);
840
- this.writeTimers.set(channelKey, timer);
841
- }
842
- // 刷新特定频道的待写入消息
843
- async flushPendingMessages(channelKey) {
844
- const messagesToWrite = this.pendingMessages.get(channelKey);
845
- if (!messagesToWrite || messagesToWrite.length === 0) return;
846
- this.pendingMessages.delete(channelKey);
847
- const cachedMessages = this.peekCachedChannelMessages(channelKey);
848
- const [selfId, channelId] = channelKey.split(":");
849
- if (!selfId || !channelId) return;
850
- const uniqueMessages = this.deduplicateMessages(messagesToWrite);
851
- if (!uniqueMessages.length) {
852
- return;
853
- }
854
- if (cachedMessages) {
855
- const nextMessages = this.mergeChannelMessages(cachedMessages, uniqueMessages);
856
- this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages));
696
+ const value = await this.db.get(legacyContactKey(platform, selfId, type));
697
+ const parsed = JSON.parse(value);
698
+ if (!Array.isArray(parsed)) return [];
699
+ const contacts = parsed;
700
+ return type === "group" ? contacts.filter(isUsableGroupContact) : contacts;
701
+ } catch {
702
+ return [];
857
703
  }
858
- this.rememberRecentMessageIds(channelKey, uniqueMessages.map((message) => message.id));
859
- this.enqueueWrite(async () => {
860
- await this.appendMessagesToChannel(selfId, channelId, uniqueMessages);
861
- this.logger.logInfo(`批量写入 ${uniqueMessages.length} 条消息到频道 ${channelKey}`);
862
- });
863
704
  }
864
- cleanExcessMessages(data) {
865
- let cleanedCount = 0;
866
- const cleanedMessages = {};
867
- for (const [channelKey, messages] of Object.entries(data.messages)) {
868
- if (messages.length > this.config.maxMessagesPerChannel) {
869
- const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp);
870
- const keptMessages = sortedMessages.slice(-this.config.maxMessagesPerChannel);
871
- cleanedCount += messages.length - keptMessages.length;
872
- cleanedMessages[channelKey] = keptMessages;
873
- this.logger.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`);
874
- } else {
875
- cleanedMessages[channelKey] = messages;
705
+ async getGroupMembers(platform, selfId, groupId) {
706
+ for (const key of [groupMemberKey(platform, selfId, groupId), legacyGroupMemberKey(platform, selfId, groupId)]) {
707
+ try {
708
+ const value = await this.db.get(key);
709
+ const parsed = JSON.parse(value);
710
+ return Array.isArray(parsed) ? parsed : [];
711
+ } catch {
876
712
  }
877
713
  }
878
- if (cleanedCount > 0) {
879
- this.logger.logInfo("总共清理超量消息:", cleanedCount, "条");
880
- }
881
- return {
882
- ...data,
883
- messages: cleanedMessages
884
- };
714
+ return [];
885
715
  }
886
- async addMessageToFile(messageInfo) {
887
- await this.ensureMetadataLoaded();
888
- const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`;
889
- if (!messageInfo.timestamp) {
890
- messageInfo.timestamp = Date.now();
891
- }
892
- const cleanedMessageInfo = await this.utils.cleanBase64ContentAsync(messageInfo, false);
893
- const pendingMessages = this.pendingMessages.get(channelKey) || [];
894
- if (pendingMessages.some((message) => message.id === cleanedMessageInfo.id)) {
895
- return;
716
+ async setGroupMembers(platform, selfId, groupId, members) {
717
+ await this.db.put(groupMemberKey(platform, selfId, groupId), JSON.stringify(members));
718
+ }
719
+ async getGroupMember(platform, selfId, groupId, userId) {
720
+ const members = await this.getGroupMembers(platform, selfId, groupId);
721
+ return members.find((item) => item.id === userId) ?? null;
722
+ }
723
+ async appendGroupMember(platform, selfId, groupId, member) {
724
+ const members = await this.getGroupMembers(platform, selfId, groupId);
725
+ const next = members.filter((item) => item.id !== member.id);
726
+ next.push(member);
727
+ await this.setGroupMembers(platform, selfId, groupId, next);
728
+ }
729
+ async getAllContacts() {
730
+ const result = [];
731
+ const byTriple = /* @__PURE__ */ new Map();
732
+ for await (const [key, value] of this.db.iterator({
733
+ gte: "c:",
734
+ lte: "c:￿"
735
+ })) {
736
+ if (!key.startsWith("c:")) continue;
737
+ let source = "legacy";
738
+ let platform = "";
739
+ let selfId = "";
740
+ let type = "";
741
+ const parts = key.slice(2).split(":");
742
+ if (parts.length === 3) {
743
+ platform = decodeKeyPart(parts[0]);
744
+ selfId = decodeKeyPart(parts[1]);
745
+ type = decodeKeyPart(parts[2]);
746
+ source = "new";
747
+ } else {
748
+ const typeIndex = key.lastIndexOf(":");
749
+ if (typeIndex <= 2) continue;
750
+ type = key.slice(typeIndex + 1);
751
+ const rest = key.slice(2, typeIndex);
752
+ const sep = rest.lastIndexOf(":");
753
+ if (sep <= 0) continue;
754
+ platform = rest.slice(0, sep);
755
+ selfId = rest.slice(sep + 1);
756
+ }
757
+ if (!platform || !selfId || !type) continue;
758
+ try {
759
+ const parsed = JSON.parse(value);
760
+ const entry = {
761
+ platform,
762
+ selfId,
763
+ type,
764
+ contacts: Array.isArray(parsed) ? parsed : []
765
+ };
766
+ if (entry.type === "group") {
767
+ entry.contacts = entry.contacts.filter(isUsableGroupContact);
768
+ }
769
+ const triple = JSON.stringify([platform, selfId, type]);
770
+ const existing = byTriple.get(triple);
771
+ if (!existing || existing.kind === "legacy" && source === "new") {
772
+ byTriple.set(triple, { kind: source, entry });
773
+ }
774
+ } catch {
775
+ this.logger.warn("联系人缓存解析失败:", key);
776
+ }
896
777
  }
897
- const cachedMessages = this.peekCachedChannelMessages(channelKey);
898
- if (cachedMessages?.some((message) => message.id === cleanedMessageInfo.id)) {
899
- return;
778
+ for (const { entry } of byTriple.values()) {
779
+ result.push(entry);
900
780
  }
901
- if (!cachedMessages) {
902
- if (this.hasRecentMessageId(channelKey, cleanedMessageInfo.id)) {
903
- return;
781
+ return result;
782
+ }
783
+ async trimMessages(platform, selfId, channelId) {
784
+ let count = 0;
785
+ const toDelete = [];
786
+ for (const prefix of [messagePrefix(platform, selfId, channelId), legacyMessagePrefix(platform, selfId, channelId)]) {
787
+ for await (const [key] of this.db.iterator({
788
+ gte: prefix,
789
+ lte: `${prefix}￿`
790
+ })) {
791
+ count += 1;
792
+ if (count > this.config.maxMessagesPerChannel) toDelete.push(key);
793
+ }
794
+ }
795
+ if (!toDelete.length) return;
796
+ await this.db.batch(toDelete.map((key) => ({ type: "del", key })));
797
+ this.logger.logInfo(`频道历史已裁剪 ${toDelete.length} 条`);
798
+ }
799
+ async trimSelfMessages(platform, selfId, channelId) {
800
+ let count = 0;
801
+ const toDelete = [];
802
+ const prefix = selfMessagePrefix(platform, selfId, channelId);
803
+ for await (const [key] of this.db.iterator({
804
+ gte: prefix,
805
+ lte: `${prefix}￿`
806
+ })) {
807
+ count += 1;
808
+ if (count > this.config.maxMessagesPerChannel) toDelete.push(key);
809
+ }
810
+ if (!toDelete.length) return;
811
+ await this.db.batch(toDelete.map((key) => ({ type: "del", key })));
812
+ this.logger.logInfo(`机器人消息已裁剪 ${toDelete.length} 条`);
813
+ }
814
+ };
815
+ __name(_ChatDatabase, "ChatDatabase");
816
+ var ChatDatabase = _ChatDatabase;
817
+
818
+ // src/recorder.ts
819
+ var MESSAGE_TYPES = /* @__PURE__ */ new Set(["message", "message-created"]);
820
+ function normalizeGroupId2(value) {
821
+ const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, "").trim();
822
+ const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/);
823
+ return wrapped ? wrapped[1] : raw || value;
824
+ }
825
+ __name(normalizeGroupId2, "normalizeGroupId");
826
+ var _Recorder = class _Recorder {
827
+ constructor(ctx, config, database, media, contactCache, logger) {
828
+ this.ctx = ctx;
829
+ this.config = config;
830
+ this.database = database;
831
+ this.media = media;
832
+ this.contactCache = contactCache;
833
+ this.logger = logger;
834
+ }
835
+ start() {
836
+ this.ctx.on("internal/session", (session) => {
837
+ void this.handleSession(session);
838
+ });
839
+ }
840
+ isBlocked(platform) {
841
+ return (this.config.blockedPlatforms ?? []).some((item) => {
842
+ return item.exactMatch ? platform === item.platformName : platform.includes(item.platformName);
843
+ });
844
+ }
845
+ async handleSession(session) {
846
+ const platform = session.platform || "unknown";
847
+ if (this.isBlocked(platform)) return;
848
+ if (!MESSAGE_TYPES.has(session.type)) return;
849
+ const event = session.toJSON();
850
+ const message = event.message;
851
+ const record = {
852
+ id: message?.id || `local-${event.sn}`,
853
+ sequence: event.sn,
854
+ type: session.type,
855
+ platform,
856
+ selfId: session.selfId,
857
+ channelId: session.channelId,
858
+ guildId: session.guildId,
859
+ userId: session.userId,
860
+ timestamp: session.timestamp,
861
+ timestampMs: session.timestamp,
862
+ receivedAt: Date.now(),
863
+ content: session.content || message?.content,
864
+ elements: message?.elements,
865
+ raw: event
866
+ };
867
+ try {
868
+ await this.database.appendMessage(record);
869
+ void this.cacheMessageContacts(event).catch((error) => {
870
+ this.logger.warn("缓存消息联系人失败:", error);
871
+ });
872
+ void this.cacheMessageMedia(event).catch((error) => {
873
+ this.logger.warn("异步缓存消息媒体失败:", error);
874
+ });
875
+ } catch (error) {
876
+ this.logger.warn("写入历史消息失败:", error);
877
+ }
878
+ }
879
+ async cacheMessageContacts(event) {
880
+ const platform = event.platform || "";
881
+ const selfId = event.selfId || "";
882
+ const user = event.user;
883
+ const guild = event.guild;
884
+ const channel = event.channel;
885
+ const member = event.member;
886
+ const userId = user?.id || "";
887
+ const guildId = guild?.id || "";
888
+ const channelId = channel?.id || "";
889
+ const channelType = String(channel?.type ?? "");
890
+ const isPrivateChannel2 = channelType === "1" || ["direct", "private"].includes(channelType.toLowerCase()) || !channelType && /^private:/i.test(channelId);
891
+ const groupId = guildId || (isPrivateChannel2 ? "" : normalizeGroupId2(channelId)) || "";
892
+ const userName = user?.name || user?.nick || member?.nick || member?.name || "";
893
+ const userAvatar = user?.avatar || member?.avatar || "";
894
+ const groupName = guild?.name || channel?.name || "";
895
+ const groupAvatar = guild?.avatar || "";
896
+ if (groupId) {
897
+ const groupItem = await this.contactCache.getGroup(
898
+ platform,
899
+ selfId,
900
+ groupId,
901
+ guildId || groupId,
902
+ channelId || groupId,
903
+ groupName,
904
+ groupAvatar,
905
+ channel?.type
906
+ );
907
+ if (userId) {
908
+ const userItem = await this.contactCache.getUser(
909
+ platform,
910
+ selfId,
911
+ userId,
912
+ guildId || groupId,
913
+ void 0,
914
+ userName,
915
+ userAvatar
916
+ );
917
+ await this.contactCache.recordGroupMember(
918
+ platform,
919
+ selfId,
920
+ groupId,
921
+ {
922
+ id: userId,
923
+ name: userItem?.name || userName || userId,
924
+ avatar: userItem?.avatar || userAvatar || void 0,
925
+ raw: {
926
+ id: userId,
927
+ user_id: userId,
928
+ nickname: userName || userItem?.name,
929
+ card: member?.nick || member?.name || "",
930
+ avatar: userItem?.avatar || userAvatar || void 0,
931
+ role: member?.title || ""
932
+ }
933
+ }
934
+ );
904
935
  }
905
- const existsInStorage = await this.channelMessageExists(messageInfo.selfId, messageInfo.channelId, cleanedMessageInfo.id);
906
- if (existsInStorage) {
907
- return;
936
+ if (!groupItem) this.logger.logInfo("群组缓存未回源到可用身份:", groupId);
937
+ } else if (userId) {
938
+ await this.contactCache.getUser(
939
+ platform,
940
+ selfId,
941
+ userId,
942
+ void 0,
943
+ channelId,
944
+ userName,
945
+ userAvatar
946
+ );
947
+ }
948
+ }
949
+ async cacheMessageMedia(event) {
950
+ const elements = Array.isArray(event.message?.elements) ? event.message.elements : [];
951
+ const channelId = String(
952
+ event.channel?.id || event.guild?.id || ""
953
+ );
954
+ const urls = [];
955
+ const collect = /* @__PURE__ */ __name((list) => {
956
+ for (const raw of list) {
957
+ const element = typeof raw === "object" && raw !== null ? raw : {};
958
+ const attrs = typeof element.attrs === "object" && element.attrs !== null ? element.attrs : {};
959
+ if (["img", "image", "mface", "audio", "video", "file"].includes(String(element.type))) {
960
+ const url = String(attrs.src ?? attrs.url ?? attrs.file ?? "");
961
+ if (url) urls.push(url);
962
+ }
963
+ if (Array.isArray(element.children)) collect(element.children);
908
964
  }
965
+ }, "collect");
966
+ collect(elements);
967
+ await Promise.allSettled(urls.map((url) => this.media.cacheUrl(url, channelId)));
968
+ }
969
+ };
970
+ __name(_Recorder, "Recorder");
971
+ var Recorder = _Recorder;
972
+
973
+ // src/media.ts
974
+ var import_node_crypto2 = require("node:crypto");
975
+ var import_node_fs = require("node:fs");
976
+ var import_node_path2 = __toESM(require("node:path"));
977
+ var import_node_url = require("node:url");
978
+ var import_file_type = __toESM(require("file-type"));
979
+ function toExtension(value) {
980
+ const ext = value.startsWith(".") ? value : `.${value}`;
981
+ return ext.toLowerCase();
982
+ }
983
+ __name(toExtension, "toExtension");
984
+ function toLocalFilePath(value) {
985
+ if (value.startsWith("file://")) {
986
+ try {
987
+ return (0, import_node_url.fileURLToPath)(value);
988
+ } catch {
989
+ return null;
909
990
  }
910
- pendingMessages.push(cleanedMessageInfo);
911
- this.pendingMessages.set(channelKey, pendingMessages);
912
- this.rememberRecentMessageIds(channelKey, [cleanedMessageInfo.id]);
913
- if (cleanedMessageInfo.type === "bot" && cleanedMessageInfo.sending) {
914
- this.registerPendingBotMessage(channelKey, cleanedMessageInfo.id);
915
- }
916
- if (cachedMessages) {
917
- const nextMessages = this.mergeChannelMessages(cachedMessages, [cleanedMessageInfo]);
918
- this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages));
919
- }
920
- this.scheduleWrite(channelKey);
921
991
  }
922
- async cleanupExcessMessagesInStorage() {
923
- let cleanedCount = 0;
924
- for (const channelKey of [...this.dirtyChannelKeys]) {
925
- const [selfId, channelId] = channelKey.split(":");
926
- if (!selfId || !channelId) {
927
- this.dirtyChannelKeys.delete(channelKey);
928
- continue;
992
+ if (/^[a-z]:[\\/]/i.test(value) || value.startsWith("\\\\")) return value;
993
+ return null;
994
+ }
995
+ __name(toLocalFilePath, "toLocalFilePath");
996
+ var _MediaManager = class _MediaManager {
997
+ constructor(ctx, config, database, logger) {
998
+ this.ctx = ctx;
999
+ this.config = config;
1000
+ this.database = database;
1001
+ this.logger = logger;
1002
+ this.mediaDir = import_node_path2.default.resolve(ctx.baseDir, "data", "chat-patch", "media");
1003
+ this.uploadDir = import_node_path2.default.resolve(ctx.baseDir, "data", "chat-patch", "upload-media");
1004
+ }
1005
+ start() {
1006
+ const route = `${this.config.basePath}/web/media`;
1007
+ this.ctx.server.get(route, async (koa) => {
1008
+ const url = String(koa.query.url ?? "");
1009
+ if (!url) {
1010
+ koa.status = 400;
1011
+ koa.body = "missing url";
1012
+ return;
929
1013
  }
930
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
931
- if (indexData.totalMessages <= this.config.maxMessagesPerChannel) {
932
- this.dirtyChannelKeys.delete(channelKey);
933
- continue;
1014
+ const filePath = await this.cacheUrl(url);
1015
+ if (!filePath) {
1016
+ koa.status = 502;
1017
+ koa.body = "download failed";
1018
+ return;
934
1019
  }
935
- const removedCount = await this.trimChannelToLimit(selfId, channelId, indexData, this.config.maxMessagesPerChannel);
936
- if (!removedCount) {
937
- continue;
1020
+ const size = (await import_node_fs.promises.stat(filePath)).size;
1021
+ koa.type = import_node_path2.default.extname(filePath) || "application/octet-stream";
1022
+ koa.set("Accept-Ranges", "bytes");
1023
+ const range = koa.headers.range;
1024
+ if (range) {
1025
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(range));
1026
+ if (!match) {
1027
+ koa.status = 416;
1028
+ koa.set("Content-Range", `bytes */${size}`);
1029
+ koa.body = "";
1030
+ return;
1031
+ }
1032
+ let start = 0;
1033
+ let end = size - 1;
1034
+ if (match[1] === "" && match[2]) {
1035
+ start = Math.max(0, size - Number(match[2]));
1036
+ } else if (match[1]) {
1037
+ start = Number(match[1]);
1038
+ end = match[2] ? Number(match[2]) : end;
1039
+ }
1040
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
1041
+ koa.status = 416;
1042
+ koa.set("Content-Range", `bytes */${size}`);
1043
+ koa.body = "";
1044
+ return;
1045
+ }
1046
+ if (end >= size) end = size - 1;
1047
+ if (end < start) end = start;
1048
+ koa.status = 206;
1049
+ koa.set("Content-Range", `bytes ${start}-${end}/${size}`);
1050
+ koa.set("Content-Length", String(end - start + 1));
1051
+ koa.body = (0, import_node_fs.createReadStream)(filePath, { start, end });
1052
+ return;
938
1053
  }
939
- cleanedCount += removedCount;
940
- if (this.peekCachedChannelMessages(channelKey)) {
941
- this.setCachedChannelMessages(channelKey, await this.loadChannelMessagesNoCache(selfId, channelId));
1054
+ koa.set("Content-Length", String(size));
1055
+ koa.body = (0, import_node_fs.createReadStream)(filePath);
1056
+ });
1057
+ this.cleanupTimer = this.ctx.setInterval(() => {
1058
+ void this.cleanup();
1059
+ }, 10 * 60 * 1e3);
1060
+ void this.cleanup();
1061
+ }
1062
+ dispose() {
1063
+ this.cleanupTimer?.();
1064
+ }
1065
+ async cacheUrl(url, channelId = "") {
1066
+ try {
1067
+ const filePath = await this.resolveAndDownload(url, channelId);
1068
+ if (filePath) {
1069
+ await this.database.recordMedia(url, filePath, channelId);
942
1070
  }
943
- this.dirtyChannelKeys.delete(channelKey);
944
- this.logger.logInfo(`频道 ${channelKey} 清理了 ${removedCount} 条旧消息,保留最新 ${indexData.totalMessages} 条`);
945
- }
946
- if (cleanedCount > 0) {
947
- this.logger.logInfo("定期清理完成,清理了", cleanedCount, "条超量消息");
1071
+ return filePath;
1072
+ } catch (error) {
1073
+ this.logger.warn("媒体缓存失败:", url, error);
1074
+ return null;
948
1075
  }
949
1076
  }
950
- async getAllChannelMessageCounts() {
951
- const counts = {};
952
- await this.scanStoredChannels(async (entry) => {
953
- counts[entry.channelKey] = await this.countChannelMessages(entry.selfId, entry.channelId);
954
- });
955
- return counts;
956
- }
957
- async deleteChannelData(selfId, channelId) {
958
- await this.ensureMetadataLoaded();
959
- const channelKey = `${selfId}:${channelId}`;
960
- const deletedMessages = await this.countChannelMessages(selfId, channelId);
961
- this.deleteCachedChannelMessages(channelKey);
962
- this.deleteRecentMessageIds(channelKey);
963
- this.deletePendingBotMessages(channelKey);
964
- this.deleteMessageChunkLocations(channelKey);
965
- this.dirtyChannelKeys.delete(channelKey);
966
- this.pendingMessages.delete(channelKey);
967
- const timer = this.writeTimers.get(channelKey);
968
- if (timer) {
969
- timer();
970
- this.writeTimers.delete(channelKey);
971
- }
972
- if (this.memoryCache.channels[selfId]?.[channelId]) {
973
- delete this.memoryCache.channels[selfId][channelId];
974
- if (!Object.keys(this.memoryCache.channels[selfId]).length) {
975
- delete this.memoryCache.channels[selfId];
1077
+ async resolveAndDownload(url, channelId) {
1078
+ try {
1079
+ await import_node_fs.promises.mkdir(this.mediaDir, { recursive: true });
1080
+ const localSource = toLocalFilePath(url);
1081
+ if (localSource && await this.exists(localSource)) {
1082
+ const hash2 = (0, import_node_crypto2.createHash)("md5").update(url).digest("hex");
1083
+ const ext2 = import_node_path2.default.extname(localSource).toLowerCase() || ".bin";
1084
+ const filePath2 = import_node_path2.default.join(this.mediaDir, `${hash2}${ext2}`);
1085
+ if (import_node_path2.default.normalize(filePath2) !== import_node_path2.default.normalize(localSource)) {
1086
+ if (!await this.exists(filePath2)) {
1087
+ await import_node_fs.promises.copyFile(localSource, filePath2);
1088
+ }
1089
+ }
1090
+ await this.database.recordMedia(url, filePath2, channelId);
1091
+ this.logger.logInfo("本地媒体已缓存:", filePath2);
1092
+ return filePath2;
976
1093
  }
977
- }
978
- this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => item !== channelKey);
979
- await this.removeChannelStorage(selfId, channelId);
980
- this.scheduleMetadataWrite();
981
- return { deletedMessages };
982
- }
983
- async deleteBotData(selfId) {
984
- await this.ensureMetadataLoaded();
985
- const botDir = import_node_path3.default.join(this.chatHistoryDir, selfId);
986
- const channels = await this.listBotChannels(selfId);
987
- let deletedMessages = 0;
988
- for (const entry of channels) {
989
- deletedMessages += await this.countChannelMessages(entry.selfId, entry.channelId);
990
- this.deleteCachedChannelMessages(entry.channelKey);
991
- this.deleteRecentMessageIds(entry.channelKey);
992
- this.deletePendingBotMessages(entry.channelKey);
993
- this.deleteMessageChunkLocations(entry.channelKey);
994
- this.dirtyChannelKeys.delete(entry.channelKey);
995
- this.pendingMessages.delete(entry.channelKey);
996
- const timer = this.writeTimers.get(entry.channelKey);
997
- if (timer) {
998
- timer();
999
- this.writeTimers.delete(entry.channelKey);
1094
+ const cached = await this.database.getMediaPath(url);
1095
+ if (cached && await this.exists(cached)) {
1096
+ return this.migrateCachedMedia(url, cached, channelId);
1000
1097
  }
1098
+ const hash = (0, import_node_crypto2.createHash)("md5").update(url).digest("hex");
1099
+ const urlExt = import_node_path2.default.extname(new URL(url).pathname);
1100
+ const fallbackPath = import_node_path2.default.join(this.mediaDir, `${hash}${urlExt || ".bin"}`);
1101
+ if (await this.exists(fallbackPath)) {
1102
+ return this.migrateCachedMedia(url, fallbackPath, channelId);
1103
+ }
1104
+ const response = await fetch(url, {
1105
+ headers: {
1106
+ "User-Agent": "Mozilla/5.0 KoishiChatPatch/1.0"
1107
+ }
1108
+ });
1109
+ if (!response.ok) {
1110
+ return null;
1111
+ }
1112
+ const buffer = Buffer.from(await response.arrayBuffer());
1113
+ const ext = await this.detectExtension(url, buffer);
1114
+ const filename = `${hash}${ext}`;
1115
+ const filePath = import_node_path2.default.join(this.mediaDir, filename);
1116
+ await import_node_fs.promises.writeFile(filePath, buffer);
1117
+ this.logger.logInfo("媒体已缓存:", filename, buffer.length);
1118
+ return filePath;
1119
+ } catch (error) {
1120
+ return null;
1001
1121
  }
1002
- const deletedChannels = channels.length;
1003
- delete this.memoryCache.bots[selfId];
1004
- delete this.memoryCache.channels[selfId];
1005
- this.memoryCache.pinnedBots = this.memoryCache.pinnedBots.filter((item) => item !== selfId);
1006
- this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => !item.startsWith(`${selfId}:`));
1122
+ }
1123
+ async detectExtension(url, buffer) {
1007
1124
  try {
1008
- await import_node_fs3.promises.rm(botDir, { recursive: true, force: true });
1125
+ const detected = await import_file_type.default.fromBuffer(buffer);
1126
+ if (detected?.ext) return toExtension(detected.ext);
1009
1127
  } catch (error) {
1010
- this.logger.error(`删除机器人目录失败 [${selfId}]:`, error);
1128
+ this.logger.logInfo("媒体类型识别失败,回退 URL 后缀:", url, error);
1011
1129
  }
1012
- this.scheduleMetadataWrite();
1013
- return {
1014
- deletedChannels,
1015
- deletedMessages
1016
- };
1130
+ const ext = import_node_path2.default.extname(new URL(url).pathname);
1131
+ return ext && ext !== ".bin" ? ext.toLowerCase() : ".bin";
1017
1132
  }
1018
- async updateUserProfileInBotData(selfId, userId, userName, avatar) {
1019
- await this.ensureMetadataLoaded();
1020
- let changed = false;
1021
- const botChannels = this.memoryCache.channels[selfId] || {};
1022
- const possibleChannelIds = [
1023
- userId,
1024
- `private:${userId}`,
1025
- `direct:${userId}`
1026
- ];
1027
- for (const channelId of possibleChannelIds) {
1028
- const channel = botChannels[channelId];
1029
- if (!channel || !channel.isDirect || !userName) {
1030
- continue;
1031
- }
1032
- const newName = `私聊(${userName})`;
1033
- if (channel.name !== newName) {
1034
- channel.name = newName;
1035
- changed = true;
1133
+ async migrateCachedMedia(url, filePath, channelId) {
1134
+ try {
1135
+ const buffer = await import_node_fs.promises.readFile(filePath);
1136
+ const ext = await this.detectExtension(url, buffer);
1137
+ const currentExt = import_node_path2.default.extname(filePath).toLowerCase();
1138
+ if (ext === ".bin" || ext === currentExt) return filePath;
1139
+ const newPath = import_node_path2.default.join(
1140
+ import_node_path2.default.dirname(filePath),
1141
+ `${import_node_path2.default.basename(filePath, import_node_path2.default.extname(filePath))}${ext}`
1142
+ );
1143
+ if (newPath === filePath) return filePath;
1144
+ if (await this.exists(newPath)) {
1145
+ await import_node_fs.promises.unlink(filePath);
1146
+ } else {
1147
+ await import_node_fs.promises.rename(filePath, newPath);
1036
1148
  }
1149
+ await this.database.recordMedia(url, newPath, channelId);
1150
+ return newPath;
1151
+ } catch (error) {
1152
+ this.logger.warn("旧媒体缓存重命名失败:", url, error);
1153
+ return filePath;
1037
1154
  }
1038
- const channels = await this.listBotChannels(selfId);
1039
- for (const entry of channels) {
1040
- const entryChanged = await this.updateUserProfileInChannel(entry.selfId, entry.channelId, userId, userName, avatar);
1041
- if (!entryChanged) {
1042
- continue;
1155
+ }
1156
+ async cleanup() {
1157
+ try {
1158
+ const allFiles = [];
1159
+ for (const dir of [this.mediaDir, this.uploadDir]) {
1160
+ try {
1161
+ const entries = await import_node_fs.promises.readdir(dir, { withFileTypes: true });
1162
+ for (const entry of entries) {
1163
+ if (!entry.isFile()) continue;
1164
+ const filePath = import_node_path2.default.join(dir, entry.name);
1165
+ try {
1166
+ const stat = await import_node_fs.promises.stat(filePath);
1167
+ allFiles.push({ filePath, mtimeMs: stat.mtimeMs, channelId: "" });
1168
+ } catch {
1169
+ }
1170
+ }
1171
+ } catch {
1172
+ }
1043
1173
  }
1044
- if (this.peekCachedChannelMessages(entry.channelKey)) {
1045
- this.setCachedChannelMessages(entry.channelKey, await this.loadChannelMessagesNoCache(entry.selfId, entry.channelId));
1174
+ if (!allFiles.length) return;
1175
+ const metadata = await this.database.getAllMedia();
1176
+ const channelByPath = new Map(metadata.map((item) => [import_node_path2.default.normalize(item.filePath), item.channelId]));
1177
+ const uploadRoot = import_node_path2.default.normalize(this.uploadDir);
1178
+ for (const file of allFiles) {
1179
+ const normalized = import_node_path2.default.normalize(file.filePath);
1180
+ file.channelId = channelByPath.get(normalized) ?? "";
1181
+ if (!file.channelId && normalized.startsWith(uploadRoot)) {
1182
+ file.channelId = "upload";
1183
+ }
1184
+ if (!file.channelId) file.channelId = "unknown";
1185
+ }
1186
+ if (allFiles.length <= this.config.maxMediaFiles) return;
1187
+ const groups = /* @__PURE__ */ new Map();
1188
+ for (const file of allFiles) {
1189
+ const list = groups.get(file.channelId) ?? [];
1190
+ list.push(file);
1191
+ groups.set(file.channelId, list);
1192
+ }
1193
+ for (const list of groups.values()) {
1194
+ list.sort((a, b) => b.mtimeMs - a.mtimeMs);
1195
+ }
1196
+ const keep = /* @__PURE__ */ new Set();
1197
+ const groupList = [...groups.values()];
1198
+ let cursor = 0;
1199
+ for (let count = 0; count < this.config.maxMediaFiles && count < allFiles.length; count++) {
1200
+ let picked = false;
1201
+ for (let offset = 0; offset < groupList.length; offset++) {
1202
+ const group = groupList[(cursor + offset) % groupList.length];
1203
+ const file = group.shift();
1204
+ if (file) {
1205
+ keep.add(file.filePath);
1206
+ cursor = (cursor + offset + 1) % groupList.length;
1207
+ picked = true;
1208
+ break;
1209
+ }
1210
+ }
1211
+ if (!picked) break;
1046
1212
  }
1047
- changed = true;
1048
- }
1049
- if (changed) {
1050
- this.scheduleMetadataWrite();
1051
- }
1052
- return changed;
1053
- }
1054
- async markLatestBotMessageAsSent(selfId, channelId, realId) {
1055
- const channelKey = `${selfId}:${channelId}`;
1056
- const tempMessageId = this.peekLatestPendingBotMessageId(channelKey);
1057
- let matched;
1058
- if (tempMessageId) {
1059
- matched = await this.findAndUpdateBotMessageByTempId(selfId, channelId, tempMessageId, realId);
1060
- this.consumePendingBotMessageId(channelKey, tempMessageId);
1061
- }
1062
- if (!matched) {
1063
- matched = await this.findAndUpdateLatestBotMessage(selfId, channelId, realId);
1064
- }
1065
- if (!matched) {
1066
- return void 0;
1067
- }
1068
- const cachedMessages = this.peekCachedChannelMessages(channelKey);
1069
- if (cachedMessages) {
1070
- const cachedMatched = cachedMessages.find((message) => message.id === matched?.id);
1071
- if (cachedMatched) {
1072
- cachedMatched.realId = realId;
1073
- cachedMatched.sending = false;
1074
- this.setCachedChannelMessages(channelKey, cachedMessages);
1213
+ let removed = 0;
1214
+ for (const file of allFiles) {
1215
+ if (keep.has(file.filePath)) continue;
1216
+ try {
1217
+ await import_node_fs.promises.unlink(file.filePath);
1218
+ removed += 1;
1219
+ } catch {
1220
+ }
1221
+ await this.database.removeMediaByPath(file.filePath).catch(() => void 0);
1075
1222
  }
1223
+ if (removed > 0) {
1224
+ this.logger.logInfo("媒体缓存已清理", removed, "个文件");
1225
+ }
1226
+ } catch {
1076
1227
  }
1077
- return matched;
1078
1228
  }
1079
- async dispose() {
1080
- this.disposed = true;
1081
- for (const [channelKey, timer] of this.writeTimers.entries()) {
1082
- timer();
1083
- await this.flushPendingMessages(channelKey);
1229
+ async exists(filePath) {
1230
+ try {
1231
+ await import_node_fs.promises.access(filePath);
1232
+ return true;
1233
+ } catch {
1234
+ return false;
1084
1235
  }
1085
- this.writeTimers.clear();
1086
- await this.writeQueue;
1087
- await this.utils.dispose();
1088
- }
1089
- createEmptyChatData() {
1090
- return {
1091
- bots: {},
1092
- channels: {},
1093
- messages: {},
1094
- pinnedBots: [],
1095
- pinnedChannels: []
1096
- };
1097
1236
  }
1098
- async ensureMetadataLoaded() {
1099
- if (!this.metadataLoadPromise) {
1100
- this.metadataLoadPromise = this.loadMetadataIntoCache();
1237
+ };
1238
+ __name(_MediaManager, "MediaManager");
1239
+ var MediaManager = _MediaManager;
1240
+
1241
+ // src/self-message.ts
1242
+ var import_koishi = require("koishi");
1243
+ var import_node_crypto3 = require("node:crypto");
1244
+ var KOISHI_STATUS_I18N_FALLBACK = {
1245
+ "commands.status.messages.status.0": "离线",
1246
+ "commands.status.messages.status.1": "运行中",
1247
+ "commands.status.messages.status.2": "连接中",
1248
+ "commands.status.messages.status.3": "异常"
1249
+ };
1250
+ function getString2(value) {
1251
+ return typeof value === "string" ? value : "";
1252
+ }
1253
+ __name(getString2, "getString");
1254
+ function getNumber(value) {
1255
+ const num = Number(value);
1256
+ return Number.isFinite(num) ? num : 0;
1257
+ }
1258
+ __name(getNumber, "getNumber");
1259
+ function getObject(value) {
1260
+ return typeof value === "object" && value !== null ? value : {};
1261
+ }
1262
+ __name(getObject, "getObject");
1263
+ function toSatoriElement(value) {
1264
+ const raw = getObject(value);
1265
+ const attrs = typeof raw.attrs === "object" && raw.attrs !== null ? raw.attrs : {};
1266
+ const children = Array.isArray(raw.children) ? raw.children.map(toSatoriElement) : [];
1267
+ return {
1268
+ type: raw.type,
1269
+ attrs,
1270
+ children
1271
+ };
1272
+ }
1273
+ __name(toSatoriElement, "toSatoriElement");
1274
+ function isForwardContainer(element) {
1275
+ return element.type === "figure" || element.type === "forward" || element.type === "message" && ("forward" in (element.attrs ?? {}) || element.attrs?.forward === true);
1276
+ }
1277
+ __name(isForwardContainer, "isForwardContainer");
1278
+ function isPrivateChannel(value) {
1279
+ return /^(?:private|direct):/i.test(value);
1280
+ }
1281
+ __name(isPrivateChannel, "isPrivateChannel");
1282
+ function toSegments(elements, resolveI18n) {
1283
+ const result = [];
1284
+ for (const raw of elements) {
1285
+ const element = toSatoriElement(raw);
1286
+ const attrs = element.attrs ?? {};
1287
+ const type = getString2(element.type);
1288
+ if (type === "text") {
1289
+ result.push({ type: "text", text: getString2(attrs.content) });
1290
+ } else if (type === "at") {
1291
+ const id = getString2(attrs.id);
1292
+ const name2 = getString2(attrs.name) || (attrs.type === "all" ? "所有人" : id);
1293
+ result.push({ type: "at", qq: id, text: name2.startsWith("@") ? name2 : `@${name2}` });
1294
+ } else if (type === "img" || type === "image") {
1295
+ const src = getString2(attrs.src) || getString2(attrs.url) || getString2(attrs.file);
1296
+ result.push({ type: "image", file: src, url: src, summary: getString2(attrs.title) });
1297
+ } else if (type === "audio" || type === "record") {
1298
+ const src = getString2(attrs.src) || getString2(attrs.url) || getString2(attrs.file);
1299
+ result.push({ type: "record", file: src, url: src });
1300
+ } else if (type === "video") {
1301
+ const src = getString2(attrs.src) || getString2(attrs.url) || getString2(attrs.file);
1302
+ result.push({ type: "video", file: src, url: src });
1303
+ } else if (type === "file") {
1304
+ const src = getString2(attrs.src) || getString2(attrs.url) || getString2(attrs.file);
1305
+ result.push({ type: "file", file: src, url: src, name: getString2(attrs.name) });
1306
+ } else if (type === "quote") {
1307
+ result.push({ type: "reply", id: getString2(attrs.id) });
1308
+ } else if (type === "json") {
1309
+ result.push({ type: "json", data: getString2(attrs.data) || getString2(attrs.content) });
1310
+ } else if (type === "xml") {
1311
+ result.push({ type: "xml", data: getString2(attrs.data) || getString2(attrs.content) });
1312
+ } else if (type === "markdown") {
1313
+ result.push({ type: "markdown", content: getString2(attrs.content) });
1314
+ } else if (isForwardContainer(element)) {
1315
+ result.push({
1316
+ type: "forward",
1317
+ id: getString2(attrs.id),
1318
+ content: toForwardNodes(element.children ?? [], resolveI18n) ?? []
1319
+ });
1320
+ } else if (type === "p") {
1321
+ result.push(...toSegments(element.children ?? [], resolveI18n));
1322
+ result.push({ type: "text", text: "\n" });
1323
+ } else if (type === "br") {
1324
+ result.push({ type: "text", text: "\n" });
1325
+ } else if (type === "i18n") {
1326
+ const path5 = getString2(attrs.path);
1327
+ const resolved = resolveI18n ? resolveI18n(attrs) : "";
1328
+ result.push({ type: "text", text: resolved || `[${path5 || "i18n"}]` });
1329
+ } else if (element.children?.length) {
1330
+ result.push(...toSegments(element.children, resolveI18n));
1331
+ } else if (type && type !== "author" && type !== "button" && type !== "keyboard") {
1332
+ result.push({ type: "text", text: `[${type}]` });
1333
+ }
1334
+ }
1335
+ return result;
1336
+ }
1337
+ __name(toSegments, "toSegments");
1338
+ function toForwardNodes(elements, resolveI18n) {
1339
+ const nodes = [];
1340
+ const collect = /* @__PURE__ */ __name((list) => {
1341
+ for (const element of list) {
1342
+ if (element.type === "figure" || element.type === "forward") {
1343
+ collect(element.children ?? []);
1344
+ continue;
1345
+ }
1346
+ if (element.type !== "message") continue;
1347
+ if (isForwardContainer(element)) {
1348
+ collect(element.children ?? []);
1349
+ continue;
1350
+ }
1351
+ const children = element.children ?? [];
1352
+ const author = children.find((item) => item.type === "author");
1353
+ const attrs = element.attrs ?? {};
1354
+ const authorAttrs = author?.attrs ?? {};
1355
+ const content = children.filter((item) => item.type !== "author");
1356
+ nodes.push({
1357
+ message_id: getString2(attrs.id),
1358
+ time: getNumber(authorAttrs.time) || getNumber(attrs.time),
1359
+ sender: {
1360
+ user_id: getString2(authorAttrs.id),
1361
+ nickname: getString2(authorAttrs.name),
1362
+ avatar: getString2(authorAttrs.avatar)
1363
+ },
1364
+ message: toSegments(content, resolveI18n)
1365
+ });
1101
1366
  }
1102
- await this.metadataLoadPromise;
1103
- }
1104
- async loadMetadataIntoCache() {
1105
- const metadata = await this.readMetadata();
1106
- this.memoryCache = {
1107
- ...metadata,
1108
- messages: this.getChannelMessagesCacheSnapshot()
1109
- };
1110
- }
1111
- async loadChannelMessages(selfId, channelId) {
1112
- const messages = await this.loadChannelMessagesNoCache(selfId, channelId);
1113
- this.setCachedChannelMessages(`${selfId}:${channelId}`, messages);
1114
- return messages;
1115
- }
1116
- async loadChannelMessagesNoCache(selfId, channelId) {
1117
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
1118
- const channelDirPath = this.getChannelDirPath(selfId, channelId);
1119
- const messages = [];
1120
- const channelKey = `${selfId}:${channelId}`;
1121
- for (const chunk of indexData.chunks) {
1122
- const chunkMessages = await this.readChunkMessages(channelDirPath, chunk.fileName);
1123
- messages.push(...chunkMessages);
1124
- this.rememberMessageChunkLocation(channelKey, chunk.fileName, chunkMessages.map((message) => message.id));
1125
- }
1126
- this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id));
1127
- return messages;
1128
- }
1129
- scheduleMetadataWrite() {
1130
- this.enqueueWrite(async () => {
1131
- const { messages, ...metadata } = this.readChatDataFromFile();
1132
- await this.writeMetadata(metadata);
1367
+ }, "collect");
1368
+ collect(elements.map(toSatoriElement));
1369
+ return nodes.length > 0 ? nodes : void 0;
1370
+ }
1371
+ __name(toForwardNodes, "toForwardNodes");
1372
+ function detectKind(elements) {
1373
+ const stack = [...elements.map(toSatoriElement)];
1374
+ while (stack.length) {
1375
+ const element = stack.pop();
1376
+ if (!element) continue;
1377
+ const type = getString2(element.type);
1378
+ if (isForwardContainer(element)) return "forward";
1379
+ if (type === "img" || type === "image" || type === "mface") return "image";
1380
+ if (type === "audio" || type === "record") return "voice";
1381
+ if (type === "video") return "video";
1382
+ if (type === "file") return "file";
1383
+ stack.push(...element.children ?? []);
1384
+ }
1385
+ return "text";
1386
+ }
1387
+ __name(detectKind, "detectKind");
1388
+ function forwardId(elements) {
1389
+ const stack = [...elements.map(toSatoriElement)];
1390
+ while (stack.length) {
1391
+ const element = stack.pop();
1392
+ if (!element) continue;
1393
+ if (isForwardContainer(element)) {
1394
+ const id = getString2(element.attrs?.id);
1395
+ if (id) return id;
1396
+ }
1397
+ stack.push(...element.children ?? []);
1398
+ }
1399
+ return void 0;
1400
+ }
1401
+ __name(forwardId, "forwardId");
1402
+ var _SelfMessageRecorder = class _SelfMessageRecorder {
1403
+ constructor(ctx, config, database, logger) {
1404
+ this.ctx = ctx;
1405
+ this.config = config;
1406
+ this.database = database;
1407
+ this.logger = logger;
1408
+ this.wrapped = /* @__PURE__ */ new Map();
1409
+ this.sequence = 0;
1410
+ }
1411
+ start() {
1412
+ for (const bot of this.ctx.bots) this.wrapBot(bot);
1413
+ this.ctx.on("bot-added", (bot) => this.wrapBot(bot));
1414
+ this.ctx.on("bot-removed", (bot) => this.unwrapBot(bot));
1415
+ this.ctx.on("send", (session) => {
1416
+ void this.recordSendEvent(session).catch((error) => {
1417
+ this.logger.warn("记录 send 事件失败:", error);
1418
+ });
1133
1419
  });
1134
1420
  }
1135
- enqueueWrite(task) {
1136
- if (this.disposed) {
1137
- return;
1421
+ dispose() {
1422
+ for (const bot of [...this.wrapped.keys()]) {
1423
+ this.unwrapBot(bot);
1138
1424
  }
1139
- this.writeQueue = this.writeQueue.then(task).catch((error) => {
1140
- this.logger.error("写入任务失败:", error);
1141
- });
1142
1425
  }
1143
- async listStoredChannels() {
1144
- const result = [];
1145
- await this.scanStoredChannels(async (entry) => {
1146
- result.push(entry);
1426
+ isBlocked(platform) {
1427
+ return (this.config.blockedPlatforms ?? []).some((item) => {
1428
+ return item.exactMatch ? platform === item.platformName : platform.includes(item.platformName);
1147
1429
  });
1148
- return result;
1149
1430
  }
1150
- async listBotChannels(selfId) {
1151
- const result = [];
1152
- await this.scanStoredChannels(async (entry) => {
1153
- if (entry.selfId === selfId) {
1154
- result.push(entry);
1431
+ // 包装三个发送入口,覆盖 Satori message.create 和插件直接发送
1432
+ wrapBot(bot) {
1433
+ if (this.wrapped.has(bot)) return;
1434
+ const methods = {
1435
+ createMessage: bot.createMessage,
1436
+ sendMessage: bot.sendMessage,
1437
+ sendPrivateMessage: bot.sendPrivateMessage
1438
+ };
1439
+ this.wrapped.set(bot, methods);
1440
+ bot.createMessage = async (channelId, content, referrer, options) => {
1441
+ const sendOptions = this.prepareOptions(options);
1442
+ if (sendOptions.chatPatchId) {
1443
+ return methods.createMessage.call(bot, channelId, content, referrer, sendOptions);
1444
+ }
1445
+ const localId = (0, import_node_crypto3.randomUUID)();
1446
+ sendOptions.chatPatchId = localId;
1447
+ try {
1448
+ const messages = await methods.createMessage.call(bot, channelId, content, referrer, sendOptions);
1449
+ const ids = Array.isArray(messages) ? messages.map((item) => getString2(getObject(item).id)).filter(Boolean) : [];
1450
+ await this.recordFromWrapper(bot, channelId, content, ids, options, "create", localId);
1451
+ return messages;
1452
+ } finally {
1453
+ delete sendOptions.chatPatchId;
1155
1454
  }
1156
- }, selfId);
1157
- return result;
1158
- }
1159
- async scanStoredChannels(visitor, botIdFilter) {
1160
- try {
1161
- const botDirs = await import_node_fs3.promises.readdir(this.chatHistoryDir, { withFileTypes: true });
1162
- for (const botEntry of botDirs) {
1163
- const botName = this.normalizeDirentName(botEntry.name);
1164
- if (!botEntry.isDirectory()) continue;
1165
- if (botIdFilter && botName !== botIdFilter) continue;
1166
- const botDir = import_node_path3.default.join(this.chatHistoryDir, botName);
1167
- const channelEntries = await import_node_fs3.promises.readdir(botDir, { withFileTypes: true });
1168
- for (const channelEntry of channelEntries) {
1169
- const channelId = await this.resolveStoredChannelId(botName, channelEntry);
1170
- if (!channelId) {
1171
- continue;
1172
- }
1173
- await visitor(this.createStoredChannelEntry(botName, channelId));
1174
- }
1455
+ };
1456
+ bot.sendMessage = async (channelId, content, referrer, options) => {
1457
+ const sendOptions = this.prepareOptions(options);
1458
+ if (sendOptions.chatPatchId) {
1459
+ return methods.sendMessage.call(bot, channelId, content, referrer, sendOptions);
1175
1460
  }
1176
- } catch (error) {
1177
- if (!this.isFileMissingError(error)) {
1178
- this.logger.error("扫描频道文件失败:", error);
1461
+ const localId = (0, import_node_crypto3.randomUUID)();
1462
+ sendOptions.chatPatchId = localId;
1463
+ try {
1464
+ const ids = await methods.sendMessage.call(bot, channelId, content, referrer, sendOptions);
1465
+ await this.recordFromWrapper(bot, channelId, content, ids, options, "send", localId);
1466
+ return ids;
1467
+ } finally {
1468
+ delete sendOptions.chatPatchId;
1179
1469
  }
1180
- }
1181
- }
1182
- createStoredChannelEntry(selfId, channelId) {
1183
- const channelDirPath = this.getChannelDirPath(selfId, channelId);
1184
- return {
1185
- selfId,
1186
- channelId,
1187
- channelKey: `${selfId}:${channelId}`,
1188
- channelDirPath,
1189
- indexFilePath: this.getChannelIndexPath(selfId, channelId)
1190
1470
  };
1191
- }
1192
- async resolveStoredChannelId(selfId, entry) {
1193
- const entryName = this.normalizeDirentName(entry.name);
1194
- if (!entry.isDirectory()) {
1195
- return void 0;
1196
- }
1197
- const encodedChannelId = entryName;
1198
- const channelDirPath = import_node_path3.default.join(this.chatHistoryDir, selfId, encodedChannelId);
1199
- const indexFilePath = import_node_path3.default.join(channelDirPath, "index.json");
1200
- try {
1201
- const jsonData = await import_node_fs3.promises.readFile(indexFilePath, "utf8");
1202
- const indexData = JSON.parse(jsonData);
1203
- if (typeof indexData.channelId === "string") {
1204
- return indexData.channelId;
1471
+ bot.sendPrivateMessage = async (userId, content, guildId, options) => {
1472
+ const sendOptions = this.prepareOptions(options);
1473
+ if (sendOptions.chatPatchId) {
1474
+ return methods.sendPrivateMessage.call(bot, userId, content, guildId, sendOptions);
1205
1475
  }
1206
- } catch (error) {
1207
- if (!this.isFileMissingError(error)) {
1208
- this.logger.error(`读取频道索引失败 [${selfId}:${entryName}]:`, error);
1476
+ const localId = (0, import_node_crypto3.randomUUID)();
1477
+ sendOptions.chatPatchId = localId;
1478
+ try {
1479
+ const ids = await methods.sendPrivateMessage.call(bot, userId, content, guildId, sendOptions);
1480
+ await this.recordFromWrapper(bot, userId, content, ids, options, "private", localId);
1481
+ return ids;
1482
+ } finally {
1483
+ delete sendOptions.chatPatchId;
1209
1484
  }
1210
- }
1211
- return this.decodeChannelId(encodedChannelId);
1212
- }
1213
- getBotDirPath(selfId) {
1214
- return import_node_path3.default.join(this.chatHistoryDir, selfId);
1215
- }
1216
- getEncodedChannelId(channelId) {
1217
- return encodeURIComponent(channelId);
1485
+ };
1218
1486
  }
1219
- decodeChannelId(encodedChannelId) {
1220
- try {
1221
- return decodeURIComponent(encodedChannelId);
1222
- } catch {
1223
- return encodedChannelId;
1487
+ unwrapBot(bot) {
1488
+ const methods = this.wrapped.get(bot);
1489
+ if (!methods) return;
1490
+ bot.createMessage = methods.createMessage;
1491
+ bot.sendMessage = methods.sendMessage;
1492
+ bot.sendPrivateMessage = methods.sendPrivateMessage;
1493
+ this.wrapped.delete(bot);
1494
+ }
1495
+ prepareOptions(options) {
1496
+ return options ?? {};
1497
+ }
1498
+ async recordFromWrapper(bot, channelId, content, ids, options, mode, localId) {
1499
+ if (!channelId || this.isBlocked(bot.platform ?? "")) return;
1500
+ const source = options?.session ? "plugin" : "bot";
1501
+ const record = this.buildRecord(
1502
+ bot.platform ?? "",
1503
+ bot.selfId,
1504
+ channelId,
1505
+ options?.session?.guildId,
1506
+ content,
1507
+ ids[0],
1508
+ source,
1509
+ mode,
1510
+ localId
1511
+ );
1512
+ await this.database.upsertSelfMessage(record);
1513
+ this.logger.logInfo("机器人消息已记录:", record.kind, record.channelId, record.messageId || record.id);
1514
+ }
1515
+ // 收到 send 事件时只补消息 id,避免重复记录
1516
+ async recordSendEvent(session) {
1517
+ if (session.type !== "send") return;
1518
+ const platform = session.platform || "";
1519
+ const selfId = session.selfId || "";
1520
+ const event = getObject(session.event);
1521
+ const eventChannel = getObject(event.channel);
1522
+ const eventMessage = getObject(event.message);
1523
+ const channelId = session.channelId || getString2(eventChannel.id);
1524
+ if (this.isBlocked(platform) || !platform || !selfId || !channelId) return;
1525
+ const messageId = session.messageId || getString2(eventMessage.id);
1526
+ if (messageId) {
1527
+ const updated = await this.database.updateSelfMessageByMessageId(
1528
+ platform,
1529
+ selfId,
1530
+ channelId,
1531
+ messageId,
1532
+ { messageId }
1533
+ );
1534
+ if (updated) return;
1535
+ } else {
1536
+ return;
1224
1537
  }
1225
- }
1226
- normalizeDirentName(name2) {
1227
- return typeof name2 === "string" ? name2 : name2.toString("utf8");
1228
- }
1229
- getChannelDirPath(selfId, channelId) {
1230
- return import_node_path3.default.join(this.getBotDirPath(selfId), this.getEncodedChannelId(channelId));
1231
- }
1232
- getChannelIndexPath(selfId, channelId) {
1233
- return import_node_path3.default.join(this.getChannelDirPath(selfId, channelId), "index.json");
1234
- }
1235
- getChunkFilePath(channelDirPath, fileName) {
1236
- return import_node_path3.default.join(channelDirPath, fileName);
1237
- }
1238
- createEmptyChannelIndex(channelId) {
1239
- return {
1240
- version: 1,
1538
+ const record = this.buildRecord(
1539
+ platform,
1540
+ selfId,
1241
1541
  channelId,
1242
- totalMessages: 0,
1243
- nextChunkId: 1,
1244
- chunks: []
1245
- };
1246
- }
1247
- createChunkFileName(chunkId) {
1248
- return `chunk-${String(chunkId).padStart(6, "0")}.json`;
1542
+ session.guildId,
1543
+ session.content ?? "",
1544
+ messageId,
1545
+ "bot",
1546
+ "send",
1547
+ (0, import_node_crypto3.randomUUID)()
1548
+ );
1549
+ await this.database.upsertSelfMessage(record);
1249
1550
  }
1250
- async loadOrCreateChannelIndex(selfId, channelId) {
1251
- const entry = this.createStoredChannelEntry(selfId, channelId);
1551
+ resolveI18nElement(attrs) {
1552
+ const path5 = getString2(attrs.path);
1553
+ if (!path5 || !this.ctx.i18n) return `[${path5 || "i18n"}]`;
1252
1554
  try {
1253
- const jsonData = await import_node_fs3.promises.readFile(entry.indexFilePath, "utf8");
1254
- const indexData2 = JSON.parse(jsonData);
1255
- const normalizedIndexData = this.normalizeChannelIndex(channelId, indexData2);
1256
- this.syncDirtyChannelState(entry.channelKey, normalizedIndexData.totalMessages);
1257
- return normalizedIndexData;
1258
- } catch (error) {
1259
- if (!this.isFileMissingError(error)) {
1260
- this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error);
1261
- }
1555
+ const locales = this.ctx.i18n.fallback([]);
1556
+ const text = this.ctx.i18n.render(locales, [path5], attrs || {});
1557
+ if (text && typeof text === "string") return text;
1558
+ } catch {
1262
1559
  }
1263
- const indexData = this.createEmptyChannelIndex(channelId);
1264
- await this.ensureDir(entry.channelDirPath);
1265
- await this.writeChannelIndex(entry.indexFilePath, indexData);
1266
- this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages);
1267
- return indexData;
1268
- }
1269
- normalizeChannelIndex(channelId, indexData) {
1560
+ const knownFallback = KOISHI_STATUS_I18N_FALLBACK[path5];
1561
+ if (knownFallback) return knownFallback;
1562
+ return `[${path5}]`;
1563
+ }
1564
+ buildRecord(platform, selfId, channelId, guildId, content, messageId, source, mode, localId) {
1565
+ const normalizedChannelId = mode === "private" && !isPrivateChannel(channelId) ? `private:${channelId}` : channelId;
1566
+ const channelType = mode === "private" || isPrivateChannel(normalizedChannelId) ? "user" : "group";
1567
+ const elements = this.normalizeElements(content);
1568
+ const contentText = typeof content === "string" ? content : import_koishi.h.toElementArray(content).join("");
1569
+ const fingerprint = (0, import_node_crypto3.createHash)("sha256").update([
1570
+ platform,
1571
+ selfId,
1572
+ normalizedChannelId,
1573
+ JSON.stringify(elements)
1574
+ ].join("\0")).digest("hex");
1270
1575
  return {
1271
- version: 1,
1272
- channelId,
1273
- totalMessages: indexData.totalMessages || 0,
1274
- nextChunkId: indexData.nextChunkId || (indexData.chunks?.length || 0) + 1,
1275
- chunks: Array.isArray(indexData.chunks) ? indexData.chunks : []
1576
+ id: `self-${localId}`,
1577
+ platform,
1578
+ selfId,
1579
+ channelId: normalizedChannelId,
1580
+ guildId,
1581
+ channelType,
1582
+ messageId,
1583
+ content: contentText,
1584
+ elements: JSON.parse(JSON.stringify(elements)),
1585
+ message: toSegments(elements, (attrs) => this.resolveI18nElement(attrs)),
1586
+ forwardId: forwardId(elements),
1587
+ forwardContent: toForwardNodes(elements, (attrs) => this.resolveI18nElement(attrs)),
1588
+ sentAt: Date.now(),
1589
+ sequence: ++this.sequence,
1590
+ source,
1591
+ kind: detectKind(elements),
1592
+ fingerprint
1276
1593
  };
1277
1594
  }
1278
- async writeChannelIndex(indexFilePath, indexData) {
1279
- await this.ensureDir(import_node_path3.default.dirname(indexFilePath));
1280
- await this.atomicWriteTextFile(indexFilePath, JSON.stringify(indexData, null, 2));
1281
- }
1282
- async readChunkMessages(channelDirPath, fileName) {
1595
+ normalizeElements(content) {
1283
1596
  try {
1284
- const jsonData = await import_node_fs3.promises.readFile(this.getChunkFilePath(channelDirPath, fileName), "utf8");
1285
- const messages = JSON.parse(jsonData);
1286
- return Array.isArray(messages) ? messages : [];
1287
- } catch (error) {
1288
- if (!this.isFileMissingError(error)) {
1289
- this.logger.error(`读取消息分块失败 [${channelDirPath}/${fileName}]:`, error);
1290
- }
1597
+ const elements = typeof content === "string" ? import_koishi.h.parse(content) : import_koishi.h.toElementArray(content);
1598
+ return JSON.parse(JSON.stringify(elements));
1599
+ } catch {
1291
1600
  return [];
1292
1601
  }
1293
1602
  }
1294
- async writeChunkMessages(channelDirPath, fileName, messages) {
1295
- await this.ensureDir(channelDirPath);
1296
- await this.atomicWriteTextFile(this.getChunkFilePath(channelDirPath, fileName), JSON.stringify(messages, null, 2));
1297
- }
1298
- async writeMessagesToChunks(channelDirPath, indexData, messages) {
1299
- const oldChunks = [...indexData.chunks];
1300
- indexData.chunks = [];
1301
- indexData.totalMessages = 0;
1302
- indexData.nextChunkId = 1;
1303
- for (let offset = 0; offset < messages.length; offset += this.config.messageChunkSize) {
1304
- const chunkMessages = messages.slice(offset, offset + this.config.messageChunkSize);
1305
- const chunkId = indexData.nextChunkId;
1306
- const fileName = this.createChunkFileName(chunkId);
1307
- await this.writeChunkMessages(channelDirPath, fileName, chunkMessages);
1308
- indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length });
1309
- indexData.nextChunkId += 1;
1310
- indexData.totalMessages += chunkMessages.length;
1311
- }
1312
- for (const chunk of oldChunks) {
1313
- try {
1314
- await import_node_fs3.promises.unlink(this.getChunkFilePath(channelDirPath, chunk.fileName));
1315
- } catch (error) {
1316
- if (!this.isFileMissingError(error)) {
1317
- this.logger.warn(`删除旧消息分块失败 [${channelDirPath}/${chunk.fileName}]:`, error);
1603
+ };
1604
+ __name(_SelfMessageRecorder, "SelfMessageRecorder");
1605
+ var SelfMessageRecorder = _SelfMessageRecorder;
1606
+
1607
+ // src/bootstrap.ts
1608
+ function resolveEndpoint(ctx, config) {
1609
+ const url = ctx.satori?.server?.url ?? "/satori";
1610
+ const clean = url.startsWith("undefined") ? url.slice(9) : url;
1611
+ if (/^https?:\/\//i.test(clean)) return clean;
1612
+ const base = ctx.server?.selfUrl ?? ctx.server?.config?.selfUrl ?? "";
1613
+ return `${base}${clean.startsWith("/") ? clean : `/${clean}`}`;
1614
+ }
1615
+ __name(resolveEndpoint, "resolveEndpoint");
1616
+ function registerBootstrap(ctx, config, database, logger) {
1617
+ ctx.console.addListener("chat-patch/bootstrap", () => {
1618
+ logger.logInfo("返回 Satori bootstrap:", {
1619
+ endpoint: resolveEndpoint(ctx, config),
1620
+ basePath: config.basePath
1621
+ });
1622
+ return {
1623
+ endpoint: resolveEndpoint(ctx, config),
1624
+ token: ctx.satori?.server?.config?.token ?? "",
1625
+ basePath: config.basePath
1626
+ };
1627
+ }, { authority: 4 });
1628
+ ctx.console.addListener("chat-patch/history", async (query) => {
1629
+ const messages = await database.listMessages(
1630
+ query.platform,
1631
+ query.selfId,
1632
+ query.channelId,
1633
+ query.limit
1634
+ );
1635
+ return { messages };
1636
+ }, { authority: 4 });
1637
+ ctx.console.addListener("chat-patch/config", () => {
1638
+ return {
1639
+ basePath: config.basePath,
1640
+ maxMessagesPerChannel: config.maxMessagesPerChannel,
1641
+ historyPageSize: config.historyPageSize,
1642
+ maxMediaFiles: config.maxMediaFiles,
1643
+ blockedPlatforms: config.blockedPlatforms ?? [],
1644
+ loggerinfo: config.loggerinfo
1645
+ };
1646
+ }, { authority: 4 });
1647
+ ctx.console.addListener("chat-patch/contact-cache", async (query) => {
1648
+ if (query.contacts) {
1649
+ if (query.append) {
1650
+ for (const contact of query.contacts) {
1651
+ await database.appendContact(query.platform, query.selfId, query.type, contact);
1318
1652
  }
1653
+ return { contacts: await database.getContacts(query.platform, query.selfId, query.type) };
1319
1654
  }
1655
+ await database.setContacts(query.platform, query.selfId, query.type, query.contacts);
1656
+ return { contacts: query.contacts };
1320
1657
  }
1321
- await this.writeChannelIndex(import_node_path3.default.join(channelDirPath, "index.json"), indexData);
1322
- }
1323
- async appendMessagesToChannel(selfId, channelId, messages) {
1324
- if (!messages.length) {
1658
+ return { contacts: await database.getContacts(query.platform, query.selfId, query.type) };
1659
+ }, { authority: 4 });
1660
+ }
1661
+ __name(registerBootstrap, "registerBootstrap");
1662
+
1663
+ // src/web.ts
1664
+ var import_node_fs2 = require("node:fs");
1665
+ var import_node_crypto4 = require("node:crypto");
1666
+ var import_node_path3 = __toESM(require("node:path"));
1667
+ var import_node_url2 = require("node:url");
1668
+ var import_koa_send = __toESM(require("koa-send"));
1669
+ var import_file_type2 = __toESM(require("file-type"));
1670
+ function registerWeb(ctx, config, database, contactCache, logger) {
1671
+ const webRoot = import_node_path3.default.resolve(__dirname, "..", "client", "web", "dist");
1672
+ const webSource = import_node_path3.default.resolve(__dirname, "..", "client", "web", "src");
1673
+ const webPublic = import_node_path3.default.resolve(__dirname, "..", "client", "web", "public");
1674
+ const webIndex = import_node_path3.default.resolve(__dirname, "..", "client", "web", "index.html");
1675
+ const uploadDir = import_node_path3.default.resolve(ctx.baseDir, "data", "chat-patch", "upload-media");
1676
+ const cacheType = /* @__PURE__ */ __name((type) => type === "user" ? "friend" : type, "cacheType");
1677
+ const getVite = /* @__PURE__ */ __name(() => {
1678
+ return ctx.console.vite;
1679
+ }, "getVite");
1680
+ const viteFsUrl = /* @__PURE__ */ __name((target) => {
1681
+ return `/vite/@fs/${import_node_path3.default.resolve(target).split(import_node_path3.default.sep).join("/")}`;
1682
+ }, "viteFsUrl");
1683
+ const transformWebIndex = /* @__PURE__ */ __name(async (vite, requestPath) => {
1684
+ if (vite.config?.server) vite.config.server.preTransformRequests = false;
1685
+ const mainFsUrl = `/@fs/${import_node_path3.default.resolve(webSource, "main.ts").split(import_node_path3.default.sep).join("/")}`;
1686
+ const html = (await import_node_fs2.promises.readFile(webIndex, "utf8")).replace(
1687
+ 'src="/src/main.ts"',
1688
+ `src="${mainFsUrl}"`
1689
+ );
1690
+ let transformed = await vite.transformIndexHtml("/chat-patch/web/index.html", html, requestPath);
1691
+ transformed = transformed.replace(/\/vite\/bcui/g, viteFsUrl(import_node_path3.default.join(webPublic, "bcui"))).replace(/\/vite\/css/g, viteFsUrl(import_node_path3.default.join(webPublic, "css")));
1692
+ return transformed;
1693
+ }, "transformWebIndex");
1694
+ const servePublicFile = /* @__PURE__ */ __name(async (koa, prefix) => {
1695
+ const relative = (koa.params?.[0] ?? "").replace(/^\/+/, "");
1696
+ const fullPath = import_node_path3.default.resolve(webPublic, prefix, relative);
1697
+ if (!fullPath.startsWith(webPublic + import_node_path3.default.sep)) {
1698
+ koa.status = 403;
1699
+ koa.body = "";
1325
1700
  return;
1326
1701
  }
1327
- const entry = this.createStoredChannelEntry(selfId, channelId);
1328
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
1329
- await this.ensureDir(entry.channelDirPath);
1330
- let remainingMessages = [...messages];
1331
- const lastChunk = indexData.chunks[indexData.chunks.length - 1];
1332
- if (lastChunk && lastChunk.messageCount < this.config.messageChunkSize) {
1333
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, lastChunk.fileName);
1334
- const writableCount = this.config.messageChunkSize - chunkMessages.length;
1335
- const appendMessages = remainingMessages.slice(0, writableCount);
1336
- if (appendMessages.length) {
1337
- chunkMessages.push(...appendMessages);
1338
- lastChunk.messageCount = chunkMessages.length;
1339
- indexData.totalMessages += appendMessages.length;
1340
- remainingMessages = remainingMessages.slice(appendMessages.length);
1341
- await this.writeChunkMessages(entry.channelDirPath, lastChunk.fileName, chunkMessages);
1342
- this.rememberMessageChunkLocation(entry.channelKey, lastChunk.fileName, appendMessages.map((message) => message.id));
1343
- }
1344
- }
1345
- while (remainingMessages.length) {
1346
- const chunkMessages = remainingMessages.slice(0, this.config.messageChunkSize);
1347
- const chunkId = indexData.nextChunkId;
1348
- const fileName = this.createChunkFileName(chunkId);
1349
- await this.writeChunkMessages(entry.channelDirPath, fileName, chunkMessages);
1350
- indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length });
1351
- indexData.nextChunkId += 1;
1352
- indexData.totalMessages += chunkMessages.length;
1353
- this.rememberMessageChunkLocation(entry.channelKey, fileName, chunkMessages.map((message) => message.id));
1354
- remainingMessages = remainingMessages.slice(chunkMessages.length);
1355
- }
1356
- this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages);
1357
- await this.writeChannelIndex(entry.indexFilePath, indexData);
1358
- }
1359
- async trimChannelToLimit(selfId, channelId, indexData, limit) {
1360
- const entry = this.createStoredChannelEntry(selfId, channelId);
1361
- let overflow = indexData.totalMessages - limit;
1362
- if (overflow <= 0) {
1363
- return 0;
1364
- }
1365
- let removedCount = 0;
1366
- while (overflow > 0 && indexData.chunks.length) {
1367
- const chunk = indexData.chunks[0];
1368
- if (chunk.messageCount <= overflow) {
1369
- const removedChunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
1370
- overflow -= chunk.messageCount;
1371
- removedCount += chunk.messageCount;
1372
- indexData.totalMessages -= chunk.messageCount;
1373
- indexData.chunks.shift();
1374
- this.forgetRecentMessageIds(entry.channelKey, removedChunkMessages.map((message) => message.id));
1375
- this.forgetMessageChunkLocation(entry.channelKey, removedChunkMessages.map((message) => message.id));
1376
- try {
1377
- await import_node_fs3.promises.unlink(this.getChunkFilePath(entry.channelDirPath, chunk.fileName));
1378
- } catch (error) {
1379
- if (!this.isFileMissingError(error)) {
1380
- this.logger.warn(`删除消息分块失败 [${entry.channelKey}:${chunk.fileName}]:`, error);
1381
- }
1382
- }
1383
- continue;
1384
- }
1385
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
1386
- const keptMessages = chunkMessages.slice(overflow);
1387
- const removedMessages = chunkMessages.slice(0, overflow);
1388
- removedCount += overflow;
1389
- indexData.totalMessages -= overflow;
1390
- chunk.messageCount = keptMessages.length;
1391
- overflow = 0;
1392
- this.forgetRecentMessageIds(entry.channelKey, removedMessages.map((message) => message.id));
1393
- this.forgetMessageChunkLocation(entry.channelKey, removedMessages.map((message) => message.id));
1394
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, keptMessages);
1395
- }
1396
- this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages);
1397
- await this.writeChannelIndex(entry.indexFilePath, indexData);
1398
- return removedCount;
1399
- }
1400
- async countChannelMessages(selfId, channelId) {
1401
- const entry = this.createStoredChannelEntry(selfId, channelId);
1402
- try {
1403
- const jsonData = await import_node_fs3.promises.readFile(entry.indexFilePath, "utf8");
1404
- const indexData = JSON.parse(jsonData);
1405
- if (typeof indexData.totalMessages === "number") {
1406
- return indexData.totalMessages;
1407
- }
1408
- } catch (error) {
1409
- if (!this.isFileMissingError(error)) {
1410
- this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error);
1411
- }
1702
+ if ((0, import_node_fs2.existsSync)(fullPath) && (0, import_node_fs2.statSync)(fullPath).isFile()) {
1703
+ await (0, import_koa_send.default)(koa, import_node_path3.default.relative(webPublic, fullPath), { root: webPublic });
1704
+ return;
1412
1705
  }
1413
- return 0;
1414
- }
1415
- async removeChannelStorage(selfId, channelId) {
1416
- const entry = this.createStoredChannelEntry(selfId, channelId);
1417
- try {
1418
- await import_node_fs3.promises.rm(entry.channelDirPath, { recursive: true, force: true });
1419
- } catch (error) {
1420
- this.logger.error(`删除频道目录失败 [${entry.channelKey}]:`, error);
1421
- }
1422
- }
1423
- async updateUserProfileInChannel(selfId, channelId, userId, userName, avatar) {
1424
- const entry = this.createStoredChannelEntry(selfId, channelId);
1425
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
1426
- let changed = false;
1427
- for (const chunk of indexData.chunks) {
1428
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
1429
- let chunkChanged = false;
1430
- for (const message of messages) {
1431
- if (message.userId !== userId) {
1432
- continue;
1433
- }
1434
- if (userName && message.username !== userName) {
1435
- message.username = userName;
1436
- chunkChanged = true;
1437
- }
1438
- if (avatar && message.avatar !== avatar) {
1439
- message.avatar = avatar;
1440
- chunkChanged = true;
1441
- }
1442
- }
1443
- if (!chunkChanged) {
1444
- continue;
1706
+ koa.status = 404;
1707
+ koa.body = "";
1708
+ }, "servePublicFile");
1709
+ const registerDevPublic = /* @__PURE__ */ __name((pattern, prefix) => {
1710
+ ctx.server.get(pattern, async (koa, next) => {
1711
+ if (!getVite()) return next();
1712
+ await servePublicFile(koa, prefix);
1713
+ });
1714
+ }, "registerDevPublic");
1715
+ registerDevPublic("/img(.*)", "img");
1716
+ const toExtension2 = /* @__PURE__ */ __name((value) => {
1717
+ const ext = value.startsWith(".") ? value : `.${value}`;
1718
+ return ext.toLowerCase();
1719
+ }, "toExtension");
1720
+ const normalizeGroupId3 = /* @__PURE__ */ __name((value) => {
1721
+ const raw = value.replace(/^(?:group|room|chat|channel|guild):/i, "").trim();
1722
+ const wrapped = raw.match(/^\[_?([\s\S]+?)_?\]$/);
1723
+ return wrapped ? wrapped[1] : raw || value;
1724
+ }, "normalizeGroupId");
1725
+ const historyChannelCandidates = /* @__PURE__ */ __name((channelId) => {
1726
+ const raw = normalizeGroupId3(channelId);
1727
+ const candidates = [channelId];
1728
+ if (raw && raw !== channelId) candidates.push(raw);
1729
+ const id = raw || channelId;
1730
+ if (!/^(?:group|room|chat|channel|guild|private):/i.test(channelId)) {
1731
+ candidates.push(`group:${id}`, `private:${id}`);
1732
+ }
1733
+ if (id) {
1734
+ candidates.push(` [_${id}_] `, `_${id}_`, `[${id}]`, `group:${id}`);
1735
+ }
1736
+ return [...new Set(candidates)];
1737
+ }, "historyChannelCandidates");
1738
+ const mimeToExt = {
1739
+ "audio/mpeg": ".mp3",
1740
+ "audio/mp3": ".mp3",
1741
+ "audio/mp4": ".m4a",
1742
+ "audio/x-m4a": ".m4a",
1743
+ "audio/wav": ".wav",
1744
+ "audio/x-wav": ".wav",
1745
+ "audio/webm": ".webm",
1746
+ "audio/ogg": ".ogg",
1747
+ "audio/aac": ".aac",
1748
+ "audio/flac": ".flac",
1749
+ "image/jpeg": ".jpg",
1750
+ "image/png": ".png",
1751
+ "image/gif": ".gif",
1752
+ "image/webp": ".webp",
1753
+ "video/mp4": ".mp4",
1754
+ "video/webm": ".webm",
1755
+ "video/quicktime": ".mov",
1756
+ "video/x-msvideo": ".avi",
1757
+ "video/x-matroska": ".mkv",
1758
+ "video/x-flv": ".flv",
1759
+ "video/3gpp": ".3gp",
1760
+ "video/ogg": ".ogv",
1761
+ "video/mp2t": ".ts"
1762
+ };
1763
+ const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
1764
+ ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
1765
+ const requestBody = koa.request.body;
1766
+ const body = requestBody ?? {};
1767
+ let source = String(body.dataUrl ?? body.data ?? "");
1768
+ let name2 = String(body.name ?? "");
1769
+ let mime = "";
1770
+ let buffer = null;
1771
+ if (source) {
1772
+ if (source.startsWith("base64://")) source = source.slice(9);
1773
+ const comma = source.indexOf("base64,");
1774
+ const base64 = comma >= 0 ? source.slice(comma + 7) : source;
1775
+ const normalizedBase64 = base64.replace(/-/g, "+").replace(/_/g, "/");
1776
+ try {
1777
+ buffer = Buffer.from(normalizedBase64, "base64");
1778
+ } catch {
1779
+ koa.status = 400;
1780
+ koa.body = { error: "invalid media data" };
1781
+ return;
1445
1782
  }
1446
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages);
1447
- changed = true;
1448
- }
1449
- return changed;
1450
- }
1451
- async findAndUpdateLatestBotMessage(selfId, channelId, realId) {
1452
- const entry = this.createStoredChannelEntry(selfId, channelId);
1453
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
1454
- for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
1455
- const chunk = indexData.chunks[index];
1456
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
1457
- const matched = [...messages].reverse().find((message) => message.type === "bot" && message.sending);
1458
- if (!matched) {
1459
- continue;
1783
+ mime = source.startsWith("data:") ? source.slice(5, source.indexOf(";")).toLowerCase() : "";
1784
+ } else {
1785
+ const chunks = [];
1786
+ for await (const chunk of koa.req) {
1787
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1460
1788
  }
1461
- matched.realId = realId;
1462
- matched.sending = false;
1463
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages);
1464
- return matched;
1465
- }
1466
- return void 0;
1467
- }
1468
- async findAndUpdateBotMessageByTempId(selfId, channelId, tempMessageId, realId) {
1469
- const channelKey = `${selfId}:${channelId}`;
1470
- const pendingMessages = this.pendingMessages.get(channelKey);
1471
- const pendingMatched = pendingMessages?.find((message) => message.id === tempMessageId);
1472
- if (pendingMatched) {
1473
- pendingMatched.realId = realId;
1474
- pendingMatched.sending = false;
1475
- return pendingMatched;
1476
- }
1477
- const cachedMessages = this.peekCachedChannelMessages(channelKey);
1478
- const cachedMatched = cachedMessages?.find((message) => message.id === tempMessageId);
1479
- if (cachedMatched) {
1480
- cachedMatched.realId = realId;
1481
- cachedMatched.sending = false;
1482
- this.setCachedChannelMessages(channelKey, cachedMessages);
1483
- }
1484
- const entry = this.createStoredChannelEntry(selfId, channelId);
1485
- const chunkFileName = this.getMessageChunkLocation(channelKey, tempMessageId);
1486
- if (chunkFileName) {
1487
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunkFileName);
1488
- const matched = chunkMessages.find((message) => message.id === tempMessageId);
1489
- if (matched) {
1490
- matched.realId = realId;
1491
- matched.sending = false;
1492
- await this.writeChunkMessages(entry.channelDirPath, chunkFileName, chunkMessages);
1493
- return matched;
1789
+ buffer = Buffer.concat(chunks);
1790
+ name2 = String(koa.query.name ?? koa.get("x-file-name") ?? "");
1791
+ try {
1792
+ name2 = decodeURIComponent(name2);
1793
+ } catch {
1494
1794
  }
1795
+ mime = String(koa.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
1495
1796
  }
1496
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
1497
- for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
1498
- const chunk = indexData.chunks[index];
1499
- const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
1500
- const matched = chunkMessages.find((message) => message.id === tempMessageId);
1501
- if (!matched) {
1502
- continue;
1503
- }
1504
- matched.realId = realId;
1505
- matched.sending = false;
1506
- await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, chunkMessages);
1507
- this.rememberMessageChunkLocation(channelKey, chunk.fileName, [matched.id]);
1508
- return matched;
1509
- }
1510
- return cachedMatched;
1511
- }
1512
- deduplicateMessages(messages) {
1513
- const seen = /* @__PURE__ */ new Set();
1514
- const deduplicated = [];
1515
- for (const message of messages) {
1516
- if (seen.has(message.id)) {
1517
- continue;
1518
- }
1519
- seen.add(message.id);
1520
- deduplicated.push(message);
1797
+ if (!buffer || !buffer.length) {
1798
+ koa.status = 400;
1799
+ koa.body = { error: "missing media data" };
1800
+ return;
1521
1801
  }
1522
- return deduplicated;
1523
- }
1524
- getCachedChannelMessages(channelKey) {
1525
- const cached = this.channelMessagesCache.get(channelKey);
1526
- if (!cached) {
1527
- return void 0;
1802
+ if (buffer.length > MAX_UPLOAD_BYTES) {
1803
+ koa.status = 413;
1804
+ koa.body = { error: "media file too large" };
1805
+ return;
1528
1806
  }
1529
- this.channelMessagesCache.delete(channelKey);
1530
- this.channelMessagesCache.set(channelKey, cached);
1531
- this.memoryCache.messages = this.getChannelMessagesCacheSnapshot();
1532
- return cached;
1533
- }
1534
- peekCachedChannelMessages(channelKey) {
1535
- return this.channelMessagesCache.get(channelKey);
1536
- }
1537
- setCachedChannelMessages(channelKey, messages) {
1538
- this.channelMessagesCache.delete(channelKey);
1539
- this.channelMessagesCache.set(channelKey, messages);
1540
- this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id));
1541
- while (this.channelMessagesCache.size > this.config.channelCacheLimit) {
1542
- const oldestKey = this.channelMessagesCache.keys().next().value;
1543
- if (!oldestKey) {
1544
- break;
1545
- }
1546
- this.channelMessagesCache.delete(oldestKey);
1547
- this.deleteRecentMessageIds(oldestKey);
1548
- this.deleteMessageChunkLocations(oldestKey);
1807
+ if (!buffer.length) {
1808
+ koa.status = 400;
1809
+ koa.body = { error: "invalid media data" };
1810
+ return;
1549
1811
  }
1550
- this.memoryCache.messages = this.getChannelMessagesCacheSnapshot();
1551
- }
1552
- deleteCachedChannelMessages(channelKey) {
1553
- this.channelMessagesCache.delete(channelKey);
1554
- delete this.memoryCache.messages[channelKey];
1555
- }
1556
- getChannelMessagesCacheSnapshot() {
1557
- return Object.fromEntries(this.channelMessagesCache.entries());
1558
- }
1559
- mergeChannelMessages(baseMessages, appendedMessages) {
1560
- const merged = [...baseMessages];
1561
- const knownIds = new Set(baseMessages.map((message) => message.id));
1562
- for (const message of appendedMessages) {
1563
- if (knownIds.has(message.id)) {
1564
- continue;
1565
- }
1566
- knownIds.add(message.id);
1567
- merged.push(message);
1812
+ await import_node_fs2.promises.mkdir(uploadDir, { recursive: true });
1813
+ let detected;
1814
+ try {
1815
+ const result = await import_file_type2.default.fromBuffer(buffer);
1816
+ detected = result ?? void 0;
1817
+ } catch {
1818
+ detected = void 0;
1819
+ }
1820
+ const mimeExt = mimeToExt[mime] || "";
1821
+ const nameExt = import_node_path3.default.extname(name2).toLowerCase();
1822
+ const ext = detected?.ext ? toExtension2(detected.ext) : mimeExt || nameExt || ".bin";
1823
+ const filename = `${(0, import_node_crypto4.createHash)("md5").update(buffer).digest("hex")}${ext}`;
1824
+ const filePath = import_node_path3.default.join(uploadDir, filename);
1825
+ if (!(0, import_node_fs2.existsSync)(filePath)) {
1826
+ await import_node_fs2.promises.writeFile(filePath, buffer);
1827
+ }
1828
+ koa.body = {
1829
+ path: (0, import_node_url2.pathToFileURL)(filePath).href,
1830
+ localPath: filePath
1831
+ };
1832
+ });
1833
+ ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
1834
+ const fileName = import_node_path3.default.basename(String(koa.query.file ?? koa.query.name ?? ""));
1835
+ if (!fileName || fileName === "." || fileName === "..") {
1836
+ koa.status = 400;
1837
+ koa.body = { error: "missing media file" };
1838
+ return;
1568
1839
  }
1569
- return merged;
1570
- }
1571
- limitChannelMessages(messages) {
1572
- if (messages.length <= this.config.maxMessagesPerChannel) {
1573
- return messages;
1840
+ const filePath = import_node_path3.default.resolve(uploadDir, fileName);
1841
+ const root = import_node_path3.default.resolve(uploadDir);
1842
+ if (filePath !== root && !filePath.startsWith(`${root}${import_node_path3.default.sep}`)) {
1843
+ koa.status = 400;
1844
+ koa.body = { error: "invalid media file" };
1845
+ return;
1574
1846
  }
1575
- return [...messages].sort((left, right) => left.timestamp - right.timestamp).slice(-this.config.maxMessagesPerChannel);
1576
- }
1577
- async channelMessageExists(selfId, channelId, messageId) {
1578
- const entry = this.createStoredChannelEntry(selfId, channelId);
1579
- const channelKey = entry.channelKey;
1580
- if (this.hasRecentMessageId(channelKey, messageId)) {
1581
- return true;
1847
+ if (!(0, import_node_fs2.existsSync)(filePath) || !(0, import_node_fs2.statSync)(filePath).isFile()) {
1848
+ koa.status = 404;
1849
+ koa.body = { error: "media file not found" };
1850
+ return;
1582
1851
  }
1583
- const indexData = await this.loadOrCreateChannelIndex(selfId, channelId);
1584
- for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
1585
- const chunk = indexData.chunks[chunkIndex];
1586
- const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName);
1587
- if (messages.some((message) => message.id === messageId)) {
1588
- this.rememberRecentMessageIds(channelKey, [messageId]);
1589
- return true;
1852
+ const size = (0, import_node_fs2.statSync)(filePath).size;
1853
+ koa.set("Accept-Ranges", "bytes");
1854
+ if (fileName.toLowerCase().endsWith(".webm")) {
1855
+ koa.type = "audio/webm";
1856
+ } else {
1857
+ koa.type = fileName;
1858
+ }
1859
+ const range = koa.headers.range;
1860
+ if (range) {
1861
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(range));
1862
+ if (!match) {
1863
+ koa.status = 416;
1864
+ koa.set("Content-Range", `bytes */${size}`);
1865
+ koa.body = "";
1866
+ return;
1590
1867
  }
1868
+ let start = 0;
1869
+ let end = size - 1;
1870
+ if (match[1] === "" && match[2]) {
1871
+ start = Math.max(0, size - Number(match[2]));
1872
+ } else if (match[1]) {
1873
+ start = Number(match[1]);
1874
+ end = match[2] ? Number(match[2]) : end;
1875
+ }
1876
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
1877
+ koa.status = 416;
1878
+ koa.set("Content-Range", `bytes */${size}`);
1879
+ koa.body = "";
1880
+ return;
1881
+ }
1882
+ if (end >= size) end = size - 1;
1883
+ if (end < start) end = start;
1884
+ koa.status = 206;
1885
+ koa.set("Content-Range", `bytes ${start}-${end}/${size}`);
1886
+ koa.set("Content-Length", String(end - start + 1));
1887
+ koa.body = (0, import_node_fs2.createReadStream)(filePath, { start, end });
1888
+ return;
1591
1889
  }
1592
- return false;
1593
- }
1594
- syncDirtyChannelState(channelKey, totalMessages) {
1595
- if (totalMessages > this.config.maxMessagesPerChannel) {
1596
- this.dirtyChannelKeys.add(channelKey);
1890
+ koa.set("Content-Length", String(size));
1891
+ koa.body = (0, import_node_fs2.createReadStream)(filePath);
1892
+ });
1893
+ ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
1894
+ const entries = await database.getAllContacts();
1895
+ const botMap = /* @__PURE__ */ new Map();
1896
+ for (const entry of entries) {
1897
+ const key = JSON.stringify([entry.platform, entry.selfId]);
1898
+ const bot = botMap.get(key) ?? {
1899
+ platform: entry.platform,
1900
+ selfId: entry.selfId,
1901
+ groups: [],
1902
+ friends: []
1903
+ };
1904
+ if (entry.type === "group") bot.groups = entry.contacts;
1905
+ if (entry.type === "friend") bot.friends = entry.contacts;
1906
+ botMap.set(key, bot);
1907
+ }
1908
+ koa.body = { bots: [...botMap.values()] };
1909
+ });
1910
+ ctx.server.get(`${config.basePath}/api/history`, async (koa) => {
1911
+ const platform = String(koa.query.platform ?? "");
1912
+ const selfId = String(koa.query.selfId ?? "");
1913
+ const channelId = String(koa.query.channelId ?? "");
1914
+ if (!platform || !selfId || !channelId) {
1915
+ koa.status = 400;
1916
+ koa.body = { error: "missing history params" };
1597
1917
  return;
1598
1918
  }
1599
- this.dirtyChannelKeys.delete(channelKey);
1600
- }
1601
- registerPendingBotMessage(channelKey, messageId) {
1602
- const messageIds = this.pendingBotMessageIds.get(channelKey) || [];
1603
- messageIds.push(messageId);
1604
- this.pendingBotMessageIds.set(channelKey, messageIds);
1605
- }
1606
- peekLatestPendingBotMessageId(channelKey) {
1607
- const messageIds = this.pendingBotMessageIds.get(channelKey);
1608
- return messageIds?.[messageIds.length - 1];
1609
- }
1610
- consumePendingBotMessageId(channelKey, messageId) {
1611
- const messageIds = this.pendingBotMessageIds.get(channelKey);
1612
- if (!messageIds?.length) {
1919
+ const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel);
1920
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel;
1921
+ const beforeTime = Number(koa.query.beforeTime ?? 0);
1922
+ const queryMessages = /* @__PURE__ */ __name(async (cid) => {
1923
+ return Number.isFinite(beforeTime) && beforeTime > 0 ? database.listMessagesBefore(platform, selfId, cid, beforeTime, limit) : database.listMessages(platform, selfId, cid, limit);
1924
+ }, "queryMessages");
1925
+ let messages = [];
1926
+ for (const candidate of historyChannelCandidates(channelId)) {
1927
+ messages = await queryMessages(candidate);
1928
+ if (messages.length) break;
1929
+ }
1930
+ let selfMessages = [];
1931
+ const querySelfMessages = /* @__PURE__ */ __name(async (cid) => {
1932
+ return Number.isFinite(beforeTime) && beforeTime > 0 ? database.listSelfMessagesBefore(platform, selfId, cid, beforeTime, limit) : database.listSelfMessages(platform, selfId, cid, limit);
1933
+ }, "querySelfMessages");
1934
+ for (const candidate of historyChannelCandidates(channelId)) {
1935
+ selfMessages = await querySelfMessages(candidate);
1936
+ if (selfMessages.length) break;
1937
+ }
1938
+ messages.sort((a, b) => {
1939
+ const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0);
1940
+ const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0);
1941
+ return timeA - timeB;
1942
+ });
1943
+ selfMessages.sort((a, b) => a.sentAt - b.sentAt);
1944
+ koa.body = { messages, selfMessages };
1945
+ });
1946
+ ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
1947
+ const platform = String(koa.query.platform ?? "");
1948
+ const selfId = String(koa.query.selfId ?? "");
1949
+ const channelId = String(koa.query.channelId ?? "");
1950
+ if (!platform || !selfId || !channelId) {
1951
+ koa.status = 400;
1952
+ koa.body = { error: "missing self-message params" };
1613
1953
  return;
1614
1954
  }
1615
- const nextMessageIds = messageIds.filter((id) => id !== messageId);
1616
- if (nextMessageIds.length) {
1617
- this.pendingBotMessageIds.set(channelKey, nextMessageIds);
1955
+ const parsedLimit = Number(koa.query.limit ?? config.historyPageSize);
1956
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.historyPageSize;
1957
+ const beforeTime = Number(koa.query.beforeTime ?? 0);
1958
+ let messages = [];
1959
+ for (const candidate of historyChannelCandidates(channelId)) {
1960
+ messages = Number.isFinite(beforeTime) && beforeTime > 0 ? await database.listSelfMessagesBefore(platform, selfId, candidate, beforeTime, limit) : await database.listSelfMessages(platform, selfId, candidate, limit);
1961
+ if (messages.length) break;
1962
+ }
1963
+ messages.sort((a, b) => a.sentAt - b.sentAt);
1964
+ koa.body = { messages };
1965
+ });
1966
+ ctx.server.post(`${config.basePath}/api/self-messages`, async (koa) => {
1967
+ const requestBody = koa.request.body;
1968
+ const body = requestBody ?? {};
1969
+ const payload = {
1970
+ id: typeof body.id === "string" ? body.id : void 0,
1971
+ platform: String(body.platform ?? ""),
1972
+ selfId: String(body.selfId ?? ""),
1973
+ channelId: String(body.channelId ?? ""),
1974
+ guildId: typeof body.guildId === "string" ? body.guildId : void 0,
1975
+ channelType: body.channelType === "user" ? "user" : "group",
1976
+ messageId: typeof body.messageId === "string" ? body.messageId : void 0,
1977
+ content: typeof body.content === "string" ? body.content : void 0,
1978
+ elements: Array.isArray(body.elements) ? body.elements : void 0,
1979
+ message: Array.isArray(body.message) ? body.message : void 0,
1980
+ forwardId: typeof body.forwardId === "string" ? body.forwardId : void 0,
1981
+ forwardContent: Array.isArray(body.forwardContent) ? body.forwardContent : void 0,
1982
+ sentAt: typeof body.sentAt === "number" && Number.isFinite(body.sentAt) ? body.sentAt : Date.now(),
1983
+ sequence: typeof body.sequence === "number" && Number.isFinite(body.sequence) ? body.sequence : 0,
1984
+ source: body.source === "plugin" ? "plugin" : "webui",
1985
+ kind: typeof body.kind === "string" ? body.kind : "text"
1986
+ };
1987
+ if (!payload.platform || !payload.selfId || !payload.channelId) {
1988
+ koa.status = 400;
1989
+ koa.body = { error: "missing self-message params" };
1618
1990
  return;
1619
1991
  }
1620
- this.pendingBotMessageIds.delete(channelKey);
1621
- }
1622
- deletePendingBotMessages(channelKey) {
1623
- this.pendingBotMessageIds.delete(channelKey);
1624
- }
1625
- getRecentMessageIdCacheLimit() {
1626
- return Math.max(this.RECENT_MESSAGE_ID_CACHE_SIZE, this.config.messageChunkSize * 2);
1627
- }
1628
- rememberRecentMessageIds(channelKey, messageIds) {
1629
- if (!messageIds.length) {
1992
+ const record = {
1993
+ id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
1994
+ platform: payload.platform,
1995
+ selfId: payload.selfId,
1996
+ channelId: payload.channelId,
1997
+ guildId: payload.guildId,
1998
+ channelType: payload.channelType,
1999
+ messageId: payload.messageId,
2000
+ content: payload.content,
2001
+ elements: payload.elements,
2002
+ message: payload.message,
2003
+ forwardId: payload.forwardId,
2004
+ forwardContent: payload.forwardContent,
2005
+ sentAt: payload.sentAt ?? Date.now(),
2006
+ sequence: payload.sequence ?? 0,
2007
+ source: payload.source ?? "webui",
2008
+ kind: payload.kind ?? "text"
2009
+ };
2010
+ await database.upsertSelfMessage(record);
2011
+ koa.body = { ok: true, message: record };
2012
+ });
2013
+ ctx.server.get(`${config.basePath}/api/forward`, async (koa) => {
2014
+ const platform = String(koa.query.platform ?? "");
2015
+ const selfId = String(koa.query.selfId ?? "");
2016
+ const channelId = String(koa.query.channelId ?? "");
2017
+ const id = String(koa.query.id ?? "");
2018
+ if (!platform || !selfId || !id) {
2019
+ koa.status = 400;
2020
+ koa.body = { error: "missing forward params" };
1630
2021
  return;
1631
2022
  }
1632
- const nextMessageIds = [...this.recentMessageIdsCache.get(channelKey) || []];
1633
- for (const messageId of messageIds) {
1634
- const existingIndex = nextMessageIds.indexOf(messageId);
1635
- if (existingIndex !== -1) {
1636
- nextMessageIds.splice(existingIndex, 1);
2023
+ if (channelId) {
2024
+ const cached = await database.findSelfForwardContent(platform, selfId, channelId, id);
2025
+ if (cached) {
2026
+ koa.body = cached;
2027
+ return;
1637
2028
  }
1638
- nextMessageIds.push(messageId);
1639
2029
  }
1640
- const maxSize = this.getRecentMessageIdCacheLimit();
1641
- this.recentMessageIdsCache.set(channelKey, nextMessageIds.slice(-maxSize));
1642
- }
1643
- forgetRecentMessageIds(channelKey, messageIds) {
1644
- const currentMessageIds = this.recentMessageIdsCache.get(channelKey);
1645
- if (!currentMessageIds?.length || !messageIds.length) {
2030
+ if (platform !== "onebot") {
2031
+ koa.status = 501;
2032
+ koa.body = { error: "forward api not supported" };
1646
2033
  return;
1647
2034
  }
1648
- const nextMessageIds = currentMessageIds.filter((messageId) => !messageIds.includes(messageId));
1649
- if (nextMessageIds.length) {
1650
- this.recentMessageIdsCache.set(channelKey, nextMessageIds);
2035
+ const bot = ctx.bots.find((item) => {
2036
+ return item.platform === platform && item.selfId === selfId;
2037
+ });
2038
+ if (!bot) {
2039
+ koa.status = 404;
2040
+ koa.body = { error: "bot not found" };
1651
2041
  return;
1652
2042
  }
1653
- this.recentMessageIdsCache.delete(channelKey);
1654
- }
1655
- hasRecentMessageId(channelKey, messageId) {
1656
- const currentMessageIds = this.recentMessageIdsCache.get(channelKey);
1657
- return !!currentMessageIds?.includes(messageId);
1658
- }
1659
- deleteRecentMessageIds(channelKey) {
1660
- this.recentMessageIdsCache.delete(channelKey);
1661
- }
1662
- rememberMessageChunkLocation(channelKey, chunkFileName, messageIds) {
1663
- if (!messageIds.length) {
2043
+ const internal = bot.internal;
2044
+ if (!internal?.getForwardMsg) {
2045
+ koa.status = 501;
2046
+ koa.body = { error: "forward api not supported" };
1664
2047
  return;
1665
2048
  }
1666
- const currentLocations = this.messageChunkLocationCache.get(channelKey) || /* @__PURE__ */ new Map();
1667
- for (const messageId of messageIds) {
1668
- currentLocations.set(messageId, chunkFileName);
2049
+ try {
2050
+ koa.body = await internal.getForwardMsg(id);
2051
+ } catch (error) {
2052
+ koa.status = 502;
2053
+ koa.body = { error: "forward api failed" };
1669
2054
  }
1670
- this.messageChunkLocationCache.set(channelKey, currentLocations);
1671
- }
1672
- forgetMessageChunkLocation(channelKey, messageIds) {
1673
- const currentLocations = this.messageChunkLocationCache.get(channelKey);
1674
- if (!currentLocations || !messageIds.length) {
2055
+ });
2056
+ ctx.server.post(`${config.basePath}/api/send-forward`, async (koa) => {
2057
+ const requestBody = koa.request.body;
2058
+ const body = requestBody ?? {};
2059
+ const platform = String(body.platform ?? "");
2060
+ const selfId = String(body.selfId ?? "");
2061
+ const type = String(body.type ?? "");
2062
+ const id = String(body.id ?? "");
2063
+ if (!platform || !selfId || !type || !id) {
2064
+ koa.status = 400;
2065
+ koa.body = { error: "missing send-forward params" };
1675
2066
  return;
1676
2067
  }
1677
- for (const messageId of messageIds) {
1678
- currentLocations.delete(messageId);
2068
+ if (platform !== "onebot") {
2069
+ koa.status = 501;
2070
+ koa.body = { error: "forward api not supported" };
2071
+ return;
1679
2072
  }
1680
- if (!currentLocations.size) {
1681
- this.messageChunkLocationCache.delete(channelKey);
2073
+ const bot = ctx.bots.find((item) => {
2074
+ return item.platform === platform && item.selfId === selfId;
2075
+ });
2076
+ if (!bot) {
2077
+ koa.status = 404;
2078
+ koa.body = { error: "bot not found" };
2079
+ return;
1682
2080
  }
1683
- }
1684
- getMessageChunkLocation(channelKey, messageId) {
1685
- return this.messageChunkLocationCache.get(channelKey)?.get(messageId);
1686
- }
1687
- deleteMessageChunkLocations(channelKey) {
1688
- this.messageChunkLocationCache.delete(channelKey);
1689
- }
1690
- isSameBotInfo(left, right) {
1691
- return left.selfId === right.selfId && left.platform === right.platform && left.username === right.username && left.avatar === right.avatar && left.status === right.status;
1692
- }
1693
- isSameChannelInfo(left, right) {
1694
- return left.id === right.id && left.name === right.name && left.type === right.type && left.channelId === right.channelId && left.guildName === right.guildName && left.isDirect === right.isDirect;
1695
- }
1696
- isFileMissingError(error) {
1697
- return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
1698
- }
1699
- async atomicWriteTextFile(filePath, content) {
1700
- const tempFilePath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
1701
- await import_node_fs3.promises.writeFile(
1702
- tempFilePath,
1703
- content,
1704
- "utf8"
1705
- );
2081
+ const internal = bot.internal;
2082
+ if (type === "group" && !internal?.sendGroupForwardMsg) {
2083
+ koa.status = 501;
2084
+ koa.body = { error: "forward api not supported" };
2085
+ return;
2086
+ }
2087
+ if (type !== "group" && !internal?.sendPrivateForwardMsg) {
2088
+ koa.status = 501;
2089
+ koa.body = { error: "forward api not supported" };
2090
+ return;
2091
+ }
2092
+ const messages = Array.isArray(body.messages) ? body.messages : [];
2093
+ const nodes = messages.map((raw) => {
2094
+ const item = typeof raw === "object" && raw !== null ? raw : {};
2095
+ const nodeData = typeof item.data === "object" && item.data !== null ? item.data : item;
2096
+ const content = Array.isArray(item.content) ? item.content : Array.isArray(nodeData.content) ? nodeData.content : [];
2097
+ const userId = String(item.user_id ?? nodeData.user_id ?? nodeData.uin ?? "");
2098
+ const nickname = String(item.nickname ?? nodeData.nickname ?? nodeData.name ?? "");
2099
+ return {
2100
+ type: "node",
2101
+ data: {
2102
+ user_id: userId,
2103
+ nickname,
2104
+ // koishi 的 OneBot adapter 发送自定义转发节点时使用 uin/name
2105
+ uin: userId,
2106
+ name: nickname,
2107
+ time: String(item.time ?? nodeData.time ?? Math.floor(Date.now() / 1e3)),
2108
+ content: content.map((segmentRaw) => {
2109
+ const segment = typeof segmentRaw === "object" && segmentRaw !== null ? segmentRaw : {};
2110
+ const copy = { ...segment };
2111
+ const segmentType = String(copy.type ?? "text");
2112
+ delete copy.type;
2113
+ return { type: segmentType, data: copy };
2114
+ })
2115
+ }
2116
+ };
2117
+ });
1706
2118
  try {
1707
- await import_node_fs3.promises.rename(tempFilePath, filePath);
2119
+ koa.body = type === "group" ? await internal?.sendGroupForwardMsg?.(id, nodes) : await internal?.sendPrivateForwardMsg?.(id, nodes);
1708
2120
  } catch (error) {
1709
- if (this.isAtomicRenameReplaceError(error)) {
1710
- await import_node_fs3.promises.rm(filePath, { force: true });
1711
- await import_node_fs3.promises.rename(tempFilePath, filePath);
1712
- return;
1713
- }
1714
- try {
1715
- await import_node_fs3.promises.rm(tempFilePath, { force: true });
1716
- } catch {
1717
- }
1718
- throw error;
2121
+ koa.status = 502;
2122
+ koa.body = { error: "send forward failed" };
1719
2123
  }
1720
- }
1721
- isAtomicRenameReplaceError(error) {
1722
- return typeof error === "object" && error !== null && "code" in error && (error.code === "EEXIST" || error.code === "EPERM");
1723
- }
1724
- };
1725
- __name(_FileManager, "FileManager");
1726
- var FileManager = _FileManager;
1727
-
1728
- // src/api-handlers.ts
1729
- var import_koishi = require("koishi");
1730
- var import_node_url2 = require("node:url");
1731
- var import_node_fs4 = require("node:fs");
1732
- var import_node_path4 = __toESM(require("node:path"));
1733
- var import_node_crypto3 = require("node:crypto");
1734
- var mime = __toESM(require("mime-types"));
1735
- var _ApiHandlers = class _ApiHandlers {
1736
- constructor(ctx, config, fileManager, messageHandler, logger) {
1737
- this.ctx = ctx;
1738
- this.config = config;
1739
- this.fileManager = fileManager;
1740
- this.messageHandler = messageHandler;
1741
- this.logger = logger;
1742
- this.currentTempVideo = null;
1743
- }
1744
- registerApiHandlers() {
1745
- this.ctx.console.addListener("clear-all-indexeddb-data", async () => {
1746
- try {
1747
- this.logInfo("收到清空 IndexedDB 数据请求");
1748
- return { success: true, message: "可以清空 IndexedDB" };
1749
- } catch (error) {
1750
- this.logger.error("清空 IndexedDB 数据失败:", error);
1751
- return { success: false, error: error?.message || String(error) };
1752
- }
1753
- });
1754
- this.ctx.console.addListener("get-chat-data", async () => {
1755
- try {
1756
- const data = await this.fileManager.readMetadataOnly();
1757
- this.logInfo("获取基础聊天数据(仅元数据)");
1758
- return {
1759
- success: true,
1760
- data: {
1761
- bots: data.bots || {},
1762
- channels: data.channels || {},
1763
- pinnedBots: data.pinnedBots || [],
1764
- pinnedChannels: data.pinnedChannels || [],
1765
- // 不返回消息数据,由前端按需加载
1766
- messages: {}
1767
- }
1768
- };
1769
- } catch (error) {
1770
- this.logger.error("获取聊天数据失败:", error);
1771
- return { success: false, error: error?.message || String(error) };
1772
- }
1773
- });
1774
- this.ctx.console.addListener("get-history-messages", async (requestData) => {
1775
- try {
1776
- const limit = Math.max(1, requestData.limit ?? this.config.messageChunkSize);
1777
- const result = await this.fileManager.readChannelMessagesPage(
1778
- requestData.selfId,
1779
- requestData.channelId,
1780
- limit,
1781
- requestData.offset || 0
2124
+ });
2125
+ ctx.server.get(`${config.basePath}/api/cache`, async (koa) => {
2126
+ const platform = String(koa.query.platform ?? "");
2127
+ let selfId = String(koa.query.selfId ?? "");
2128
+ if (!selfId) {
2129
+ selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? "";
2130
+ }
2131
+ const type = String(koa.query.type ?? "");
2132
+ if (!platform || !selfId || !type) {
2133
+ koa.status = 400;
2134
+ koa.body = { error: "missing cache params" };
2135
+ return;
2136
+ }
2137
+ const id = String(koa.query.userId ?? koa.query.groupId ?? koa.query.id ?? "");
2138
+ if (id) {
2139
+ if (type === "user" || type === "friend") {
2140
+ const channelId = String(koa.query.channelId ?? "");
2141
+ const contact = await contactCache.getUser(
2142
+ platform,
2143
+ selfId,
2144
+ id,
2145
+ String(koa.query.guildId ?? ""),
2146
+ channelId,
2147
+ String(koa.query.name ?? ""),
2148
+ String(koa.query.avatar ?? "")
1782
2149
  );
1783
- this.logInfo("获取历史消息:", `${requestData.selfId}:${requestData.channelId}`, "共", result.messages.length, "条消息");
1784
- return {
1785
- success: true,
1786
- messages: result.messages,
1787
- total: result.total
1788
- };
1789
- } catch (error) {
1790
- this.logger.error("获取历史消息失败:", error);
1791
- return { success: false, error: error?.message || String(error), messages: [], total: 0 };
1792
- }
1793
- });
1794
- this.ctx.console.addListener("get-all-channel-message-counts", async () => {
1795
- try {
1796
- const counts = await this.fileManager.getAllChannelMessageCounts();
1797
- this.logInfo("获取所有频道消息数量:", {
1798
- 频道数: Object.keys(counts).length,
1799
- 总消息数: Object.values(counts).reduce((total, count) => total + count, 0)
1800
- });
1801
- return {
1802
- success: true,
1803
- counts
1804
- };
1805
- } catch (error) {
1806
- this.logger.error("获取频道消息数量失败:", error);
1807
- return { success: false, error: error?.message || String(error), counts: {} };
1808
- }
1809
- });
1810
- this.ctx.console.addListener("fetch-image", async (data) => {
1811
- try {
1812
- if (data.url.includes("/vite/@fs/")) {
1813
- return {
1814
- success: true,
1815
- viteUrl: data.url
1816
- };
1817
- }
1818
- if (this.isFileUrl(data.url)) {
1819
- this.logInfo("处理本地文件请求:", data.url);
1820
- const filePath2 = require("node:url").fileURLToPath(data.url);
1821
- const normalizedPath2 = filePath2.replace(/\\/g, "/");
1822
- return {
1823
- success: true,
1824
- viteUrl: `/vite/@fs/${normalizedPath2}`
1825
- };
1826
- }
1827
- const dir = import_node_path4.default.join(this.ctx.baseDir, "data", "chat-patch", "persist-media", "images");
1828
- await import_node_fs4.promises.mkdir(dir, { recursive: true });
1829
- const hash = (0, import_node_crypto3.createHash)("md5").update(data.url).digest("hex");
1830
- const ext = import_node_path4.default.extname(new import_node_url2.URL(data.url).pathname) || ".jpg";
1831
- const filename = `${hash}${ext}`;
1832
- const filePath = import_node_path4.default.join(dir, filename);
1833
- if (!await this.fileExists(filePath)) {
1834
- const response = await fetch(data.url, {
1835
- headers: {
1836
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
1837
- "Referer": ""
1838
- }
1839
- });
1840
- if (!response.ok) {
1841
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1842
- }
1843
- const buffer = await response.arrayBuffer();
1844
- await import_node_fs4.promises.writeFile(filePath, Buffer.from(buffer));
1845
- }
1846
- const normalizedPath = filePath.replace(/\\/g, "/");
1847
- return {
1848
- success: true,
1849
- viteUrl: `/vite/@fs/${normalizedPath}`
1850
- };
1851
- } catch (error) {
1852
- return { success: false, error: this.getClientErrorMessage(error) };
1853
- }
1854
- });
1855
- this.ctx.console.addListener("clear-channel-history", async (data) => {
1856
- try {
1857
- this.logInfo("收到清理历史记录请求(已废弃,建议使用删除频道数据):", data);
1858
- const channelKey = `${data.selfId}:${data.channelId}`;
1859
- const { deletedMessages } = await this.fileManager.deleteChannelData(data.selfId, data.channelId);
1860
- if (!deletedMessages) {
1861
- return { success: true, message: "频道没有历史消息" };
1862
- }
1863
- this.logInfo(`频道 ${channelKey} 历史记录已清空:`, {
1864
- 清理消息数: deletedMessages
1865
- });
1866
- return {
1867
- success: true,
1868
- message: `成功清理 ${deletedMessages} 条历史消息`,
1869
- clearedCount: deletedMessages,
1870
- keptCount: 0
1871
- };
1872
- } catch (error) {
1873
- this.logger.error("清理频道历史记录失败:", error);
1874
- return { success: false, error: error?.message || String(error) };
1875
- }
1876
- });
1877
- this.ctx.console.addListener("send-message", async (data) => {
1878
- try {
1879
- this.logInfo("收到发送消息请求:", data);
1880
- const bot = this.ctx.bots.find((bot2) => bot2.selfId === data.selfId || bot2.user?.id === data.selfId);
1881
- if (!bot) {
1882
- this.logger.error("未找到机器人:", data.selfId, "当前可用机器人:", this.ctx.bots.map((b) => b.selfId));
1883
- return { success: false, error: `未找到机器人 ${data.selfId},请检查机器人是否在线` };
1884
- }
1885
- if (bot.status !== import_koishi.Universal.Status.ONLINE) {
1886
- this.logger.error("机器人离线:", data.selfId, "状态:", bot.status);
1887
- return { success: false, error: `机器人 ${data.selfId} 当前离线` };
1888
- }
1889
- let messageContent = data.content;
1890
- if (data.images && data.images.length > 0) {
1891
- const tempDir = import_node_path4.default.join(this.ctx.baseDir, "data", "chat-patch", "temp");
1892
- const tempFiles = await this.safeReadDir(tempDir);
1893
- for (const image of data.images) {
1894
- const files = tempFiles.filter((file) => file.includes(`temp_${image.tempId}`));
1895
- if (files.length > 0) {
1896
- const imagePath = import_node_path4.default.join(tempDir, files[0]);
1897
- const fileUrl = this.createFileUrl(imagePath);
1898
- messageContent += import_koishi.h.image(fileUrl).toString();
1899
- this.logInfo("添加图片到消息:", { imagePath, fileUrl });
1900
- }
1901
- }
1902
- }
1903
- const parsedContent = import_koishi.h.parse(messageContent);
1904
- this.messageHandler.setCorrectChannelId(data.selfId, data.channelId);
1905
- const result = await bot.sendMessage(data.channelId, parsedContent);
1906
- this.logInfo("消息发送成功:", result);
1907
- const messageId = Array.isArray(result) ? result[0] : result;
1908
- if (messageId) {
1909
- const channelKey = `${data.selfId}:${data.channelId}`;
1910
- const msg = await this.fileManager.markLatestBotMessageAsSent(data.selfId, data.channelId, messageId);
1911
- if (msg) {
1912
- this.ctx.console.broadcast("bot-message-updated", {
1913
- channelKey,
1914
- tempId: msg.id,
1915
- realId: messageId
1916
- });
1917
- }
1918
- }
1919
- return {
1920
- success: !!messageId,
1921
- messageId,
1922
- tempImageIds: data.images?.map((img) => img.tempId) || []
1923
- };
1924
- } catch (error) {
1925
- this.logger.error("发送消息失败:", error);
1926
- return { success: false, error: error?.message || String(error) };
1927
- }
1928
- });
1929
- this.ctx.console.addListener("cleanup-temp-images", async (data) => {
1930
- try {
1931
- this.logInfo("收到清理临时图片请求:", data.tempImageIds);
1932
- this.logInfo("临时图片将由定时任务清理,保持文件可用性");
1933
- return { success: true, cleanedCount: 0 };
1934
- } catch (error) {
1935
- this.logger.error("清理临时图片失败:", error);
1936
- return { success: false, error: error?.message || String(error) };
1937
- }
1938
- });
1939
- this.ctx.console.addListener("delete-bot-data", async (data) => {
1940
- try {
1941
- this.logInfo("收到删除机器人数据请求:", data);
1942
- const { deletedChannels, deletedMessages } = await this.fileManager.deleteBotData(data.selfId);
1943
- this.logInfo(`机器人 ${data.selfId} 数据删除完成:`, {
1944
- 删除频道数: deletedChannels,
1945
- 删除消息数: deletedMessages
1946
- });
1947
- return {
1948
- success: true,
1949
- message: `成功删除机器人数据:${deletedChannels} 个频道,${deletedMessages} 条消息`,
1950
- deletedChannels,
1951
- deletedMessages
1952
- };
1953
- } catch (error) {
1954
- this.logger.error("删除机器人数据失败:", error);
1955
- return { success: false, error: error?.message || String(error) };
1956
- }
1957
- });
1958
- this.ctx.console.addListener("delete-channel-data", async (data) => {
1959
- try {
1960
- this.logInfo("收到删除频道数据请求:", data);
1961
- const channelKey = `${data.selfId}:${data.channelId}`;
1962
- const { deletedMessages } = await this.fileManager.deleteChannelData(data.selfId, data.channelId);
1963
- this.logInfo(`频道 ${channelKey} 数据删除完成:`, {
1964
- 删除消息数: deletedMessages
1965
- });
1966
- return {
1967
- success: true,
1968
- message: `成功删除频道数据:${deletedMessages} 条消息`,
1969
- deletedMessages
1970
- };
1971
- } catch (error) {
1972
- this.logger.error("删除频道数据失败:", error);
1973
- return { success: false, error: error?.message || String(error) };
1974
- }
1975
- });
1976
- this.ctx.console.addListener("set-pinned-bots", async (data) => {
1977
- try {
1978
- this.logInfo("收到设置置顶机器人请求:", data.pinnedBots);
1979
- await this.fileManager.setPinnedBots(data.pinnedBots);
1980
- return { success: true };
1981
- } catch (error) {
1982
- this.logger.error("设置置顶机器人失败:", error);
1983
- return { success: false, error: error?.message || String(error) };
1984
- }
1985
- });
1986
- this.ctx.console.addListener("set-pinned-channels", async (data) => {
1987
- try {
1988
- this.logInfo("收到设置置顶频道请求:", data.pinnedChannels);
1989
- await this.fileManager.setPinnedChannels(data.pinnedChannels);
1990
- return { success: true };
1991
- } catch (error) {
1992
- this.logger.error("设置置顶频道失败:", error);
1993
- return { success: false, error: error?.message || String(error) };
1994
- }
1995
- });
1996
- this.ctx.console.addListener("upload-image", async (data) => {
1997
- try {
1998
- this.logInfo("收到图片上传请求:", { filename: data.filename, mimeType: data.mimeType, isGif: data.isGif });
1999
- const base64Data = data.file.replace(/^data:image\/\w+;base64,/, "");
2000
- const buffer = Buffer.from(base64Data, "base64");
2001
- const tempId = Date.now() + "_" + Math.random().toString(36).substring(2, 11);
2002
- const extension = data.filename.split(".").pop()?.toLowerCase() || (data.isGif ? "gif" : "jpg");
2003
- const tempFilename = `temp_${tempId}.${extension}`;
2004
- const tempDir = import_node_path4.default.join(this.ctx.baseDir, "data", "chat-patch", "temp");
2005
- await import_node_fs4.promises.mkdir(tempDir, { recursive: true });
2006
- const tempPath = import_node_path4.default.join(tempDir, tempFilename);
2007
- await import_node_fs4.promises.writeFile(tempPath, buffer);
2008
- this.logInfo("图片上传成功:", { tempPath, size: buffer.length, isGif: data.isGif });
2009
- return {
2010
- success: true,
2011
- tempId,
2012
- tempPath,
2013
- filename: data.filename,
2014
- size: buffer.length,
2015
- isGif: data.isGif
2016
- };
2017
- } catch (error) {
2018
- this.logger.error("图片上传失败:", error);
2019
- return { success: false, error: error?.message || String(error) };
2020
- }
2021
- });
2022
- this.ctx.console.addListener("delete-temp-image", async (data) => {
2023
- try {
2024
- const tempDir = import_node_path4.default.join(this.ctx.baseDir, "data", "chat-patch", "temp");
2025
- const files = (await this.safeReadDir(tempDir)).filter((file) => file.includes(`temp_${data.tempId}`));
2026
- for (const file of files) {
2027
- const filePath = import_node_path4.default.join(tempDir, file);
2028
- if (await this.fileExists(filePath)) {
2029
- await import_node_fs4.promises.unlink(filePath);
2030
- this.logInfo("删除临时图片:", filePath);
2031
- }
2032
- }
2033
- return { success: true };
2034
- } catch (error) {
2035
- this.logger.error("删除临时图片失败:", error);
2036
- return { success: false, error: error?.message || String(error) };
2037
- }
2038
- });
2039
- this.setupTempFileCleanup();
2040
- this.ctx.console.addListener("get-plugin-config", async () => {
2041
- try {
2042
- return {
2043
- success: true,
2044
- config: {
2045
- maxMessagesPerChannel: this.config.maxMessagesPerChannel,
2046
- messageChunkSize: this.config.messageChunkSize,
2047
- channelCacheLimit: this.config.channelCacheLimit,
2048
- maxPersistImages: this.config.maxPersistImages,
2049
- loggerinfo: this.config.loggerinfo,
2050
- blockedPlatforms: this.config.blockedPlatforms || [],
2051
- clearIndexedDBOnStart: this.config.clearIndexedDBOnStart
2052
- }
2053
- };
2054
- } catch (error) {
2055
- this.logger.error("获取插件配置失败:", error);
2056
- return { success: false, error: error?.message || String(error) };
2150
+ koa.body = contact ?? null;
2151
+ return;
2057
2152
  }
2058
- });
2059
- this.ctx.console.addListener("get-user-info", async (data) => {
2060
- try {
2061
- const bot = this.ctx.bots.find((b) => b.selfId === data.selfId);
2062
- if (!bot) return { success: false, error: "机器人不存在" };
2063
- if (!bot.getUser || typeof bot.getUser !== "function") {
2064
- return { success: false, error: "此平台不支持查看用户信息" };
2065
- }
2066
- const user = await bot.getUser(data.userId, data.guildId);
2067
- if (user) {
2068
- if (user.avatar) {
2069
- user.avatar = await this.messageHandler.downloadAndCacheMedia(user.avatar, "avatar");
2070
- }
2071
- const changed = await this.fileManager.updateUserProfileInBotData(data.selfId, data.userId, user.name, user.avatar);
2072
- if (changed) {
2073
- this.ctx.console.broadcast("chat-data-updated", {});
2074
- }
2075
- }
2076
- return { success: true, data: user };
2077
- } catch (error) {
2078
- return { success: false, error: error?.message || "获取用户信息失败" };
2153
+ if (type === "group") {
2154
+ const guildId = String(koa.query.guildId ?? "");
2155
+ const channelId = String(koa.query.channelId ?? "");
2156
+ const channelType = koa.query.channelType;
2157
+ const contact = await contactCache.getGroup(
2158
+ platform,
2159
+ selfId,
2160
+ id,
2161
+ guildId,
2162
+ channelId,
2163
+ String(koa.query.name ?? ""),
2164
+ String(koa.query.avatar ?? ""),
2165
+ channelType
2166
+ );
2167
+ koa.body = contact ?? null;
2168
+ return;
2079
2169
  }
2080
- });
2081
- this.ctx.console.addListener("fetch-video-temp", async (data) => {
2082
- try {
2083
- this.logInfo("收到视频临时加载请求:", data.url);
2084
- if (data.url.includes("/vite/@fs/")) {
2085
- return {
2086
- success: true,
2087
- viteUrl: data.url
2088
- };
2089
- }
2090
- if (this.isFileUrl(data.url)) {
2091
- const filePath2 = require("node:url").fileURLToPath(data.url);
2092
- const normalizedPath2 = filePath2.replace(/\\/g, "/");
2093
- return {
2094
- success: true,
2095
- viteUrl: `/vite/@fs/${normalizedPath2}`
2096
- };
2170
+ if (type === "member") {
2171
+ const groupId = String(koa.query.groupId ?? koa.query.id ?? "");
2172
+ if (!groupId) {
2173
+ koa.status = 400;
2174
+ koa.body = { error: "missing groupId" };
2175
+ return;
2097
2176
  }
2098
- const dir = import_node_path4.default.join(this.ctx.baseDir, "data", "chat-patch", "persist-media", "media");
2099
- await import_node_fs4.promises.mkdir(dir, { recursive: true });
2100
- const hash = (0, import_node_crypto3.createHash)("md5").update(data.url).digest("hex");
2101
- const ext = import_node_path4.default.extname(new import_node_url2.URL(data.url).pathname) || ".mp4";
2102
- const filename = `${hash}${ext}`;
2103
- const filePath = import_node_path4.default.join(dir, filename);
2104
- if (!await this.fileExists(filePath)) {
2105
- const response = await fetch(data.url, {
2106
- headers: {
2107
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
2108
- "Referer": ""
2109
- }
2110
- });
2111
- if (!response.ok) {
2112
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2113
- }
2114
- const buffer = await response.arrayBuffer();
2115
- await import_node_fs4.promises.writeFile(filePath, Buffer.from(buffer));
2116
- this.logInfo("视频下载成功:", { size: buffer.byteLength, path: filePath });
2177
+ const memberId = String(koa.query.userId ?? "");
2178
+ if (memberId) {
2179
+ koa.body = await database.getGroupMember(platform, selfId, groupId, memberId) ?? null;
2180
+ return;
2117
2181
  }
2118
- const normalizedPath = filePath.replace(/\\/g, "/");
2119
- return {
2120
- success: true,
2121
- viteUrl: `/vite/@fs/${normalizedPath}`
2182
+ koa.body = {
2183
+ members: await database.getGroupMembers(platform, selfId, groupId)
2122
2184
  };
2123
- } catch (error) {
2124
- return { success: false, error: this.getClientErrorMessage(error) };
2185
+ return;
2125
2186
  }
2126
- });
2127
- }
2128
- isFileUrl(url) {
2129
- try {
2130
- const parsedUrl = new import_node_url2.URL(url);
2131
- return parsedUrl.protocol === "file:";
2132
- } catch {
2133
- return false;
2187
+ koa.status = 400;
2188
+ koa.body = { error: "unsupported cache type" };
2189
+ return;
2134
2190
  }
2135
- }
2136
- createFileUrl(filePath) {
2137
- try {
2138
- return (0, import_node_url2.pathToFileURL)(filePath).href;
2139
- } catch (error) {
2140
- this.logger.error("创建文件URL失败:", { filePath, error });
2141
- return `file://${filePath}`;
2191
+ koa.body = {
2192
+ contacts: await database.getContacts(platform, selfId, cacheType(type))
2193
+ };
2194
+ });
2195
+ ctx.server.post(`${config.basePath}/api/cache`, async (koa) => {
2196
+ const requestBody = koa.request.body;
2197
+ const body = requestBody ?? {};
2198
+ const platform = String(body.platform ?? "");
2199
+ let selfId = String(body.selfId ?? "");
2200
+ if (!selfId) {
2201
+ selfId = ctx.bots.find((bot) => bot.platform === platform)?.selfId ?? "";
2202
+ }
2203
+ const type = String(body.type ?? "");
2204
+ if (!platform || !selfId || !type) {
2205
+ koa.status = 400;
2206
+ koa.body = { error: "missing cache params" };
2207
+ return;
2142
2208
  }
2143
- }
2144
- async handleLocalFileRequest(fileUrl) {
2145
- try {
2146
- const filePath = (0, import_node_url2.fileURLToPath)(fileUrl);
2147
- const buffer = await import_node_fs4.promises.readFile(filePath);
2148
- const base64 = buffer.toString("base64");
2149
- const contentType = mime.lookup(filePath) || "application/octet-stream";
2150
- this.logInfo("成功读取本地文件:", { fileUrl, filePath, contentType });
2151
- return {
2152
- success: true,
2153
- base64,
2154
- contentType,
2155
- dataUrl: `data:${contentType};base64,${base64}`
2156
- };
2157
- } catch (error) {
2158
- return {
2159
- success: false,
2160
- error: `读取本地文件失败: ${error.message}`
2209
+ const normalizedType = cacheType(type);
2210
+ if (type === "member" && body.groupId) {
2211
+ const groupId = String(body.groupId);
2212
+ const contacts = Array.isArray(body.contacts) ? body.contacts : [];
2213
+ if (body.append === true) {
2214
+ for (const contact of contacts) {
2215
+ await database.appendGroupMember(platform, selfId, groupId, contact);
2216
+ }
2217
+ } else {
2218
+ await database.setGroupMembers(platform, selfId, groupId, contacts);
2219
+ }
2220
+ koa.body = {
2221
+ members: await database.getGroupMembers(platform, selfId, groupId)
2161
2222
  };
2162
- }
2163
- }
2164
- setupTempFileCleanup() {
2165
- this.ctx.setInterval(() => {
2166
- this.cleanupMediaCache();
2167
- }, 5 * 60 * 1e3);
2168
- }
2169
- async cleanupMediaCache() {
2170
- const baseDir = import_node_path4.default.join(this.ctx.baseDir, "data", "chat-patch", "persist-media");
2171
- await this.cleanupMediaCacheDir(import_node_path4.default.join(baseDir, "images"), 100);
2172
- await this.cleanupMediaCacheDir(import_node_path4.default.join(baseDir, "media"), 20);
2173
- }
2174
- logInfo(...args) {
2175
- if (this.config.loggerinfo) {
2176
- Reflect.apply(this.logger.info, this.logger, args);
2177
- }
2178
- }
2179
- async fileExists(filePath) {
2180
- try {
2181
- await import_node_fs4.promises.access(filePath);
2182
- return true;
2183
- } catch {
2184
- return false;
2185
- }
2186
- }
2187
- async safeReadDir(dirPath) {
2188
- try {
2189
- return await import_node_fs4.promises.readdir(dirPath);
2190
- } catch {
2191
- return [];
2192
- }
2193
- }
2194
- async cleanupMediaCacheDir(dirPath, limit) {
2195
- const files = await this.safeReadDir(dirPath);
2196
- if (!files.length) {
2197
2223
  return;
2198
2224
  }
2199
- const fileStats = await Promise.all(files.map(async (fileName) => {
2200
- const filePath = import_node_path4.default.join(dirPath, fileName);
2201
- const stats = await import_node_fs4.promises.stat(filePath);
2202
- return { path: filePath, mtime: stats.mtimeMs };
2203
- }));
2204
- fileStats.sort((left, right) => right.mtime - left.mtime);
2205
- for (const file of fileStats.slice(limit)) {
2206
- try {
2207
- await import_node_fs4.promises.unlink(file.path);
2208
- } catch {
2209
- continue;
2225
+ if (Array.isArray(body.contacts)) {
2226
+ const contacts = body.contacts;
2227
+ if (body.append === true) {
2228
+ for (const contact of contacts) {
2229
+ await database.appendContact(platform, selfId, normalizedType, contact);
2230
+ }
2231
+ } else {
2232
+ await database.setContacts(platform, selfId, normalizedType, contacts);
2210
2233
  }
2211
2234
  }
2212
- }
2213
- getClientErrorMessage(error) {
2214
- if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
2215
- return error.message;
2235
+ koa.body = {
2236
+ contacts: await database.getContacts(platform, selfId, normalizedType)
2237
+ };
2238
+ });
2239
+ const serveFile = /* @__PURE__ */ __name(async (koa, fileName) => {
2240
+ const fullPath = import_node_path3.default.join(webRoot, fileName);
2241
+ if ((0, import_node_fs2.existsSync)(fullPath) && (0, import_node_fs2.statSync)(fullPath).isFile()) {
2242
+ await (0, import_koa_send.default)(koa, import_node_path3.default.relative(webRoot, fullPath), { root: webRoot });
2243
+ return;
2216
2244
  }
2217
- return String(error);
2218
- }
2219
- };
2220
- __name(_ApiHandlers, "ApiHandlers");
2221
- var ApiHandlers = _ApiHandlers;
2222
-
2223
- // src/logger.ts
2224
- function createPluginLogger(logger, config) {
2225
- return {
2226
- logInfo(...args) {
2227
- if (config.loggerinfo) {
2228
- Reflect.apply(logger.info, logger, args);
2229
- }
2230
- },
2231
- info(...args) {
2232
- Reflect.apply(logger.info, logger, args);
2233
- },
2234
- warn(...args) {
2235
- Reflect.apply(logger.warn, logger, args);
2236
- },
2237
- error(...args) {
2238
- Reflect.apply(logger.error, logger, args);
2245
+ koa.status = 200;
2246
+ koa.body = "";
2247
+ await (0, import_koa_send.default)(koa, "index.html", { root: webRoot });
2248
+ }, "serveFile");
2249
+ const serveDevWeb = /* @__PURE__ */ __name(async (koa) => {
2250
+ const vite = getVite();
2251
+ if (!vite) return false;
2252
+ koa.status = 200;
2253
+ koa.type = "html";
2254
+ koa.body = await transformWebIndex(vite, koa.path);
2255
+ return true;
2256
+ }, "serveDevWeb");
2257
+ ctx.server.get(`${config.basePath}/web(/.*)?`, async (koa) => {
2258
+ if (await serveDevWeb(koa)) return;
2259
+ if (!(0, import_node_fs2.existsSync)(webRoot)) {
2260
+ koa.status = 404;
2261
+ koa.body = "";
2262
+ return;
2239
2263
  }
2240
- };
2264
+ const fileName = koa.params?.[0]?.replace(/^\/+/, "") || "index.html";
2265
+ await serveFile(koa, fileName);
2266
+ });
2267
+ if (!(0, import_node_fs2.existsSync)(webRoot) && process.env.NODE_ENV !== "development") {
2268
+ logger.warn("未找到 client/web/dist,发布前请先执行 client/web 目录下的 npm run build");
2269
+ }
2241
2270
  }
2242
- __name(createPluginLogger, "createPluginLogger");
2271
+ __name(registerWeb, "registerWeb");
2243
2272
 
2244
2273
  // src/config.ts
2245
2274
  var import_koishi2 = require("koishi");
2246
2275
  var Config = import_koishi2.Schema.intersect([
2247
2276
  import_koishi2.Schema.object({
2248
- maxMessagesPerChannel: import_koishi2.Schema.number().default(500).description("每个群组最大保存消息数量").min(50).max(1500).step(1),
2249
- messageChunkSize: import_koishi2.Schema.number().default(100).description("单个消息分块文件最大消息数量").min(20).max(500).step(1),
2250
- channelCacheLimit: import_koishi2.Schema.number().default(50).description("内存中最多缓存的频道消息数量").min(1).max(200).step(1),
2251
- maxPersistImages: import_koishi2.Schema.number().default(100).description("持久化存储的图片缓存数量").min(10).max(500).step(1),
2252
- blockedPlatforms: import_koishi2.Schema.array(import_koishi2.Schema.object({
2253
- platformName: import_koishi2.Schema.string().description("平台名称或关键词"),
2254
- exactMatch: import_koishi2.Schema.boolean().default(false).description("完全匹配?如果关闭,包含关键词即屏蔽").default(true)
2255
- })).role("table").description("屏蔽的平台列表").default(
2256
- [
2257
- {
2258
- "platformName": "qq",
2259
- "exactMatch": true
2260
- },
2261
- {
2262
- "platformName": "qqguild",
2263
- "exactMatch": true
2264
- },
2265
- {
2266
- "platformName": "sandbox",
2267
- "exactMatch": false
2268
- }
2269
- ]
2270
- )
2277
+ basePath: import_koishi2.Schema.string().default("/chat-patch").description("Web 聊天室挂载路径"),
2278
+ maxMessagesPerChannel: import_koishi2.Schema.number().default(500).min(50).max(5e3).step(1).description("每个频道最多保存的历史消息数"),
2279
+ historyPageSize: import_koishi2.Schema.number().default(50).min(10).max(500).step(1).description("历史消息分页大小"),
2280
+ maxMediaFiles: import_koishi2.Schema.number().default(200).min(20).max(2e3).step(1).description("媒体缓存文件上限")
2271
2281
  }).description("基础设置"),
2272
2282
  import_koishi2.Schema.object({
2273
- clearIndexedDBOnStart: import_koishi2.Schema.boolean().default(true).description("启动时强制清空IndexedDB缓存(适用于紧急情况,防止浏览器卡死)"),
2274
- loggerinfo: import_koishi2.Schema.boolean().default(false).description("日志调试模式").experimental()
2275
- }).description("开发者选项")
2283
+ blockedPlatforms: import_koishi2.Schema.array(import_koishi2.Schema.object({
2284
+ platformName: import_koishi2.Schema.string(),
2285
+ exactMatch: import_koishi2.Schema.boolean().default(true)
2286
+ })).role("table").description("不记录历史的平台").default([
2287
+ { platformName: "qq", exactMatch: true },
2288
+ { platformName: "qqguild", exactMatch: true },
2289
+ { platformName: "sandbox", exactMatch: false }
2290
+ ]),
2291
+ loggerinfo: import_koishi2.Schema.boolean().default(false).description("调试日志开关").hidden().experimental()
2292
+ }).description("高级设置")
2276
2293
  ]);
2277
2294
 
2278
2295
  // src/index.ts
2279
2296
  var name = "chat-patch";
2280
2297
  var reusable = false;
2281
- var filter = true;
2298
+ var filter = false;
2282
2299
  var inject = {
2283
- required: ["console"]
2300
+ required: ["console", "server", "satori.server"]
2284
2301
  };
2285
2302
  var usage = `
2286
-
2287
2303
  ---
2288
2304
 
2289
- 开启后,即可在koishi控制台操作机器人收发消息啦
2290
-
2291
- 暂时只支持接受图文消息 / 发送文字消息
2305
+ 基于 Satori 协议的 Koishi 后台聊天室。
2306
+ 需要在 Koishi 中启用 server-satori,并在插件内构建 web 应用。
2292
2307
 
2293
2308
  ---
2294
2309
  `;
2295
2310
  async function apply(ctx, config) {
2296
2311
  const pluginLogger = createPluginLogger(ctx.logger("chat-patch"), config);
2297
- const fileManager = new FileManager(ctx, config, pluginLogger);
2298
- await fileManager.initialize();
2299
- const messageHandler = new MessageHandler(ctx, config, fileManager, pluginLogger);
2300
- const apiHandlers = new ApiHandlers(ctx, config, fileManager, messageHandler, pluginLogger);
2301
- const utils = new Utils(config, ctx);
2302
- const metadata = await fileManager.readMetadataOnly();
2303
- pluginLogger.logInfo("插件加载完成,元数据统计:", {
2304
- 机器人数量: Object.keys(metadata.bots).length,
2305
- 频道数量: Object.keys(metadata.channels).reduce((total, botId) => total + Object.keys(metadata.channels[botId] || {}).length, 0),
2306
- 置顶机器人数量: metadata.pinnedBots.length,
2307
- 置顶频道数量: metadata.pinnedChannels.length
2308
- });
2309
- ctx.on("message", (session) => {
2310
- if (utils.isPlatformBlocked(session.platform || "unknown")) return;
2311
- messageHandler.recordUserMessage(session, Date.now());
2312
- });
2313
- ctx.on("before-send", (session) => {
2314
- if (utils.isPlatformBlocked(session.platform || "unknown")) return;
2315
- messageHandler.recordBotMessage(session, Date.now());
2316
- });
2317
- let cleanupDelayTimer;
2318
- let cleanupInterval;
2319
- ctx.on("ready", async () => {
2320
- pluginLogger.logInfo("插件启动完成,开始监听消息");
2321
- cleanupDelayTimer = ctx.setTimeout(() => {
2322
- void fileManager.cleanupExcessMessagesInStorage();
2323
- }, 15e3);
2324
- cleanupInterval = ctx.setInterval(() => {
2325
- void fileManager.cleanupExcessMessagesInStorage();
2326
- }, 3e5);
2327
- });
2328
- apiHandlers.registerApiHandlers();
2312
+ const database = new ChatDatabase(ctx, config, pluginLogger);
2313
+ await database.initialize();
2314
+ const contactCache = new ContactCacheService(ctx, database, pluginLogger);
2315
+ const media = new MediaManager(ctx, config, database, pluginLogger);
2316
+ media.start();
2317
+ const recorder = new Recorder(ctx, config, database, media, contactCache, pluginLogger);
2318
+ recorder.start();
2319
+ const selfMessages = new SelfMessageRecorder(ctx, config, database, pluginLogger);
2320
+ selfMessages.start();
2321
+ registerBootstrap(ctx, config, database, pluginLogger);
2322
+ registerWeb(ctx, config, database, contactCache, pluginLogger);
2329
2323
  ctx.console.addEntry({
2330
- dev: import_node_path5.default.resolve(__dirname, "../client/index.ts"),
2331
- prod: import_node_path5.default.resolve(__dirname, "../dist")
2324
+ dev: import_node_path4.default.resolve(__dirname, "../client/index.ts"),
2325
+ prod: import_node_path4.default.resolve(__dirname, "../dist")
2332
2326
  });
2333
2327
  ctx.on("dispose", () => {
2334
- cleanupDelayTimer?.();
2335
- cleanupInterval?.();
2336
- messageHandler.dispose();
2337
- void fileManager.dispose();
2338
- pluginLogger.logInfo("插件已卸载,所有待处理的消息已写入");
2328
+ selfMessages.dispose();
2329
+ media.dispose();
2330
+ void database.dispose();
2331
+ pluginLogger.logInfo("chat-patch 已卸载");
2339
2332
  });
2340
2333
  }
2341
2334
  __name(apply, "apply");