diff --git a/README.md b/README.md index 81a399b..76e40bb 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,21 @@ chatFallbackModelAliases: [] 修改配置后执行 `/jgpt reload`。其他模型、工具和实验功能按需配置,缺少依赖或凭据的可选工具不会启用。 +### 群聊触发限制 + +默认开启 `requireOwnerInGroup`:只有配置的主人也在群内,JChatGPT 才允许触发群聊对话。请在 `Config.yml` 中填写主人的 QQ: + +```yaml +ownerId: 123456789 # 替换为你的 QQ 号 +requireOwnerInGroup: true +``` + +此限制优先于聊天权限,覆盖 `@Bot`、引用回复、关键词以及连续会话触发。主人未配置、QQ 号无效、不在群内或成员查询失败时,均静默忽略,不发送拒绝提示。检查使用 Overflow 提供的当前群成员信息,不使用历史联系人快照;成员变动的生效时间取决于 Overflow 的同步。 + +私聊仍按原有逻辑处理,聊天记录继续保存,画像相关配置、管理命令和其他插件不受此开关影响。开关限制新的消息触发,不会强制取消已经开始的模型请求或工具调用。设为 `false` 可恢复原有群聊触发行为,修改后执行 `/jgpt reload`。 + +升级后此开关也默认开启;未配置 `ownerId` 时,所有群聊对话触发都会被忽略。 + ## 使用 - 群聊中 `@Bot`,或回复 Bot 的消息 diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index 89c3e67..57e357d 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -32,6 +32,7 @@ import top.jie65535.mirai.config.ModelConfig import top.jie65535.mirai.config.ModelConfigMigration import top.jie65535.mirai.conversation.ConversationContext import top.jie65535.mirai.conversation.ConversationEngine +import top.jie65535.mirai.conversation.allowsGroupChatTrigger import top.jie65535.mirai.data.ChatHistoryStore import top.jie65535.mirai.data.ChatMessageRecord import top.jie65535.mirai.data.ContactSnapshotRefresher @@ -175,6 +176,18 @@ object JChatGPT : KotlinPlugin( private suspend fun onMessage(event: MessageEvent) { if (LargeLanguageModels.chat == null) return + if (event is GroupMessageEvent && !allowsGroupChatTrigger( + requireOwnerInGroup = PluginConfig.requireOwnerInGroup, + ownerId = PluginConfig.ownerId, + isMember = { ownerId -> event.group[ownerId] != null }, + onFailure = { cause -> + logger.warning( + "检查主人是否在群内失败,忽略群聊触发:bot=${event.bot.id}, group=${event.group.id}", + cause, + ) + }, + )) return + if (ConversationEngine.isExpectedUser(event)) { if (shouldIgnoreBecauseMuted(event)) return if (ConversationEngine.resumeObserved(event)) return diff --git a/src/main/kotlin/config/PluginConfig.kt b/src/main/kotlin/config/PluginConfig.kt index caf6fbd..99119d3 100644 --- a/src/main/kotlin/config/PluginConfig.kt +++ b/src/main/kotlin/config/PluginConfig.kt @@ -21,6 +21,9 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("主人QQ,AI可以通过工具向主人发起请求,会等待一段时间") val ownerId: Long by value() + @ValueDescription("是否仅在主人(ownerId)也在的群内允许聊天触发,默认开启;主人未配置、不在群内或无法确认时静默忽略。仅限制群聊,不影响私聊和聊天记录保存") + val requireOwnerInGroup: Boolean by value(true) + @ValueDescription("OpenAI API base url") val openAiApi: String by value("https://dashscope.aliyuncs.com/compatible-mode/v1/") diff --git a/src/main/kotlin/conversation/GroupChatTriggerPolicy.kt b/src/main/kotlin/conversation/GroupChatTriggerPolicy.kt new file mode 100644 index 0000000..c501840 --- /dev/null +++ b/src/main/kotlin/conversation/GroupChatTriggerPolicy.kt @@ -0,0 +1,21 @@ +package top.jie65535.mirai.conversation + +import kotlinx.coroutines.CancellationException + +internal fun allowsGroupChatTrigger( + requireOwnerInGroup: Boolean, + ownerId: Long, + isMember: (Long) -> Boolean, + onFailure: (Exception) -> Unit, +): Boolean { + if (!requireOwnerInGroup) return true + if (ownerId <= 0) return false + return try { + isMember(ownerId) + } catch (cause: CancellationException) { + throw cause + } catch (cause: Exception) { + onFailure(cause) + false + } +} diff --git a/src/test/kotlin/conversation/GroupChatTriggerPolicyTest.kt b/src/test/kotlin/conversation/GroupChatTriggerPolicyTest.kt new file mode 100644 index 0000000..a2b8987 --- /dev/null +++ b/src/test/kotlin/conversation/GroupChatTriggerPolicyTest.kt @@ -0,0 +1,53 @@ +package top.jie65535.mirai.conversation + +import kotlinx.coroutines.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class GroupChatTriggerPolicyTest { + @Test + fun disabledRestrictionDoesNotLookUpOwner() { + assertTrue(allowsGroupChatTrigger(false, 0, { error("Unexpected lookup") }, { throw it })) + } + + @Test + fun missingOrInvalidOwnerBlocksWithoutLookup() { + for (ownerId in listOf(0L, -1L)) { + assertFalse(allowsGroupChatTrigger(true, ownerId, { error("Unexpected lookup") }, { throw it })) + } + } + + @Test + fun configuredOwnerMustBePresentAndMembershipIsCheckedAgainOnEachTrigger() { + val members = mutableSetOf(123L, 456L) + val isMember: (Long) -> Boolean = { ownerId -> + assertEquals(123L, ownerId) + ownerId in members + } + assertTrue(allowsGroupChatTrigger(true, 123L, isMember, { throw it })) + members.remove(123L) + assertFalse(allowsGroupChatTrigger(true, 123L, isMember, { throw it })) + members.add(123L) + assertTrue(allowsGroupChatTrigger(true, 123L, isMember, { throw it })) + } + + @Test + fun unavailableMemberLookupBlocksAndReportsFailure() { + val failure = UnsupportedOperationException("Member lookup unavailable") + var reported: Exception? = null + assertFalse(allowsGroupChatTrigger(true, 123L, { throw failure }, { reported = it })) + assertSame(failure, reported) + } + + @Test + fun cancellationPropagatesWithoutReportingLookupFailure() { + val cancellation = CancellationException("Cancelled") + assertSame(cancellation, assertFailsWith { + allowsGroupChatTrigger(true, 123L, { throw cancellation }, { error("Unexpected failure report") }) + }) + } +}