Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3f78158
text and reason mismatch
rurudo-ruo May 19, 2026
57cd638
fix: include context in notification for sensitive messages
rurudo-ruo May 19, 2026
070be91
feat: add configuration options for message context in sensitive monitor
rurudo-ruo May 24, 2026
59e7fc4
feat: improve context fetching for LLM judgment and enhance command v…
rurudo-ruo May 26, 2026
1b3a2be
fix: always provide context to LLM judgment, gate only notification d…
Windsland52 May 26, 2026
9ae77b2
fix: extract clean text from structured messages instead of raw CQ codes
Windsland52 May 26, 2026
6ad21dd
Merge branch 'main' of https://github.com/rurudo-ruo/37Bot
rurudo-ruo Jun 4, 2026
5473ec0
feat: implement notification cooldown for sensitive messages
rurudo-ruo Jun 4, 2026
0679b27
fix: enhance context handling in LLM judgment and improve sensitive m…
rurudo-ruo Jun 4, 2026
8cda53e
fix: improve regex for sensitive content detection in LLM responses
rurudo-ruo Jun 4, 2026
6de8628
删除 llm_20260605003558.py
rurudo-ruo Jun 4, 2026
8d4e73f
删除 llm_20260605003540.py
rurudo-ruo Jun 4, 2026
7e19d8c
Merge branch 'main' into main
rurudo-ruo Jun 4, 2026
f0e5e87
feat: add Skland sign-in plugin with SMS and QR login support
rurudo-ruo Jun 4, 2026
3fab9f0
feat: Implement sensitive message monitoring plugin with configuratio…
rurudo-ruo Jun 4, 2026
dd5990a
Remove outdated SensitiveMonitorPlugin implementations from history
rurudo-ruo Jun 4, 2026
5e395e5
Merge branch 'main' of https://github.com/rurudo-ruo/37Bot
rurudo-ruo Jun 4, 2026
1058701
feat: Add sensitive message monitoring plugin with configuration and …
rurudo-ruo Jun 4, 2026
d4fce02
refactor: Remove outdated sensitive message monitoring plugin impleme…
rurudo-ruo Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions plugins/_ai/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import json
import re
import time
import httpx
from ncatbot.utils import get_log
Expand Down Expand Up @@ -242,7 +243,7 @@ async def judge_sensitive(self, message_text: str, context: str = "") -> tuple[b
)
user_prompt = f"请判断以下 QQ 群消息是否包含政治敏感内容:\n\n消息内容:\n{message_text}"
if context:
user_prompt += f"\n\n群聊上下文(其他群友的反应):\n{context}"
user_prompt += f"\n\n群聊上下文(此消息之前的对话历史):\n{context}"
user_prompt += "\n\n请先回答「是」或「否」,然后简要说明理由(不超过30字)。"

