profile: add Overflow contact snapshots

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