koishi-plugin-chat-patch 3.0.1 → 5.0.0

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