messages = [
Expand All @@ -254,9 +255,16 @@ async def judge_sensitive(self, message_text: str, context: str = "") -> tuple[b
if reply is None:
return False, "LLM 请求失败"

is_sensitive = reply.strip().startswith("是")
reason = reply.strip()
return is_sensitive, reason
cleaned = reply.strip()
# Match the first 「是」/「否」 or bare 是/否 at the start
m = re.search(r'[「((]?\s*([是否])\s*[」))]?', cleaned)
if m:
is_sensitive = m.group(1) == "是"
else:
# Fallback: check first non-whitespace character
first_char = cleaned.lstrip()[:1] if cleaned else ""
is_sensitive = first_char == "是"
return is_sensitive, cleaned[:200]
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

async def judge_question(self, project: str, message_text: str, context: str = "") -> bool:
"""判断消息是否在询问项目相关问题。返回 True/False。"""
Expand Down
9 changes: 9 additions & 0 deletions plugins/sensitive_monitor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@ class SensitiveGroupConfig:
enabled: bool = False
notify_users: list[str] = field(default_factory=list)
warn_in_group: bool = False
# Whether to append recent conversation context to notifications
append_context: bool = False
# Maximum number of recent messages to include when appending context
max_context_messages: int = 5
# Maximum total characters for the appended context
max_context_chars: int = 800
# Cooldown seconds: after a sensitive notification, suppress further
# notifications from this group for this duration to avoid cascade
notify_cooldown_seconds: int = 120
157 changes: 131 additions & 26 deletions plugins/sensitive_monitor/plugin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""敏感消息监听插件"""

import json
import time

from ncatbot.plugin_system import NcatBotPlugin, command_registry, param, on_message
from ncatbot.core.event import GroupMessageEvent, PrivateMessageEvent
Expand All @@ -15,6 +16,25 @@
RECENT_SENSITIVE: set[str] = set()
MAX_RECENT = 500
MIN_TEXT_LENGTH = 4
# Per-group cooldown: tracks the last notification time for each group
LAST_NOTIFY_TIME: dict[str, float] = {}


def _build_clean_message(message) -> str:
"""Convert a MessageArray to clean text, replacing non-text segments with summaries."""
parts = []
for seg in message:
if seg.msg_seg_type == "text":
parts.append(seg.text)
elif seg.msg_seg_type == "at":
parts.append(f"@{seg.qq}" if seg.qq != "all" else "@全体成员")
elif seg.msg_seg_type == "reply":
continue
else:
summary = seg.get_summary()
if summary and summary != "该消息不支持预览":
parts.append(summary)
return "".join(parts)


class SensitiveMonitorPlugin(NcatBotPlugin):
Expand All @@ -36,6 +56,10 @@ def _load_config(self) -> dict[str, SensitiveGroupConfig]:
enabled=g.get("enabled", False),
notify_users=g.get("notify_users", []),
warn_in_group=g.get("warn_in_group", False),
append_context=g.get("append_context", False),
max_context_messages=g.get("max_context_messages", 5),
max_context_chars=g.get("max_context_chars", 800),
notify_cooldown_seconds=g.get("notify_cooldown_seconds", 120),
)
for gid, g in data.items()
}
Expand All @@ -45,10 +69,22 @@ def _load_config(self) -> dict[str, SensitiveGroupConfig]:

def _save_config(self):
self.config_path.write_text(
json.dumps({
gid: {"enabled": g.enabled, "notify_users": g.notify_users, "warn_in_group": g.warn_in_group}
for gid, g in self.groups.items()
}, ensure_ascii=False, indent=2),
json.dumps(
{
gid: {
"enabled": g.enabled,
"notify_users": g.notify_users,
"warn_in_group": g.warn_in_group,
"append_context": g.append_context,
"max_context_messages": g.max_context_messages,
"max_context_chars": g.max_context_chars,
"notify_cooldown_seconds": g.notify_cooldown_seconds,
}
for gid, g in self.groups.items()
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)

Expand All @@ -72,15 +108,15 @@ async def _on_message(self, event):
if not cfg or not cfg.enabled:
return

text = (event.raw_message or "").strip()
text = event.message.concatenate_text().strip()
if not text or text.startswith("/"):
return
if len(text) < MIN_TEXT_LENGTH:
return

if event.message_id in RECENT_PROCESSED:
if str(event.message_id) in RECENT_PROCESSED:
return
RECENT_PROCESSED.add(event.message_id)
RECENT_PROCESSED.add(str(event.message_id))
if len(RECENT_PROCESSED) > MAX_RECENT:
RECENT_PROCESSED.clear()
RECENT_SENSITIVE.clear()
Expand All @@ -90,42 +126,74 @@ async def _on_message(self, event):

context = ""
try:
recent = await self.api.get_group_msg_history(group_id, count=10)
prev = [
m for m in recent
if m.time < event.time
and m.message_id != event.message_id
and str(m.message_id) not in RECENT_SENSITIVE
]
# Fetch more than needed to account for filtered-out messages
fetch_count = max(cfg.max_context_messages, 10) + 10
recent = await self.api.get_group_msg_history(group_id, count=fetch_count)
# Collect messages before current, replacing sensitive ones with placeholder
prev = [m for m in recent if m.time < event.time and m.message_id != event.message_id]
if prev:
context = "\n".join(
f"[{m.user_id}]: {m.raw_message}" for m in reversed(prev[-5:])
)
lines = []
for m in reversed(prev[-cfg.max_context_messages :]):
if str(m.message_id) in RECENT_SENSITIVE:
lines.append("[敏感内容已过滤]")
else:
msg_text = _build_clean_message(m.message)
if msg_text:
lines.append(f"[{m.user_id}]: {msg_text}")
context = "\n".join(lines)
except Exception as e:
logger.error(f"获取消息上下文失败: {e}")

is_sensitive, reason = await get_llm().judge_sensitive(text, context)
if is_sensitive:
RECENT_SENSITIVE.add(event.message_id)
logger.info(f"敏感消息: group={group_id}, user={event.user_id}, reason={reason}")
await self._notify(cfg, group_id, str(event.user_id), text, reason)
RECENT_SENSITIVE.add(str(event.message_id))
logger.info(
f"敏感消息: group={group_id}, user={event.user_id}, reason={reason}"
)
# Cooldown check: suppress notification if we recently sent one in this group
now = time.time()
last = LAST_NOTIFY_TIME.get(group_id, 0)
if now - last < cfg.notify_cooldown_seconds:
logger.info(
f"群 {group_id} 处于通知冷却中 (剩余 {int(cfg.notify_cooldown_seconds - (now - last))}s),跳过通知"
)
else:
await self._notify(
cfg, group_id, str(event.user_id), text, reason, context
)
LAST_NOTIFY_TIME[group_id] = time.time()

async def _notify(self, cfg: SensitiveGroupConfig, group_id: str, user_id: str, text: str, reason: str):
async def _notify(
self,
cfg: SensitiveGroupConfig,
group_id: str,
user_id: str,
text: str,
reason: str,
context: str,
):
msg = (
f"敏感消息提醒\n"
f"群: {group_id}\n"
f"发送者: {user_id}\n"
f"内容: {text}\n"
f"原因: {reason}"
)
if cfg.append_context and context:
truncated = context
if len(context) > cfg.max_context_chars:
truncated = context[: cfg.max_context_chars].rstrip() + "..."
msg += f"\n对话背景:\n{truncated}"
for uid in cfg.notify_users:
try:
await self.api.post_private_msg(uid, text=msg)
except Exception as e:
logger.error(f"私聊通知 {uid} 失败: {e}")
if cfg.warn_in_group:
try:
await self.api.post_group_msg(group_id, text="请注意发言内容,避免发送敏感信息。")
await self.api.post_group_msg(
group_id, text="请注意发言内容,避免发送敏感信息。"
)
except Exception as e:
logger.error(f"群内警告失败: {e}")

Expand All @@ -136,8 +204,12 @@ def _get_cfg(self, group_id: str) -> SensitiveGroupConfig:
self.groups[group_id] = SensitiveGroupConfig()
return self.groups[group_id]

@command_registry.command("sensitive_llm", description="[root] 配置 LLM API(私聊,全局共享)")
async def cmd_llm(self, event: PrivateMessageEvent, base_url: str, api_key: str, model: str):
@command_registry.command(
"sensitive_llm", description="[root] 配置 LLM API(私聊,全局共享)"
)
async def cmd_llm(
self, event: PrivateMessageEvent, base_url: str, api_key: str, model: str
):
if event.message_type != "private":
await event.reply("请私聊使用此命令")
return
Expand All @@ -163,7 +235,9 @@ async def cmd_enable(self, event: GroupMessageEvent, action: str = "on"):
self._save_config()
await event.reply(f"敏感消息监听已{'启用' if cfg.enabled else '禁用'}")

@command_registry.command("sensitive_notify", description="[管理员] 通知目标 切换添加/移除")
@command_registry.command(
"sensitive_notify", description="[管理员] 通知目标 切换添加/移除"
)
@param(name="qq", default="", help="接收通知的 QQ 号")
async def cmd_notify(self, event: GroupMessageEvent, qq: str = ""):
if not await self._is_group_admin(event.group_id, event.user_id):
Expand Down Expand Up @@ -204,11 +278,42 @@ async def cmd_status(self, event: GroupMessageEvent):
f" 状态: {'启用' if cfg and cfg.enabled else '禁用'}",
]
if cfg and cfg.enabled:
lines.append(f" 通知对象: {', '.join(cfg.notify_users) if cfg.notify_users else '无'}")
lines.append(
f" 通知对象: {', '.join(cfg.notify_users) if cfg.notify_users else '无'}"
)
lines.append(f" 群内警告: {'是' if cfg.warn_in_group else '否'}")
lines.append(f" 附加对话背景: {'是' if cfg.append_context else '否'}")
lines.append(f" 对话消息数上限: {cfg.max_context_messages}")
lines.append(f" 对话字符上限: {cfg.max_context_chars}")
lines.append(f" 通知冷却: {cfg.notify_cooldown_seconds}秒")
llm_cfg = load_llm_config()
lines.append(f"LLM: {'已配置' if llm_cfg.base_url else '未配置'}")
await event.reply("\n".join(lines))

@command_registry.command(
"sensitive_cooldown", description="[管理员] 设置通知冷却秒数"
)
@param(name="seconds", default="120", help="冷却秒数,设为0禁用冷却")
async def cmd_cooldown(self, event: GroupMessageEvent, seconds: str = "120"):
if not await self._is_group_admin(event.group_id, event.user_id):
await event.reply("需要群主或管理员权限")
return
try:
val = int(seconds)
if val < 0:
await event.reply("冷却秒数不能为负数")
return
except ValueError:
await event.reply("请输入有效的数字")
return
group_id = str(event.group_id)
cfg = self._get_cfg(group_id)
cfg.notify_cooldown_seconds = val
self._save_config()
if val == 0:
await event.reply("通知冷却已禁用")
else:
await event.reply(f"通知冷却已设置为 {val} 秒")


__all__ = ["SensitiveMonitorPlugin"]