koishi-plugin-chat-patch 3.0.2 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/vue/index.vue +60 -634
- package/client/web/dist/assets/AppIcon-DIku6-h0.png +0 -0
- package/client/web/dist/assets/AppIconDark-BYymN71t.png +0 -0
- package/client/web/dist/assets/CapacitorAppIcon-CSZwNhvi.png +0 -0
- package/client/web/dist/assets/CapacitorAppIconDark-BMvMOzsg.png +0 -0
- package/client/web/dist/assets/Chat-CTdyeaX2.css +1 -0
- package/client/web/dist/assets/Chat-DkZvD7d8.js +28 -0
- package/client/web/dist/assets/MealHungryPan-Beos3lRI.js +1 -0
- package/client/web/dist/assets/MsgBody-aZ4_aija.js +1 -0
- package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CPZjRLp6.js +67 -0
- package/client/web/dist/assets/NapcatAppIcon-C7VOAkW9.png +0 -0
- package/client/web/dist/assets/Vue-lEhHouxB.png +0 -0
- package/client/web/dist/assets/append_darwin-CqBNU62J.css +1 -0
- package/client/web/dist/assets/append_full_vibrancy-j0D2LM9m.css +1 -0
- package/client/web/dist/assets/append_linux-uhxeBqeC.css +1 -0
- package/client/web/dist/assets/append_linux_vibrancy-COiZq8MS.css +1 -0
- package/client/web/dist/assets/append_napcat-DV8CAyAd.css +1 -0
- package/client/web/dist/assets/append_new-407Lo5_V.css +1 -0
- package/client/web/dist/assets/append_vibrancy-6FHctkOY.css +1 -0
- package/client/web/dist/assets/append_win32-srmD-78y.css +1 -0
- package/client/web/dist/assets/cakes-CxTVKdnK.svg +189 -0
- package/client/web/dist/assets/core-DhEqZVGG.js +1 -0
- package/client/web/dist/assets/event-Cadp6m6J.js +1 -0
- package/client/web/dist/assets/index-CPTXiemn.js +4045 -0
- package/client/web/dist/assets/index-Cg_esKB0.js +1 -0
- package/client/web/dist/assets/index-Cln012sP.css +1 -0
- package/client/web/dist/assets//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223-CJKIKBn3.ttf +0 -0
- package/client/web/dist/assets//346/234/237/345/276/205-C90_OY60.png +0 -0
- package/client/web/dist/bcui/css/color-dark.css +35 -0
- package/client/web/dist/bcui/css/color-light.css +35 -0
- package/client/web/dist/bcui/css/style.css +468 -0
- package/client/web/dist/bcui/js/main.js +193 -0
- package/client/web/dist/css/append-dark.css +13 -0
- package/client/web/dist/css/append-light.css +13 -0
- package/client/web/dist/css/append-napcat-dark.css +14 -0
- package/client/web/dist/css/append-napcat-light.css +14 -0
- package/client/web/dist/favicon.ico +0 -0
- package/client/web/dist/img/icons/icon-for-fuck-apple.png +0 -0
- package/client/web/dist/img/icons/icon-maskable.png +0 -0
- package/client/web/dist/img/icons/icon.png +0 -0
- package/client/web/dist/img/icons/icon.svg +10 -0
- package/client/web/dist/index.html +22 -0
- package/client/web/dist/robots.txt +2 -0
- package/client/web/dist/sw.js +58 -0
- package/client/web/env.d.ts +56 -0
- package/client/web/index.html +21 -0
- package/client/web/package-lock.json +3419 -0
- package/client/web/package.json +62 -0
- package/client/web/public/bcui/css/color-dark.css +35 -0
- package/client/web/public/bcui/css/color-light.css +35 -0
- package/client/web/public/bcui/css/style.css +468 -0
- package/client/web/public/bcui/js/main.js +193 -0
- package/client/web/public/css/append-dark.css +13 -0
- package/client/web/public/css/append-light.css +13 -0
- package/client/web/public/css/append-napcat-dark.css +14 -0
- package/client/web/public/css/append-napcat-light.css +14 -0
- package/client/web/public/favicon.ico +0 -0
- package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
- package/client/web/public/img/icons/icon-maskable.png +0 -0
- package/client/web/public/img/icons/icon.png +0 -0
- package/client/web/public/img/icons/icon.svg +10 -0
- package/client/web/public/robots.txt +2 -0
- package/client/web/public/sw.js +58 -0
- package/client/web/scripts/copy-bcui.mjs +17 -0
- package/client/web/src/App.vue +1127 -0
- package/client/web/src/assets/css/append/append_darwin.css +68 -0
- package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
- package/client/web/src/assets/css/append/append_linux.css +125 -0
- package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
- package/client/web/src/assets/css/append/append_napcat.css +35 -0
- package/client/web/src/assets/css/append/append_new.css +843 -0
- package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
- package/client/web/src/assets/css/append/append_win32.css +109 -0
- package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
- package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
- package/client/web/src/assets/css/chat.css +2104 -0
- package/client/web/src/assets/css/msg.css +941 -0
- package/client/web/src/assets/css/options.css +811 -0
- package/client/web/src/assets/css/sys_notice.css +62 -0
- package/client/web/src/assets/css/view.css +2105 -0
- package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
- package/client/web/src/assets/img/Vue.png +0 -0
- package/client/web/src/assets/img/cakes.svg +189 -0
- package/client/web/src/assets/img/icons/AppIcon.png +0 -0
- package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
- package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
- package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
- package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
- package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
- package/client/web/src/assets/img/icons/exit.png +0 -0
- package/client/web/src/assets/img/icons/pen.png +0 -0
- package/client/web/src/assets/img/stars.svg +89 -0
- package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
- package/client/web/src/assets/l10n/_About I10n.md +35 -0
- package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
- package/client/web/src/assets/l10n/zh-CN.po +1941 -0
- package/client/web/src/assets/logo.png +0 -0
- package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
- package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
- package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
- package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
- package/client/web/src/assets/qed.txt +1 -0
- package/client/web/src/components/AboutPan.vue +197 -0
- package/client/web/src/components/BulletinBody.vue +129 -0
- package/client/web/src/components/DepPan.vue +310 -0
- package/client/web/src/components/EmojiFace.vue +32 -0
- package/client/web/src/components/FacePan.vue +461 -0
- package/client/web/src/components/FileBody.vue +129 -0
- package/client/web/src/components/FileManager.vue +532 -0
- package/client/web/src/components/FriendBody.vue +54 -0
- package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
- package/client/web/src/components/History.vue +49 -0
- package/client/web/src/components/LazyLottie.vue +82 -0
- package/client/web/src/components/MergePan.vue +127 -0
- package/client/web/src/components/MsgBody.vue +1478 -0
- package/client/web/src/components/MusicPlayer.vue +549 -0
- package/client/web/src/components/NoticeBody.vue +168 -0
- package/client/web/src/components/QzonePermissionPan.vue +150 -0
- package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
- package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
- package/client/web/src/components/TinySessionBody.vue +51 -0
- package/client/web/src/components/UmamiInfoPan.vue +1549 -0
- package/client/web/src/components/UpdatePan.vue +263 -0
- package/client/web/src/components/ViewerCom.vue +1360 -0
- package/client/web/src/components/VoiceMsg.vue +626 -0
- package/client/web/src/components/WelPan.vue +894 -0
- package/client/web/src/components/msg-body/.gitkeep +0 -0
- package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
- package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
- package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
- package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
- package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
- package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
- package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
- package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
- package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
- package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
- package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
- package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
- package/client/web/src/components/notice-component/.gitkeep +0 -0
- package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
- package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
- package/client/web/src/components/tooltip/Tooltip.vue +50 -0
- package/client/web/src/components/tooltip/Tooltips.vue +14 -0
- package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
- package/client/web/src/function/base.ts +238 -0
- package/client/web/src/function/connect.ts +2042 -0
- package/client/web/src/function/debug.ts +10 -0
- package/client/web/src/function/elements/information.ts +141 -0
- package/client/web/src/function/elements/system.ts +101 -0
- package/client/web/src/function/elements/vueComp.ts +62 -0
- package/client/web/src/function/favicon.ts +76 -0
- package/client/web/src/function/input.ts +69 -0
- package/client/web/src/function/model/emoji.ts +127 -0
- package/client/web/src/function/model/img.ts +150 -0
- package/client/web/src/function/model/msg-body.ts +51 -0
- package/client/web/src/function/msg.ts +2674 -0
- package/client/web/src/function/notify.ts +225 -0
- package/client/web/src/function/option.ts +749 -0
- package/client/web/src/function/satori-model.ts +818 -0
- package/client/web/src/function/satori.ts +325 -0
- package/client/web/src/function/sender.ts +178 -0
- package/client/web/src/function/tooltip.ts +49 -0
- package/client/web/src/function/utils/appUtil.ts +2288 -0
- package/client/web/src/function/utils/avatarUtil.ts +31 -0
- package/client/web/src/function/utils/backgroundUtil.ts +110 -0
- package/client/web/src/function/utils/linkViewUtil.ts +101 -0
- package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
- package/client/web/src/function/utils/msgUtil.ts +1242 -0
- package/client/web/src/function/utils/pinyin.ts +141 -0
- package/client/web/src/function/utils/recordUtil.ts +187 -0
- package/client/web/src/function/utils/sessionUtil.ts +114 -0
- package/client/web/src/function/utils/systemUtil.ts +664 -0
- package/client/web/src/main.ts +91 -0
- package/client/web/src/pages/Chat.vue +3453 -0
- package/client/web/src/pages/Friends.vue +636 -0
- package/client/web/src/pages/Info.vue +717 -0
- package/client/web/src/pages/Messages.vue +875 -0
- package/client/web/src/pages/Options.vue +82 -0
- package/client/web/src/pages/Qzone.vue +1178 -0
- package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
- package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
- package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
- package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
- package/client/web/src/pages/options/OptAccount.vue +107 -0
- package/client/web/src/pages/options/OptAddon.vue +185 -0
- package/client/web/src/pages/options/OptDev.vue +704 -0
- package/client/web/src/pages/options/OptFunction.vue +403 -0
- package/client/web/src/pages/options/OptInfo.vue +165 -0
- package/client/web/src/pages/options/OptView.vue +846 -0
- package/client/web/src/runtime/backend.ts +310 -0
- package/client/web/src/state/auth.ts +14 -0
- package/client/web/src/state/chat.ts +36 -0
- package/client/web/src/state/connection.ts +20 -0
- package/client/web/src/state/contact.ts +37 -0
- package/client/web/src/state/qzone.ts +18 -0
- package/client/web/src/state/sessionHistory.ts +25 -0
- package/client/web/src/state/settings.ts +18 -0
- package/client/web/src/state/sticker.ts +10 -0
- package/client/web/src/state/ui.ts +56 -0
- package/client/web/tsconfig.json +27 -0
- package/client/web/tsconfig.tsbuildinfo +1 -0
- package/client/web/vite.config.ts +33 -0
- package/dist/index.js +1 -12
- package/dist/style.css +1 -1
- package/lib/index.js +2070 -2077
- package/package.json +15 -2
- package/readme.md +0 -22
- package/src/bootstrap.ts +71 -0
- package/src/cache.ts +319 -0
- package/src/config.ts +23 -35
- package/src/database.ts +650 -0
- package/src/index.ts +27 -59
- package/src/media.ts +302 -0
- package/src/recorder.ts +175 -0
- package/src/self-message.ts +456 -0
- package/src/server.d.ts +26 -0
- package/src/types.ts +108 -48
- package/src/web.ts +712 -0
- package/client/vue/chat-logic.ts +0 -683
- package/client/vue/composables/useChatActions.ts +0 -61
- package/client/vue/composables/useChatData.ts +0 -185
- package/client/vue/composables/useImageCache.ts +0 -281
- package/client/vue/composables/useVideoCache.ts +0 -125
- package/client/vue/types.ts +0 -72
- package/lib/api-handlers.d.ts +0 -25
- package/lib/config.d.ts +0 -14
- package/lib/file-manager.d.ts +0 -124
- package/lib/index.d.ts +0 -17
- package/lib/logger.d.ts +0 -9
- package/lib/message-handler.d.ts +0 -35
- package/lib/types.d.ts +0 -57
- package/lib/utils.d.ts +0 -18
- package/src/api-handlers.ts +0 -646
- package/src/file-manager.ts +0 -1436
- package/src/message-handler.ts +0 -507
- package/src/utils.ts +0 -189
package/src/file-manager.ts
DELETED
|
@@ -1,1436 +0,0 @@
|
|
|
1
|
-
import { BotInfo, ChannelInfo, ChatData, MessageInfo } from './types'
|
|
2
|
-
import { Context } from 'koishi'
|
|
3
|
-
import { Config } from './config'
|
|
4
|
-
import { Utils } from './utils'
|
|
5
|
-
import { PluginLogger } from './logger'
|
|
6
|
-
|
|
7
|
-
import path from 'node:path'
|
|
8
|
-
import { promises as fs } from 'node:fs'
|
|
9
|
-
|
|
10
|
-
interface ChannelChunkMeta {
|
|
11
|
-
id: number
|
|
12
|
-
fileName: string
|
|
13
|
-
messageCount: number
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface ChannelIndexData {
|
|
17
|
-
version: 1
|
|
18
|
-
channelId: string
|
|
19
|
-
totalMessages: number
|
|
20
|
-
nextChunkId: number
|
|
21
|
-
chunks: ChannelChunkMeta[]
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface StoredChannelEntry {
|
|
25
|
-
selfId: string
|
|
26
|
-
channelId: string
|
|
27
|
-
channelKey: string
|
|
28
|
-
channelDirPath: string
|
|
29
|
-
indexFilePath: string
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class FileManager {
|
|
33
|
-
private storageBaseDir: string
|
|
34
|
-
private chatHistoryDir: string // 聊天记录根目录
|
|
35
|
-
private metadataFilePath: string // 元数据文件路径(存储bots、channels、pinned等信息)
|
|
36
|
-
private utils: Utils
|
|
37
|
-
|
|
38
|
-
private memoryCache: ChatData = this.createEmptyChatData()
|
|
39
|
-
|
|
40
|
-
private channelMessagesCache: Map<string, MessageInfo[]> = new Map()
|
|
41
|
-
|
|
42
|
-
private recentMessageIdsCache: Map<string, string[]> = new Map()
|
|
43
|
-
|
|
44
|
-
private pendingBotMessageIds: Map<string, string[]> = new Map()
|
|
45
|
-
|
|
46
|
-
private messageChunkLocationCache: Map<string, Map<string, string>> = new Map()
|
|
47
|
-
|
|
48
|
-
private dirtyChannelKeys: Set<string> = new Set()
|
|
49
|
-
|
|
50
|
-
private pendingMessages: Map<string, MessageInfo[]> = new Map() // 按channelKey分组的待写入消息
|
|
51
|
-
|
|
52
|
-
private writeTimers: Map<string, (() => void)> = new Map() // 每个频道独立的写入定时器
|
|
53
|
-
|
|
54
|
-
private metadataLoadPromise: Promise<void> | null = null
|
|
55
|
-
|
|
56
|
-
private channelLoadPromises: Map<string, Promise<MessageInfo[]>> = new Map()
|
|
57
|
-
|
|
58
|
-
private writeQueue: Promise<void> = Promise.resolve()
|
|
59
|
-
|
|
60
|
-
private disposed = false
|
|
61
|
-
|
|
62
|
-
private readonly WRITE_DEBOUNCE_MS = 1000
|
|
63
|
-
|
|
64
|
-
private readonly RECENT_MESSAGE_ID_CACHE_SIZE = 200
|
|
65
|
-
|
|
66
|
-
constructor(
|
|
67
|
-
private ctx: Context,
|
|
68
|
-
private config: Config,
|
|
69
|
-
private logger: PluginLogger
|
|
70
|
-
) {
|
|
71
|
-
const baseDir = path.resolve(ctx.baseDir, 'data', 'chat-patch')
|
|
72
|
-
this.storageBaseDir = path.join(baseDir, 'v2')
|
|
73
|
-
this.chatHistoryDir = path.join(this.storageBaseDir, 'chat-history')
|
|
74
|
-
this.metadataFilePath = path.join(this.storageBaseDir, 'metadata.json')
|
|
75
|
-
this.utils = new Utils(config, ctx)
|
|
76
|
-
|
|
77
|
-
// 异步清理旧版本数据,避免阻塞启动。
|
|
78
|
-
this.ctx.setTimeout(() => {
|
|
79
|
-
void this.cleanupLegacyStorage(baseDir)
|
|
80
|
-
}, 0)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async initialize() {
|
|
84
|
-
await this.ensureMetadataLoaded()
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
readChatDataFromFile(): ChatData {
|
|
88
|
-
this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
|
|
89
|
-
return this.memoryCache
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
getCachedChannelInfo(selfId: string, channelId: string): ChannelInfo | undefined {
|
|
93
|
-
return this.memoryCache.channels[selfId]?.[channelId]
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async readMetadataOnly(): Promise<Omit<ChatData, 'messages'>> {
|
|
97
|
-
await this.ensureMetadataLoaded()
|
|
98
|
-
const { messages, ...metadata } = this.memoryCache
|
|
99
|
-
return { ...metadata }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async upsertBotInfo(botInfo: BotInfo) {
|
|
103
|
-
await this.ensureMetadataLoaded()
|
|
104
|
-
const current = this.memoryCache.bots[botInfo.selfId]
|
|
105
|
-
if (current && this.isSameBotInfo(current, botInfo)) {
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
this.memoryCache.bots[botInfo.selfId] = botInfo
|
|
110
|
-
this.scheduleMetadataWrite()
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async upsertChannelInfo(selfId: string, channelId: string, channelInfo: ChannelInfo) {
|
|
114
|
-
await this.ensureMetadataLoaded()
|
|
115
|
-
|
|
116
|
-
if (!this.memoryCache.channels[selfId]) {
|
|
117
|
-
this.memoryCache.channels[selfId] = {}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const current = this.memoryCache.channels[selfId][channelId]
|
|
121
|
-
if (current && this.isSameChannelInfo(current, channelInfo)) {
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
this.memoryCache.channels[selfId][channelId] = channelInfo
|
|
126
|
-
this.scheduleMetadataWrite()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async setPinnedBots(pinnedBots: string[]) {
|
|
130
|
-
await this.ensureMetadataLoaded()
|
|
131
|
-
this.memoryCache.pinnedBots = [...pinnedBots]
|
|
132
|
-
this.scheduleMetadataWrite()
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async setPinnedChannels(pinnedChannels: string[]) {
|
|
136
|
-
await this.ensureMetadataLoaded()
|
|
137
|
-
this.memoryCache.pinnedChannels = [...pinnedChannels]
|
|
138
|
-
this.scheduleMetadataWrite()
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 清理旧版本数据目录
|
|
142
|
-
private async cleanupLegacyStorage(baseDir: string) {
|
|
143
|
-
const oldFiles = ['chat-data.json', 'data.json', 'messages.json']
|
|
144
|
-
for (const fileName of oldFiles) {
|
|
145
|
-
const filePath = path.join(baseDir, fileName)
|
|
146
|
-
try {
|
|
147
|
-
await fs.unlink(filePath)
|
|
148
|
-
this.logger.logInfo(`已删除旧版本数据文件: ${fileName}`)
|
|
149
|
-
} catch (error) {
|
|
150
|
-
if (!this.isFileMissingError(error)) {
|
|
151
|
-
this.logger.warn(`删除旧版本数据文件失败: ${fileName}`, error)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const legacyPaths = [
|
|
157
|
-
path.join(baseDir, 'metadata.json'),
|
|
158
|
-
path.join(baseDir, 'chat-history')
|
|
159
|
-
]
|
|
160
|
-
|
|
161
|
-
for (const legacyPath of legacyPaths) {
|
|
162
|
-
try {
|
|
163
|
-
await fs.rm(legacyPath, { recursive: true, force: true })
|
|
164
|
-
this.logger.logInfo(`已清理旧版数据路径: ${legacyPath}`)
|
|
165
|
-
} catch (error) {
|
|
166
|
-
this.logger.warn(`清理旧版数据路径失败: ${legacyPath}`, error)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// 确保目录存在
|
|
172
|
-
private async ensureDir(dirPath: string) {
|
|
173
|
-
await fs.mkdir(dirPath, { recursive: true })
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// 读取单个频道的消息(公共方法)
|
|
177
|
-
async readChannelMessages(selfId: string, channelId: string): Promise<MessageInfo[]> {
|
|
178
|
-
await this.ensureMetadataLoaded()
|
|
179
|
-
|
|
180
|
-
const channelKey = `${selfId}:${channelId}`
|
|
181
|
-
const cached = this.getCachedChannelMessages(channelKey)
|
|
182
|
-
if (cached) {
|
|
183
|
-
return cached
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const existingPromise = this.channelLoadPromises.get(channelKey)
|
|
187
|
-
if (existingPromise) {
|
|
188
|
-
return existingPromise
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const loadPromise = this.loadChannelMessages(selfId, channelId)
|
|
192
|
-
this.channelLoadPromises.set(channelKey, loadPromise)
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
return await loadPromise
|
|
196
|
-
} finally {
|
|
197
|
-
this.channelLoadPromises.delete(channelKey)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async readChannelMessagesPage(selfId: string, channelId: string, limit: number, offset = 0): Promise<{ messages: MessageInfo[], total: number }> {
|
|
202
|
-
await this.ensureMetadataLoaded()
|
|
203
|
-
|
|
204
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
205
|
-
if (limit <= 0 || offset >= indexData.totalMessages) {
|
|
206
|
-
return {
|
|
207
|
-
messages: [],
|
|
208
|
-
total: indexData.totalMessages
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
213
|
-
const collected: MessageInfo[] = []
|
|
214
|
-
let skipped = 0
|
|
215
|
-
|
|
216
|
-
for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
|
|
217
|
-
const chunk = indexData.chunks[chunkIndex]
|
|
218
|
-
const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
219
|
-
|
|
220
|
-
for (let messageIndex = chunkMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
|
221
|
-
if (skipped < offset) {
|
|
222
|
-
skipped += 1
|
|
223
|
-
continue
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
collected.push(chunkMessages[messageIndex])
|
|
227
|
-
if (collected.length >= limit) {
|
|
228
|
-
return {
|
|
229
|
-
messages: collected.reverse(),
|
|
230
|
-
total: indexData.totalMessages
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return {
|
|
237
|
-
messages: collected.reverse(),
|
|
238
|
-
total: indexData.totalMessages
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async findChannelMessageById(selfId: string, channelId: string, messageId: string): Promise<MessageInfo | null> {
|
|
243
|
-
await this.ensureMetadataLoaded()
|
|
244
|
-
|
|
245
|
-
const channelKey = `${selfId}:${channelId}`
|
|
246
|
-
const cachedMessages = this.peekCachedChannelMessages(channelKey)
|
|
247
|
-
const cachedMatched = cachedMessages?.find((message) => message.id === messageId)
|
|
248
|
-
if (cachedMatched) {
|
|
249
|
-
return cachedMatched
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
253
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
254
|
-
for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
|
|
255
|
-
const chunk = indexData.chunks[chunkIndex]
|
|
256
|
-
const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
257
|
-
const matched = messages.find((message) => message.id === messageId)
|
|
258
|
-
if (matched) {
|
|
259
|
-
return matched
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return null
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// 读取元数据(bots、channels、pinned等)
|
|
267
|
-
private async readMetadata(): Promise<Omit<ChatData, 'messages'>> {
|
|
268
|
-
try {
|
|
269
|
-
const jsonData = await fs.readFile(this.metadataFilePath, 'utf8')
|
|
270
|
-
const data = JSON.parse(jsonData)
|
|
271
|
-
return {
|
|
272
|
-
bots: data.bots || {},
|
|
273
|
-
channels: data.channels || {},
|
|
274
|
-
pinnedBots: data.pinnedBots || [],
|
|
275
|
-
pinnedChannels: data.pinnedChannels || [],
|
|
276
|
-
lastSaveTime: data.lastSaveTime
|
|
277
|
-
}
|
|
278
|
-
} catch (error) {
|
|
279
|
-
if (this.isFileMissingError(error)) {
|
|
280
|
-
return {
|
|
281
|
-
bots: {},
|
|
282
|
-
channels: {},
|
|
283
|
-
pinnedBots: [],
|
|
284
|
-
pinnedChannels: []
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
this.logger.error('读取元数据失败:', error)
|
|
289
|
-
return {
|
|
290
|
-
bots: {},
|
|
291
|
-
channels: {},
|
|
292
|
-
pinnedBots: [],
|
|
293
|
-
pinnedChannels: []
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// 写入元数据
|
|
299
|
-
private async writeMetadata(metadata: Omit<ChatData, 'messages'>) {
|
|
300
|
-
try {
|
|
301
|
-
await this.ensureDir(path.dirname(this.metadataFilePath))
|
|
302
|
-
const dataToWrite = {
|
|
303
|
-
...metadata,
|
|
304
|
-
lastSaveTime: Date.now()
|
|
305
|
-
}
|
|
306
|
-
const jsonData = JSON.stringify(dataToWrite, null, 2)
|
|
307
|
-
await this.atomicWriteTextFile(this.metadataFilePath, jsonData)
|
|
308
|
-
} catch (error) {
|
|
309
|
-
this.logger.error('写入元数据失败:', error)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// 为特定频道安排写入
|
|
314
|
-
private scheduleWrite(channelKey: string) {
|
|
315
|
-
// 取消之前的定时器
|
|
316
|
-
const existingTimer = this.writeTimers.get(channelKey)
|
|
317
|
-
if (existingTimer) {
|
|
318
|
-
existingTimer()
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// 创建新的定时器
|
|
322
|
-
const timer = this.ctx.setTimeout(() => {
|
|
323
|
-
this.writeTimers.delete(channelKey)
|
|
324
|
-
void this.flushPendingMessages(channelKey)
|
|
325
|
-
}, this.WRITE_DEBOUNCE_MS)
|
|
326
|
-
|
|
327
|
-
this.writeTimers.set(channelKey, timer)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// 刷新特定频道的待写入消息
|
|
331
|
-
private async flushPendingMessages(channelKey: string) {
|
|
332
|
-
const messagesToWrite = this.pendingMessages.get(channelKey)
|
|
333
|
-
if (!messagesToWrite || messagesToWrite.length === 0) return
|
|
334
|
-
|
|
335
|
-
this.pendingMessages.delete(channelKey)
|
|
336
|
-
|
|
337
|
-
const cachedMessages = this.peekCachedChannelMessages(channelKey)
|
|
338
|
-
const [selfId, channelId] = channelKey.split(':')
|
|
339
|
-
|
|
340
|
-
if (!selfId || !channelId) return
|
|
341
|
-
|
|
342
|
-
const uniqueMessages = this.deduplicateMessages(messagesToWrite)
|
|
343
|
-
if (!uniqueMessages.length) {
|
|
344
|
-
return
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (cachedMessages) {
|
|
348
|
-
const nextMessages = this.mergeChannelMessages(cachedMessages, uniqueMessages)
|
|
349
|
-
this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages))
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
this.rememberRecentMessageIds(channelKey, uniqueMessages.map((message) => message.id))
|
|
353
|
-
|
|
354
|
-
// 只向最后一个 chunk 追加,避免整频道重写。
|
|
355
|
-
this.enqueueWrite(async () => {
|
|
356
|
-
await this.appendMessagesToChannel(selfId, channelId, uniqueMessages)
|
|
357
|
-
this.logger.logInfo(`批量写入 ${uniqueMessages.length} 条消息到频道 ${channelKey}`)
|
|
358
|
-
})
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
cleanExcessMessages(data: ChatData): ChatData {
|
|
362
|
-
let cleanedCount = 0
|
|
363
|
-
const cleanedMessages: Record<string, MessageInfo[]> = {}
|
|
364
|
-
|
|
365
|
-
for (const [channelKey, messages] of Object.entries(data.messages)) {
|
|
366
|
-
if (messages.length > this.config.maxMessagesPerChannel) {
|
|
367
|
-
const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp)
|
|
368
|
-
const keptMessages = sortedMessages.slice(-this.config.maxMessagesPerChannel)
|
|
369
|
-
cleanedCount += messages.length - keptMessages.length
|
|
370
|
-
cleanedMessages[channelKey] = keptMessages
|
|
371
|
-
this.logger.logInfo(`频道 ${channelKey} 清理了 ${messages.length - keptMessages.length} 条旧消息,保留最新 ${keptMessages.length} 条`)
|
|
372
|
-
} else {
|
|
373
|
-
cleanedMessages[channelKey] = messages
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (cleanedCount > 0) {
|
|
378
|
-
this.logger.logInfo('总共清理超量消息:', cleanedCount, '条')
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return {
|
|
382
|
-
...data,
|
|
383
|
-
messages: cleanedMessages
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
async addMessageToFile(messageInfo: MessageInfo) {
|
|
388
|
-
await this.ensureMetadataLoaded()
|
|
389
|
-
const channelKey = `${messageInfo.selfId}:${messageInfo.channelId}`
|
|
390
|
-
|
|
391
|
-
if (!messageInfo.timestamp) {
|
|
392
|
-
messageInfo.timestamp = Date.now()
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const cleanedMessageInfo = await this.utils.cleanBase64ContentAsync(messageInfo, false)
|
|
396
|
-
const pendingMessages = this.pendingMessages.get(channelKey) || []
|
|
397
|
-
if (pendingMessages.some((message) => message.id === cleanedMessageInfo.id)) {
|
|
398
|
-
return
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const cachedMessages = this.peekCachedChannelMessages(channelKey)
|
|
402
|
-
if (cachedMessages?.some((message) => message.id === cleanedMessageInfo.id)) {
|
|
403
|
-
return
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (!cachedMessages) {
|
|
407
|
-
if (this.hasRecentMessageId(channelKey, cleanedMessageInfo.id)) {
|
|
408
|
-
return
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const existsInStorage = await this.channelMessageExists(messageInfo.selfId, messageInfo.channelId, cleanedMessageInfo.id)
|
|
412
|
-
if (existsInStorage) {
|
|
413
|
-
return
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
pendingMessages.push(cleanedMessageInfo)
|
|
418
|
-
this.pendingMessages.set(channelKey, pendingMessages)
|
|
419
|
-
this.rememberRecentMessageIds(channelKey, [cleanedMessageInfo.id])
|
|
420
|
-
|
|
421
|
-
if (cleanedMessageInfo.type === 'bot' && cleanedMessageInfo.sending) {
|
|
422
|
-
this.registerPendingBotMessage(channelKey, cleanedMessageInfo.id)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (cachedMessages) {
|
|
426
|
-
const nextMessages = this.mergeChannelMessages(cachedMessages, [cleanedMessageInfo])
|
|
427
|
-
this.setCachedChannelMessages(channelKey, this.limitChannelMessages(nextMessages))
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// 安排写入
|
|
431
|
-
this.scheduleWrite(channelKey)
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
async cleanupExcessMessagesInStorage() {
|
|
435
|
-
let cleanedCount = 0
|
|
436
|
-
|
|
437
|
-
for (const channelKey of [...this.dirtyChannelKeys]) {
|
|
438
|
-
const [selfId, channelId] = channelKey.split(':')
|
|
439
|
-
if (!selfId || !channelId) {
|
|
440
|
-
this.dirtyChannelKeys.delete(channelKey)
|
|
441
|
-
continue
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
445
|
-
if (indexData.totalMessages <= this.config.maxMessagesPerChannel) {
|
|
446
|
-
this.dirtyChannelKeys.delete(channelKey)
|
|
447
|
-
continue
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const removedCount = await this.trimChannelToLimit(selfId, channelId, indexData, this.config.maxMessagesPerChannel)
|
|
451
|
-
if (!removedCount) {
|
|
452
|
-
continue
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
cleanedCount += removedCount
|
|
456
|
-
if (this.peekCachedChannelMessages(channelKey)) {
|
|
457
|
-
this.setCachedChannelMessages(channelKey, await this.loadChannelMessagesNoCache(selfId, channelId))
|
|
458
|
-
}
|
|
459
|
-
this.dirtyChannelKeys.delete(channelKey)
|
|
460
|
-
this.logger.logInfo(`频道 ${channelKey} 清理了 ${removedCount} 条旧消息,保留最新 ${indexData.totalMessages} 条`)
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (cleanedCount > 0) {
|
|
464
|
-
this.logger.logInfo('定期清理完成,清理了', cleanedCount, '条超量消息')
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
async getAllChannelMessageCounts(): Promise<Record<string, number>> {
|
|
469
|
-
const counts: Record<string, number> = {}
|
|
470
|
-
|
|
471
|
-
await this.scanStoredChannels(async (entry) => {
|
|
472
|
-
counts[entry.channelKey] = await this.countChannelMessages(entry.selfId, entry.channelId)
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
return counts
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
async deleteChannelData(selfId: string, channelId: string) {
|
|
479
|
-
await this.ensureMetadataLoaded()
|
|
480
|
-
|
|
481
|
-
const channelKey = `${selfId}:${channelId}`
|
|
482
|
-
const deletedMessages = await this.countChannelMessages(selfId, channelId)
|
|
483
|
-
|
|
484
|
-
this.deleteCachedChannelMessages(channelKey)
|
|
485
|
-
this.deleteRecentMessageIds(channelKey)
|
|
486
|
-
this.deletePendingBotMessages(channelKey)
|
|
487
|
-
this.deleteMessageChunkLocations(channelKey)
|
|
488
|
-
this.dirtyChannelKeys.delete(channelKey)
|
|
489
|
-
this.pendingMessages.delete(channelKey)
|
|
490
|
-
|
|
491
|
-
const timer = this.writeTimers.get(channelKey)
|
|
492
|
-
if (timer) {
|
|
493
|
-
timer()
|
|
494
|
-
this.writeTimers.delete(channelKey)
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if (this.memoryCache.channels[selfId]?.[channelId]) {
|
|
498
|
-
delete this.memoryCache.channels[selfId][channelId]
|
|
499
|
-
if (!Object.keys(this.memoryCache.channels[selfId]).length) {
|
|
500
|
-
delete this.memoryCache.channels[selfId]
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => item !== channelKey)
|
|
505
|
-
|
|
506
|
-
await this.removeChannelStorage(selfId, channelId)
|
|
507
|
-
|
|
508
|
-
this.scheduleMetadataWrite()
|
|
509
|
-
|
|
510
|
-
return { deletedMessages }
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
async deleteBotData(selfId: string) {
|
|
514
|
-
await this.ensureMetadataLoaded()
|
|
515
|
-
|
|
516
|
-
const botDir = path.join(this.chatHistoryDir, selfId)
|
|
517
|
-
const channels = await this.listBotChannels(selfId)
|
|
518
|
-
let deletedMessages = 0
|
|
519
|
-
|
|
520
|
-
for (const entry of channels) {
|
|
521
|
-
deletedMessages += await this.countChannelMessages(entry.selfId, entry.channelId)
|
|
522
|
-
this.deleteCachedChannelMessages(entry.channelKey)
|
|
523
|
-
this.deleteRecentMessageIds(entry.channelKey)
|
|
524
|
-
this.deletePendingBotMessages(entry.channelKey)
|
|
525
|
-
this.deleteMessageChunkLocations(entry.channelKey)
|
|
526
|
-
this.dirtyChannelKeys.delete(entry.channelKey)
|
|
527
|
-
this.pendingMessages.delete(entry.channelKey)
|
|
528
|
-
|
|
529
|
-
const timer = this.writeTimers.get(entry.channelKey)
|
|
530
|
-
if (timer) {
|
|
531
|
-
timer()
|
|
532
|
-
this.writeTimers.delete(entry.channelKey)
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
const deletedChannels = channels.length
|
|
537
|
-
|
|
538
|
-
delete this.memoryCache.bots[selfId]
|
|
539
|
-
delete this.memoryCache.channels[selfId]
|
|
540
|
-
this.memoryCache.pinnedBots = this.memoryCache.pinnedBots.filter((item) => item !== selfId)
|
|
541
|
-
this.memoryCache.pinnedChannels = this.memoryCache.pinnedChannels.filter((item) => !item.startsWith(`${selfId}:`))
|
|
542
|
-
|
|
543
|
-
try {
|
|
544
|
-
await fs.rm(botDir, { recursive: true, force: true })
|
|
545
|
-
} catch (error) {
|
|
546
|
-
this.logger.error(`删除机器人目录失败 [${selfId}]:`, error)
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
this.scheduleMetadataWrite()
|
|
550
|
-
|
|
551
|
-
return {
|
|
552
|
-
deletedChannels,
|
|
553
|
-
deletedMessages
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
async updateUserProfileInBotData(selfId: string, userId: string, userName?: string, avatar?: string) {
|
|
558
|
-
await this.ensureMetadataLoaded()
|
|
559
|
-
|
|
560
|
-
let changed = false
|
|
561
|
-
|
|
562
|
-
const botChannels = this.memoryCache.channels[selfId] || {}
|
|
563
|
-
const possibleChannelIds = [
|
|
564
|
-
userId,
|
|
565
|
-
`private:${userId}`,
|
|
566
|
-
`direct:${userId}`
|
|
567
|
-
]
|
|
568
|
-
|
|
569
|
-
for (const channelId of possibleChannelIds) {
|
|
570
|
-
const channel = botChannels[channelId]
|
|
571
|
-
if (!channel || !channel.isDirect || !userName) {
|
|
572
|
-
continue
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const newName = `私聊(${userName})`
|
|
576
|
-
if (channel.name !== newName) {
|
|
577
|
-
channel.name = newName
|
|
578
|
-
changed = true
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const channels = await this.listBotChannels(selfId)
|
|
583
|
-
for (const entry of channels) {
|
|
584
|
-
const entryChanged = await this.updateUserProfileInChannel(entry.selfId, entry.channelId, userId, userName, avatar)
|
|
585
|
-
if (!entryChanged) {
|
|
586
|
-
continue
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
if (this.peekCachedChannelMessages(entry.channelKey)) {
|
|
590
|
-
this.setCachedChannelMessages(entry.channelKey, await this.loadChannelMessagesNoCache(entry.selfId, entry.channelId))
|
|
591
|
-
}
|
|
592
|
-
changed = true
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
if (changed) {
|
|
596
|
-
this.scheduleMetadataWrite()
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
return changed
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
async markLatestBotMessageAsSent(selfId: string, channelId: string, realId: string): Promise<MessageInfo | undefined> {
|
|
603
|
-
const channelKey = `${selfId}:${channelId}`
|
|
604
|
-
const tempMessageId = this.peekLatestPendingBotMessageId(channelKey)
|
|
605
|
-
|
|
606
|
-
let matched: MessageInfo | undefined
|
|
607
|
-
if (tempMessageId) {
|
|
608
|
-
matched = await this.findAndUpdateBotMessageByTempId(selfId, channelId, tempMessageId, realId)
|
|
609
|
-
this.consumePendingBotMessageId(channelKey, tempMessageId)
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
if (!matched) {
|
|
613
|
-
matched = await this.findAndUpdateLatestBotMessage(selfId, channelId, realId)
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (!matched) {
|
|
617
|
-
return undefined
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const cachedMessages = this.peekCachedChannelMessages(channelKey)
|
|
621
|
-
if (cachedMessages) {
|
|
622
|
-
const cachedMatched = cachedMessages.find((message) => message.id === matched?.id)
|
|
623
|
-
if (cachedMatched) {
|
|
624
|
-
cachedMatched.realId = realId
|
|
625
|
-
cachedMatched.sending = false
|
|
626
|
-
this.setCachedChannelMessages(channelKey, cachedMessages)
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
return matched
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
async dispose() {
|
|
634
|
-
this.disposed = true
|
|
635
|
-
|
|
636
|
-
// 取消所有定时器
|
|
637
|
-
for (const [channelKey, timer] of this.writeTimers.entries()) {
|
|
638
|
-
timer()
|
|
639
|
-
await this.flushPendingMessages(channelKey)
|
|
640
|
-
}
|
|
641
|
-
this.writeTimers.clear()
|
|
642
|
-
|
|
643
|
-
await this.writeQueue
|
|
644
|
-
await this.utils.dispose()
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
private createEmptyChatData(): ChatData {
|
|
648
|
-
return {
|
|
649
|
-
bots: {},
|
|
650
|
-
channels: {},
|
|
651
|
-
messages: {},
|
|
652
|
-
pinnedBots: [],
|
|
653
|
-
pinnedChannels: []
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
private async ensureMetadataLoaded() {
|
|
658
|
-
if (!this.metadataLoadPromise) {
|
|
659
|
-
this.metadataLoadPromise = this.loadMetadataIntoCache()
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
await this.metadataLoadPromise
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
private async loadMetadataIntoCache() {
|
|
666
|
-
const metadata = await this.readMetadata()
|
|
667
|
-
this.memoryCache = {
|
|
668
|
-
...metadata,
|
|
669
|
-
messages: this.getChannelMessagesCacheSnapshot()
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
private async loadChannelMessages(selfId: string, channelId: string): Promise<MessageInfo[]> {
|
|
674
|
-
const messages = await this.loadChannelMessagesNoCache(selfId, channelId)
|
|
675
|
-
this.setCachedChannelMessages(`${selfId}:${channelId}`, messages)
|
|
676
|
-
return messages
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
private async loadChannelMessagesNoCache(selfId: string, channelId: string): Promise<MessageInfo[]> {
|
|
680
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
681
|
-
const channelDirPath = this.getChannelDirPath(selfId, channelId)
|
|
682
|
-
const messages: MessageInfo[] = []
|
|
683
|
-
const channelKey = `${selfId}:${channelId}`
|
|
684
|
-
|
|
685
|
-
for (const chunk of indexData.chunks) {
|
|
686
|
-
const chunkMessages = await this.readChunkMessages(channelDirPath, chunk.fileName)
|
|
687
|
-
messages.push(...chunkMessages)
|
|
688
|
-
this.rememberMessageChunkLocation(channelKey, chunk.fileName, chunkMessages.map((message) => message.id))
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id))
|
|
692
|
-
|
|
693
|
-
return messages
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
private scheduleMetadataWrite() {
|
|
697
|
-
this.enqueueWrite(async () => {
|
|
698
|
-
const { messages, ...metadata } = this.readChatDataFromFile()
|
|
699
|
-
await this.writeMetadata(metadata)
|
|
700
|
-
})
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
private enqueueWrite(task: () => Promise<void>) {
|
|
704
|
-
if (this.disposed) {
|
|
705
|
-
return
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
this.writeQueue = this.writeQueue
|
|
709
|
-
.then(task)
|
|
710
|
-
.catch((error) => {
|
|
711
|
-
this.logger.error('写入任务失败:', error)
|
|
712
|
-
})
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
private async listStoredChannels(): Promise<StoredChannelEntry[]> {
|
|
716
|
-
const result: StoredChannelEntry[] = []
|
|
717
|
-
|
|
718
|
-
await this.scanStoredChannels(async (entry) => {
|
|
719
|
-
result.push(entry)
|
|
720
|
-
})
|
|
721
|
-
|
|
722
|
-
return result
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
private async listBotChannels(selfId: string): Promise<StoredChannelEntry[]> {
|
|
726
|
-
const result: StoredChannelEntry[] = []
|
|
727
|
-
|
|
728
|
-
await this.scanStoredChannels(async (entry) => {
|
|
729
|
-
if (entry.selfId === selfId) {
|
|
730
|
-
result.push(entry)
|
|
731
|
-
}
|
|
732
|
-
}, selfId)
|
|
733
|
-
|
|
734
|
-
return result
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
private async scanStoredChannels(visitor: (entry: StoredChannelEntry) => Promise<void>, botIdFilter?: string) {
|
|
738
|
-
try {
|
|
739
|
-
const botDirs = await fs.readdir(this.chatHistoryDir, { withFileTypes: true })
|
|
740
|
-
for (const botEntry of botDirs) {
|
|
741
|
-
const botName = this.normalizeDirentName(botEntry.name)
|
|
742
|
-
if (!botEntry.isDirectory()) continue
|
|
743
|
-
if (botIdFilter && botName !== botIdFilter) continue
|
|
744
|
-
|
|
745
|
-
const botDir = path.join(this.chatHistoryDir, botName)
|
|
746
|
-
const channelEntries = await fs.readdir(botDir, { withFileTypes: true })
|
|
747
|
-
|
|
748
|
-
for (const channelEntry of channelEntries) {
|
|
749
|
-
const channelId = await this.resolveStoredChannelId(botName, channelEntry)
|
|
750
|
-
if (!channelId) {
|
|
751
|
-
continue
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
await visitor(this.createStoredChannelEntry(botName, channelId))
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
} catch (error) {
|
|
758
|
-
if (!this.isFileMissingError(error)) {
|
|
759
|
-
this.logger.error('扫描频道文件失败:', error)
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
private createStoredChannelEntry(selfId: string, channelId: string): StoredChannelEntry {
|
|
765
|
-
const channelDirPath = this.getChannelDirPath(selfId, channelId)
|
|
766
|
-
return {
|
|
767
|
-
selfId,
|
|
768
|
-
channelId,
|
|
769
|
-
channelKey: `${selfId}:${channelId}`,
|
|
770
|
-
channelDirPath,
|
|
771
|
-
indexFilePath: this.getChannelIndexPath(selfId, channelId)
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
private async resolveStoredChannelId(selfId: string, entry: { isFile(): boolean, isDirectory(): boolean, name: string | Buffer }) {
|
|
776
|
-
const entryName = this.normalizeDirentName(entry.name)
|
|
777
|
-
|
|
778
|
-
if (!entry.isDirectory()) {
|
|
779
|
-
return undefined
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const encodedChannelId = entryName
|
|
783
|
-
const channelDirPath = path.join(this.chatHistoryDir, selfId, encodedChannelId)
|
|
784
|
-
const indexFilePath = path.join(channelDirPath, 'index.json')
|
|
785
|
-
|
|
786
|
-
try {
|
|
787
|
-
const jsonData = await fs.readFile(indexFilePath, 'utf8')
|
|
788
|
-
const indexData = JSON.parse(jsonData) as Partial<ChannelIndexData>
|
|
789
|
-
if (typeof indexData.channelId === 'string') {
|
|
790
|
-
return indexData.channelId
|
|
791
|
-
}
|
|
792
|
-
} catch (error) {
|
|
793
|
-
if (!this.isFileMissingError(error)) {
|
|
794
|
-
this.logger.error(`读取频道索引失败 [${selfId}:${entryName}]:`, error)
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
return this.decodeChannelId(encodedChannelId)
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
private getBotDirPath(selfId: string) {
|
|
802
|
-
return path.join(this.chatHistoryDir, selfId)
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
private getEncodedChannelId(channelId: string) {
|
|
806
|
-
return encodeURIComponent(channelId)
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
private decodeChannelId(encodedChannelId: string) {
|
|
810
|
-
try {
|
|
811
|
-
return decodeURIComponent(encodedChannelId)
|
|
812
|
-
} catch {
|
|
813
|
-
return encodedChannelId
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
private normalizeDirentName(name: string | Buffer) {
|
|
818
|
-
return typeof name === 'string' ? name : name.toString('utf8')
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
private getChannelDirPath(selfId: string, channelId: string) {
|
|
822
|
-
return path.join(this.getBotDirPath(selfId), this.getEncodedChannelId(channelId))
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
private getChannelIndexPath(selfId: string, channelId: string) {
|
|
826
|
-
return path.join(this.getChannelDirPath(selfId, channelId), 'index.json')
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
private getChunkFilePath(channelDirPath: string, fileName: string) {
|
|
830
|
-
return path.join(channelDirPath, fileName)
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
private createEmptyChannelIndex(channelId: string): ChannelIndexData {
|
|
834
|
-
return {
|
|
835
|
-
version: 1,
|
|
836
|
-
channelId,
|
|
837
|
-
totalMessages: 0,
|
|
838
|
-
nextChunkId: 1,
|
|
839
|
-
chunks: []
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
private createChunkFileName(chunkId: number) {
|
|
844
|
-
return `chunk-${String(chunkId).padStart(6, '0')}.json`
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
private async loadOrCreateChannelIndex(selfId: string, channelId: string): Promise<ChannelIndexData> {
|
|
848
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
849
|
-
|
|
850
|
-
try {
|
|
851
|
-
const jsonData = await fs.readFile(entry.indexFilePath, 'utf8')
|
|
852
|
-
const indexData = JSON.parse(jsonData) as ChannelIndexData
|
|
853
|
-
const normalizedIndexData = this.normalizeChannelIndex(channelId, indexData)
|
|
854
|
-
this.syncDirtyChannelState(entry.channelKey, normalizedIndexData.totalMessages)
|
|
855
|
-
return normalizedIndexData
|
|
856
|
-
} catch (error) {
|
|
857
|
-
if (!this.isFileMissingError(error)) {
|
|
858
|
-
this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error)
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const indexData = this.createEmptyChannelIndex(channelId)
|
|
863
|
-
await this.ensureDir(entry.channelDirPath)
|
|
864
|
-
|
|
865
|
-
await this.writeChannelIndex(entry.indexFilePath, indexData)
|
|
866
|
-
this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
|
|
867
|
-
|
|
868
|
-
return indexData
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
private normalizeChannelIndex(channelId: string, indexData: ChannelIndexData): ChannelIndexData {
|
|
872
|
-
return {
|
|
873
|
-
version: 1,
|
|
874
|
-
channelId,
|
|
875
|
-
totalMessages: indexData.totalMessages || 0,
|
|
876
|
-
nextChunkId: indexData.nextChunkId || (indexData.chunks?.length || 0) + 1,
|
|
877
|
-
chunks: Array.isArray(indexData.chunks) ? indexData.chunks : []
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
private async writeChannelIndex(indexFilePath: string, indexData: ChannelIndexData) {
|
|
882
|
-
await this.ensureDir(path.dirname(indexFilePath))
|
|
883
|
-
await this.atomicWriteTextFile(indexFilePath, JSON.stringify(indexData, null, 2))
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
private async readChunkMessages(channelDirPath: string, fileName: string): Promise<MessageInfo[]> {
|
|
887
|
-
try {
|
|
888
|
-
const jsonData = await fs.readFile(this.getChunkFilePath(channelDirPath, fileName), 'utf8')
|
|
889
|
-
const messages = JSON.parse(jsonData)
|
|
890
|
-
return Array.isArray(messages) ? messages : []
|
|
891
|
-
} catch (error) {
|
|
892
|
-
if (!this.isFileMissingError(error)) {
|
|
893
|
-
this.logger.error(`读取消息分块失败 [${channelDirPath}/${fileName}]:`, error)
|
|
894
|
-
}
|
|
895
|
-
return []
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
private async writeChunkMessages(channelDirPath: string, fileName: string, messages: MessageInfo[]) {
|
|
900
|
-
await this.ensureDir(channelDirPath)
|
|
901
|
-
await this.atomicWriteTextFile(this.getChunkFilePath(channelDirPath, fileName), JSON.stringify(messages, null, 2))
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
private async writeMessagesToChunks(channelDirPath: string, indexData: ChannelIndexData, messages: MessageInfo[]) {
|
|
905
|
-
const oldChunks = [...indexData.chunks]
|
|
906
|
-
indexData.chunks = []
|
|
907
|
-
indexData.totalMessages = 0
|
|
908
|
-
indexData.nextChunkId = 1
|
|
909
|
-
|
|
910
|
-
for (let offset = 0; offset < messages.length; offset += this.config.messageChunkSize) {
|
|
911
|
-
const chunkMessages = messages.slice(offset, offset + this.config.messageChunkSize)
|
|
912
|
-
const chunkId = indexData.nextChunkId
|
|
913
|
-
const fileName = this.createChunkFileName(chunkId)
|
|
914
|
-
await this.writeChunkMessages(channelDirPath, fileName, chunkMessages)
|
|
915
|
-
indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length })
|
|
916
|
-
indexData.nextChunkId += 1
|
|
917
|
-
indexData.totalMessages += chunkMessages.length
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
for (const chunk of oldChunks) {
|
|
921
|
-
try {
|
|
922
|
-
await fs.unlink(this.getChunkFilePath(channelDirPath, chunk.fileName))
|
|
923
|
-
} catch (error) {
|
|
924
|
-
if (!this.isFileMissingError(error)) {
|
|
925
|
-
this.logger.warn(`删除旧消息分块失败 [${channelDirPath}/${chunk.fileName}]:`, error)
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
await this.writeChannelIndex(path.join(channelDirPath, 'index.json'), indexData)
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
private async appendMessagesToChannel(selfId: string, channelId: string, messages: MessageInfo[]) {
|
|
934
|
-
if (!messages.length) {
|
|
935
|
-
return
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
939
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
940
|
-
await this.ensureDir(entry.channelDirPath)
|
|
941
|
-
|
|
942
|
-
let remainingMessages = [...messages]
|
|
943
|
-
const lastChunk = indexData.chunks[indexData.chunks.length - 1]
|
|
944
|
-
|
|
945
|
-
if (lastChunk && lastChunk.messageCount < this.config.messageChunkSize) {
|
|
946
|
-
const chunkMessages = await this.readChunkMessages(entry.channelDirPath, lastChunk.fileName)
|
|
947
|
-
const writableCount = this.config.messageChunkSize - chunkMessages.length
|
|
948
|
-
const appendMessages = remainingMessages.slice(0, writableCount)
|
|
949
|
-
if (appendMessages.length) {
|
|
950
|
-
chunkMessages.push(...appendMessages)
|
|
951
|
-
lastChunk.messageCount = chunkMessages.length
|
|
952
|
-
indexData.totalMessages += appendMessages.length
|
|
953
|
-
remainingMessages = remainingMessages.slice(appendMessages.length)
|
|
954
|
-
await this.writeChunkMessages(entry.channelDirPath, lastChunk.fileName, chunkMessages)
|
|
955
|
-
this.rememberMessageChunkLocation(entry.channelKey, lastChunk.fileName, appendMessages.map((message) => message.id))
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
while (remainingMessages.length) {
|
|
960
|
-
const chunkMessages = remainingMessages.slice(0, this.config.messageChunkSize)
|
|
961
|
-
const chunkId = indexData.nextChunkId
|
|
962
|
-
const fileName = this.createChunkFileName(chunkId)
|
|
963
|
-
await this.writeChunkMessages(entry.channelDirPath, fileName, chunkMessages)
|
|
964
|
-
indexData.chunks.push({ id: chunkId, fileName, messageCount: chunkMessages.length })
|
|
965
|
-
indexData.nextChunkId += 1
|
|
966
|
-
indexData.totalMessages += chunkMessages.length
|
|
967
|
-
this.rememberMessageChunkLocation(entry.channelKey, fileName, chunkMessages.map((message) => message.id))
|
|
968
|
-
remainingMessages = remainingMessages.slice(chunkMessages.length)
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
|
|
972
|
-
await this.writeChannelIndex(entry.indexFilePath, indexData)
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
private async trimChannelToLimit(selfId: string, channelId: string, indexData: ChannelIndexData, limit: number) {
|
|
976
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
977
|
-
let overflow = indexData.totalMessages - limit
|
|
978
|
-
if (overflow <= 0) {
|
|
979
|
-
return 0
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
let removedCount = 0
|
|
983
|
-
|
|
984
|
-
while (overflow > 0 && indexData.chunks.length) {
|
|
985
|
-
const chunk = indexData.chunks[0]
|
|
986
|
-
if (chunk.messageCount <= overflow) {
|
|
987
|
-
const removedChunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
988
|
-
overflow -= chunk.messageCount
|
|
989
|
-
removedCount += chunk.messageCount
|
|
990
|
-
indexData.totalMessages -= chunk.messageCount
|
|
991
|
-
indexData.chunks.shift()
|
|
992
|
-
this.forgetRecentMessageIds(entry.channelKey, removedChunkMessages.map((message) => message.id))
|
|
993
|
-
this.forgetMessageChunkLocation(entry.channelKey, removedChunkMessages.map((message) => message.id))
|
|
994
|
-
try {
|
|
995
|
-
await fs.unlink(this.getChunkFilePath(entry.channelDirPath, chunk.fileName))
|
|
996
|
-
} catch (error) {
|
|
997
|
-
if (!this.isFileMissingError(error)) {
|
|
998
|
-
this.logger.warn(`删除消息分块失败 [${entry.channelKey}:${chunk.fileName}]:`, error)
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
continue
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
1005
|
-
const keptMessages = chunkMessages.slice(overflow)
|
|
1006
|
-
const removedMessages = chunkMessages.slice(0, overflow)
|
|
1007
|
-
removedCount += overflow
|
|
1008
|
-
indexData.totalMessages -= overflow
|
|
1009
|
-
chunk.messageCount = keptMessages.length
|
|
1010
|
-
overflow = 0
|
|
1011
|
-
this.forgetRecentMessageIds(entry.channelKey, removedMessages.map((message) => message.id))
|
|
1012
|
-
this.forgetMessageChunkLocation(entry.channelKey, removedMessages.map((message) => message.id))
|
|
1013
|
-
await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, keptMessages)
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
this.syncDirtyChannelState(entry.channelKey, indexData.totalMessages)
|
|
1017
|
-
await this.writeChannelIndex(entry.indexFilePath, indexData)
|
|
1018
|
-
return removedCount
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
private async countChannelMessages(selfId: string, channelId: string) {
|
|
1022
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
1023
|
-
|
|
1024
|
-
try {
|
|
1025
|
-
const jsonData = await fs.readFile(entry.indexFilePath, 'utf8')
|
|
1026
|
-
const indexData = JSON.parse(jsonData) as Partial<ChannelIndexData>
|
|
1027
|
-
if (typeof indexData.totalMessages === 'number') {
|
|
1028
|
-
return indexData.totalMessages
|
|
1029
|
-
}
|
|
1030
|
-
} catch (error) {
|
|
1031
|
-
if (!this.isFileMissingError(error)) {
|
|
1032
|
-
this.logger.error(`读取频道索引失败 [${entry.channelKey}]:`, error)
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
return 0
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
private async removeChannelStorage(selfId: string, channelId: string) {
|
|
1040
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
1041
|
-
|
|
1042
|
-
try {
|
|
1043
|
-
await fs.rm(entry.channelDirPath, { recursive: true, force: true })
|
|
1044
|
-
} catch (error) {
|
|
1045
|
-
this.logger.error(`删除频道目录失败 [${entry.channelKey}]:`, error)
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
private async updateUserProfileInChannel(selfId: string, channelId: string, userId: string, userName?: string, avatar?: string) {
|
|
1050
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
1051
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
1052
|
-
let changed = false
|
|
1053
|
-
|
|
1054
|
-
for (const chunk of indexData.chunks) {
|
|
1055
|
-
const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
1056
|
-
let chunkChanged = false
|
|
1057
|
-
|
|
1058
|
-
for (const message of messages) {
|
|
1059
|
-
if (message.userId !== userId) {
|
|
1060
|
-
continue
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
if (userName && message.username !== userName) {
|
|
1064
|
-
message.username = userName
|
|
1065
|
-
chunkChanged = true
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
if (avatar && message.avatar !== avatar) {
|
|
1069
|
-
message.avatar = avatar
|
|
1070
|
-
chunkChanged = true
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
if (!chunkChanged) {
|
|
1075
|
-
continue
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages)
|
|
1079
|
-
changed = true
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
return changed
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
private async findAndUpdateLatestBotMessage(selfId: string, channelId: string, realId: string): Promise<MessageInfo | undefined> {
|
|
1086
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
1087
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
1088
|
-
|
|
1089
|
-
for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
|
|
1090
|
-
const chunk = indexData.chunks[index]
|
|
1091
|
-
const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
1092
|
-
const matched = [...messages].reverse().find((message) => message.type === 'bot' && message.sending)
|
|
1093
|
-
if (!matched) {
|
|
1094
|
-
continue
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
matched.realId = realId
|
|
1098
|
-
matched.sending = false
|
|
1099
|
-
await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, messages)
|
|
1100
|
-
return matched
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
return undefined
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
private async findAndUpdateBotMessageByTempId(selfId: string, channelId: string, tempMessageId: string, realId: string): Promise<MessageInfo | undefined> {
|
|
1107
|
-
const channelKey = `${selfId}:${channelId}`
|
|
1108
|
-
const pendingMessages = this.pendingMessages.get(channelKey)
|
|
1109
|
-
const pendingMatched = pendingMessages?.find((message) => message.id === tempMessageId)
|
|
1110
|
-
if (pendingMatched) {
|
|
1111
|
-
pendingMatched.realId = realId
|
|
1112
|
-
pendingMatched.sending = false
|
|
1113
|
-
return pendingMatched
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
const cachedMessages = this.peekCachedChannelMessages(channelKey)
|
|
1117
|
-
const cachedMatched = cachedMessages?.find((message) => message.id === tempMessageId)
|
|
1118
|
-
if (cachedMatched) {
|
|
1119
|
-
cachedMatched.realId = realId
|
|
1120
|
-
cachedMatched.sending = false
|
|
1121
|
-
this.setCachedChannelMessages(channelKey, cachedMessages)
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
1125
|
-
const chunkFileName = this.getMessageChunkLocation(channelKey, tempMessageId)
|
|
1126
|
-
if (chunkFileName) {
|
|
1127
|
-
const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunkFileName)
|
|
1128
|
-
const matched = chunkMessages.find((message) => message.id === tempMessageId)
|
|
1129
|
-
if (matched) {
|
|
1130
|
-
matched.realId = realId
|
|
1131
|
-
matched.sending = false
|
|
1132
|
-
await this.writeChunkMessages(entry.channelDirPath, chunkFileName, chunkMessages)
|
|
1133
|
-
return matched
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
1138
|
-
for (let index = indexData.chunks.length - 1; index >= 0; index -= 1) {
|
|
1139
|
-
const chunk = indexData.chunks[index]
|
|
1140
|
-
const chunkMessages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
1141
|
-
const matched = chunkMessages.find((message) => message.id === tempMessageId)
|
|
1142
|
-
if (!matched) {
|
|
1143
|
-
continue
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
matched.realId = realId
|
|
1147
|
-
matched.sending = false
|
|
1148
|
-
await this.writeChunkMessages(entry.channelDirPath, chunk.fileName, chunkMessages)
|
|
1149
|
-
this.rememberMessageChunkLocation(channelKey, chunk.fileName, [matched.id])
|
|
1150
|
-
return matched
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
return cachedMatched
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
private deduplicateMessages(messages: MessageInfo[]) {
|
|
1157
|
-
const seen = new Set<string>()
|
|
1158
|
-
const deduplicated: MessageInfo[] = []
|
|
1159
|
-
|
|
1160
|
-
for (const message of messages) {
|
|
1161
|
-
if (seen.has(message.id)) {
|
|
1162
|
-
continue
|
|
1163
|
-
}
|
|
1164
|
-
seen.add(message.id)
|
|
1165
|
-
deduplicated.push(message)
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
return deduplicated
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
private getCachedChannelMessages(channelKey: string) {
|
|
1172
|
-
const cached = this.channelMessagesCache.get(channelKey)
|
|
1173
|
-
if (!cached) {
|
|
1174
|
-
return undefined
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
this.channelMessagesCache.delete(channelKey)
|
|
1178
|
-
this.channelMessagesCache.set(channelKey, cached)
|
|
1179
|
-
this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
|
|
1180
|
-
return cached
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
private peekCachedChannelMessages(channelKey: string) {
|
|
1184
|
-
return this.channelMessagesCache.get(channelKey)
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
private setCachedChannelMessages(channelKey: string, messages: MessageInfo[]) {
|
|
1188
|
-
this.channelMessagesCache.delete(channelKey)
|
|
1189
|
-
this.channelMessagesCache.set(channelKey, messages)
|
|
1190
|
-
this.rememberRecentMessageIds(channelKey, messages.slice(-this.getRecentMessageIdCacheLimit()).map((message) => message.id))
|
|
1191
|
-
|
|
1192
|
-
while (this.channelMessagesCache.size > this.config.channelCacheLimit) {
|
|
1193
|
-
const oldestKey = this.channelMessagesCache.keys().next().value as string | undefined
|
|
1194
|
-
if (!oldestKey) {
|
|
1195
|
-
break
|
|
1196
|
-
}
|
|
1197
|
-
this.channelMessagesCache.delete(oldestKey)
|
|
1198
|
-
this.deleteRecentMessageIds(oldestKey)
|
|
1199
|
-
this.deleteMessageChunkLocations(oldestKey)
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
this.memoryCache.messages = this.getChannelMessagesCacheSnapshot()
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
private deleteCachedChannelMessages(channelKey: string) {
|
|
1206
|
-
this.channelMessagesCache.delete(channelKey)
|
|
1207
|
-
delete this.memoryCache.messages[channelKey]
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
private getChannelMessagesCacheSnapshot() {
|
|
1211
|
-
return Object.fromEntries(this.channelMessagesCache.entries())
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
private mergeChannelMessages(baseMessages: MessageInfo[], appendedMessages: MessageInfo[]) {
|
|
1215
|
-
const merged = [...baseMessages]
|
|
1216
|
-
const knownIds = new Set(baseMessages.map((message) => message.id))
|
|
1217
|
-
|
|
1218
|
-
for (const message of appendedMessages) {
|
|
1219
|
-
if (knownIds.has(message.id)) {
|
|
1220
|
-
continue
|
|
1221
|
-
}
|
|
1222
|
-
knownIds.add(message.id)
|
|
1223
|
-
merged.push(message)
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
return merged
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
private limitChannelMessages(messages: MessageInfo[]) {
|
|
1230
|
-
if (messages.length <= this.config.maxMessagesPerChannel) {
|
|
1231
|
-
return messages
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
return [...messages]
|
|
1235
|
-
.sort((left, right) => left.timestamp - right.timestamp)
|
|
1236
|
-
.slice(-this.config.maxMessagesPerChannel)
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
private async channelMessageExists(selfId: string, channelId: string, messageId: string) {
|
|
1240
|
-
const entry = this.createStoredChannelEntry(selfId, channelId)
|
|
1241
|
-
const channelKey = entry.channelKey
|
|
1242
|
-
|
|
1243
|
-
if (this.hasRecentMessageId(channelKey, messageId)) {
|
|
1244
|
-
return true
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
const indexData = await this.loadOrCreateChannelIndex(selfId, channelId)
|
|
1248
|
-
|
|
1249
|
-
for (let chunkIndex = indexData.chunks.length - 1; chunkIndex >= 0; chunkIndex -= 1) {
|
|
1250
|
-
const chunk = indexData.chunks[chunkIndex]
|
|
1251
|
-
const messages = await this.readChunkMessages(entry.channelDirPath, chunk.fileName)
|
|
1252
|
-
if (messages.some((message) => message.id === messageId)) {
|
|
1253
|
-
this.rememberRecentMessageIds(channelKey, [messageId])
|
|
1254
|
-
return true
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
return false
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
private syncDirtyChannelState(channelKey: string, totalMessages: number) {
|
|
1262
|
-
if (totalMessages > this.config.maxMessagesPerChannel) {
|
|
1263
|
-
this.dirtyChannelKeys.add(channelKey)
|
|
1264
|
-
return
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
this.dirtyChannelKeys.delete(channelKey)
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
private registerPendingBotMessage(channelKey: string, messageId: string) {
|
|
1271
|
-
const messageIds = this.pendingBotMessageIds.get(channelKey) || []
|
|
1272
|
-
messageIds.push(messageId)
|
|
1273
|
-
this.pendingBotMessageIds.set(channelKey, messageIds)
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
private peekLatestPendingBotMessageId(channelKey: string) {
|
|
1277
|
-
const messageIds = this.pendingBotMessageIds.get(channelKey)
|
|
1278
|
-
return messageIds?.[messageIds.length - 1]
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
private consumePendingBotMessageId(channelKey: string, messageId: string) {
|
|
1282
|
-
const messageIds = this.pendingBotMessageIds.get(channelKey)
|
|
1283
|
-
if (!messageIds?.length) {
|
|
1284
|
-
return
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
const nextMessageIds = messageIds.filter((id) => id !== messageId)
|
|
1288
|
-
if (nextMessageIds.length) {
|
|
1289
|
-
this.pendingBotMessageIds.set(channelKey, nextMessageIds)
|
|
1290
|
-
return
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
this.pendingBotMessageIds.delete(channelKey)
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
private deletePendingBotMessages(channelKey: string) {
|
|
1297
|
-
this.pendingBotMessageIds.delete(channelKey)
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
private getRecentMessageIdCacheLimit() {
|
|
1301
|
-
return Math.max(this.RECENT_MESSAGE_ID_CACHE_SIZE, this.config.messageChunkSize * 2)
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
private rememberRecentMessageIds(channelKey: string, messageIds: string[]) {
|
|
1305
|
-
if (!messageIds.length) {
|
|
1306
|
-
return
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
const nextMessageIds = [...(this.recentMessageIdsCache.get(channelKey) || [])]
|
|
1310
|
-
for (const messageId of messageIds) {
|
|
1311
|
-
const existingIndex = nextMessageIds.indexOf(messageId)
|
|
1312
|
-
if (existingIndex !== -1) {
|
|
1313
|
-
nextMessageIds.splice(existingIndex, 1)
|
|
1314
|
-
}
|
|
1315
|
-
nextMessageIds.push(messageId)
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
const maxSize = this.getRecentMessageIdCacheLimit()
|
|
1319
|
-
this.recentMessageIdsCache.set(channelKey, nextMessageIds.slice(-maxSize))
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
private forgetRecentMessageIds(channelKey: string, messageIds: string[]) {
|
|
1323
|
-
const currentMessageIds = this.recentMessageIdsCache.get(channelKey)
|
|
1324
|
-
if (!currentMessageIds?.length || !messageIds.length) {
|
|
1325
|
-
return
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
const nextMessageIds = currentMessageIds.filter((messageId) => !messageIds.includes(messageId))
|
|
1329
|
-
if (nextMessageIds.length) {
|
|
1330
|
-
this.recentMessageIdsCache.set(channelKey, nextMessageIds)
|
|
1331
|
-
return
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
this.recentMessageIdsCache.delete(channelKey)
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
private hasRecentMessageId(channelKey: string, messageId: string) {
|
|
1338
|
-
const currentMessageIds = this.recentMessageIdsCache.get(channelKey)
|
|
1339
|
-
return !!currentMessageIds?.includes(messageId)
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
private deleteRecentMessageIds(channelKey: string) {
|
|
1343
|
-
this.recentMessageIdsCache.delete(channelKey)
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
private rememberMessageChunkLocation(channelKey: string, chunkFileName: string, messageIds: string[]) {
|
|
1347
|
-
if (!messageIds.length) {
|
|
1348
|
-
return
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
const currentLocations = this.messageChunkLocationCache.get(channelKey) || new Map<string, string>()
|
|
1352
|
-
for (const messageId of messageIds) {
|
|
1353
|
-
currentLocations.set(messageId, chunkFileName)
|
|
1354
|
-
}
|
|
1355
|
-
this.messageChunkLocationCache.set(channelKey, currentLocations)
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
private forgetMessageChunkLocation(channelKey: string, messageIds: string[]) {
|
|
1359
|
-
const currentLocations = this.messageChunkLocationCache.get(channelKey)
|
|
1360
|
-
if (!currentLocations || !messageIds.length) {
|
|
1361
|
-
return
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
for (const messageId of messageIds) {
|
|
1365
|
-
currentLocations.delete(messageId)
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
if (!currentLocations.size) {
|
|
1369
|
-
this.messageChunkLocationCache.delete(channelKey)
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
private getMessageChunkLocation(channelKey: string, messageId: string) {
|
|
1374
|
-
return this.messageChunkLocationCache.get(channelKey)?.get(messageId)
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
private deleteMessageChunkLocations(channelKey: string) {
|
|
1378
|
-
this.messageChunkLocationCache.delete(channelKey)
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
private isSameBotInfo(left: BotInfo, right: BotInfo) {
|
|
1382
|
-
return left.selfId === right.selfId
|
|
1383
|
-
&& left.platform === right.platform
|
|
1384
|
-
&& left.username === right.username
|
|
1385
|
-
&& left.avatar === right.avatar
|
|
1386
|
-
&& left.status === right.status
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
private isSameChannelInfo(left: ChannelInfo, right: ChannelInfo) {
|
|
1390
|
-
return left.id === right.id
|
|
1391
|
-
&& left.name === right.name
|
|
1392
|
-
&& left.type === right.type
|
|
1393
|
-
&& left.channelId === right.channelId
|
|
1394
|
-
&& left.guildName === right.guildName
|
|
1395
|
-
&& left.isDirect === right.isDirect
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
private isFileMissingError(error: unknown): boolean {
|
|
1399
|
-
return typeof error === 'object'
|
|
1400
|
-
&& error !== null
|
|
1401
|
-
&& 'code' in error
|
|
1402
|
-
&& error.code === 'ENOENT'
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
private async atomicWriteTextFile(filePath: string, content: string) {
|
|
1406
|
-
const tempFilePath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
|
1407
|
-
|
|
1408
|
-
await fs.writeFile(tempFilePath, content, 'utf8'
|
|
1409
|
-
)
|
|
1410
|
-
|
|
1411
|
-
try {
|
|
1412
|
-
await fs.rename(tempFilePath, filePath)
|
|
1413
|
-
} catch (error) {
|
|
1414
|
-
if (this.isAtomicRenameReplaceError(error)) {
|
|
1415
|
-
await fs.rm(filePath, { force: true })
|
|
1416
|
-
await fs.rename(tempFilePath, filePath)
|
|
1417
|
-
return
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
try {
|
|
1421
|
-
await fs.rm(tempFilePath, { force: true })
|
|
1422
|
-
} catch {
|
|
1423
|
-
// 忽略临时文件清理失败,保留原始错误即可。
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
throw error
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
private isAtomicRenameReplaceError(error: unknown): boolean {
|
|
1431
|
-
return typeof error === 'object'
|
|
1432
|
-
&& error !== null
|
|
1433
|
-
&& 'code' in error
|
|
1434
|
-
&& (error.code === 'EEXIST' || error.code === 'EPERM')
|
|
1435
|
-
}
|
|
1436
|
-
}
|