From 07d11c2b16b702c5bdda71b1711fca97a73e24c7 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Mon, 27 Jul 2026 22:11:31 +0800 Subject: [PATCH] history: replace external recorder with SQLite --- README.md | 8 +- build.gradle.kts | 8 +- src/main/kotlin/ChatHistoryStore.kt | 368 +++++++++++++++++++++ src/main/kotlin/ChatMessageRecord.kt | 71 ++++ src/main/kotlin/JChatGPT.kt | 74 +++-- src/main/kotlin/tools/SearchChatHistory.kt | 24 +- src/test/kotlin/ChatHistoryStoreTest.kt | 67 ++++ 7 files changed, 573 insertions(+), 47 deletions(-) create mode 100644 src/main/kotlin/ChatHistoryStore.kt create mode 100644 src/main/kotlin/ChatMessageRecord.kt create mode 100644 src/test/kotlin/ChatHistoryStoreTest.kt diff --git a/README.md b/README.md index 163999a..1a52032 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模 - **LaTeX 渲染**:自动将数学表达式渲染为图片 - **灵活的触发方式**:@机器人、关键字触发、回复消息等 - **权限控制**:细粒度的权限管理系统 -- **历史消息集成**:可选的历史消息上下文(需配合 mirai-hibernate-plugin) +- **内置历史消息**:使用插件自维护的 SQLite 保存、检索群聊和私聊消息 ## 用法 @@ -172,6 +172,9 @@ searchHistoryMaxDays: 30 searchHistoryMaxRecords: 5000 ``` +聊天记录保存在插件数据目录的 `chat-history.sqlite` 中,并使用 SQLite WAL 模式支持记录与查询并行进行。 +数据库由插件在首次启动时自动创建和维护,无需安装额外的聊天记录插件。 + ### 和风天气 天气工具使用[和风天气开发服务](https://dev.qweather.com/docs/start/)和 JWT 凭据,简单配置流程如下: @@ -367,7 +370,7 @@ fallbackCooldownMinutes: 5 9. **SendVoiceMessage** - 发送语音消息 10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合) 11. **WeatherService** - 天气查询 -12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin) +12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史 ## 用户画像系统 @@ -593,7 +596,6 @@ JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消 - Java 11 或更高版本 - Mirai Console 2.16.0 或更高版本 -- 可选:mirai-hibernate-plugin(用于历史消息上下文) - 相关 API Tokens(根据需要启用的功能配置) ## 备注 diff --git a/build.gradle.kts b/build.gradle.kts index 89bd5c7..2d03117 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "top.jie65535.mirai" -version = "1.13.0" +version = "1.14.0" mirai { jvmTarget = JavaVersion.VERSION_11 @@ -29,7 +29,7 @@ val openaiClientVersion = "4.1.0" val ktorVersion = "3.0.3" val jLatexMathVersion = "1.0.7" val commonTextVersion = "1.13.0" -val hibernateVersion = "2.9.0" +val sqliteVersion = "3.46.1.0" val overflowVersion = "1.0.7" val eddsaVersion = "0.3.0" @@ -39,9 +39,7 @@ dependencies { implementation("net.i2p.crypto:eddsa:$eddsaVersion") implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion") implementation("org.apache.commons:commons-text:$commonTextVersion") - - // 聊天记录插件 - compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion") + implementation("org.xerial:sqlite-jdbc:$sqliteVersion") testImplementation(kotlin("test-junit5")) diff --git a/src/main/kotlin/ChatHistoryStore.kt b/src/main/kotlin/ChatHistoryStore.kt new file mode 100644 index 0000000..aabe050 --- /dev/null +++ b/src/main/kotlin/ChatHistoryStore.kt @@ -0,0 +1,368 @@ +package top.jie65535.mirai + +import net.mamoe.mirai.Bot +import net.mamoe.mirai.contact.Contact +import net.mamoe.mirai.contact.Friend +import net.mamoe.mirai.contact.Group +import net.mamoe.mirai.contact.Member +import net.mamoe.mirai.contact.Stranger +import net.mamoe.mirai.event.events.MessageEvent +import net.mamoe.mirai.event.events.MessagePostSendEvent +import net.mamoe.mirai.event.events.MessageRecallEvent +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.message.data.MessageSourceKind +import net.mamoe.mirai.message.data.source +import net.mamoe.mirai.message.data.toMessageChain +import java.io.File +import java.sql.Connection +import java.sql.DriverManager +import java.sql.ResultSet + +/** + * 基于 SQLite 的聊天记录存储。 + * + * 写连接由 [writeLock] 串行保护;SQLite 使用 WAL + NORMAL synchronous,允许并发读取。 + */ +object ChatHistoryStore { + private const val SCHEMA_VERSION = 2 + private const val BUSY_TIMEOUT_MS = 30_000 + + private val lifecycleLock = Any() + private val writeLock = Any() + + @Volatile + private var initialized = false + private lateinit var databaseFile: File + private var writeConnection: Connection? = null + + val isAvailable: Boolean + get() = initialized + + fun init(dataFolder: File) { + synchronized(lifecycleLock) { + if (initialized) return + + Class.forName("org.sqlite.JDBC") + dataFolder.mkdirs() + databaseFile = dataFolder.resolve("chat-history.sqlite") + + val connection = openConnection() + try { + configureWriteConnection(connection) + createSchema(connection) + writeConnection = connection + initialized = true + } catch (cause: Throwable) { + connection.close() + throw cause + } + } + } + + fun close() { + synchronized(lifecycleLock) { + if (!initialized) return + synchronized(writeLock) { + writeConnection?.let { connection -> + runCatching { + connection.createStatement().use { statement -> + statement.execute("PRAGMA wal_checkpoint(TRUNCATE)") + } + }.onFailure { JChatGPT.logger.warning("SQLite WAL checkpoint 失败", it) } + connection.close() + } + writeConnection = null + initialized = false + } + } + } + + fun record(event: MessageEvent) { + if (!initialized) return + val message = event.message.asSequence() + .filterNot { it is MessageSource } + .toMessageChain() + insert(ChatMessageRecord.fromSuccess(event.message.source, message)) + } + + fun record(event: MessagePostSendEvent<*>) { + if (!initialized) return + val source = event.receipt?.source ?: return + val message = event.message.asSequence() + .filterNot { it is MessageSource } + .toMessageChain() + insert(ChatMessageRecord.fromSuccess(source, message)) + } + + fun markRecalled(event: MessageRecallEvent) { + if (!initialized) return + val (kind, targetId, recalled) = when (event) { + is MessageRecallEvent.FriendRecall -> Triple( + MessageSourceKind.FRIEND, + event.bot.id, + 2, + ) + is MessageRecallEvent.GroupRecall -> Triple( + MessageSourceKind.GROUP, + event.group.id, + if ((event.operator?.id ?: event.bot.id) == event.authorId) 2 else 3, + ) + } + val messageIds = event.messageIds.joinToString(",") + val messageInternalIds = event.messageInternalIds.joinToString(",") + + withWriteConnection { connection -> + connection.prepareStatement( + """ + UPDATE message_record + SET recalled = ? + WHERE id = ( + SELECT id + FROM message_record + WHERE bot_id = ? + AND kind = ? + AND from_id = ? + AND target_id = ? + AND (ids = ? OR internal_ids = ?) + ORDER BY ABS(time - ?) ASC, id DESC + LIMIT 1 + ) + """.trimIndent() + ).use { statement -> + statement.setInt(1, recalled) + statement.setLong(2, event.bot.id) + statement.setInt(3, kind.ordinal) + statement.setLong(4, event.authorId) + statement.setLong(5, targetId) + statement.setString(6, messageIds) + statement.setString(7, messageInternalIds) + statement.setInt(8, event.messageTime) + val updated = statement.executeUpdate() + if (updated == 0) { + JChatGPT.logger.warning( + "未在 SQLite 中找到撤回消息: bot=${event.bot.id}, " + + "author=${event.authorId}, target=$targetId, ids=$messageIds, " + + "internalIds=$messageInternalIds, " + + "time=${event.messageTime}" + ) + } + } + } + } + + fun query( + contact: Contact, + start: Int, + end: Int, + limit: Int, + fromId: Long? = null, + ): List { + check(initialized) { "聊天记录数据库尚未初始化" } + require(start <= end) { "start must not be after end" } + + val conditions = mutableListOf() + val parameters = mutableListOf() + + conditions += "bot_id = ?" + parameters += contact.bot.id + conditions += "time BETWEEN ? AND ?" + parameters += start + parameters += end + + when (contact) { + is Group -> { + conditions += "kind = ?" + parameters += MessageSourceKind.GROUP.ordinal + conditions += "target_id = ?" + parameters += contact.id + } + is Member -> { + conditions += "kind = ?" + parameters += MessageSourceKind.GROUP.ordinal + conditions += "target_id = ?" + parameters += contact.group.id + conditions += "from_id = ?" + parameters += contact.id + } + is Friend -> { + conditions += "kind = ?" + parameters += MessageSourceKind.FRIEND.ordinal + conditions += "(from_id = ? OR target_id = ?)" + parameters += contact.id + parameters += contact.id + } + is Stranger -> { + conditions += "kind = ?" + parameters += MessageSourceKind.STRANGER.ordinal + conditions += "(from_id = ? OR target_id = ?)" + parameters += contact.id + parameters += contact.id + } + is Bot -> Unit + else -> error("不支持查询的联系人 $contact") + } + + if (fromId != null && contact !is Member) { + conditions += "from_id = ?" + parameters += fromId + } + + val sql = buildString { + append( + """ + SELECT id, bot_id, from_id, target_id, ids, internal_ids, + time, kind, code, recalled + FROM message_record + WHERE + """.trimIndent() + ) + append(' ') + append(conditions.joinToString(" AND ")) + append(" ORDER BY time DESC, id DESC LIMIT ?") + } + + return openReadConnection().use { connection -> + connection.prepareStatement(sql).use { statement -> + parameters.forEachIndexed { index, value -> + when (value) { + is Int -> statement.setInt(index + 1, value) + is Long -> statement.setLong(index + 1, value) + else -> error("不支持的查询参数类型 ${value::class}") + } + } + statement.setInt(parameters.size + 1, limit.coerceAtLeast(1)) + statement.executeQuery().use { results -> + buildList { + while (results.next()) { + add(results.toRecord()) + } + } + } + } + } + } + + private fun insert(record: ChatMessageRecord) { + withWriteConnection { connection -> + connection.prepareStatement( + """ + INSERT INTO message_record( + bot_id, from_id, target_id, ids, internal_ids, + time, kind, code, recalled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).use { statement -> + statement.setLong(1, record.botId) + statement.setLong(2, record.fromId) + statement.setLong(3, record.targetId) + statement.setString(4, record.ids) + statement.setString(5, record.internalIds) + statement.setInt(6, record.time) + statement.setInt(7, record.kind.ordinal) + statement.setString(8, record.code) + statement.setInt(9, record.recalled) + statement.executeUpdate() + } + } + } + + private fun withWriteConnection(block: (Connection) -> Unit) { + synchronized(writeLock) { + check(initialized) { "聊天记录数据库尚未初始化" } + val connection = writeConnection?.takeUnless(Connection::isClosed) + ?: openConnection().also { + configureWriteConnection(it) + writeConnection = it + } + block(connection) + } + } + + private fun openConnection(): Connection { + return DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}") + } + + private fun openReadConnection(): Connection { + return openConnection().also { connection -> + connection.createStatement().use { statement -> + statement.execute("PRAGMA busy_timeout=$BUSY_TIMEOUT_MS") + statement.execute("PRAGMA query_only=ON") + } + } + } + + private fun configureWriteConnection(connection: Connection) { + connection.createStatement().use { statement -> + statement.execute("PRAGMA journal_mode=WAL") + statement.execute("PRAGMA synchronous=NORMAL") + statement.execute("PRAGMA busy_timeout=$BUSY_TIMEOUT_MS") + statement.execute("PRAGMA wal_autocheckpoint=1000") + } + } + + private fun createSchema(connection: Connection) { + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS message_record( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id INTEGER NOT NULL, + from_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + ids TEXT, + internal_ids TEXT, + time INTEGER NOT NULL, + kind INTEGER NOT NULL, + code TEXT NOT NULL, + recalled INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_message_subject_time " + + "ON message_record(bot_id, kind, target_id, time DESC, id DESC)" + ) + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_message_sender_subject_time " + + "ON message_record(bot_id, kind, target_id, from_id, time DESC, id DESC)" + ) + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_message_recall_identity " + + "ON message_record(bot_id, kind, target_id, from_id, time, ids)" + ) + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS chat_history_meta( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """.trimIndent() + ) + } + connection.prepareStatement( + "INSERT INTO chat_history_meta(key, value) VALUES ('schema_version', ?) " + + "ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ).use { statement -> + statement.setString(1, SCHEMA_VERSION.toString()) + statement.executeUpdate() + } + } + + private fun ResultSet.toRecord(): ChatMessageRecord { + val kindOrdinal = getInt("kind") + val kind = MessageSourceKind.values().getOrNull(kindOrdinal) + ?: error("未知的消息类型序号 $kindOrdinal") + return ChatMessageRecord( + id = getLong("id"), + botId = getLong("bot_id"), + fromId = getLong("from_id"), + targetId = getLong("target_id"), + ids = getString("ids"), + internalIds = getString("internal_ids"), + time = getInt("time"), + kind = kind, + code = getString("code"), + recalled = getInt("recalled"), + ) + } +} diff --git a/src/main/kotlin/ChatMessageRecord.kt b/src/main/kotlin/ChatMessageRecord.kt new file mode 100644 index 0000000..4330f9c --- /dev/null +++ b/src/main/kotlin/ChatMessageRecord.kt @@ -0,0 +1,71 @@ +package top.jie65535.mirai + +import kotlinx.serialization.SerializationException +import net.mamoe.mirai.Mirai +import net.mamoe.mirai.message.code.MiraiCode +import net.mamoe.mirai.message.data.MessageChain +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.message.data.MessageSourceKind +import net.mamoe.mirai.message.data.buildMessageSource + +/** + * 插件自维护的聊天消息记录。 + * + * [recalled]:0=正常、1=发送失败、2=自行撤回、3=管理员撤回。 + */ +data class ChatMessageRecord( + val id: Long = 0, + val botId: Long, + val fromId: Long, + val targetId: Long, + val ids: String?, + val internalIds: String?, + val time: Int, + val kind: MessageSourceKind, + val code: String, + val recalled: Int = 0, +) { + fun toMessageSource(): MessageSource { + return Mirai.buildMessageSource(botId, kind) { + fromId = this@ChatMessageRecord.fromId + targetId = this@ChatMessageRecord.targetId + ids = this@ChatMessageRecord.ids.toIntArray() + internalIds = this@ChatMessageRecord.internalIds.toIntArray() + time = this@ChatMessageRecord.time + messages(messages = toMessageChain()) + } + } + + fun toMessageChain(): MessageChain { + return try { + MessageChain.deserializeFromJsonString(code) + } catch (cause: SerializationException) { + try { + MiraiCode.deserializeMiraiCode(code) + } catch (_: Throwable) { + throw cause + } + } + } + + companion object { + fun fromSuccess(source: MessageSource, message: MessageChain): ChatMessageRecord = ChatMessageRecord( + botId = source.botId, + fromId = source.fromId, + targetId = source.targetId, + ids = source.ids.joinToString(","), + internalIds = source.internalIds.joinToString(","), + time = source.time, + kind = source.kind, + code = with(MessageChain) { message.serializeToJsonString() }, + ) + + private fun String?.toIntArray(): IntArray { + return if (isNullOrEmpty()) { + IntArray(0) + } else { + split(',').map { it.trim().toInt() }.toIntArray() + } + } + } +} diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index 2e0e88d..a312fe7 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -24,17 +24,18 @@ import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin import net.mamoe.mirai.contact.* import net.mamoe.mirai.contact.MemberPermission.* +import net.mamoe.mirai.event.EventPriority import net.mamoe.mirai.event.GlobalEventChannel import net.mamoe.mirai.event.events.FriendMessageEvent import net.mamoe.mirai.event.events.GroupMessageEvent import net.mamoe.mirai.event.events.MessageEvent +import net.mamoe.mirai.event.events.MessagePostSendEvent +import net.mamoe.mirai.event.events.MessageRecallEvent import net.mamoe.mirai.message.data.* import net.mamoe.mirai.message.data.Image.Key.queryUrl import net.mamoe.mirai.utils.info import top.jie65535.mirai.tools.* import util.LunarDateUtil -import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder -import xyz.cssxsh.mirai.hibernate.entry.MessageRecord import java.io.File import java.time.Instant import java.time.OffsetDateTime @@ -53,10 +54,9 @@ object JChatGPT : KotlinPlugin( JvmPluginDescription( id = "top.jie65535.mirai.JChatGPT", name = "J ChatGPT", - version = "1.13.0", + version = "1.14.0", ) { author("jie65535") -// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true) } ) { /** @@ -86,26 +86,39 @@ object JChatGPT : KotlinPlugin( // 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群) SkillStore.init(dataFolder) + // 初始化插件自维护的 SQLite 聊天记录 + includeHistory = try { + ChatHistoryStore.init(dataFolder) + true + } catch (e: Throwable) { + logger.error("初始化 SQLite 聊天记录失败,历史上下文与搜索将暂时禁用", e) + false + } + // 设置Token LargeLanguageModels.reload() // 注册插件命令 PluginCommands.register() - // 检查消息记录插件是否存在 - includeHistory = try { - MiraiHibernateRecorder - true - } catch (_: Throwable) { - false - } - if (PluginConfig.callKeyword.isNotEmpty()) { keyword = Regex(PluginConfig.callKeyword) } - GlobalEventChannel.parentScope(this) - .subscribeAlways { event -> onMessage(event) } + val eventChannel = GlobalEventChannel.parentScope(this) + eventChannel.subscribeAlways(priority = EventPriority.HIGHEST) { event -> + runCatching { ChatHistoryStore.record(event) } + .onFailure { logger.warning("保存接收消息到 SQLite 失败", it) } + } + eventChannel.subscribeAlways>(priority = EventPriority.HIGHEST) { event -> + runCatching { ChatHistoryStore.record(event) } + .onFailure { logger.warning("保存发送消息到 SQLite 失败", it) } + } + eventChannel.subscribeAlways(priority = EventPriority.HIGHEST) { event -> + runCatching { ChatHistoryStore.markRecalled(event) } + .onFailure { logger.warning("更新 SQLite 消息撤回状态失败", it) } + } + eventChannel.subscribeAlways { event -> onMessage(event) } // 启动定时任务处理好感度时间偏移 if (PluginConfig.enableFavorabilitySystem) { @@ -120,6 +133,10 @@ object JChatGPT : KotlinPlugin( logger.info { "Plugin loaded" } } + override fun onDisable() { + ChatHistoryStore.close() + } + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss") private val requestMap = ConcurrentSet() @@ -158,11 +175,11 @@ object JChatGPT : KotlinPlugin( * 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。 */ class ReplyIndex { - private val byIndex = LinkedHashMap() + private val byIndex = LinkedHashMap() private val indexByIds = HashMap() private var counter = 0 - fun add(record: MessageRecord): Int { + fun add(record: ChatMessageRecord): Int { // ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号 val ids = record.ids if (ids != null) { @@ -176,7 +193,7 @@ object JChatGPT : KotlinPlugin( return i } - fun get(index: Int): MessageRecord? = byIndex[index] + fun get(index: Int): ChatMessageRecord? = byIndex[index] fun indexOfIds(ids: String): Int? = indexByIds[ids] } @@ -187,7 +204,7 @@ object JChatGPT : KotlinPlugin( private val imageIndexMap = ConcurrentMap() /** 供发言工具按编号查找被引用的历史消息 */ - internal fun lookupReplyTarget(subjectId: Long, index: Int): MessageRecord? = + internal fun lookupReplyTarget(subjectId: Long, index: Int): ChatMessageRecord? = replyIndexMap[subjectId]?.get(index) /** 将从原消息图片取得的精确 URL 登记为短编号,供历史搜索等工具追加图片引用。 */ @@ -374,15 +391,22 @@ object JChatGPT : KotlinPlugin( // 现在时间 val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt() // 最近这段时间的历史对话 - val history = MiraiHibernateRecorder[event.subject, time, nowTimestamp] - .take(PluginConfig.historyMessageLimit) // 只取最近的部分消息,避免上下文过长 - .sortedBy { it.time } // 按时间排序 - .toMutableList() + val history = try { + ChatHistoryStore.query( + contact = event.subject, + start = time, + end = nowTimestamp, + limit = PluginConfig.historyMessageLimit, + ).sortedBy { it.time }.toMutableList() + } catch (e: Throwable) { + logger.warning("查询 SQLite 消息历史失败", e) + mutableListOf() + } // 有一定概率最后一条消息没加入,这里检查然后补充一下 val msgIds = event.message.ids.joinToString(",") if (!history.any { it.ids == msgIds }) { - history.add(MessageRecord.fromSuccess(event.message.source, event.message)) + history.add(ChatMessageRecord.fromSuccess(event.message.source, event.message)) } // 构造历史消息 @@ -464,7 +488,7 @@ object JChatGPT : KotlinPlugin( */ private fun appendGroupMessageRecord( historyText: StringBuilder, - record: MessageRecord, + record: ChatMessageRecord, event: GroupMessageEvent, replyIndex: ReplyIndex, imageIndex: ImageIndex, @@ -560,7 +584,7 @@ object JChatGPT : KotlinPlugin( */ private fun appendMessageRecord( historyText: StringBuilder, - record: MessageRecord, + record: ChatMessageRecord, event: MessageEvent, replyIndex: ReplyIndex, imageIndex: ImageIndex, diff --git a/src/main/kotlin/tools/SearchChatHistory.kt b/src/main/kotlin/tools/SearchChatHistory.kt index 009f2fe..ea944e5 100644 --- a/src/main/kotlin/tools/SearchChatHistory.kt +++ b/src/main/kotlin/tools/SearchChatHistory.kt @@ -13,8 +13,8 @@ import net.mamoe.mirai.message.data.SingleMessage import net.mamoe.mirai.message.data.content import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.PluginConfig -import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder -import xyz.cssxsh.mirai.hibernate.entry.MessageRecord +import top.jie65535.mirai.ChatHistoryStore +import top.jie65535.mirai.ChatMessageRecord import java.time.Instant import java.time.LocalDateTime import java.time.OffsetDateTime @@ -92,17 +92,13 @@ class SearchChatHistory : BaseAgent( val maxRecords = PluginConfig.searchHistoryMaxRecords val records = try { - // 有 sender 时用 Member 重载,在数据库层过滤 fromId;否则用 Contact 重载 - if (senderQq != null && event is GroupMessageEvent) { - val member = event.group[senderQq] - if (member != null) { - MiraiHibernateRecorder[member, startEpoch, endEpoch] - } else { - MiraiHibernateRecorder[event.subject, startEpoch, endEpoch] - } - } else { - MiraiHibernateRecorder[event.subject, startEpoch, endEpoch] - }.take(maxRecords).sortedBy { it.time } + ChatHistoryStore.query( + contact = event.subject, + start = startEpoch, + end = endEpoch, + limit = maxRecords, + fromId = senderQq, + ).sortedBy { it.time } } catch (e: Throwable) { JChatGPT.logger.warning("查询消息历史失败", e) return "查询消息历史失败: ${e.message}" @@ -148,7 +144,7 @@ class SearchChatHistory : BaseAgent( private suspend fun appendHistory( sb: StringBuilder, - records: List, + records: List, event: MessageEvent ) { val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") diff --git a/src/test/kotlin/ChatHistoryStoreTest.kt b/src/test/kotlin/ChatHistoryStoreTest.kt new file mode 100644 index 0000000..c1364c3 --- /dev/null +++ b/src/test/kotlin/ChatHistoryStoreTest.kt @@ -0,0 +1,67 @@ +package top.jie65535.mirai + +import java.nio.file.Files +import java.sql.DriverManager +import kotlin.io.path.absolutePathString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChatHistoryStoreTest { + @Test + fun initializesSQLiteSchemaAndWalMode() { + val directory = Files.createTempDirectory("jchatgpt-history-test-") + try { + ChatHistoryStore.init(directory.toFile()) + assertTrue(ChatHistoryStore.isAvailable) + + val database = directory.resolve("chat-history.sqlite") + assertTrue(Files.isRegularFile(database)) + + DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection -> + connection.createStatement().use { statement -> + statement.executeQuery("PRAGMA journal_mode").use { results -> + assertTrue(results.next()) + assertEquals("wal", results.getString(1).lowercase()) + } + statement.executeQuery( + "SELECT COUNT(*) FROM sqlite_master " + + "WHERE type = 'table' AND name IN ('message_record', 'chat_history_meta')" + ).use { results -> + assertTrue(results.next()) + assertEquals(2, results.getInt(1)) + } + statement.executeQuery("PRAGMA table_info(message_record)").use { results -> + val columns = buildSet { + while (results.next()) add(results.getString("name")) + } + assertEquals( + setOf( + "id", + "bot_id", + "from_id", + "target_id", + "ids", + "internal_ids", + "time", + "kind", + "code", + "recalled", + ), + columns, + ) + } + statement.executeQuery( + "SELECT value FROM chat_history_meta WHERE key = 'schema_version'" + ).use { results -> + assertTrue(results.next()) + assertEquals("2", results.getString(1)) + } + } + } + } finally { + ChatHistoryStore.close() + directory.toFile().deleteRecursively() + } + } +}