profile: add Overflow contact snapshots

This commit is contained in:
2026-08-03 10:39:56 +08:00
parent f102264a55
commit 2a5e7fd2f9
18 changed files with 1794 additions and 58 deletions
+30 -7
View File
@@ -1,6 +1,6 @@
# JChatGPT
JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模型(LLM)集成到即时通讯平台中。该插件支持多种 AI 模型和丰富的工具功能,使用户能够在群聊和私聊中与 AI 进行交互
JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模型(LLM)集成到即时通讯平台中。生产运行链路为 NapCat -> OneBot -> Overflow -> Mirai Console -> JChatGPT;插件使用 Mirai 兼容 API,但联系人刷新等运行时能力以 Overflow 为准
## 功能特性
@@ -15,7 +15,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
- **LaTeX 渲染**:自动将数学表达式渲染为图片
- **灵活的触发方式**@机器人、关键字触发、回复消息等
- **权限控制**:细粒度的权限管理系统
- **内置历史消息**:使用插件自维护的 SQLite 保存、检索群聊和私聊消息
- **内置历史与联系人快照**:使用插件自维护的 SQLite 保存、检索消息,并异步同步好友、群和群成员公开信
## 用法
@@ -35,6 +35,7 @@ AI 可以自动调用多种工具来完成复杂任务:
- 记忆管理(添加和修改对话记忆)
- 技能管理(沉淀、加载、迭代、删除可复用知识技能)
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
- 用户画像查询(按当前发送者、QQ号或群名片读取已归纳的长期画像)
## 权限列表
@@ -105,6 +106,11 @@ profileModelTemperature: null
profileModelExtraBody: ''
# 留空使用插件自己的聊天库;本地实验可填写外部SQLite历史库的绝对路径
profileHistoryDatabasePath: ''
# 异步刷新好友、群、群成员联系人快照,写入 chat-history.sqlite
contactSnapshotEnabled: true
contactSnapshotInitialDelaySeconds: 30
contactSnapshotRefreshIntervalMinutes: 1440
contactSnapshotGroupDelayMillis: 200
# 每批目标用户消息数、片段软间隔及上下文限制
profileBatchTargetMessages: 120
profileBatchMaxEpisodes: 16
@@ -202,9 +208,15 @@ searchHistoryMaxDays: 30
searchHistoryMaxRecords: 5000
```
聊天记录保存在插件数据目录的 `chat-history.sqlite` 中,并使用 SQLite WAL 模式支持记录与查询并行进行。
聊天记录与联系人快照保存在插件数据目录的 `chat-history.sqlite` 中,并使用 SQLite WAL 模式支持记录与查询并行进行。
数据库由插件在首次启动时自动创建和维护,无需安装额外的聊天记录插件。
联系人刷新在后台通过 Overflow 的公开 `RemoteBot.executeAction` 调用 OneBot
`get_friend_list``get_group_list``get_group_member_list`,不使用反射,也不依赖 Overflow 的 internal
实现类。默认启动延迟 30 秒后刷新、此后每 24 小时刷新一次;单个群失败时保留上一版成员快照,完整刷新成功后
会淘汰已经删除的好友关系和已经离群的成员关系。周期任务不会逐人调用 `queryProfile()`,避免对数万群成员
产生高频资料卡请求和账号风控风险。
### 渐进式历史画像
画像维护不需要群友执行命令。Bot 成功完成一轮群聊后,系统沿用上下文缓存的超时时间进行防抖;期间再次
@@ -217,7 +229,15 @@ searchHistoryMaxRecords: 5000
旧历史可通过 `profileAnalyze``profileAnalyzeGroup` 手动分批推进。
下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的
长期画像按同一个人合并渲染,但两套数据仍独立保存,自动画像不会修改好感度
长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数
模型需要完整细节时可主动调用 `queryUserProfile`,按 QQ 号、群名片、昵称或好友备注读取画像。两套画像数据
仍独立保存,自动画像不会修改好感度。
`queryUserProfile` 解析出唯一联系人后才按需调用一次 `queryProfile()`,并在内存中缓存 10 分钟;周期联系人
同步不会触发该调用,因此群成员总量不会放大资料卡请求数。
好友昵称、群名片、群角色、签名、年龄等联系人公开资料只作为人物识别提示,不直接视为画像证据。画像模型
仍必须依据对应用户本人在聊天历史中的发言,才能新增或确认长期画像条目。
画像结果保存在插件数据目录的 `user-profile.sqlite`,不会覆盖现有好感度或旧印象数据。实验时可把历史
备份配置为只读来源,例如:
@@ -427,6 +447,7 @@ fallbackCooldownMinutes: 5
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
11. **WeatherService** - 天气查询
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史
13. **QueryUserProfile** - 按当前发送者、QQ 号、群名片、昵称或好友备注读取长期画像,并针对该联系人按需读取公开资料卡
## 用户画像系统
@@ -445,9 +466,10 @@ JChatGPT 维护对每位用户的画像,由好感度、Bot 自定义代号、
- 好感度为 0 时仍会保留该用户的代号、标签、印象和调整原因
### 注入到上下文
- 群聊:列出会话历史中"认识的群友"name/tags/impression 任一非空)
- 私聊:仅当对方有 name/tags/impression 时注入对方画像
- 仅有好感度数值、其它字段全空的用户不会被列出,避免提示词噪声
- 群聊:合并列出当前相关群友的关系状态,以及可靠长期画像的摘要和条目数
- 私聊:合并注入对方的关系状态,以及可靠长期画像的摘要和条目数
- 模型需要画像明细时主动调用 `queryUserProfile`,不必把全部条目常驻在上下文中
- 只有好感度数值、其它关系字段和可靠长期画像均为空的用户不会被列出,避免提示词噪声
### 配置选项
- `enableFavorabilitySystem` - 是否启用画像系统(默认:true)
@@ -568,6 +590,7 @@ JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消
- Java 11 或更高版本
- Mirai Console 2.16.0 或更高版本
- Overflow(生产运行时;由 OneBot 对接 NapCat
- 相关 API Tokens(根据需要启用的功能配置)
## 备注
+1
View File
@@ -42,6 +42,7 @@ dependencies {
implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion")
implementation("org.apache.commons:commons-text:$commonTextVersion")
implementation("org.xerial:sqlite-jdbc:$sqliteVersion")
compileOnly("top.mrxiaom.mirai:overflow-core-api:$overflowVersion")
testImplementation(kotlin("test-junit5"))
+10
View File
@@ -27,6 +27,8 @@ import top.jie65535.mirai.conversation.ConversationContext
import top.jie65535.mirai.conversation.ConversationEngine
import top.jie65535.mirai.data.ChatHistoryStore
import top.jie65535.mirai.data.ChatMessageRecord
import top.jie65535.mirai.data.ContactSnapshotRefresher
import top.jie65535.mirai.data.ContactSnapshotStore
import top.jie65535.mirai.data.PluginData
import top.jie65535.mirai.data.SkillStore
import top.jie65535.mirai.data.TokenUsageStore
@@ -66,6 +68,10 @@ object JChatGPT : KotlinPlugin(
logger.error("初始化 SQLite 聊天记录失败,历史上下文与搜索将暂时禁用", cause)
false
}
if (includeHistory) {
runCatching { ContactSnapshotStore.init(dataFolder) }
.onFailure { logger.error("初始化联系人快照数据库失败,联系人画像辅助将暂时禁用", it) }
}
runCatching { UserProfileStore.init(dataFolder) }
.onFailure { logger.error("初始化用户画像数据库失败,画像分析将暂时禁用", it) }
@@ -76,6 +82,7 @@ object JChatGPT : KotlinPlugin(
val eventChannel = GlobalEventChannel.parentScope(this)
eventChannel.subscribeAlways<MessageEvent>(priority = EventPriority.HIGHEST) { event ->
ContactSnapshotRefresher.schedule(event.bot, "message")
runCatching { ChatHistoryStore.record(event) }
.onFailure { logger.warning("保存接收消息到 SQLite 失败", it) }
}
@@ -88,6 +95,7 @@ object JChatGPT : KotlinPlugin(
.onFailure { logger.warning("更新 SQLite 消息撤回状态失败", it) }
}
eventChannel.subscribeAlways<MessageEvent> { event -> onMessage(event) }
ContactSnapshotRefresher.scheduleAll("startup")
logger.info { "Plugin loaded" }
}
@@ -96,7 +104,9 @@ object JChatGPT : KotlinPlugin(
ConversationEngine.clear()
ConversationContext.clearAll()
ProfileAutoMaintenance.clear()
ContactSnapshotRefresher.clear()
UserProfileStore.close()
ContactSnapshotStore.close()
ChatHistoryStore.close()
}
+12
View File
@@ -78,6 +78,18 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("画像分析使用的聊天记录SQLite路径。留空时使用插件自己的chat-history.sqlite;本地实验可填写历史库绝对路径")
val profileHistoryDatabasePath: String by value("")
@ValueDescription("是否异步刷新好友、群、群成员联系人快照;结果写入 chat-history.sqlite,供历史画像和上下文显示使用")
val contactSnapshotEnabled: Boolean by value(true)
@ValueDescription("插件启动或首次收到消息后延迟多少秒开始刷新联系人快照,避免阻塞启动")
val contactSnapshotInitialDelaySeconds: Int by value(30)
@ValueDescription("联系人快照定时刷新间隔(分钟)。设为0仅启动后刷新一次")
val contactSnapshotRefreshIntervalMinutes: Long by value(24 * 60L)
@ValueDescription("刷新每个群成员列表后的额外延迟(毫秒),用于降低 OneBot 端压力")
val contactSnapshotGroupDelayMillis: Long by value(200L)
@ValueDescription("每个画像分析批次最多读取目标用户多少条消息")
val profileBatchTargetMessages: Int by value(120)
@@ -29,6 +29,7 @@ import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.config.PluginConfig
import top.jie65535.mirai.data.ChatHistoryStore
import top.jie65535.mirai.data.ChatMessageRecord
import top.jie65535.mirai.data.ContactSnapshotStore
import top.jie65535.mirai.data.PluginData
import top.jie65535.mirai.data.SkillStore
import top.jie65535.mirai.llm.LargeLanguageModels
@@ -197,7 +198,7 @@ internal object ConversationContext {
lastTime = record.time.toLong()
}
} else {
appendPrivateFavorabilityContext(result, event)
appendPrivateUserContext(result, event)
result.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)")
history.forEach { record ->
val showSender = lastUserId != record.fromId
@@ -291,7 +292,12 @@ internal object ConversationContext {
val favorability = if (PluginConfig.enableFavorabilitySystem) {
candidateIds.mapNotNull { id -> PluginData.userFavorability[id]?.let { id to it } }.toMap()
} else emptyMap()
val names = candidateIds.associateWith { id -> event.group[id]?.nameCardOrNick ?: id.toString() }
val snapshotNames = runCatching {
ContactSnapshotStore.loadDisplayNames(event.bot.id, event.group.id, candidateIds)
}.getOrDefault(emptyMap())
val names = candidateIds.associateWith { id ->
event.group[id]?.nameCardOrNick ?: snapshotNames[id] ?: id.toString()
}
target.append(
UserProfileContextRenderer.render(
profiles = profiles,
@@ -303,17 +309,34 @@ internal object ConversationContext {
)
}
private fun appendPrivateFavorabilityContext(target: StringBuilder, event: MessageEvent) {
if (!PluginConfig.enableFavorabilitySystem) return
val info = PluginData.userFavorability[event.sender.id] ?: return
if (info.name.isEmpty() && info.tags.isEmpty() && info.impression.isEmpty()) return
val displayName = info.name.ifEmpty { event.senderName }
target.appendLine("【你认识的对方】")
.append("- ").append(displayName).append("(${event.sender.id})")
.append(" 好感度${if (info.value >= 0) "+" else ""}${info.value}")
if (info.tags.isNotEmpty()) target.append(" [${info.tags.joinToString(", ")}]")
if (info.impression.isNotEmpty()) target.append(" ${info.impression}")
target.appendLine().appendLine()
private fun appendPrivateUserContext(target: StringBuilder, event: MessageEvent) {
if (!PluginConfig.profileAutoInjectEnabled && !PluginConfig.enableFavorabilitySystem) return
val userId = event.sender.id
val profiles = if (PluginConfig.profileEnabled && PluginConfig.profileAutoInjectEnabled &&
UserProfileStore.isAvailable
) {
listOfNotNull(
runCatching { UserProfileStore.load(userId) }
.onFailure { JChatGPT.logger.warning("读取用户画像失败: user=$userId", it) }
.getOrNull()
)
} else emptyList()
val favorability = if (PluginConfig.enableFavorabilitySystem) {
PluginData.userFavorability[userId]?.let { mapOf(userId to it) }.orEmpty()
} else emptyMap()
val snapshotName = runCatching {
ContactSnapshotStore.loadDisplayName(event.bot.id, null, userId)
}.getOrNull()
target.append(
UserProfileContextRenderer.render(
profiles = profiles,
favorabilityByUserId = favorability,
displayNames = mapOf(userId to (snapshotName ?: event.senderName)),
activeUserIds = setOf(userId),
summaryMaxChars = PluginConfig.profileAutoInjectSummaryMaxChars,
sectionTitle = "你对对方的认识",
)
)
}
private fun appendGroupMessageRecord(
@@ -31,6 +31,7 @@ import top.jie65535.mirai.tools.ImageAgent
import top.jie65535.mirai.tools.LoadSkill
import top.jie65535.mirai.tools.MemoryAppend
import top.jie65535.mirai.tools.MemoryReplace
import top.jie65535.mirai.tools.QueryUserProfileAgent
import top.jie65535.mirai.tools.ReasoningAgent
import top.jie65535.mirai.tools.RequestOwner
import top.jie65535.mirai.tools.RunCode
@@ -68,6 +69,7 @@ internal object ConversationEngine {
SaveSkill(),
DeleteSkill(),
SearchChatHistory(),
QueryUserProfileAgent(),
WebSearch(),
VisitWeb(),
RunCode(),
@@ -0,0 +1,320 @@
package top.jie65535.mirai.data
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import net.mamoe.mirai.Bot
import net.mamoe.mirai.contact.Friend
import net.mamoe.mirai.contact.Member
import net.mamoe.mirai.contact.NormalMember
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.config.PluginConfig
import top.mrxiaom.overflow.contact.RemoteBot
import top.mrxiaom.overflow.contact.RemoteGroup
import top.mrxiaom.overflow.contact.RemoteUser
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
object ContactSnapshotRefresher {
private const val ACTION_TIMEOUT_MS = 60_000L
private const val GET_FRIEND_LIST = "get_friend_list"
private const val GET_GROUP_LIST = "get_group_list"
private const val GET_GROUP_MEMBER_LIST = "get_group_member_list"
private val json = Json { ignoreUnknownKeys = true }
private val jobs = ConcurrentHashMap<Long, Job>()
fun scheduleAll(reason: String) {
Bot.instances.forEach { bot -> schedule(bot, reason) }
}
fun schedule(bot: Bot, reason: String) {
if (!PluginConfig.contactSnapshotEnabled || !ContactSnapshotStore.isAvailable) return
synchronized(jobs) {
if (jobs.containsKey(bot.id)) return
jobs[bot.id] = JChatGPT.launch(Dispatchers.IO) {
delay(PluginConfig.contactSnapshotInitialDelaySeconds.coerceAtLeast(0).seconds)
while (isActive) {
refresh(bot, reason)
val minutes = PluginConfig.contactSnapshotRefreshIntervalMinutes
if (minutes <= 0) break
delay(minutes.minutes)
}
}
}
}
fun clear() {
synchronized(jobs) {
jobs.values.forEach(Job::cancel)
jobs.clear()
}
}
suspend fun refresh(bot: Bot, reason: String = "manual"): ContactSnapshotBatch? = withContext(Dispatchers.IO) {
if (!PluginConfig.contactSnapshotEnabled || !ContactSnapshotStore.isAvailable) return@withContext null
try {
val batch = (bot as? RemoteBot)?.let { remoteBot ->
pullFromOneBot(bot, remoteBot)
} ?: pullFromMirai(bot)
ContactSnapshotStore.save(batch)
JChatGPT.logger.info(
"CONTACT_SNAPSHOT bot=${bot.id} reason=$reason friends=${batch.friends.size} " +
"groups=${batch.groups.size} members=${batch.members.size} users=${batch.users.size}"
)
batch
} catch (cause: CancellationException) {
throw cause
} catch (cause: Throwable) {
JChatGPT.logger.warning("联系人快照刷新失败: bot=${bot.id}, reason=$reason", cause)
null
}
}
private suspend fun pullFromOneBot(
bot: Bot,
remoteBot: RemoteBot,
): ContactSnapshotBatch {
val capturedAt = System.currentTimeMillis()
val users = linkedMapOf<Long, ContactUserSnapshot>()
val friendIds = mutableListOf<Long>()
val groups = mutableListOf<ContactGroupSnapshot>()
val members = mutableListOf<ContactGroupMemberSnapshot>()
val completeMemberGroupIds = mutableSetOf<Long>()
val cachedFriends = bot.friends.associateBy { friend -> friend.id }
executeList(remoteBot, GET_FRIEND_LIST).forEach { data ->
val userId = data.long("user_id")?.takeIf { it > 0 } ?: return@forEach
val friend = cachedFriends[userId]
friendIds += userId
users.merge(data.toFriendSnapshot(bot.id, userId, capturedAt, friend))
}
val cachedGroups = bot.groups.associateBy { it.id }
executeList(remoteBot, GET_GROUP_LIST).forEach { groupData ->
val groupId = groupData.long("group_id")?.takeIf { it > 0 } ?: return@forEach
val memberRows = try {
executeList(
remoteBot,
GET_GROUP_MEMBER_LIST,
buildJsonObject {
put("group_id", groupId)
put("no_cache", false)
}.toString(),
).also { completeMemberGroupIds += groupId }
} catch (cause: CancellationException) {
throw cause
} catch (cause: Throwable) {
JChatGPT.logger.warning("拉取群 $groupId 成员列表失败,保留上一版成员快照", cause)
null
}
groups += ContactGroupSnapshot(
botId = bot.id,
groupId = groupId,
name = groupData.text("group_name").ifBlank { cachedGroups[groupId]?.name.orEmpty() },
memberCount = groupData.int("member_count") ?: memberRows?.size ?: 0,
maxMemberCount = groupData.int("max_member_count") ?: 0,
updatedAt = capturedAt,
)
memberRows.orEmpty().forEach { memberData ->
val userId = memberData.long("user_id")?.takeIf { it > 0 } ?: return@forEach
val member = cachedGroups[groupId]?.get(userId)
members += memberData.toMemberSnapshot(bot.id, groupId, userId, capturedAt, member)
users.merge(memberData.toMemberUserSnapshot(bot.id, userId, capturedAt, member))
}
delayBetweenGroups()
}
return ContactSnapshotBatch(
botId = bot.id,
capturedAt = capturedAt,
users = users.values.toList(),
friends = friendIds,
groups = groups,
members = members,
completeFriendList = true,
completeGroupList = true,
completeMemberGroupIds = completeMemberGroupIds,
)
}
private suspend fun pullFromMirai(bot: Bot): ContactSnapshotBatch {
val capturedAt = System.currentTimeMillis()
val users = linkedMapOf<Long, ContactUserSnapshot>()
val friendIds = mutableListOf<Long>()
val groups = mutableListOf<ContactGroupSnapshot>()
val members = mutableListOf<ContactGroupMemberSnapshot>()
val completeMemberGroupIds = mutableSetOf<Long>()
bot.friends.toList().forEach { friend ->
friendIds += friend.id
users.merge(friend.toUserSnapshot(bot.id, capturedAt))
}
bot.groups.toList().forEach { group ->
val groupData = group.onebotJson()
val groupMembers = try {
((group as? RemoteGroup)?.updateGroupMemberList()?.toList() ?: group.members.toList())
.also { completeMemberGroupIds += group.id }
} catch (cause: CancellationException) {
throw cause
} catch (cause: Throwable) {
JChatGPT.logger.warning("刷新群 ${group.id} 成员列表失败,保留上一版成员快照", cause)
emptyList()
}
groups += ContactGroupSnapshot(
botId = bot.id,
groupId = group.id,
name = group.name,
memberCount = groupData.int("member_count") ?: groupMembers.size,
maxMemberCount = groupData.int("max_member_count") ?: 0,
updatedAt = capturedAt,
)
groupMembers.forEach { member ->
val memberData = member.onebotJson()
members += member.toMemberSnapshot(bot.id, capturedAt, memberData)
users.merge(member.toUserSnapshot(bot.id, capturedAt, memberData))
}
delayBetweenGroups()
}
return ContactSnapshotBatch(
botId = bot.id,
capturedAt = capturedAt,
users = users.values.toList(),
friends = friendIds,
groups = groups,
members = members,
completeFriendList = true,
completeGroupList = true,
completeMemberGroupIds = completeMemberGroupIds,
)
}
private suspend fun executeList(remoteBot: RemoteBot, action: String, params: String? = null): List<JsonObject> {
val payload = withTimeout(ACTION_TIMEOUT_MS) { remoteBot.executeAction(action, params) }
return OneBotContactPayloadParser.parseObjectList(action, payload)
}
private suspend fun delayBetweenGroups() {
val delayMs = PluginConfig.contactSnapshotGroupDelayMillis.coerceAtLeast(0)
if (delayMs > 0) delay(delayMs.milliseconds)
}
private fun JsonObject.toFriendSnapshot(
botId: Long,
userId: Long,
updatedAt: Long,
friend: Friend?,
): ContactUserSnapshot = ContactUserSnapshot(
botId = botId,
userId = userId,
nickname = text("nickname", "user_name").ifBlank { friend?.nick.orEmpty() },
remark = text("remark", "user_remark").ifBlank { friend?.remark.orEmpty() },
sex = text("sex"),
age = int("age") ?: 0,
qLevel = int("level", "qq_level", "qqLevel") ?: 0,
email = text("email", "eMail"),
sign = text("longNick", "long_nick", "sign"),
updatedAt = updatedAt,
)
private fun JsonObject.toMemberUserSnapshot(
botId: Long,
userId: Long,
updatedAt: Long,
member: Member?,
): ContactUserSnapshot = ContactUserSnapshot(
botId = botId,
userId = userId,
nickname = text("nickname").ifBlank { member?.nick.orEmpty() },
sex = text("sex"),
age = int("age") ?: 0,
qLevel = int("qq_level", "qqLevel") ?: 0,
updatedAt = updatedAt,
)
private fun JsonObject.toMemberSnapshot(
botId: Long,
groupId: Long,
userId: Long,
updatedAt: Long,
member: Member?,
): ContactGroupMemberSnapshot = ContactGroupMemberSnapshot(
botId = botId,
groupId = groupId,
userId = userId,
nickname = text("nickname").ifBlank { member?.nick.orEmpty() },
nameCard = text("card").ifBlank { member?.nameCard.orEmpty() },
role = text("role").ifBlank { member?.permission?.name?.lowercase().orEmpty() },
specialTitle = text("title").ifBlank { member?.let { runCatching { it.specialTitle }.getOrDefault("") }.orEmpty() },
sex = text("sex"),
age = int("age") ?: 0,
area = text("area"),
level = int("level") ?: 0,
qLevel = int("qq_level", "qqLevel") ?: 0,
joinTime = int("join_time") ?: 0,
lastSpeakTime = int("last_sent_time") ?: 0,
updatedAt = updatedAt,
)
private fun Friend.toUserSnapshot(
botId: Long,
updatedAt: Long,
): ContactUserSnapshot = onebotJson().toFriendSnapshot(botId, id, updatedAt, this)
private fun Member.toUserSnapshot(
botId: Long,
updatedAt: Long,
data: JsonObject,
): ContactUserSnapshot = data.toMemberUserSnapshot(botId, id, updatedAt, this)
private fun NormalMember.toMemberSnapshot(
botId: Long,
updatedAt: Long,
data: JsonObject,
): ContactGroupMemberSnapshot = data.toMemberSnapshot(botId, group.id, id, updatedAt, this).copy(
joinTime = data.int("join_time") ?: joinTimestamp,
lastSpeakTime = data.int("last_sent_time") ?: lastSpeakTimestamp,
)
private fun MutableMap<Long, ContactUserSnapshot>.merge(snapshot: ContactUserSnapshot) {
val current = this[snapshot.userId]
this[snapshot.userId] = if (current == null) {
snapshot
} else {
snapshot.copy(
nickname = snapshot.nickname.ifBlank { current.nickname },
remark = snapshot.remark.ifBlank { current.remark },
sex = snapshot.sex.ifBlank { current.sex },
age = snapshot.age.takeIf { it > 0 } ?: current.age,
qLevel = snapshot.qLevel.takeIf { it > 0 } ?: current.qLevel,
email = snapshot.email.ifBlank { current.email },
sign = snapshot.sign.ifBlank { current.sign },
updatedAt = maxOf(snapshot.updatedAt, current.updatedAt),
)
}
}
private fun Any.onebotJson(): JsonObject {
val raw = (this as? RemoteUser)?.onebotData.orEmpty()
if (raw.isBlank()) return JsonObject(emptyMap())
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrDefault(JsonObject(emptyMap()))
}
}
@@ -0,0 +1,723 @@
package top.jie65535.mirai.data
import org.sqlite.SQLiteConfig
import java.io.File
import java.sql.Connection
import java.sql.DriverManager
import java.sql.ResultSet
data class ContactSnapshotBatch(
val botId: Long,
val capturedAt: Long,
val users: List<ContactUserSnapshot> = emptyList(),
val friends: List<Long> = emptyList(),
val groups: List<ContactGroupSnapshot> = emptyList(),
val members: List<ContactGroupMemberSnapshot> = emptyList(),
val completeFriendList: Boolean = false,
val completeGroupList: Boolean = false,
val completeMemberGroupIds: Set<Long> = emptySet(),
)
data class ContactUserSnapshot(
val botId: Long,
val userId: Long,
val nickname: String = "",
val remark: String = "",
val sex: String = "",
val age: Int = 0,
val qLevel: Int = 0,
val email: String = "",
val sign: String = "",
val updatedAt: Long,
)
data class ContactGroupSnapshot(
val botId: Long,
val groupId: Long,
val name: String = "",
val memberCount: Int = 0,
val maxMemberCount: Int = 0,
val updatedAt: Long,
)
data class ContactGroupMemberSnapshot(
val botId: Long,
val groupId: Long,
val userId: Long,
val nickname: String = "",
val nameCard: String = "",
val role: String = "",
val specialTitle: String = "",
val sex: String = "",
val age: Int = 0,
val area: String = "",
val level: Int = 0,
val qLevel: Int = 0,
val joinTime: Int = 0,
val lastSpeakTime: Int = 0,
val updatedAt: Long,
)
data class ContactProfileHint(
val userId: Long,
val nickname: String = "",
val remark: String = "",
val sex: String = "",
val age: Int = 0,
val qLevel: Int = 0,
val sign: String = "",
val isFriend: Boolean = false,
val memberships: List<ContactGroupMemberHint> = emptyList(),
) {
val displayName: String
get() = memberships.asSequence().map(ContactGroupMemberHint::nameCard).firstOrNull(String::isNotBlank)
?: remark.takeIf(String::isNotBlank)
?: nickname
}
data class ContactGroupMemberHint(
val groupId: Long,
val groupName: String = "",
val nickname: String = "",
val nameCard: String = "",
val role: String = "",
val specialTitle: String = "",
val sex: String = "",
val age: Int = 0,
val area: String = "",
val level: Int = 0,
val qLevel: Int = 0,
val joinTime: Int = 0,
val lastSpeakTime: Int = 0,
)
data class ContactNameMatch(
val userId: Long,
val displayName: String,
val matchRank: Int,
)
object ContactSnapshotStore {
private const val SCHEMA_VERSION = 1
private const val BUSY_TIMEOUT_MS = 30_000
private const val DATABASE_NAME = "chat-history.sqlite"
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(DATABASE_NAME)
val connection = openConnection(databaseFile)
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?.close()
writeConnection = null
initialized = false
}
}
}
fun save(batch: ContactSnapshotBatch) {
if (!initialized) return
withWriteConnection { connection ->
val oldAutoCommit = connection.autoCommit
connection.autoCommit = false
try {
upsertUsers(connection, batch.users)
upsertFriends(connection, batch.botId, batch.friends, batch.capturedAt)
upsertGroups(connection, batch.groups)
upsertMembers(connection, batch.members)
reconcileSnapshot(connection, batch)
connection.commit()
} catch (cause: Throwable) {
connection.rollback()
throw cause
} finally {
connection.autoCommit = oldAutoCommit
}
}
}
fun loadProfileHints(
databaseFile: File,
botId: Long,
groupIds: Collection<Long>,
userIds: Collection<Long>,
): Map<Long, ContactProfileHint> {
if (userIds.isEmpty() || !databaseFile.isFile) return emptyMap()
Class.forName("org.sqlite.JDBC")
return openReadConnection(databaseFile).use { connection ->
if (!hasContactSchema(connection)) return@use emptyMap()
val userSet = userIds.toSet()
val userSnapshots = queryUsers(connection, botId, userSet)
val friendIds = queryFriendIds(connection, botId, userSet)
val memberships = if (groupIds.isEmpty()) {
emptyMap()
} else {
queryMemberships(connection, botId, groupIds.toSet(), userSet)
}
userSet.mapNotNull { userId ->
val user = userSnapshots[userId]
val memberHints = memberships[userId].orEmpty()
if (user == null && memberHints.isEmpty() && userId !in friendIds) {
null
} else {
userId to ContactProfileHint(
userId = userId,
nickname = user?.nickname.orEmpty(),
remark = user?.remark.orEmpty(),
sex = user?.sex.orEmpty(),
age = user?.age ?: 0,
qLevel = user?.qLevel ?: 0,
sign = user?.sign.orEmpty(),
isFriend = userId in friendIds,
memberships = memberHints,
)
}
}.toMap()
}
}
fun loadProfileHints(
botId: Long,
groupIds: Collection<Long>,
userIds: Collection<Long>,
): Map<Long, ContactProfileHint> {
if (!initialized) return emptyMap()
return loadProfileHints(databaseFile, botId, groupIds, userIds)
}
fun loadDisplayNames(
botId: Long,
groupId: Long?,
userIds: Collection<Long>,
): Map<Long, String> {
if (!initialized) return emptyMap()
val groups = groupId?.let(::setOf).orEmpty()
return loadProfileHints(databaseFile, botId, groups, userIds)
.mapValues { (_, hint) -> hint.displayName }
.filterValues(String::isNotBlank)
}
fun loadDisplayName(botId: Long, groupId: Long?, userId: Long): String? =
loadDisplayNames(botId, groupId, listOf(userId))[userId]
fun findUsersByName(
botId: Long,
groupId: Long?,
query: String,
limit: Int = 5,
): List<ContactNameMatch> {
if (!initialized || query.isBlank() || limit <= 0) return emptyList()
val normalizedQuery = query.trim()
return openReadConnection(databaseFile).use { connection ->
val sql = if (groupId != null) {
"""
SELECT m.user_id, m.name_card, m.nickname AS member_nickname,
COALESCE(u.remark, '') AS remark,
COALESCE(u.nickname, '') AS user_nickname
FROM contact_group_member_snapshot m
LEFT JOIN contact_user_snapshot u
ON u.bot_id = m.bot_id AND u.user_id = m.user_id
WHERE m.bot_id = ? AND m.group_id = ?
""".trimIndent()
} else {
"""
SELECT f.user_id, '' AS name_card, '' AS member_nickname,
COALESCE(u.remark, '') AS remark,
COALESCE(u.nickname, '') AS user_nickname
FROM contact_friend_snapshot f
LEFT JOIN contact_user_snapshot u
ON u.bot_id = f.bot_id AND u.user_id = f.user_id
WHERE f.bot_id = ?
""".trimIndent()
}
connection.prepareStatement(sql).use { statement ->
statement.setLong(1, botId)
if (groupId != null) statement.setLong(2, groupId)
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
val names = listOf(
results.getString("name_card").orEmpty(),
results.getString("remark").orEmpty(),
results.getString("member_nickname").orEmpty(),
results.getString("user_nickname").orEmpty(),
).filter(String::isNotBlank)
val rank = names.minOfOrNull { name -> name.matchRank(normalizedQuery) }
?.takeIf { it < Int.MAX_VALUE }
?: continue
add(ContactNameMatch(
userId = results.getLong("user_id"),
displayName = names.firstOrNull().orEmpty(),
matchRank = rank,
))
}
}.sortedWith(compareBy<ContactNameMatch>({ it.matchRank }, { it.userId }))
.distinctBy(ContactNameMatch::userId)
.take(limit)
}
}
}
}
private fun upsertUsers(connection: Connection, users: List<ContactUserSnapshot>) {
if (users.isEmpty()) return
connection.prepareStatement(
"""
INSERT INTO contact_user_snapshot(
bot_id, user_id, nickname, remark, sex, age, q_level, email, sign, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET
nickname = excluded.nickname,
remark = excluded.remark,
sex = CASE WHEN excluded.sex <> '' THEN excluded.sex ELSE contact_user_snapshot.sex END,
age = CASE WHEN excluded.age > 0 THEN excluded.age ELSE contact_user_snapshot.age END,
q_level = CASE WHEN excluded.q_level > 0 THEN excluded.q_level ELSE contact_user_snapshot.q_level END,
email = CASE WHEN excluded.email <> '' THEN excluded.email ELSE contact_user_snapshot.email END,
sign = CASE WHEN excluded.sign <> '' THEN excluded.sign ELSE contact_user_snapshot.sign END,
updated_at = excluded.updated_at
""".trimIndent()
).use { statement ->
users.forEach { user ->
statement.setLong(1, user.botId)
statement.setLong(2, user.userId)
statement.setString(3, user.nickname)
statement.setString(4, user.remark)
statement.setString(5, user.sex)
statement.setInt(6, user.age)
statement.setInt(7, user.qLevel)
statement.setString(8, user.email)
statement.setString(9, user.sign)
statement.setLong(10, user.updatedAt)
statement.addBatch()
}
statement.executeBatch()
}
}
private fun upsertFriends(connection: Connection, botId: Long, friendIds: List<Long>, updatedAt: Long) {
if (friendIds.isEmpty()) return
connection.prepareStatement(
"""
INSERT INTO contact_friend_snapshot(bot_id, user_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET
updated_at = excluded.updated_at
""".trimIndent()
).use { statement ->
friendIds.distinct().forEach { userId ->
statement.setLong(1, botId)
statement.setLong(2, userId)
statement.setLong(3, updatedAt)
statement.addBatch()
}
statement.executeBatch()
}
}
private fun upsertGroups(connection: Connection, groups: List<ContactGroupSnapshot>) {
if (groups.isEmpty()) return
connection.prepareStatement(
"""
INSERT INTO contact_group_snapshot(
bot_id, group_id, name, member_count, max_member_count, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(bot_id, group_id) DO UPDATE SET
name = excluded.name,
member_count = excluded.member_count,
max_member_count = excluded.max_member_count,
updated_at = excluded.updated_at
""".trimIndent()
).use { statement ->
groups.forEach { group ->
statement.setLong(1, group.botId)
statement.setLong(2, group.groupId)
statement.setString(3, group.name)
statement.setInt(4, group.memberCount)
statement.setInt(5, group.maxMemberCount)
statement.setLong(6, group.updatedAt)
statement.addBatch()
}
statement.executeBatch()
}
}
private fun upsertMembers(connection: Connection, members: List<ContactGroupMemberSnapshot>) {
if (members.isEmpty()) return
connection.prepareStatement(
"""
INSERT INTO contact_group_member_snapshot(
bot_id, group_id, user_id, nickname, name_card, role, special_title,
sex, age, area, level, q_level, join_time, last_speak_time, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(bot_id, group_id, user_id) DO UPDATE SET
nickname = excluded.nickname,
name_card = excluded.name_card,
role = excluded.role,
special_title = excluded.special_title,
sex = excluded.sex,
age = excluded.age,
area = excluded.area,
level = excluded.level,
q_level = excluded.q_level,
join_time = excluded.join_time,
last_speak_time = excluded.last_speak_time,
updated_at = excluded.updated_at
""".trimIndent()
).use { statement ->
members.forEach { member ->
statement.setLong(1, member.botId)
statement.setLong(2, member.groupId)
statement.setLong(3, member.userId)
statement.setString(4, member.nickname)
statement.setString(5, member.nameCard)
statement.setString(6, member.role)
statement.setString(7, member.specialTitle)
statement.setString(8, member.sex)
statement.setInt(9, member.age)
statement.setString(10, member.area)
statement.setInt(11, member.level)
statement.setInt(12, member.qLevel)
statement.setInt(13, member.joinTime)
statement.setInt(14, member.lastSpeakTime)
statement.setLong(15, member.updatedAt)
statement.addBatch()
}
statement.executeBatch()
}
}
private fun reconcileSnapshot(connection: Connection, batch: ContactSnapshotBatch) {
if (batch.completeFriendList) {
connection.prepareStatement(
"DELETE FROM contact_friend_snapshot WHERE bot_id = ? AND updated_at <> ?"
).use { statement ->
statement.setLong(1, batch.botId)
statement.setLong(2, batch.capturedAt)
statement.executeUpdate()
}
}
if (batch.completeGroupList) {
connection.prepareStatement(
"DELETE FROM contact_group_snapshot WHERE bot_id = ? AND updated_at <> ?"
).use { statement ->
statement.setLong(1, batch.botId)
statement.setLong(2, batch.capturedAt)
statement.executeUpdate()
}
connection.prepareStatement(
"""
DELETE FROM contact_group_member_snapshot
WHERE bot_id = ?
AND NOT EXISTS (
SELECT 1 FROM contact_group_snapshot g
WHERE g.bot_id = contact_group_member_snapshot.bot_id
AND g.group_id = contact_group_member_snapshot.group_id
)
""".trimIndent()
).use { statement ->
statement.setLong(1, batch.botId)
statement.executeUpdate()
}
}
if (batch.completeMemberGroupIds.isNotEmpty()) {
connection.prepareStatement(
"""
DELETE FROM contact_group_member_snapshot
WHERE bot_id = ? AND group_id = ? AND updated_at <> ?
""".trimIndent()
).use { statement ->
batch.completeMemberGroupIds.forEach { groupId ->
statement.setLong(1, batch.botId)
statement.setLong(2, groupId)
statement.setLong(3, batch.capturedAt)
statement.addBatch()
}
statement.executeBatch()
}
}
}
private fun queryUsers(
connection: Connection,
botId: Long,
userIds: Set<Long>,
): Map<Long, ContactUserSnapshot> {
val placeholders = userIds.joinToString(",") { "?" }
return connection.prepareStatement(
"""
SELECT bot_id, user_id, nickname, remark, sex, age, q_level, email, sign, updated_at
FROM contact_user_snapshot
WHERE bot_id = ? AND user_id IN ($placeholders)
""".trimIndent()
).use { statement ->
statement.setLong(1, botId)
userIds.forEachIndexed { index, userId -> statement.setLong(index + 2, userId) }
statement.executeQuery().use { results ->
buildMap {
while (results.next()) {
val user = results.toUserSnapshot()
put(user.userId, user)
}
}
}
}
}
private fun queryFriendIds(
connection: Connection,
botId: Long,
userIds: Set<Long>,
): Set<Long> {
val placeholders = userIds.joinToString(",") { "?" }
return connection.prepareStatement(
"""
SELECT user_id
FROM contact_friend_snapshot
WHERE bot_id = ? AND user_id IN ($placeholders)
""".trimIndent()
).use { statement ->
statement.setLong(1, botId)
userIds.forEachIndexed { index, userId -> statement.setLong(index + 2, userId) }
statement.executeQuery().use { results ->
buildSet {
while (results.next()) add(results.getLong("user_id"))
}
}
}
}
private fun queryMemberships(
connection: Connection,
botId: Long,
groupIds: Set<Long>,
userIds: Set<Long>,
): Map<Long, List<ContactGroupMemberHint>> {
val groupPlaceholders = groupIds.joinToString(",") { "?" }
val userPlaceholders = userIds.joinToString(",") { "?" }
return connection.prepareStatement(
"""
SELECT m.group_id, m.user_id, m.nickname, m.name_card, m.role, m.special_title,
m.sex, m.age, m.area, m.level, m.q_level, m.join_time, m.last_speak_time,
g.name AS group_name
FROM contact_group_member_snapshot m
LEFT JOIN contact_group_snapshot g
ON g.bot_id = m.bot_id AND g.group_id = m.group_id
WHERE m.bot_id = ?
AND m.group_id IN ($groupPlaceholders)
AND m.user_id IN ($userPlaceholders)
ORDER BY m.updated_at DESC, m.group_id ASC
""".trimIndent()
).use { statement ->
var index = 1
statement.setLong(index++, botId)
groupIds.forEach { groupId -> statement.setLong(index++, groupId) }
userIds.forEach { userId -> statement.setLong(index++, userId) }
statement.executeQuery().use { results ->
buildMap<Long, MutableList<ContactGroupMemberHint>> {
while (results.next()) {
val userId = results.getLong("user_id")
getOrPut(userId) { mutableListOf() } += ContactGroupMemberHint(
groupId = results.getLong("group_id"),
groupName = results.getString("group_name").orEmpty(),
nickname = results.getString("nickname").orEmpty(),
nameCard = results.getString("name_card").orEmpty(),
role = results.getString("role").orEmpty(),
specialTitle = results.getString("special_title").orEmpty(),
sex = results.getString("sex").orEmpty(),
age = results.getInt("age"),
area = results.getString("area").orEmpty(),
level = results.getInt("level"),
qLevel = results.getInt("q_level"),
joinTime = results.getInt("join_time"),
lastSpeakTime = results.getInt("last_speak_time"),
)
}
}
}
}
}
private fun withWriteConnection(block: (Connection) -> Unit) {
synchronized(writeLock) {
check(initialized) { "联系人快照数据库尚未初始化" }
val connection = writeConnection?.takeUnless(Connection::isClosed)
?: openConnection(databaseFile).also {
configureWriteConnection(it)
writeConnection = it
}
block(connection)
}
}
private fun createSchema(connection: Connection) {
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS contact_user_snapshot(
bot_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
sex TEXT NOT NULL DEFAULT '',
age INTEGER NOT NULL DEFAULT 0,
q_level INTEGER NOT NULL DEFAULT 0,
email TEXT NOT NULL DEFAULT '',
sign TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
PRIMARY KEY(bot_id, user_id)
)
""".trimIndent()
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS contact_friend_snapshot(
bot_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(bot_id, user_id)
)
""".trimIndent()
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS contact_group_snapshot(
bot_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '',
member_count INTEGER NOT NULL DEFAULT 0,
max_member_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY(bot_id, group_id)
)
""".trimIndent()
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS contact_group_member_snapshot(
bot_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
name_card TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
special_title TEXT NOT NULL DEFAULT '',
sex TEXT NOT NULL DEFAULT '',
age INTEGER NOT NULL DEFAULT 0,
area TEXT NOT NULL DEFAULT '',
level INTEGER NOT NULL DEFAULT 0,
q_level INTEGER NOT NULL DEFAULT 0,
join_time INTEGER NOT NULL DEFAULT 0,
last_speak_time INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY(bot_id, group_id, user_id)
)
""".trimIndent()
)
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_contact_member_user " +
"ON contact_group_member_snapshot(bot_id, user_id, updated_at DESC)"
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS contact_snapshot_meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""".trimIndent()
)
}
connection.prepareStatement(
"INSERT INTO contact_snapshot_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 hasContactSchema(connection: Connection): Boolean =
connection.prepareStatement(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'contact_user_snapshot' LIMIT 1"
).use { statement ->
statement.executeQuery().use(ResultSet::next)
}
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 openConnection(databaseFile: File): Connection =
DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}")
private fun openReadConnection(databaseFile: File): Connection {
val config = SQLiteConfig().apply {
setReadOnly(true)
setBusyTimeout(BUSY_TIMEOUT_MS)
}
return DriverManager.getConnection(
"jdbc:sqlite:${databaseFile.absolutePath}",
config.toProperties(),
).also { connection ->
connection.createStatement().use { statement ->
statement.execute("PRAGMA query_only=ON")
}
}
}
private fun ResultSet.toUserSnapshot(): ContactUserSnapshot = ContactUserSnapshot(
botId = getLong("bot_id"),
userId = getLong("user_id"),
nickname = getString("nickname").orEmpty(),
remark = getString("remark").orEmpty(),
sex = getString("sex").orEmpty(),
age = getInt("age"),
qLevel = getInt("q_level"),
email = getString("email").orEmpty(),
sign = getString("sign").orEmpty(),
updatedAt = getLong("updated_at"),
)
private fun String.matchRank(query: String): Int = when {
equals(query, ignoreCase = true) -> 0
startsWith(query, ignoreCase = true) -> 1
contains(query, ignoreCase = true) -> 2
else -> Int.MAX_VALUE
}
}
@@ -0,0 +1,55 @@
package top.jie65535.mirai.data
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
internal object OneBotContactPayloadParser {
private val json = Json { ignoreUnknownKeys = true }
fun parseObjectList(action: String, payload: String): List<JsonObject> {
val root = runCatching { json.parseToJsonElement(payload) }
.getOrElse { cause -> throw IllegalStateException("OneBot $action 返回了无效 JSON", cause) }
val data = when (root) {
is JsonArray -> root
is JsonObject -> root.unwrapData(action)
else -> throw IllegalStateException("OneBot $action 返回格式不是对象或数组")
}
return data.mapIndexed { index, element ->
element as? JsonObject
?: throw IllegalStateException("OneBot $action 的 data[$index] 不是对象")
}
}
private fun JsonObject.unwrapData(action: String): JsonArray {
val status = text("status")
val retcode = long("retcode")
if ((status.isNotBlank() && status != "ok") || (retcode != null && retcode != 0L)) {
val detail = text("message", "wording").ifBlank { "status=$status, retcode=${retcode ?: "unknown"}" }
throw IllegalStateException("OneBot $action 调用失败: $detail")
}
return this["data"] as? JsonArray
?: throw IllegalStateException("OneBot $action 返回缺少数组 data")
}
}
internal fun JsonObject.text(vararg keys: String): String =
keys.asSequence()
.mapNotNull { key -> this[key] as? JsonPrimitive }
.mapNotNull(JsonPrimitive::contentOrNull)
.firstOrNull(String::isNotBlank)
.orEmpty()
internal fun JsonObject.int(vararg keys: String): Int? =
keys.asSequence()
.mapNotNull { key -> this[key] as? JsonPrimitive }
.mapNotNull { value -> value.contentOrNull?.toIntOrNull() }
.firstOrNull()
internal fun JsonObject.long(vararg keys: String): Long? =
keys.asSequence()
.mapNotNull { key -> this[key] as? JsonPrimitive }
.mapNotNull { value -> value.contentOrNull?.toLongOrNull() }
.firstOrNull()
@@ -3,6 +3,8 @@ package top.jie65535.mirai.profile
import net.mamoe.mirai.message.data.MessageSourceKind
import org.sqlite.SQLiteConfig
import top.jie65535.mirai.data.ChatMessageRecord
import top.jie65535.mirai.data.ContactProfileHint
import top.jie65535.mirai.data.ContactSnapshotStore
import java.io.File
import java.security.MessageDigest
import java.sql.Connection
@@ -344,6 +346,7 @@ class ProfileHistoryReader(private val databaseFile: File) {
messages = promptMessages,
aliases = aliases,
inputHash = calculateInputHash(promptMessages),
contactHints = loadContactHints(records, participantIds),
)
}
@@ -386,9 +389,64 @@ class ProfileHistoryReader(private val databaseFile: File) {
messages = promptMessages,
aliases = aliases,
inputHash = calculateInputHash(promptMessages),
contactHints = loadContactHints(records.map { it.second }, participantIds),
)
}
private fun loadContactHints(
records: List<ChatMessageRecord>,
participantIds: Set<Long>,
): Map<Long, ContactProfileHint> {
if (records.isEmpty() || participantIds.isEmpty()) return emptyMap()
return records.groupBy(ChatMessageRecord::botId)
.values
.fold(emptyMap()) { accumulated, botRecords ->
val botId = botRecords.first().botId
val groupIds = botRecords.mapTo(hashSetOf(), ChatMessageRecord::targetId)
val hints = runCatching {
ContactSnapshotStore.loadProfileHints(
databaseFile = databaseFile,
botId = botId,
groupIds = groupIds,
userIds = participantIds,
)
}.getOrDefault(emptyMap())
mergeContactHints(accumulated, hints)
}
}
private fun mergeContactHints(
left: Map<Long, ContactProfileHint>,
right: Map<Long, ContactProfileHint>,
): Map<Long, ContactProfileHint> {
if (left.isEmpty()) return right
if (right.isEmpty()) return left
return buildMap {
putAll(left)
right.forEach { (userId, hint) ->
val current = this[userId]
put(
userId,
if (current == null) {
hint
} else {
hint.copy(
nickname = hint.nickname.ifBlank { current.nickname },
remark = hint.remark.ifBlank { current.remark },
sex = hint.sex.ifBlank { current.sex },
age = hint.age.takeIf { it > 0 } ?: current.age,
qLevel = hint.qLevel.takeIf { it > 0 } ?: current.qLevel,
sign = hint.sign.ifBlank { current.sign },
isFriend = hint.isFriend || current.isFriend,
memberships = (current.memberships + hint.memberships)
.distinctBy { it.groupId },
)
},
)
}
}
}
private fun contextDistance(
record: ChatMessageRecord,
targetMessages: List<ChatMessageRecord>,
+99 -34
View File
@@ -1,11 +1,12 @@
package top.jie65535.mirai.profile
import top.jie65535.mirai.data.ContactProfileHint
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
object ProfilePromptStore {
const val PROMPT_VERSION = "profile-v5"
const val PROMPT_VERSION = "profile-v6"
const val COMPACTION_PROMPT_VERSION = "profile-compact-v3"
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
@@ -43,6 +44,7 @@ object ProfilePromptStore {
}
}
appendLine("当前短摘要: ${ProfilePersistentText.summaryForDisplay(profile.summary).ifBlank { "(空)" }}")
appendLine("当前条目数: ${profile.items.size}")
appendLine()
appendLine("## 本批参与者别名")
@@ -50,6 +52,7 @@ object ProfilePromptStore {
appendLine("- $alias")
}
appendLine()
appendContactHints(batch.aliases, batch.contactHints)
appendLine("## 带上下文的原始群聊")
var currentEpisode = -1
@@ -106,8 +109,10 @@ object ProfilePromptStore {
}
val summary = profile?.summary?.let(ProfilePersistentText::summaryForDisplay).orEmpty()
appendLine("当前短摘要: ${summary.ifBlank { "(空)" }}")
appendLine("当前条目数: ${profile?.items?.size ?: 0}")
}
appendLine()
appendContactHints(batch.aliases, batch.contactHints, eligibleUserIds)
appendLine("## 本批保留的闭合群聊消息")
batch.messages.forEach { message ->
@@ -161,6 +166,64 @@ object ProfilePromptStore {
private fun formatTime(epochSecond: Int): String =
dateTimeFormatter.format(Instant.ofEpochSecond(epochSecond.toLong()))
private fun StringBuilder.appendContactHints(
aliases: Map<Long, String>,
hints: Map<Long, ContactProfileHint>,
userIds: Set<Long> = aliases.keys,
) {
val rows = userIds.asSequence()
.mapNotNull { userId -> hints[userId]?.let { userId to it } }
.filter { (_, hint) -> hint.hasRenderableInfo() }
.sortedBy { (userId, _) -> aliases[userId] ?: userId.toString() }
.toList()
if (rows.isEmpty()) return
appendLine("## 联系人快照(辅助识别,不是画像证据)")
appendLine("以下来自好友/群/群成员列表或公开资料卡,只能用于识别昵称、群名片、角色和公开资料;不得仅凭本节新增、确认或删除画像条目。")
rows.forEach { (userId, hint) ->
val alias = aliases[userId] ?: userId.toString()
append("- ").append(alias)
val names = buildList {
if (hint.nickname.isNotBlank()) add("昵称=${hint.nickname.normalized()}")
if (hint.remark.isNotBlank()) add("好友备注=${hint.remark.normalized()}")
if (hint.isFriend) add("好友")
if (hint.sex.isNotBlank() && hint.sex != "unknown") add("性别=${hint.sex}")
if (hint.age > 0) add("年龄=${hint.age}")
if (hint.qLevel > 0) add("QQ等级=${hint.qLevel}")
if (hint.sign.isNotBlank()) add("签名=${hint.sign.normalized().take(80)}")
}
if (names.isNotEmpty()) append(" | ").append(names.joinToString(""))
hint.memberships.take(3).forEach { member ->
val parts = buildList {
if (member.groupName.isNotBlank()) add("群=${member.groupName.normalized()}")
if (member.nameCard.isNotBlank()) add("群名片=${member.nameCard.normalized()}")
if (member.nickname.isNotBlank() && member.nickname != hint.nickname) {
add("群内昵称=${member.nickname.normalized()}")
}
if (member.role.isNotBlank() && member.role != "member") add("角色=${member.role}")
if (member.specialTitle.isNotBlank()) add("头衔=${member.specialTitle.normalized()}")
if (member.area.isNotBlank()) add("地区=${member.area.normalized()}")
if (member.level > 0) add("群等级=${member.level}")
}
if (parts.isNotEmpty()) append(" | ").append(parts.joinToString(""))
}
appendLine()
}
appendLine()
}
private fun ContactProfileHint.hasRenderableInfo(): Boolean =
nickname.isNotBlank() || remark.isNotBlank() || sex.isNotBlank() || age > 0 ||
qLevel > 0 || sign.isNotBlank() || isFriend ||
memberships.any { member ->
member.groupName.isNotBlank() || member.nickname.isNotBlank() ||
member.nameCard.isNotBlank() || member.role.isNotBlank() ||
member.specialTitle.isNotBlank() || member.area.isNotBlank() ||
member.level > 0 || member.qLevel > 0
}
private fun String.normalized(): String = trim().replace(Regex("\\s+"), " ")
private fun ProfileCategory.wireName(): String = name.lowercase()
private fun ProfileConfidence.wireName(): String = name.lowercase()
@@ -181,21 +244,22 @@ object ProfilePromptStore {
严格原则:
1. 当前画像只是可修正状态,不是证据。所有操作必须引用本批 [e:n]。
2. 每个操作至少引用一条 TARGET 自己的发言。其他人的消息只能帮助理解上下文和关系
3. 引用原文的作者不是回复者;不要把被引用者的话归给回复者
4. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断 TARGET 的事实
5. 一次技术回答或同一话题中的连续补充只算一个语境。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须由至少两个独立对话片段中的一致表现支持;单个片段最多用于 CONFIRM 已有条目。本人明确自述的 notable_fact 和 preference 不受此限制
6. 每个条目只表达一个主题。禁止把不同时间、不同领域的内容拼成一个所谓稳定特点
7. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述
8. ADD 不填写 item_refUPDATE、CONFIRM、DELETE 必须填写当前画像中的 P 编号作为 item_ref
9. relationship_note 必须填写本批存在的 related_user_aliascontent 只描述互动方式,不重复人物别名,不推断现实亲疏
10. 新证据与旧画像无关时不要勉强更新。未输出的旧条目由程序自动保留
11. DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定,不能因为本批没提到就删除
12. summary 是应用 operations 并保留所有未操作旧条目之后,对完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述
13. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要
14. summary 必须自然、克制,不写证据编号、QQ 号、内部条目 ID、逐条清单或具体关系流水
15. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号
16. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达
2. 联系人快照只帮助识别人物和称呼,不是画像证据;不得仅凭昵称、群名片、头衔、签名、年龄、等级、地区新增或确认画像
3. 每个操作至少引用一条 TARGET 自己的发言。其他人的消息只能帮助理解上下文和关系
4. 引用原文的作者不是回复者;不要把被引用者的话归给回复者
5. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断 TARGET 的事实
6. 一次技术回答或同一话题中的连续补充只算一个语境。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须由至少两个独立对话片段中的一致表现支持;单个片段最多用于 CONFIRM 已有条目。本人明确自述的 notable_fact 和 preference 不受此限制
7. 每个条目只表达一个主题。禁止把不同时间、不同领域的内容拼成一个所谓稳定特点
8. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述
9. ADD 不填写 item_refUPDATE、CONFIRM、DELETE 必须填写当前画像中的 P 编号作为 item_ref
10. relationship_note 必须填写本批存在的 related_user_aliascontent 只描述互动方式,不重复人物别名,不推断现实亲疏
11. 新证据与旧画像无关时不要勉强更新。未输出的旧条目由程序自动保留
12. DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定,不能因为本批没提到就删除
13. summary 是应用 operations 并保留所有未操作旧条目之后,对完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述
14. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要
15. summary 必须自然、克制,不写证据编号、QQ 号、内部条目 ID、逐条清单或具体关系流水
16. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号
17. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达。
只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式:
{
@@ -230,24 +294,25 @@ object ProfilePromptStore {
严格原则:
1. 当前画像只是可修正状态,不是事实证据。所有操作必须引用本批 [e:n]。
2. 每个用户操作至少引用一条该 user_alias 本人说出的消息。其他人的消息只能帮助理解上下文和关系
3. 引用原文的作者不是回复者;不要把被引用者的话归给回复者
4. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断某人的事实
5. 一次明确自述可以支持 notable_fact 或 preference。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须在本会话中有多条分离的本人证据;证据不足时宁可不写
6. 每个条目只表达一个主题,禁止把不同人的特点或不同领域拼接在一起
7. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述
8. ADD 不填写 item_refUPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 P 编号作为 item_ref,不能引用其他用户的条目
9. relationship_note 必须填写本批存在的 related_user_aliascontent 只描述互动方式,不重复人物别名,不推断现实亲疏
10. 未输出的用户和旧条目由程序自动保留。DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定
11. summary 是应用该用户 operations 并保留所有未操作旧条目之后,对其完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述
12. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要
13. summary 必须自然、克制,不写证据编号、QQ 号、内部 ID、逐条清单或具体关系流水
14. 不生成或修改好感度、代号、主观印象和标签;这些属于另一套 Bot 关系状态
15. 本批只是一段会话。除非当前画像已有同类条目且本批在确认它,否则不得使用“长期、持续、一贯、总是、通常”等跨时间措辞;只能描述本批确实支持的事实、关注点或表现
16. 对尚无同类条目的用户,thinking_style、expression_style、social_mode、expertise_signal 和 relationship_note 必须有多个彼此分离的本人证据才可新增,并保持 low 或 medium 可信度;同一问答链中的连续补充不算多次独立表现。
17. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号
18. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达
19. users 只能输出“候选用户别名”中明确列出的用户;消息里出现但不在候选名单中的上下文用户不要输出
2. 联系人快照只帮助识别人物和称呼,不是画像证据;不得仅凭昵称、群名片、头衔、签名、年龄、等级、地区新增或确认画像
3. 每个用户操作至少引用一条该 user_alias 本人说出的消息。其他人的消息只能帮助理解上下文和关系
4. 引用原文的作者不是回复者;不要把被引用者的话归给回复者
5. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断某人的事实
6. 一次明确自述可以支持 notable_fact 或 preference。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须在本会话中有多条分离的本人证据;证据不足时宁可不写
7. 每个条目只表达一个主题,禁止把不同人的特点或不同领域拼接在一起
8. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述
9. ADD 不填写 item_refUPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 P 编号作为 item_ref,不能引用其他用户的条目
10. relationship_note 必须填写本批存在的 related_user_aliascontent 只描述互动方式,不重复人物别名,不推断现实亲疏
11. 未输出的用户和旧条目由程序自动保留。DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定
12. summary 是应用该用户 operations 并保留所有未操作旧条目之后,对其完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述
13. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要
14. summary 必须自然、克制,不写证据编号、QQ 号、内部 ID、逐条清单或具体关系流水
15. 不生成或修改好感度、代号、主观印象和标签;这些属于另一套 Bot 关系状态
16. 本批只是一段会话。除非当前画像已有同类条目且本批在确认它,否则不得使用“长期、持续、一贯、总是、通常”等跨时间措辞;只能描述本批确实支持的事实、关注点或表现。
17. 对尚无同类旧条目的用户,thinking_style、expression_style、social_mode、expertise_signal 和 relationship_note 必须有多个彼此分离的本人证据才可新增,并保持 low 或 medium 可信度;同一问答链中的连续补充不算多次独立表现
18. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号
19. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达
20. users 只能输出“候选用户别名”中明确列出的用户;消息里出现但不在候选名单中的上下文用户不要输出。
只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式:
{
@@ -9,10 +9,12 @@ object UserProfileContextRenderer {
displayNames: Map<Long, String>,
activeUserIds: Set<Long>,
summaryMaxChars: Int,
sectionTitle: String = "你对相关群友的认识",
): String {
val profilesByUserId = profiles
.filter { profile ->
profile.reliable && ProfilePersistentText.summaryForDisplay(profile.summary).isNotBlank()
profile.reliable &&
(ProfilePersistentText.summaryForDisplay(profile.summary).isNotBlank() || profile.items.isNotEmpty())
}
.associateBy { it.userId }
val userIds = activeUserIds.filter { userId ->
@@ -22,7 +24,7 @@ object UserProfileContextRenderer {
val maxChars = summaryMaxChars.coerceAtLeast(50)
return buildString {
appendLine("## 你对相关群友的认识")
append("## ").appendLine(sectionTitle)
appendLine("好感度、代号和主观印象代表你的关系状态;画像认识来自可修正的历史归纳。仅在当前话题相关时自然运用,不要逐条复述或提及信息来源。")
userIds.forEach { userId ->
val profile = profilesByUserId[userId]
@@ -38,8 +40,11 @@ object UserProfileContextRenderer {
}
profile?.summary?.let(ProfilePersistentText::summaryForDisplay)
?.takeIf(String::isNotBlank)?.let { summary ->
append(" | 画像认识")
append(" | 画像认识")
append("").append(profile.items.size).append("条):")
.append(summary.normalized().take(maxChars))
} ?: profile?.takeIf { it.items.isNotEmpty() }?.let {
append(" | 画像认识:已有").append(it.items.size).append("条记录,可用 queryUserProfile 查询详情")
}
profile?.items?.asSequence()
@@ -3,6 +3,7 @@ package top.jie65535.mirai.profile
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import top.jie65535.mirai.data.ChatMessageRecord
import top.jie65535.mirai.data.ContactProfileHint
@Serializable
enum class ProfileCategory {
@@ -115,6 +116,7 @@ data class ProfileHistoryBatch(
val messages: List<ProfilePromptMessage>,
val aliases: Map<Long, String>,
val inputHash: String,
val contactHints: Map<Long, ContactProfileHint> = emptyMap(),
) {
val evidenceByRef: Map<Int, ProfilePromptMessage> = messages
.mapNotNull { message -> message.evidenceRef?.let { it to message } }
@@ -168,6 +170,7 @@ data class ConversationProfileBatch(
val messages: List<ProfilePromptMessage>,
val aliases: Map<Long, String>,
val inputHash: String,
val contactHints: Map<Long, ContactProfileHint> = emptyMap(),
) {
val evidenceByRef: Map<Int, ProfilePromptMessage> = messages
.mapNotNull { message -> message.evidenceRef?.let { it to message } }
@@ -187,6 +190,7 @@ data class ConversationProfileBatch(
messages = messages,
aliases = aliases,
inputHash = inputHash,
contactHints = contactHints,
)
}
@@ -0,0 +1,254 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
import net.mamoe.mirai.contact.User
import net.mamoe.mirai.contact.nameCardOrNick
import net.mamoe.mirai.data.UserProfile
import net.mamoe.mirai.event.events.GroupMessageEvent
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.config.PluginConfig
import top.jie65535.mirai.data.ContactSnapshotStore
import top.jie65535.mirai.data.PluginData
import top.jie65535.mirai.profile.ProfileCategory
import top.jie65535.mirai.profile.ProfilePersistentText
import top.jie65535.mirai.profile.UserProfileItem
import top.jie65535.mirai.profile.UserProfileSnapshot
import top.jie65535.mirai.profile.UserProfileStore
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
class QueryUserProfileAgent : BaseAgent(
tool = Tool.function(
name = "queryUserProfile",
description = "查询你已归纳的用户画像,并按需读取该联系人的公开资料卡。用于了解某个群友/好友的长期信息、兴趣、表达方式、关系备注。默认查询当前对话发送者;在群聊中也可用QQ号、群名片或昵称查询别人。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("userId") {
put("type", "integer")
put("description", "要查询的用户QQ号;留空时查询当前发送者")
}
putJsonObject("name") {
put("type", "string")
put("description", "群名片、昵称或好友备注关键词;仅在未填写userId时用于群聊中匹配用户")
}
putJsonObject("includeItems") {
put("type", "boolean")
put("description", "是否返回画像条目明细,默认true")
}
putJsonObject("limit") {
put("type", "integer")
put("description", "最多返回多少条画像条目,默认20,最大50")
}
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.profileEnabled && UserProfileStore.isAvailable
override val loadingMessage: String
get() = "查询用户画像中..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
val userId = resolveUserId(args, event)
?: return "未找到唯一匹配用户。请提供 userId,或使用更明确的群名片、昵称、好友备注。"
val profile = runCatching { UserProfileStore.load(userId) }
.getOrElse { cause -> return "读取用户 $userId 画像失败:${cause.message ?: cause::class.simpleName}" }
val publicProfile = loadPublicProfile(userId, event)
if (profile == null && publicProfile == null) return "用户 $userId 尚无画像,当前联系人也没有可读取的公开资料卡。"
val includeItems = args?.get("includeItems")?.jsonPrimitive?.booleanOrNull ?: true
val limit = args?.get("limit")?.jsonPrimitive?.intOrNull?.coerceIn(1, 50) ?: 20
val displayName = resolveDisplayName(userId, event)
return formatProfile(userId, profile, publicProfile, displayName, includeItems, limit)
}
private fun resolveUserId(args: JsonObject?, event: MessageEvent): Long? {
args?.get("userId")?.jsonPrimitive?.longOrNull?.takeIf { it > 0 }?.let { return it }
val name = args?.get("name")?.jsonPrimitive?.contentOrNull?.trim().orEmpty()
if (name.isBlank()) return event.sender.id
name.toLongOrNull()?.takeIf { it > 0 }?.let { return it }
if (event is GroupMessageEvent) {
selectUniqueNameMatch(
event.group.members.map { member -> member.id to listOf(member.nameCardOrNick, member.nick) },
name,
)?.let { return it }
}
val groupId = (event as? GroupMessageEvent)?.group?.id
val snapshotMatches = ContactSnapshotStore.findUsersByName(event.bot.id, groupId, name, limit = 10)
val bestRank = snapshotMatches.firstOrNull()?.matchRank ?: return null
return snapshotMatches.asSequence()
.takeWhile { match -> match.matchRank == bestRank }
.map { match -> match.userId }
.distinct()
.singleOrNull()
}
private fun resolveDisplayName(userId: Long, event: MessageEvent): String {
val favorabilityName = PluginData.userFavorability[userId]?.name.orEmpty()
if (favorabilityName.isNotBlank()) return favorabilityName
if (event is GroupMessageEvent) {
event.group[userId]?.nameCardOrNick?.takeIf(String::isNotBlank)?.let { return it }
}
ContactSnapshotStore.loadDisplayName(
event.bot.id,
(event as? GroupMessageEvent)?.group?.id,
userId,
)?.let { return it }
return userId.toString()
}
private fun selectUniqueNameMatch(
candidates: Collection<Pair<Long, List<String>>>,
query: String,
): Long? {
val ranked = candidates.mapNotNull { (userId, names) ->
val rank = names.asSequence()
.filter(String::isNotBlank)
.map { name ->
when {
name.equals(query, ignoreCase = true) -> 0
name.startsWith(query, ignoreCase = true) -> 1
name.contains(query, ignoreCase = true) -> 2
else -> Int.MAX_VALUE
}
}
.minOrNull()
?.takeIf { it < Int.MAX_VALUE }
?: return@mapNotNull null
userId to rank
}
val bestRank = ranked.minOfOrNull(Pair<Long, Int>::second) ?: return null
return ranked.asSequence()
.filter { (_, rank) -> rank == bestRank }
.map(Pair<Long, Int>::first)
.distinct()
.singleOrNull()
}
private fun formatProfile(
userId: Long,
profile: UserProfileSnapshot?,
publicProfile: UserProfile?,
displayName: String,
includeItems: Boolean,
limit: Int,
): String = buildString {
appendLine("用户画像:$displayName($userId)")
if (profile != null) {
appendLine("版本:v${profile.version};可靠:${if (profile.reliable) "是" else "否"};条目数:${profile.items.size}")
}
if (profile != null && profile.cursorTime > 0) {
appendLine("历史回顾覆盖至:${formatTime(profile.cursorTime)}")
} else {
appendLine("历史回顾:尚未开始或来自自动会话归纳")
}
appendLine("摘要:${profile?.summary?.let(ProfilePersistentText::summaryForDisplay).orEmpty().ifBlank { "(暂无)" }}")
publicProfile?.let { appendPublicProfile(it) }
if (includeItems && profile?.items?.isNotEmpty() == true) {
appendLine("条目:")
profile.items
.sortedWith(compareBy<UserProfileItem>({ it.category.ordinal }, { it.firstSeenAt }, { it.id }))
.take(limit)
.forEach { item ->
append("- ")
append(item.category.label()).append('/').append(item.confidence.name.lowercase())
append(" · ").append(formatDate(item.firstSeenAt))
if (item.lastConfirmedAt != item.firstSeenAt) {
append("~").append(formatDate(item.lastConfirmedAt))
}
item.relatedUserId?.let { append(" · related=").append(it) }
append("")
appendLine(ProfilePersistentText.itemForDisplay(
item.content,
relationship = item.category == ProfileCategory.RELATIONSHIP_NOTE,
))
}
if (profile.items.size > limit) {
appendLine("其余 ${profile.items.size - limit} 条已省略,可提高 limit 继续查询。")
}
}
}.trim()
private suspend fun loadPublicProfile(userId: Long, event: MessageEvent): UserProfile? {
val key = "${event.bot.id}:$userId"
val now = System.currentTimeMillis()
PUBLIC_PROFILE_CACHE[key]?.takeIf { it.expiresAt > now }?.let { return it.profile }
val user = resolveContact(userId, event) ?: return null
val profile = withTimeoutOrNull(PUBLIC_PROFILE_TIMEOUT_MS) {
try {
user.queryProfile()
} catch (cause: CancellationException) {
throw cause
} catch (cause: Throwable) {
JChatGPT.logger.debug(
"按需读取公开资料卡失败: bot=${event.bot.id}, user=$userId, cause=${cause.message}"
)
null
}
}
PUBLIC_PROFILE_CACHE[key] = CachedPublicProfile(
profile = profile,
expiresAt = now + PUBLIC_PROFILE_CACHE_MS,
)
return profile
}
private fun resolveContact(userId: Long, event: MessageEvent): User? {
if (event.sender.id == userId) return event.sender
if (event is GroupMessageEvent) event.group[userId]?.let { return it }
return event.bot.friends[userId]
}
private fun StringBuilder.appendPublicProfile(profile: UserProfile) {
val fields = buildList {
profile.sex.toString().takeUnless { it.equals("unknown", ignoreCase = true) }?.let { add("性别=$it") }
profile.age.takeIf { it > 0 }?.let { add("年龄=$it") }
profile.qLevel.takeIf { it > 0 }?.let { add("QQ等级=$it") }
profile.email.takeIf(String::isNotBlank)?.let { add("邮箱=$it") }
profile.sign.takeIf(String::isNotBlank)?.let { add("签名=${it.normalized().take(120)}") }
}
if (fields.isNotEmpty()) appendLine("公开资料卡:${fields.joinToString("")}")
}
private fun ProfileCategory.label(): String = name.lowercase()
private fun formatTime(epochSecond: Int): String =
TIME_FORMATTER.format(Instant.ofEpochSecond(epochSecond.toLong()))
private fun formatDate(epochSecond: Int): String =
DATE_FORMATTER.format(Instant.ofEpochSecond(epochSecond.toLong()))
companion object {
private const val PUBLIC_PROFILE_TIMEOUT_MS = 5_000L
private const val PUBLIC_PROFILE_CACHE_MS = 10 * 60_000L
private val PUBLIC_PROFILE_CACHE = ConcurrentHashMap<String, CachedPublicProfile>()
private val TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd")
.withZone(ZoneId.systemDefault())
}
private data class CachedPublicProfile(
val profile: UserProfile?,
val expiresAt: Long,
)
private fun String.normalized(): String = trim().replace(Regex("\\s+"), " ")
}
@@ -0,0 +1,107 @@
package top.jie65535.mirai.data
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ContactSnapshotStoreTest {
@Test
fun storesAndLoadsContactProfileHintsFromHistoryDatabase() {
val directory = Files.createTempDirectory("jchatgpt-contact-snapshot-test-")
try {
ContactSnapshotStore.init(directory.toFile())
ContactSnapshotStore.save(
ContactSnapshotBatch(
botId = 1,
capturedAt = 1_000,
users = listOf(
ContactUserSnapshot(
botId = 1,
userId = 100,
nickname = "昵称",
remark = "好友备注",
sex = "female",
age = 18,
qLevel = 80,
sign = "公开签名",
updatedAt = 1_000,
)
),
friends = listOf(100),
groups = listOf(
ContactGroupSnapshot(
botId = 1,
groupId = 300,
name = "测试群",
memberCount = 2,
maxMemberCount = 500,
updatedAt = 1_000,
)
),
members = listOf(
ContactGroupMemberSnapshot(
botId = 1,
groupId = 300,
userId = 100,
nickname = "群内昵称",
nameCard = "群名片",
role = "admin",
specialTitle = "头衔",
area = "上海",
level = 12,
qLevel = 80,
joinTime = 900,
lastSpeakTime = 990,
updatedAt = 1_000,
)
),
completeFriendList = true,
completeGroupList = true,
completeMemberGroupIds = setOf(300),
)
)
val database = directory.resolve("chat-history.sqlite").toFile()
val hint = ContactSnapshotStore.loadProfileHints(
databaseFile = database,
botId = 1,
groupIds = listOf(300),
userIds = listOf(100),
).getValue(100)
assertEquals("昵称", hint.nickname)
assertEquals("好友备注", hint.remark)
assertTrue(hint.isFriend)
assertEquals("群名片", hint.displayName)
assertEquals("测试群", hint.memberships.single().groupName)
assertEquals("admin", hint.memberships.single().role)
assertEquals(
100L,
ContactSnapshotStore.findUsersByName(1, 300, "群名").single().userId,
)
ContactSnapshotStore.save(
ContactSnapshotBatch(
botId = 1,
capturedAt = 2_000,
completeFriendList = true,
completeGroupList = true,
)
)
val staleRelationsRemoved = ContactSnapshotStore.loadProfileHints(
databaseFile = database,
botId = 1,
groupIds = listOf(300),
userIds = listOf(100),
).getValue(100)
assertFalse(staleRelationsRemoved.isFriend)
assertTrue(staleRelationsRemoved.memberships.isEmpty())
assertTrue(ContactSnapshotStore.findUsersByName(1, 300, "群名").isEmpty())
} finally {
ContactSnapshotStore.close()
directory.toFile().deleteRecursively()
}
}
}
@@ -0,0 +1,42 @@
package top.jie65535.mirai.data
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class OneBotContactPayloadParserTest {
@Test
fun parsesSuccessfulOneBotResponse() {
val rows = OneBotContactPayloadParser.parseObjectList(
"get_friend_list",
"""{"status":"ok","retcode":0,"data":[{"user_id":100,"nickname":"Alice"}]}""",
)
assertEquals(1, rows.size)
assertEquals(100L, rows.single().long("user_id"))
assertEquals("Alice", rows.single().text("nickname"))
}
@Test
fun acceptsNumericFieldsEncodedAsStrings() {
val rows = OneBotContactPayloadParser.parseObjectList(
"get_group_member_list",
"""[{"user_id":"100","level":"12"}]""",
)
assertEquals(100L, rows.single().long("user_id"))
assertEquals(12, rows.single().int("level"))
}
@Test
fun rejectsFailedOneBotResponseWithoutLeakingPayload() {
val failure = assertFailsWith<IllegalStateException> {
OneBotContactPayloadParser.parseObjectList(
"get_group_list",
"""{"status":"failed","retcode":1404,"message":"unsupported","data":null}""",
)
}
assertEquals("OneBot get_group_list 调用失败: unsupported", failure.message)
}
}
@@ -38,7 +38,7 @@ class UserProfileContextRendererTest {
assertContains(rendered, "小明代号(100)")
assertContains(rendered, "好感度+12")
assertContains(rendered, "画像认识:长期关注 Kotlin 开发")
assertContains(rendered, "画像认识2条):长期关注 Kotlin 开发")
assertContains(rendered, "与小王:经常与对方讨论技术方案")
assertFalse(rendered.contains("U12"))
assertFalse(rendered.contains("小李"))
@@ -2,6 +2,8 @@ package top.jie65535.mirai.profile
import net.mamoe.mirai.message.data.MessageSourceKind
import top.jie65535.mirai.data.ChatMessageRecord
import top.jie65535.mirai.data.ContactGroupMemberHint
import top.jie65535.mirai.data.ContactProfileHint
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -154,6 +156,36 @@ class UserProfileReducerTest {
assertFalse(prompt.contains(item.id))
}
@Test
fun rendersContactSnapshotAsNonEvidenceAndIncludesItemCount() {
val profile = emptyProfile().copy(items = listOf(existingItem()))
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "我平时会写 Kotlin")).copy(
contactHints = mapOf(
TARGET to ContactProfileHint(
userId = TARGET,
nickname = "公开昵称",
sign = "公开签名",
memberships = listOf(
ContactGroupMemberHint(
groupId = 10,
groupName = "测试群",
nameCard = "群名片",
role = "admin",
)
),
)
),
)
val prompt = ProfilePromptStore.buildUserPrompt(profile, batch)
assertTrue(prompt.contains("当前条目数: 1"))
assertTrue(prompt.contains("联系人快照(辅助识别,不是画像证据)"))
assertTrue(prompt.contains("昵称=公开昵称"))
assertTrue(prompt.contains("群名片=群名片"))
assertTrue(ProfilePromptStore.systemPrompt.contains("不得仅凭昵称、群名片"))
}
@Test
fun keepsComprehensiveSummaryWhenBatchOnlyConfirmsOneItem() {
val interest = existingItem()