From 2a5e7fd2f9d25ddd47d31db71eae524de29268fd Mon Sep 17 00:00:00 2001 From: jie65535 Date: Mon, 3 Aug 2026 10:39:56 +0800 Subject: [PATCH] profile: add Overflow contact snapshots --- README.md | 37 +- build.gradle.kts | 1 + src/main/kotlin/JChatGPT.kt | 10 + src/main/kotlin/config/PluginConfig.kt | 12 + .../conversation/ConversationContext.kt | 49 +- .../kotlin/conversation/ConversationEngine.kt | 2 + .../kotlin/data/ContactSnapshotRefresher.kt | 320 ++++++++ src/main/kotlin/data/ContactSnapshotStore.kt | 723 ++++++++++++++++++ .../kotlin/data/OneBotContactPayloadParser.kt | 55 ++ .../kotlin/profile/ProfileHistoryReader.kt | 58 ++ src/main/kotlin/profile/ProfilePromptStore.kt | 133 +++- .../profile/UserProfileContextRenderer.kt | 11 +- src/main/kotlin/profile/UserProfileModels.kt | 4 + .../kotlin/tools/QueryUserProfileAgent.kt | 254 ++++++ .../kotlin/data/ContactSnapshotStoreTest.kt | 107 +++ .../data/OneBotContactPayloadParserTest.kt | 42 + .../profile/UserProfileContextRendererTest.kt | 2 +- .../kotlin/profile/UserProfileReducerTest.kt | 32 + 18 files changed, 1794 insertions(+), 58 deletions(-) create mode 100644 src/main/kotlin/data/ContactSnapshotRefresher.kt create mode 100644 src/main/kotlin/data/ContactSnapshotStore.kt create mode 100644 src/main/kotlin/data/OneBotContactPayloadParser.kt create mode 100644 src/main/kotlin/tools/QueryUserProfileAgent.kt create mode 100644 src/test/kotlin/data/ContactSnapshotStoreTest.kt create mode 100644 src/test/kotlin/data/OneBotContactPayloadParserTest.kt diff --git a/README.md b/README.md index 36f1192..456263d 100644 --- a/README.md +++ b/README.md @@ -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(根据需要启用的功能配置) ## 备注 diff --git a/build.gradle.kts b/build.gradle.kts index a3b4f04..5e8e066 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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")) diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index 54c7094..1cc4c7c 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -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(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 { 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() } diff --git a/src/main/kotlin/config/PluginConfig.kt b/src/main/kotlin/config/PluginConfig.kt index 3966146..a169687 100644 --- a/src/main/kotlin/config/PluginConfig.kt +++ b/src/main/kotlin/config/PluginConfig.kt @@ -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) diff --git a/src/main/kotlin/conversation/ConversationContext.kt b/src/main/kotlin/conversation/ConversationContext.kt index 9462309..7ba2b11 100644 --- a/src/main/kotlin/conversation/ConversationContext.kt +++ b/src/main/kotlin/conversation/ConversationContext.kt @@ -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( diff --git a/src/main/kotlin/conversation/ConversationEngine.kt b/src/main/kotlin/conversation/ConversationEngine.kt index f394d20..711b7d5 100644 --- a/src/main/kotlin/conversation/ConversationEngine.kt +++ b/src/main/kotlin/conversation/ConversationEngine.kt @@ -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(), diff --git a/src/main/kotlin/data/ContactSnapshotRefresher.kt b/src/main/kotlin/data/ContactSnapshotRefresher.kt new file mode 100644 index 0000000..4323e33 --- /dev/null +++ b/src/main/kotlin/data/ContactSnapshotRefresher.kt @@ -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() + + 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() + val friendIds = mutableListOf() + val groups = mutableListOf() + val members = mutableListOf() + val completeMemberGroupIds = mutableSetOf() + + 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() + val friendIds = mutableListOf() + val groups = mutableListOf() + val members = mutableListOf() + val completeMemberGroupIds = mutableSetOf() + + 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 { + 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.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())) + } +} diff --git a/src/main/kotlin/data/ContactSnapshotStore.kt b/src/main/kotlin/data/ContactSnapshotStore.kt new file mode 100644 index 0000000..90c4843 --- /dev/null +++ b/src/main/kotlin/data/ContactSnapshotStore.kt @@ -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 = emptyList(), + val friends: List = emptyList(), + val groups: List = emptyList(), + val members: List = emptyList(), + val completeFriendList: Boolean = false, + val completeGroupList: Boolean = false, + val completeMemberGroupIds: Set = 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 = 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, + userIds: Collection, + ): Map { + 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, + userIds: Collection, + ): Map { + if (!initialized) return emptyMap() + return loadProfileHints(databaseFile, botId, groupIds, userIds) + } + + fun loadDisplayNames( + botId: Long, + groupId: Long?, + userIds: Collection, + ): Map { + 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 { + 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({ it.matchRank }, { it.userId })) + .distinctBy(ContactNameMatch::userId) + .take(limit) + } + } + } + } + + private fun upsertUsers(connection: Connection, users: List) { + 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, 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) { + 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) { + 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, + ): Map { + 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, + ): Set { + 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, + userIds: Set, + ): Map> { + 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> { + 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 + } +} diff --git a/src/main/kotlin/data/OneBotContactPayloadParser.kt b/src/main/kotlin/data/OneBotContactPayloadParser.kt new file mode 100644 index 0000000..4c00120 --- /dev/null +++ b/src/main/kotlin/data/OneBotContactPayloadParser.kt @@ -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 { + 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() diff --git a/src/main/kotlin/profile/ProfileHistoryReader.kt b/src/main/kotlin/profile/ProfileHistoryReader.kt index dc6eeff..81f25a4 100644 --- a/src/main/kotlin/profile/ProfileHistoryReader.kt +++ b/src/main/kotlin/profile/ProfileHistoryReader.kt @@ -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, + participantIds: Set, + ): Map { + 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, + right: Map, + ): Map { + 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, diff --git a/src/main/kotlin/profile/ProfilePromptStore.kt b/src/main/kotlin/profile/ProfilePromptStore.kt index ab03567..56625c3 100644 --- a/src/main/kotlin/profile/ProfilePromptStore.kt +++ b/src/main/kotlin/profile/ProfilePromptStore.kt @@ -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, + hints: Map, + userIds: Set = 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_ref;UPDATE、CONFIRM、DELETE 必须填写当前画像中的 P 编号作为 item_ref。 -9. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。 -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_ref;UPDATE、CONFIRM、DELETE 必须填写当前画像中的 P 编号作为 item_ref。 +10. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。 +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_ref;UPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 P 编号作为 item_ref,不能引用其他用户的条目。 -9. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。 -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_ref;UPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 P 编号作为 item_ref,不能引用其他用户的条目。 +10. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。 +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 代码围栏,不要解释。格式: { diff --git a/src/main/kotlin/profile/UserProfileContextRenderer.kt b/src/main/kotlin/profile/UserProfileContextRenderer.kt index 205ea1f..6ccab61 100644 --- a/src/main/kotlin/profile/UserProfileContextRenderer.kt +++ b/src/main/kotlin/profile/UserProfileContextRenderer.kt @@ -9,10 +9,12 @@ object UserProfileContextRenderer { displayNames: Map, activeUserIds: Set, 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() diff --git a/src/main/kotlin/profile/UserProfileModels.kt b/src/main/kotlin/profile/UserProfileModels.kt index 29c5ba2..7a0bdaa 100644 --- a/src/main/kotlin/profile/UserProfileModels.kt +++ b/src/main/kotlin/profile/UserProfileModels.kt @@ -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, val aliases: Map, val inputHash: String, + val contactHints: Map = emptyMap(), ) { val evidenceByRef: Map = messages .mapNotNull { message -> message.evidenceRef?.let { it to message } } @@ -168,6 +170,7 @@ data class ConversationProfileBatch( val messages: List, val aliases: Map, val inputHash: String, + val contactHints: Map = emptyMap(), ) { val evidenceByRef: Map = messages .mapNotNull { message -> message.evidenceRef?.let { it to message } } @@ -187,6 +190,7 @@ data class ConversationProfileBatch( messages = messages, aliases = aliases, inputHash = inputHash, + contactHints = contactHints, ) } diff --git a/src/main/kotlin/tools/QueryUserProfileAgent.kt b/src/main/kotlin/tools/QueryUserProfileAgent.kt new file mode 100644 index 0000000..3950521 --- /dev/null +++ b/src/main/kotlin/tools/QueryUserProfileAgent.kt @@ -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>>, + 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::second) ?: return null + return ranked.asSequence() + .filter { (_, rank) -> rank == bestRank } + .map(Pair::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({ 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() + 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+"), " ") +} diff --git a/src/test/kotlin/data/ContactSnapshotStoreTest.kt b/src/test/kotlin/data/ContactSnapshotStoreTest.kt new file mode 100644 index 0000000..5d4e0b7 --- /dev/null +++ b/src/test/kotlin/data/ContactSnapshotStoreTest.kt @@ -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() + } + } +} diff --git a/src/test/kotlin/data/OneBotContactPayloadParserTest.kt b/src/test/kotlin/data/OneBotContactPayloadParserTest.kt new file mode 100644 index 0000000..798583e --- /dev/null +++ b/src/test/kotlin/data/OneBotContactPayloadParserTest.kt @@ -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 { + OneBotContactPayloadParser.parseObjectList( + "get_group_list", + """{"status":"failed","retcode":1404,"message":"unsupported","data":null}""", + ) + } + + assertEquals("OneBot get_group_list 调用失败: unsupported", failure.message) + } +} diff --git a/src/test/kotlin/profile/UserProfileContextRendererTest.kt b/src/test/kotlin/profile/UserProfileContextRendererTest.kt index 1147272..669a45e 100644 --- a/src/test/kotlin/profile/UserProfileContextRendererTest.kt +++ b/src/test/kotlin/profile/UserProfileContextRendererTest.kt @@ -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("小李")) diff --git a/src/test/kotlin/profile/UserProfileReducerTest.kt b/src/test/kotlin/profile/UserProfileReducerTest.kt index a599ca7..c382702 100644 --- a/src/test/kotlin/profile/UserProfileReducerTest.kt +++ b/src/test/kotlin/profile/UserProfileReducerTest.kt @@ -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()