WIP::私聊功能实现

This commit is contained in:
XokoukioX 2025-11-06 00:31:17 +08:00
parent 2f62365460
commit 36a47fa5e2
4 changed files with 510 additions and 57 deletions

View file

@ -8,7 +8,7 @@ import random
import re
import ssl
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union
import aiofiles
import httpx
@ -21,7 +21,7 @@ from nonebot import (
on_message,
require,
)
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment
from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment, PrivateMessageEvent
from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
from nonebot.params import CommandArg
from nonebot.permission import SUPERUSER
@ -86,16 +86,36 @@ class GroupState:
self.last_active = time.time()
self.past_events = deque(maxlen=plugin_config.past_events_size)
self.group_prompt: str | None = None
self.user_prompt: str | None = None
self.output_reasoning_content = False
self.random_trigger_prob = plugin_config.random_trigger_prob
# 初始化私聊状态
class PrivateChatState:
def __init__(self):
self.preset_name = plugin_config.private_chat_preset
self.history = deque(maxlen=plugin_config.history_size * 2)
self.queue = asyncio.Queue()
self.processing = False
self.last_active = time.time()
self.past_events = deque(maxlen=plugin_config.past_events_size)
self.group_prompt: str | None = None
self.user_prompt: str | None = None
self.output_reasoning_content = False
group_states: dict[int, GroupState] = defaultdict(GroupState)
private_chat_states: dict[int, PrivateChatState] = defaultdict(PrivateChatState)
# 获取当前预设配置
def get_preset(group_id: int) -> PresetConfig:
state = group_states[group_id]
def get_preset(context_id: int, is_group: bool = True) -> PresetConfig:
if is_group:
state = group_states[context_id]
else:
state = private_chat_states[context_id]
for preset in plugin_config.api_presets:
if preset.name == state.preset_name:
return preset
@ -103,12 +123,12 @@ def get_preset(group_id: int) -> PresetConfig:
# 消息格式转换
def format_message(event: GroupMessageEvent) -> str:
def format_message(event: Union[GroupMessageEvent, PrivateMessageEvent]) -> str:
text_message = ""
if event.reply is not None:
if isinstance(event, GroupMessageEvent) and event.reply is not None:
text_message += f"[回复 {event.reply.sender.nickname} 的消息 {event.reply.message.extract_plain_text()}]\n"
if event.is_tome():
if isinstance(event, GroupMessageEvent) and event.is_tome():
text_message += f"@{next(iter(driver.config.nickname))} "
for msgseg in event.get_message():
@ -123,13 +143,22 @@ def format_message(event: GroupMessageEvent) -> str:
elif msgseg.type == "text":
text_message += msgseg.data.get("text", "")
message = {
"SenderNickname": str(event.sender.card or event.sender.nickname),
"SenderUserId": str(event.user_id),
"Message": text_message,
"MessageID": event.message_id,
"SendTime": datetime.fromtimestamp(event.time).isoformat(),
}
if isinstance(event, GroupMessageEvent):
message = {
"SenderNickname": str(event.sender.card or event.sender.nickname),
"SenderUserId": str(event.user_id),
"Message": text_message,
"MessageID": event.message_id,
"SendTime": datetime.fromtimestamp(event.time).isoformat(),
}
else: # PrivateMessageEvent
message = {
"SenderNickname": str(event.sender.nickname),
"SenderUserId": str(event.user_id),
"Message": text_message,
"MessageID": event.message_id,
"SendTime": datetime.fromtimestamp(event.time).isoformat(),
}
return json.dumps(message, ensure_ascii=False)
@ -157,32 +186,60 @@ def build_reasoning_forward_nodes(self_id: str, reasoning_content: str):
return nodes
async def is_triggered(event: GroupMessageEvent) -> bool:
async def is_triggered(event: Union[GroupMessageEvent, PrivateMessageEvent]) -> bool:
"""扩展后的消息处理规则"""
state = group_states[event.group_id]
if isinstance(event, GroupMessageEvent):
state = group_states[event.group_id]
if state.preset_name == "off":
return False
# 黑名单用户
if event.user_id in plugin_config.blacklist_user_ids:
return False
# 忽略特定前缀的消息
msg_text = event.get_plaintext().strip()
for prefix in plugin_config.ignore_prefixes:
if msg_text.startswith(prefix):
if state.preset_name == "off":
return False
state.past_events.append(event)
# 黑名单用户
if event.user_id in plugin_config.blacklist_user_ids:
return False
# 原有@触发条件
if event.is_tome():
return True
# 忽略特定前缀的消息
msg_text = event.get_plaintext().strip()
for prefix in plugin_config.ignore_prefixes:
if msg_text.startswith(prefix):
return False
# 随机触发条件
if random.random() < state.random_trigger_prob:
state.past_events.append(event)
# 原有@触发条件
if event.is_tome():
return True
# 随机触发条件
if random.random() < state.random_trigger_prob:
return True
return False
elif isinstance(event, PrivateMessageEvent):
# 检查私聊功能是否启用
if not plugin_config.enable_private_chat:
return False
state = private_chat_states[event.user_id]
if state.preset_name == "off":
return False
# 黑名单用户
if event.user_id in plugin_config.blacklist_user_ids:
return False
# 忽略特定前缀的消息
msg_text = event.get_plaintext().strip()
for prefix in plugin_config.ignore_prefixes:
if msg_text.startswith(prefix):
return False
state.past_events.append(event)
# 私聊默认触发
return True
return False
@ -197,18 +254,27 @@ handler = on_message(
@handler.handle()
async def handle_message(event: GroupMessageEvent):
group_id = event.group_id
logger.debug(
f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}"
)
state = group_states[group_id]
async def handle_message(event: Union[GroupMessageEvent, PrivateMessageEvent]):
if isinstance(event, GroupMessageEvent):
group_id = event.group_id
logger.debug(
f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}"
)
state = group_states[group_id]
context_id = group_id
else: # PrivateMessageEvent
user_id = event.user_id
logger.debug(
f"收到私聊消息 用户:{user_id} 内容:{event.get_plaintext()}"
)
state = private_chat_states[user_id]
context_id = user_id
await state.queue.put(event)
if not state.processing:
state.processing = True
task = asyncio.create_task(process_messages(group_id))
is_group = isinstance(event, GroupMessageEvent)
task = asyncio.create_task(process_messages(context_id, is_group))
task.add_done_callback(tasks.discard)
tasks.add(task)
@ -253,9 +319,16 @@ async def send_split_messages(message_handler, content: str):
logger.debug(f"发送消息分段 内容:{segment[:50]}...") # 只记录前50个字符避免日志过大
await message_handler.send(Message(segment))
async def process_messages(group_id: int):
state = group_states[group_id]
preset = get_preset(group_id)
async def process_messages(context_id: int, is_group: bool = True):
if is_group:
group_id = context_id
state = group_states[group_id]
else:
user_id = context_id
state = private_chat_states[user_id]
group_id = None
preset = get_preset(context_id, is_group)
# 初始化OpenAI客户端
if preset.proxy != "":
@ -273,16 +346,21 @@ async def process_messages(group_id: int):
)
logger.info(
f"开始处理群聊消息 群号:{group_id} 当前队列长度:{state.queue.qsize()}"
f"开始处理{'群聊' if is_group else '私聊'}消息 {'群号' if is_group else '用户'}{context_id} 当前队列长度:{state.queue.qsize()}"
)
while not state.queue.empty():
event = await state.queue.get()
logger.debug(f"从队列获取消息 群号:{group_id} 消息ID{event.message_id}")
if is_group:
logger.debug(f"从队列获取消息 群号:{context_id} 消息ID{event.message_id}")
group_id = context_id
else:
logger.debug(f"从队列获取消息 用户:{context_id} 消息ID{event.message_id}")
group_id = None
past_events_snapshot = []
mcp_client = MCPClient.get_instance(plugin_config.mcp_servers)
try:
systemPrompt = f"""
我想要你帮我在群聊中闲聊大家一般叫你{"".join(list(driver.config.nickname))}我将会在后面的信息中告诉你每条群聊信息的发送者和发送时间你可以直接称呼发送者为他对应的昵称
我想要你帮我在{"群聊" if is_group else "私聊"}中闲聊大家一般叫你{"".join(list(driver.config.nickname))}我将会在后面的信息中告诉你每条{"群聊" if is_group else "私聊"}信息的发送者和发送时间你可以直接称呼发送者为他对应的昵称
你的回复需要遵守以下几点规则
- 你可以使用多条消息回复每两条消息之间使用<botbr>分隔<botbr>前后不需要包含额外的换行和空格
- <botbr>消息中不应该包含其他类似的标记
@ -296,7 +374,7 @@ async def process_messages(group_id: int):
- 如果你选择完全不回复你只需要直接输出一个<botbr>
- 如果你需要思考的话你应该思考尽量少以节省时间
下面是关于你性格的设定如果设定中提到让你扮演某个人或者设定中有提到名字则优先使用设定中的名字
{state.group_prompt or plugin_config.default_prompt}
{(state.group_prompt if is_group else state.user_prompt) or plugin_config.default_prompt}
"""
if preset.support_mcp:
systemPrompt += "你也可以使用一些工具,下面是关于这些工具的额外说明:\n"
@ -382,12 +460,24 @@ async def process_messages(group_id: int):
await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}"))
# 执行工具调用传递群组和机器人信息用于QQ工具
result = await mcp_client.call_tool(
tool_name,
tool_args,
group_id=event.group_id,
bot_id=str(event.self_id)
)
if is_group:
result = await mcp_client.call_tool(
tool_name,
tool_args,
group_id=event.group_id,
bot_id=str(event.self_id)
)
else:
# 私聊时某些工具不可用(如群操作工具),跳过这些工具
if tool_name.startswith("ob__"):
result = f"私聊不支持{mcp_client.get_friendly_name(tool_name)}工具"
else:
result = await mcp_client.call_tool(
tool_name,
tool_args,
group_id=None,
bot_id=str(event.self_id)
)
new_messages.append({
"role": "tool",
@ -452,7 +542,7 @@ async def process_messages(group_id: int):
await handler.send(image_msg)
except Exception as e:
logger.opt(exception=e).error(f"API请求失败 群号:{group_id}")
logger.opt(exception=e).error(f"API请求失败 {'群号' if is_group else '用户'}{context_id}")
# 如果在处理过程中出现异常恢复未处理的消息到state中
state.past_events.extendleft(reversed(past_events_snapshot))
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}"))
@ -564,12 +654,113 @@ async def handle_think(event: GroupMessageEvent, args: Message = CommandArg()):
)
# region 私聊相关指令
# 私聊预设切换命令
private_preset_handler = on_command(
"私聊API预设",
priority=1,
block=True,
permission=SUPERUSER,
)
@private_preset_handler.handle()
async def handle_private_preset(event: PrivateMessageEvent, args: Message = CommandArg()):
if not plugin_config.enable_private_chat:
await private_preset_handler.finish("私聊功能未启用")
user_id = event.user_id
preset_name = args.extract_plain_text().strip()
if preset_name == "off":
private_chat_states[user_id].preset_name = preset_name
await private_preset_handler.finish("已关闭llmchat私聊功能")
available_presets = {p.name for p in plugin_config.api_presets}
if preset_name not in available_presets:
available_presets_str = "\n- ".join(available_presets)
await private_preset_handler.finish(
f"当前API预设{private_chat_states[user_id].preset_name}\n可用API预设\n- {available_presets_str}"
)
private_chat_states[user_id].preset_name = preset_name
await private_preset_handler.finish(f"已切换至API预设{preset_name}")
# 私聊设定修改命令
private_edit_preset_handler = on_command(
"私聊修改设定",
priority=1,
block=True,
permission=SUPERUSER,
)
@private_edit_preset_handler.handle()
async def handle_private_edit_preset(event: PrivateMessageEvent, args: Message = CommandArg()):
if not plugin_config.enable_private_chat:
await private_edit_preset_handler.finish("私聊功能未启用")
user_id = event.user_id
user_prompt = args.extract_plain_text().strip()
private_chat_states[user_id].group_prompt = user_prompt
await private_edit_preset_handler.finish("修改成功")
# 私聊记忆清除命令
private_reset_handler = on_command(
"私聊记忆清除",
priority=1,
block=True,
permission=SUPERUSER,
)
@private_reset_handler.handle()
async def handle_private_reset(event: PrivateMessageEvent, args: Message = CommandArg()):
if not plugin_config.enable_private_chat:
await private_reset_handler.finish("私聊功能未启用")
user_id = event.user_id
private_chat_states[user_id].past_events.clear()
private_chat_states[user_id].history.clear()
await private_reset_handler.finish("记忆已清空")
# 私聊思维输出切换命令
private_think_handler = on_command(
"私聊切换思维输出",
priority=1,
block=True,
permission=SUPERUSER,
)
@private_think_handler.handle()
async def handle_private_think(event: PrivateMessageEvent, args: Message = CommandArg()):
if not plugin_config.enable_private_chat:
await private_think_handler.finish("私聊功能未启用")
state = private_chat_states[event.user_id]
state.output_reasoning_content = not state.output_reasoning_content
await private_think_handler.finish(
f"{(state.output_reasoning_content and '开启') or '关闭'}思维输出"
)
# endregion
# region 持久化与定时任务
# 获取插件数据目录
data_dir = store.get_plugin_data_dir()
# 获取插件数据文件
data_file = store.get_plugin_data_file("llmchat_state.json")
private_data_file = store.get_plugin_data_file("llmchat_private_state.json")
async def save_state():
@ -590,6 +781,24 @@ async def save_state():
os.makedirs(os.path.dirname(data_file), exist_ok=True)
async with aiofiles.open(data_file, "w", encoding="utf8") as f:
await f.write(json.dumps(data, ensure_ascii=False))
# 保存私聊状态
if plugin_config.enable_private_chat:
logger.info(f"开始保存私聊状态到文件:{private_data_file}")
private_data = {
uid: {
"preset": state.preset_name,
"history": list(state.history),
"last_active": state.last_active,
"group_prompt": state.group_prompt,
"output_reasoning_content": state.output_reasoning_content,
}
for uid, state in private_chat_states.items()
}
os.makedirs(os.path.dirname(private_data_file), exist_ok=True)
async with aiofiles.open(private_data_file, "w", encoding="utf8") as f:
await f.write(json.dumps(private_data, ensure_ascii=False))
async def load_state():
@ -611,6 +820,23 @@ async def load_state():
state.output_reasoning_content = state_data["output_reasoning_content"]
state.random_trigger_prob = state_data.get("random_trigger_prob", plugin_config.random_trigger_prob)
group_states[int(gid)] = state
# 加载私聊状态
if plugin_config.enable_private_chat:
logger.info(f"从文件加载私聊状态:{private_data_file}")
if os.path.exists(private_data_file):
async with aiofiles.open(private_data_file, encoding="utf8") as f:
private_data = json.loads(await f.read())
for uid, state_data in private_data.items():
state = PrivateChatState()
state.preset_name = state_data["preset"]
state.history = deque(
state_data["history"], maxlen=plugin_config.history_size * 2
)
state.last_active = state_data["last_active"]
state.group_prompt = state_data["group_prompt"]
state.output_reasoning_content = state_data["output_reasoning_content"]
private_chat_states[int(uid)] = state
# 注册生命周期事件

View file

@ -49,6 +49,8 @@ class ScopedConfig(BaseModel):
default_factory=list,
description="需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理"
)
enable_private_chat: bool = Field(False, description="是否启用私聊功能")
private_chat_preset: str = Field("off", description="私聊默认使用的预设名称")
class Config(BaseModel):