mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
history: replace external recorder with SQLite
This commit is contained in:
@@ -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
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user