history: replace external recorder with SQLite

This commit is contained in:
2026-07-27 22:19:35 +08:00
parent c328a798f7
commit 07d11c2b16
7 changed files with 573 additions and 47 deletions
+5 -3
View File
@@ -14,7 +14,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
- **LaTeX 渲染**:自动将数学表达式渲染为图片 - **LaTeX 渲染**:自动将数学表达式渲染为图片
- **灵活的触发方式**@机器人、关键字触发、回复消息等 - **灵活的触发方式**@机器人、关键字触发、回复消息等
- **权限控制**:细粒度的权限管理系统 - **权限控制**:细粒度的权限管理系统
- **历史消息集成**可选的历史消息上下文(需配合 mirai-hibernate-plugin - **内置历史消息**使用插件自维护的 SQLite 保存、检索群聊和私聊消息
## 用法 ## 用法
@@ -172,6 +172,9 @@ searchHistoryMaxDays: 30
searchHistoryMaxRecords: 5000 searchHistoryMaxRecords: 5000
``` ```
聊天记录保存在插件数据目录的 `chat-history.sqlite` 中,并使用 SQLite WAL 模式支持记录与查询并行进行。
数据库由插件在首次启动时自动创建和维护,无需安装额外的聊天记录插件。
### 和风天气 ### 和风天气
天气工具使用[和风天气开发服务](https://dev.qweather.com/docs/start/)和 JWT 凭据,简单配置流程如下: 天气工具使用[和风天气开发服务](https://dev.qweather.com/docs/start/)和 JWT 凭据,简单配置流程如下:
@@ -367,7 +370,7 @@ fallbackCooldownMinutes: 5
9. **SendVoiceMessage** - 发送语音消息 9. **SendVoiceMessage** - 发送语音消息
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合) 10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
11. **WeatherService** - 天气查询 11. **WeatherService** - 天气查询
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin 12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史
## 用户画像系统 ## 用户画像系统
@@ -593,7 +596,6 @@ JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消
- Java 11 或更高版本 - Java 11 或更高版本
- Mirai Console 2.16.0 或更高版本 - Mirai Console 2.16.0 或更高版本
- 可选:mirai-hibernate-plugin(用于历史消息上下文)
- 相关 API Tokens(根据需要启用的功能配置) - 相关 API Tokens(根据需要启用的功能配置)
## 备注 ## 备注
+3 -5
View File
@@ -7,7 +7,7 @@ plugins {
} }
group = "top.jie65535.mirai" group = "top.jie65535.mirai"
version = "1.13.0" version = "1.14.0"
mirai { mirai {
jvmTarget = JavaVersion.VERSION_11 jvmTarget = JavaVersion.VERSION_11
@@ -29,7 +29,7 @@ val openaiClientVersion = "4.1.0"
val ktorVersion = "3.0.3" val ktorVersion = "3.0.3"
val jLatexMathVersion = "1.0.7" val jLatexMathVersion = "1.0.7"
val commonTextVersion = "1.13.0" val commonTextVersion = "1.13.0"
val hibernateVersion = "2.9.0" val sqliteVersion = "3.46.1.0"
val overflowVersion = "1.0.7" val overflowVersion = "1.0.7"
val eddsaVersion = "0.3.0" val eddsaVersion = "0.3.0"
@@ -39,9 +39,7 @@ dependencies {
implementation("net.i2p.crypto:eddsa:$eddsaVersion") implementation("net.i2p.crypto:eddsa:$eddsaVersion")
implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion") implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion")
implementation("org.apache.commons:commons-text:$commonTextVersion") implementation("org.apache.commons:commons-text:$commonTextVersion")
implementation("org.xerial:sqlite-jdbc:$sqliteVersion")
// 聊天记录插件
compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion")
testImplementation(kotlin("test-junit5")) testImplementation(kotlin("test-junit5"))
+368
View File
@@ -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<ChatMessageRecord> {
check(initialized) { "聊天记录数据库尚未初始化" }
require(start <= end) { "start must not be after end" }
val conditions = mutableListOf<String>()
val parameters = mutableListOf<Any>()
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"),
)
}
}
+71
View File
@@ -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()
}
}
}
}
+49 -25
View File
@@ -24,17 +24,18 @@ import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
import net.mamoe.mirai.contact.* import net.mamoe.mirai.contact.*
import net.mamoe.mirai.contact.MemberPermission.* import net.mamoe.mirai.contact.MemberPermission.*
import net.mamoe.mirai.event.EventPriority
import net.mamoe.mirai.event.GlobalEventChannel import net.mamoe.mirai.event.GlobalEventChannel
import net.mamoe.mirai.event.events.FriendMessageEvent import net.mamoe.mirai.event.events.FriendMessageEvent
import net.mamoe.mirai.event.events.GroupMessageEvent import net.mamoe.mirai.event.events.GroupMessageEvent
import net.mamoe.mirai.event.events.MessageEvent 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.*
import net.mamoe.mirai.message.data.Image.Key.queryUrl import net.mamoe.mirai.message.data.Image.Key.queryUrl
import net.mamoe.mirai.utils.info import net.mamoe.mirai.utils.info
import top.jie65535.mirai.tools.* import top.jie65535.mirai.tools.*
import util.LunarDateUtil import util.LunarDateUtil
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -53,10 +54,9 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription( JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT", id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT", name = "J ChatGPT",
version = "1.13.0", version = "1.14.0",
) { ) {
author("jie65535") author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
} }
) { ) {
/** /**
@@ -86,26 +86,39 @@ object JChatGPT : KotlinPlugin(
// 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群) // 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群)
SkillStore.init(dataFolder) SkillStore.init(dataFolder)
// 初始化插件自维护的 SQLite 聊天记录
includeHistory = try {
ChatHistoryStore.init(dataFolder)
true
} catch (e: Throwable) {
logger.error("初始化 SQLite 聊天记录失败,历史上下文与搜索将暂时禁用", e)
false
}
// 设置Token // 设置Token
LargeLanguageModels.reload() LargeLanguageModels.reload()
// 注册插件命令 // 注册插件命令
PluginCommands.register() PluginCommands.register()
// 检查消息记录插件是否存在
includeHistory = try {
MiraiHibernateRecorder
true
} catch (_: Throwable) {
false
}
if (PluginConfig.callKeyword.isNotEmpty()) { if (PluginConfig.callKeyword.isNotEmpty()) {
keyword = Regex(PluginConfig.callKeyword) keyword = Regex(PluginConfig.callKeyword)
} }
GlobalEventChannel.parentScope(this) val eventChannel = GlobalEventChannel.parentScope(this)
.subscribeAlways<MessageEvent> { event -> onMessage(event) } eventChannel.subscribeAlways<MessageEvent>(priority = EventPriority.HIGHEST) { event ->
runCatching { ChatHistoryStore.record(event) }
.onFailure { logger.warning("保存接收消息到 SQLite 失败", it) }
}
eventChannel.subscribeAlways<MessagePostSendEvent<*>>(priority = EventPriority.HIGHEST) { event ->
runCatching { ChatHistoryStore.record(event) }
.onFailure { logger.warning("保存发送消息到 SQLite 失败", it) }
}
eventChannel.subscribeAlways<MessageRecallEvent>(priority = EventPriority.HIGHEST) { event ->
runCatching { ChatHistoryStore.markRecalled(event) }
.onFailure { logger.warning("更新 SQLite 消息撤回状态失败", it) }
}
eventChannel.subscribeAlways<MessageEvent> { event -> onMessage(event) }
// 启动定时任务处理好感度时间偏移 // 启动定时任务处理好感度时间偏移
if (PluginConfig.enableFavorabilitySystem) { if (PluginConfig.enableFavorabilitySystem) {
@@ -120,6 +133,10 @@ object JChatGPT : KotlinPlugin(
logger.info { "Plugin loaded" } logger.info { "Plugin loaded" }
} }
override fun onDisable() {
ChatHistoryStore.close()
}
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss") private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
private val requestMap = ConcurrentSet<Long>() private val requestMap = ConcurrentSet<Long>()
@@ -158,11 +175,11 @@ object JChatGPT : KotlinPlugin(
* 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。 * 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。
*/ */
class ReplyIndex { class ReplyIndex {
private val byIndex = LinkedHashMap<Int, MessageRecord>() private val byIndex = LinkedHashMap<Int, ChatMessageRecord>()
private val indexByIds = HashMap<String, Int>() private val indexByIds = HashMap<String, Int>()
private var counter = 0 private var counter = 0
fun add(record: MessageRecord): Int { fun add(record: ChatMessageRecord): Int {
// ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号 // ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号
val ids = record.ids val ids = record.ids
if (ids != null) { if (ids != null) {
@@ -176,7 +193,7 @@ object JChatGPT : KotlinPlugin(
return i return i
} }
fun get(index: Int): MessageRecord? = byIndex[index] fun get(index: Int): ChatMessageRecord? = byIndex[index]
fun indexOfIds(ids: String): Int? = indexByIds[ids] fun indexOfIds(ids: String): Int? = indexByIds[ids]
} }
@@ -187,7 +204,7 @@ object JChatGPT : KotlinPlugin(
private val imageIndexMap = ConcurrentMap<Long, ImageIndex>() private val imageIndexMap = ConcurrentMap<Long, ImageIndex>()
/** 供发言工具按编号查找被引用的历史消息 */ /** 供发言工具按编号查找被引用的历史消息 */
internal fun lookupReplyTarget(subjectId: Long, index: Int): MessageRecord? = internal fun lookupReplyTarget(subjectId: Long, index: Int): ChatMessageRecord? =
replyIndexMap[subjectId]?.get(index) replyIndexMap[subjectId]?.get(index)
/** 将从原消息图片取得的精确 URL 登记为短编号,供历史搜索等工具追加图片引用。 */ /** 将从原消息图片取得的精确 URL 登记为短编号,供历史搜索等工具追加图片引用。 */
@@ -374,15 +391,22 @@ object JChatGPT : KotlinPlugin(
// 现在时间 // 现在时间
val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt() val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt()
// 最近这段时间的历史对话 // 最近这段时间的历史对话
val history = MiraiHibernateRecorder[event.subject, time, nowTimestamp] val history = try {
.take(PluginConfig.historyMessageLimit) // 只取最近的部分消息,避免上下文过长 ChatHistoryStore.query(
.sortedBy { it.time } // 按时间排序 contact = event.subject,
.toMutableList() 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(",") val msgIds = event.message.ids.joinToString(",")
if (!history.any { it.ids == msgIds }) { 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( private fun appendGroupMessageRecord(
historyText: StringBuilder, historyText: StringBuilder,
record: MessageRecord, record: ChatMessageRecord,
event: GroupMessageEvent, event: GroupMessageEvent,
replyIndex: ReplyIndex, replyIndex: ReplyIndex,
imageIndex: ImageIndex, imageIndex: ImageIndex,
@@ -560,7 +584,7 @@ object JChatGPT : KotlinPlugin(
*/ */
private fun appendMessageRecord( private fun appendMessageRecord(
historyText: StringBuilder, historyText: StringBuilder,
record: MessageRecord, record: ChatMessageRecord,
event: MessageEvent, event: MessageEvent,
replyIndex: ReplyIndex, replyIndex: ReplyIndex,
imageIndex: ImageIndex, imageIndex: ImageIndex,
+10 -14
View File
@@ -13,8 +13,8 @@ import net.mamoe.mirai.message.data.SingleMessage
import net.mamoe.mirai.message.data.content import net.mamoe.mirai.message.data.content
import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig import top.jie65535.mirai.PluginConfig
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder import top.jie65535.mirai.ChatHistoryStore
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord import top.jie65535.mirai.ChatMessageRecord
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -92,17 +92,13 @@ class SearchChatHistory : BaseAgent(
val maxRecords = PluginConfig.searchHistoryMaxRecords val maxRecords = PluginConfig.searchHistoryMaxRecords
val records = try { val records = try {
// 有 sender 时用 Member 重载,在数据库层过滤 fromId;否则用 Contact 重载 ChatHistoryStore.query(
if (senderQq != null && event is GroupMessageEvent) { contact = event.subject,
val member = event.group[senderQq] start = startEpoch,
if (member != null) { end = endEpoch,
MiraiHibernateRecorder[member, startEpoch, endEpoch] limit = maxRecords,
} else { fromId = senderQq,
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch] ).sortedBy { it.time }
}
} else {
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
}.take(maxRecords).sortedBy { it.time }
} catch (e: Throwable) { } catch (e: Throwable) {
JChatGPT.logger.warning("查询消息历史失败", e) JChatGPT.logger.warning("查询消息历史失败", e)
return "查询消息历史失败: ${e.message}" return "查询消息历史失败: ${e.message}"
@@ -148,7 +144,7 @@ class SearchChatHistory : BaseAgent(
private suspend fun appendHistory( private suspend fun appendHistory(
sb: StringBuilder, sb: StringBuilder,
records: List<MessageRecord>, records: List<ChatMessageRecord>,
event: MessageEvent event: MessageEvent
) { ) {
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+67
View File
@@ -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()
}
}
}