mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
profile: add Overflow contact snapshots
This commit is contained in:
@@ -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(根据需要启用的功能配置)
|
||||
|
||||
## 备注
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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_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 代码围栏,不要解释。格式:
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user