修复代码格式和文件名大小写问题
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "web_search_tool",
|
||||
"version": "1.0.0",
|
||||
"description": "一个用于在互联网上搜索信息的工具",
|
||||
"author": {
|
||||
"name": "MoFox-Studio",
|
||||
"url": "https://github.com/MoFox-Studio"
|
||||
},
|
||||
"license": "GPL-v3.0-or-later",
|
||||
|
||||
"host_application": {
|
||||
"min_version": "0.10.0"
|
||||
},
|
||||
"keywords": ["web_search", "url_parser"],
|
||||
"categories": ["web_search", "url_parser"],
|
||||
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
|
||||
"plugin_info": {
|
||||
"is_built_in": false,
|
||||
"plugin_type": "web_search"
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
Web Search Tool Plugin
|
||||
|
||||
一个功能强大的网络搜索和URL解析插件,支持多种搜索引擎和解析策略。
|
||||
"""
|
||||
from typing import List, Tuple, Type
|
||||
|
||||
from src.plugin_system import (
|
||||
BasePlugin,
|
||||
register_plugin,
|
||||
ComponentInfo,
|
||||
ConfigField,
|
||||
PythonDependency
|
||||
)
|
||||
from src.plugin_system.apis import config_api
|
||||
from src.common.logger import get_logger
|
||||
|
||||
from .tools.web_search import WebSurfingTool
|
||||
from .tools.url_parser import URLParserTool
|
||||
|
||||
logger = get_logger("web_search_plugin")
|
||||
|
||||
|
||||
@register_plugin
|
||||
class WEBSEARCHPLUGIN(BasePlugin):
|
||||
"""
|
||||
网络搜索工具插件
|
||||
|
||||
提供网络搜索和URL解析功能,支持多种搜索引擎:
|
||||
- Exa (需要API密钥)
|
||||
- Tavily (需要API密钥)
|
||||
- DuckDuckGo (免费)
|
||||
- Bing (免费)
|
||||
"""
|
||||
|
||||
# 插件基本信息
|
||||
plugin_name: str = "web_search_tool" # 内部标识符
|
||||
enable_plugin: bool = True
|
||||
dependencies: List[str] = [] # 插件依赖列表
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""初始化插件,立即加载所有搜索引擎"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# 立即初始化所有搜索引擎,触发API密钥管理器的日志输出
|
||||
logger.info("🚀 正在初始化所有搜索引擎...")
|
||||
try:
|
||||
from .engines.exa_engine import ExaSearchEngine
|
||||
from .engines.tavily_engine import TavilySearchEngine
|
||||
from .engines.ddg_engine import DDGSearchEngine
|
||||
from .engines.bing_engine import BingSearchEngine
|
||||
|
||||
# 实例化所有搜索引擎,这会触发API密钥管理器的初始化
|
||||
exa_engine = ExaSearchEngine()
|
||||
tavily_engine = TavilySearchEngine()
|
||||
ddg_engine = DDGSearchEngine()
|
||||
bing_engine = BingSearchEngine()
|
||||
|
||||
# 报告每个引擎的状态
|
||||
engines_status = {
|
||||
"Exa": exa_engine.is_available(),
|
||||
"Tavily": tavily_engine.is_available(),
|
||||
"DuckDuckGo": ddg_engine.is_available(),
|
||||
"Bing": bing_engine.is_available()
|
||||
}
|
||||
|
||||
available_engines = [name for name, available in engines_status.items() if available]
|
||||
unavailable_engines = [name for name, available in engines_status.items() if not available]
|
||||
|
||||
if available_engines:
|
||||
logger.info(f"✅ 可用搜索引擎: {', '.join(available_engines)}")
|
||||
if unavailable_engines:
|
||||
logger.info(f"❌ 不可用搜索引擎: {', '.join(unavailable_engines)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 搜索引擎初始化失败: {e}", exc_info=True)
|
||||
|
||||
# Python包依赖列表
|
||||
python_dependencies: List[PythonDependency] = [
|
||||
PythonDependency(
|
||||
package_name="asyncddgs",
|
||||
description="异步DuckDuckGo搜索库",
|
||||
optional=False
|
||||
),
|
||||
PythonDependency(
|
||||
package_name="exa_py",
|
||||
description="Exa搜索API客户端库",
|
||||
optional=True # 如果没有API密钥,这个是可选的
|
||||
),
|
||||
PythonDependency(
|
||||
package_name="tavily",
|
||||
install_name="tavily-python", # 安装时使用这个名称
|
||||
description="Tavily搜索API客户端库",
|
||||
optional=True # 如果没有API密钥,这个是可选的
|
||||
),
|
||||
PythonDependency(
|
||||
package_name="httpx",
|
||||
version=">=0.20.0",
|
||||
install_name="httpx[socks]", # 安装时使用这个名称(包含可选依赖)
|
||||
description="支持SOCKS代理的HTTP客户端库",
|
||||
optional=False
|
||||
)
|
||||
]
|
||||
config_file_name: str = "config.toml" # 配置文件名
|
||||
|
||||
# 配置节描述
|
||||
config_section_descriptions = {
|
||||
"plugin": "插件基本信息",
|
||||
"proxy": "链接本地解析代理配置"
|
||||
}
|
||||
|
||||
# 配置Schema定义
|
||||
# 注意:EXA配置和组件设置已迁移到主配置文件(bot_config.toml)的[exa]和[web_search]部分
|
||||
config_schema: dict = {
|
||||
"plugin": {
|
||||
"name": ConfigField(type=str, default="WEB_SEARCH_PLUGIN", description="插件名称"),
|
||||
"version": ConfigField(type=str, default="1.0.0", description="插件版本"),
|
||||
"enabled": ConfigField(type=bool, default=False, description="是否启用插件"),
|
||||
},
|
||||
"proxy": {
|
||||
"http_proxy": ConfigField(
|
||||
type=str,
|
||||
default=None,
|
||||
description="HTTP代理地址,格式如: http://proxy.example.com:8080"
|
||||
),
|
||||
"https_proxy": ConfigField(
|
||||
type=str,
|
||||
default=None,
|
||||
description="HTTPS代理地址,格式如: http://proxy.example.com:8080"
|
||||
),
|
||||
"socks5_proxy": ConfigField(
|
||||
type=str,
|
||||
default=None,
|
||||
description="SOCKS5代理地址,格式如: socks5://proxy.example.com:1080"
|
||||
),
|
||||
"enable_proxy": ConfigField(
|
||||
type=bool,
|
||||
default=False,
|
||||
description="是否启用代理"
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]:
|
||||
"""
|
||||
获取插件组件列表
|
||||
|
||||
Returns:
|
||||
组件信息和类型的元组列表
|
||||
"""
|
||||
enable_tool = []
|
||||
|
||||
# 从主配置文件读取组件启用配置
|
||||
if config_api.get_global_config("web_search.enable_web_search_tool", True):
|
||||
enable_tool.append((WebSurfingTool.get_tool_info(), WebSurfingTool))
|
||||
|
||||
if config_api.get_global_config("web_search.enable_url_tool", True):
|
||||
enable_tool.append((URLParserTool.get_tool_info(), URLParserTool))
|
||||
|
||||
return enable_tool
|
||||
@@ -6,13 +6,15 @@ from src.plugin_system import (
|
||||
register_plugin,
|
||||
BaseAction,
|
||||
ActionInfo,
|
||||
ActionActivationType
|
||||
ActionActivationType,
|
||||
)
|
||||
from src.person_info.person_info import get_person_info_manager
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_system.base.component_types import ChatType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AtAction(BaseAction):
|
||||
"""发送艾特消息"""
|
||||
|
||||
@@ -24,11 +26,12 @@ class AtAction(BaseAction):
|
||||
chat_type_allow = ChatType.GROUP
|
||||
|
||||
# === 功能描述(必须填写)===
|
||||
action_parameters = {
|
||||
"user_name": "需要艾特用户的名字",
|
||||
"at_message": "艾特用户时要发送的消,注意消息里不要有@"
|
||||
}
|
||||
action_require = ["当需要艾特某个用户时使用","当你需要提醒特定用户查看消息时使用","在回复中需要明确指向某个用户时使用"]
|
||||
action_parameters = {"user_name": "需要艾特用户的名字", "at_message": "艾特用户时要发送的消,注意消息里不要有@"}
|
||||
action_require = [
|
||||
"当需要艾特某个用户时使用",
|
||||
"当你需要提醒特定用户查看消息时使用",
|
||||
"在回复中需要明确指向某个用户时使用",
|
||||
]
|
||||
llm_judge_prompt = """
|
||||
判定是否需要使用艾特用户动作的条件:
|
||||
1. 你在对话中提到了某个具体的人,并且需要提醒他/她。
|
||||
@@ -48,11 +51,10 @@ class AtAction(BaseAction):
|
||||
await self.store_action_info(
|
||||
action_build_into_prompt=True,
|
||||
action_prompt_display=f"执行了艾特用户动作:艾特用户 {user_name} 并发送消息: {at_message},失败了,因为没有提供必要参数",
|
||||
action_done=False
|
||||
action_done=False,
|
||||
)
|
||||
return False, "缺少必要参数"
|
||||
|
||||
|
||||
user_info = await get_person_info_manager().get_person_info_by_name(user_name)
|
||||
if not user_info or not user_info.get("user_id"):
|
||||
logger.info(f"找不到名为 '{user_name}' 的用户。")
|
||||
@@ -60,17 +62,18 @@ class AtAction(BaseAction):
|
||||
await self.send_command(
|
||||
"SEND_AT_MESSAGE",
|
||||
args={"qq_id": user_info.get("user_id"), "text": at_message},
|
||||
display_message=f"艾特用户 {user_name} 并发送消息: {at_message}"
|
||||
display_message=f"艾特用户 {user_name} 并发送消息: {at_message}",
|
||||
)
|
||||
await self.store_action_info(
|
||||
action_build_into_prompt=True,
|
||||
action_prompt_display=f"执行了艾特用户动作:艾特用户 {user_name} 并发送消息: {at_message}",
|
||||
action_done=True
|
||||
)
|
||||
action_build_into_prompt=True,
|
||||
action_prompt_display=f"执行了艾特用户动作:艾特用户 {user_name} 并发送消息: {at_message}",
|
||||
action_done=True,
|
||||
)
|
||||
|
||||
logger.info("艾特用户的动作已触发,但具体实现待完成。")
|
||||
return True, "艾特用户的动作已触发,但具体实现待完成。"
|
||||
|
||||
|
||||
class AtCommand(BaseCommand):
|
||||
command_name: str = "at_user"
|
||||
description: str = "通过名字艾特用户"
|
||||
@@ -92,15 +95,16 @@ class AtCommand(BaseCommand):
|
||||
return False, "用户不存在", True
|
||||
|
||||
user_id = user_info.get("user_id")
|
||||
|
||||
|
||||
await self.send_command(
|
||||
"SEND_AT_MESSAGE",
|
||||
args={"qq_id": user_id, "text": text},
|
||||
display_message=f"艾特用户 {name} 并发送消息: {text}"
|
||||
display_message=f"艾特用户 {name} 并发送消息: {text}",
|
||||
)
|
||||
|
||||
|
||||
return True, "艾特消息已发送", True
|
||||
|
||||
|
||||
@register_plugin
|
||||
class AtUserPlugin(BasePlugin):
|
||||
plugin_name: str = "at_user_plugin"
|
||||
@@ -109,8 +113,8 @@ class AtUserPlugin(BasePlugin):
|
||||
python_dependencies: list[str] = []
|
||||
config_file_name: str = "config.toml"
|
||||
config_schema: dict = {}
|
||||
|
||||
|
||||
def get_plugin_components(self) -> List[Tuple[CommandInfo | ActionInfo, Type[BaseCommand] | Type[BaseAction]]]:
|
||||
return [
|
||||
(AtAction.get_action_info(), AtAction),
|
||||
]
|
||||
]
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
- 测试功能
|
||||
"""
|
||||
|
||||
|
||||
from src.plugin_system.base import BaseCommand
|
||||
from src.chat.antipromptinjector import get_anti_injector
|
||||
from src.common.logger import get_logger
|
||||
@@ -18,7 +17,7 @@ logger = get_logger("anti_injector.commands")
|
||||
|
||||
class AntiInjectorStatusCommand(BaseCommand):
|
||||
"""反注入系统状态查看命令"""
|
||||
|
||||
|
||||
command_name = "反注入状态" # 命令名称,作为唯一标识符
|
||||
command_description = "查看反注入系统状态和统计信息" # 命令描述
|
||||
command_pattern = r"^/反注入状态$" # 命令匹配的正则表达式
|
||||
@@ -27,35 +26,35 @@ class AntiInjectorStatusCommand(BaseCommand):
|
||||
try:
|
||||
anti_injector = get_anti_injector()
|
||||
stats = await anti_injector.get_stats()
|
||||
|
||||
|
||||
# 检查反注入系统是否禁用
|
||||
if stats.get("status") == "disabled":
|
||||
await self.send_text("❌ 反注入系统未启用\n\n💡 请在配置文件中启用反注入功能后重试")
|
||||
return True, "反注入系统未启用", True
|
||||
|
||||
|
||||
if stats.get("error"):
|
||||
await self.send_text(f"❌ 获取状态失败: {stats['error']}")
|
||||
return False, f"获取状态失败: {stats['error']}", True
|
||||
|
||||
|
||||
status_text = f"""🛡️ 反注入系统状态报告
|
||||
|
||||
📊 运行统计:
|
||||
• 运行时间: {stats['uptime']}
|
||||
• 处理消息总数: {stats['total_messages']}
|
||||
• 检测到注入: {stats['detected_injections']}
|
||||
• 阻止消息: {stats['blocked_messages']}
|
||||
• 加盾消息: {stats['shielded_messages']}
|
||||
• 运行时间: {stats["uptime"]}
|
||||
• 处理消息总数: {stats["total_messages"]}
|
||||
• 检测到注入: {stats["detected_injections"]}
|
||||
• 阻止消息: {stats["blocked_messages"]}
|
||||
• 加盾消息: {stats["shielded_messages"]}
|
||||
|
||||
📈 性能指标:
|
||||
• 检测率: {stats['detection_rate']}
|
||||
• 平均处理时间: {stats['average_processing_time']}
|
||||
• 最后处理时间: {stats['last_processing_time']}
|
||||
• 检测率: {stats["detection_rate"]}
|
||||
• 平均处理时间: {stats["average_processing_time"]}
|
||||
• 最后处理时间: {stats["last_processing_time"]}
|
||||
|
||||
⚠️ 错误计数: {stats['error_count']}"""
|
||||
⚠️ 错误计数: {stats["error_count"]}"""
|
||||
await self.send_text(status_text)
|
||||
return True, status_text, True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取反注入系统状态失败: {e}")
|
||||
await self.send_text(f"获取状态失败: {str(e)}")
|
||||
return False, f"获取状态失败: {str(e)}", True
|
||||
return False, f"获取状态失败: {str(e)}", True
|
||||
|
||||
@@ -22,7 +22,7 @@ class NoReplyAction(BaseAction):
|
||||
# 动作基本信息
|
||||
action_name = "no_reply"
|
||||
action_description = "暂时不回复消息"
|
||||
|
||||
|
||||
# 最近三次no_reply的新消息兴趣度记录
|
||||
_recent_interest_records: deque = deque(maxlen=3)
|
||||
|
||||
@@ -46,9 +46,9 @@ class NoReplyAction(BaseAction):
|
||||
|
||||
try:
|
||||
reason = self.action_data.get("reason", "")
|
||||
|
||||
|
||||
logger.info(f"{self.log_prefix} 选择不回复,原因: {reason}")
|
||||
|
||||
|
||||
await self.store_action_info(
|
||||
action_build_into_prompt=False,
|
||||
action_prompt_display=reason,
|
||||
@@ -77,4 +77,3 @@ class NoReplyAction(BaseAction):
|
||||
def get_recent_interest_records(cls) -> List[float]:
|
||||
"""获取最近的兴趣度记录"""
|
||||
return list(cls._recent_interest_records)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ReplyAction(BaseAction):
|
||||
"""执行回复动作"""
|
||||
try:
|
||||
reason = self.action_data.get("reason", "")
|
||||
|
||||
|
||||
logger.info(f"{self.log_prefix} 执行基本回复动作,原因: {reason}")
|
||||
|
||||
# 获取当前消息和上下文
|
||||
@@ -45,15 +45,15 @@ class ReplyAction(BaseAction):
|
||||
return False, ""
|
||||
|
||||
latest_message = self.chat_stream.get_latest_message()
|
||||
|
||||
|
||||
# 使用生成器API生成回复
|
||||
success, reply_set, _ = await generator_api.generate_reply(
|
||||
target_message=latest_message.processed_plain_text,
|
||||
chat_stream=self.chat_stream,
|
||||
reasoning=reason,
|
||||
action_message={}
|
||||
action_message={},
|
||||
)
|
||||
|
||||
|
||||
if success and reply_set:
|
||||
# 提取回复文本
|
||||
reply_text = ""
|
||||
@@ -61,7 +61,7 @@ class ReplyAction(BaseAction):
|
||||
if message_type == "text":
|
||||
reply_text += content
|
||||
break
|
||||
|
||||
|
||||
if reply_text:
|
||||
logger.info(f"{self.log_prefix} 回复生成成功: {reply_text[:50]}...")
|
||||
return True, reply_text
|
||||
@@ -75,5 +75,6 @@ class ReplyAction(BaseAction):
|
||||
except Exception as e:
|
||||
logger.error(f"{self.log_prefix} 执行回复动作时发生异常: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False, ""
|
||||
|
||||
@@ -9,7 +9,9 @@ from src.common.logger import get_logger
|
||||
|
||||
# 导入API模块 - 标准Python包方式
|
||||
from src.plugin_system.apis import emoji_api, llm_api, message_api
|
||||
# NoReplyAction已集成到heartFC_chat.py中,不再需要导入
|
||||
|
||||
# 注释:不再需要导入NoReplyAction,因为计数器管理已移至heartFC_chat.py
|
||||
# from src.plugins.built_in.core_actions.no_reply import NoReplyAction
|
||||
from src.config.config import global_config
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ class CoreActionsPlugin(BasePlugin):
|
||||
"enable_no_reply": ConfigField(type=bool, default=True, description="是否启用不回复动作"),
|
||||
"enable_reply": ConfigField(type=bool, default=True, description="是否启用基本回复动作"),
|
||||
"enable_emoji": ConfigField(type=bool, default=True, description="是否启用发送表情/图片动作"),
|
||||
"enable_anti_injector_manager": ConfigField(type=bool, default=True, description="是否启用反注入系统管理命令"),
|
||||
"enable_anti_injector_manager": ConfigField(
|
||||
type=bool, default=True, description="是否启用反注入系统管理命令"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -77,5 +79,4 @@ class CoreActionsPlugin(BasePlugin):
|
||||
if self.get_config("components.enable_anti_injector_manager", True):
|
||||
components.append((AntiInjectorStatusCommand.get_command_info(), AntiInjectorStatusCommand))
|
||||
|
||||
|
||||
return components
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
让框架能够发现并加载子目录中的组件。
|
||||
"""
|
||||
|
||||
from .plugin import MaiZoneRefactoredPlugin as MaiZoneRefactoredPlugin
|
||||
from .actions.send_feed_action import SendFeedAction as SendFeedAction
|
||||
from .actions.read_feed_action import ReadFeedAction as ReadFeedAction
|
||||
from .commands.send_feed_command import SendFeedCommand as SendFeedCommand
|
||||
from .commands.send_feed_command import SendFeedCommand as SendFeedCommand
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
阅读说说动作组件
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from src.common.logger import get_logger
|
||||
@@ -17,6 +18,7 @@ class ReadFeedAction(BaseAction):
|
||||
"""
|
||||
当检测到用户想要阅读好友动态时,此动作被激活。
|
||||
"""
|
||||
|
||||
action_name: str = "read_feed"
|
||||
action_description: str = "读取好友的最新动态并进行评论点赞"
|
||||
activation_type: ActionActivationType = ActionActivationType.KEYWORD
|
||||
@@ -35,7 +37,7 @@ class ReadFeedAction(BaseAction):
|
||||
"""检查当前用户是否有权限执行此动作"""
|
||||
platform = self.chat_stream.platform
|
||||
user_id = self.chat_stream.user_info.user_id
|
||||
|
||||
|
||||
# 使用权限API检查用户是否有阅读说说的权限
|
||||
return permission_api.check_permission(platform, user_id, "plugin.maizone.read_feed")
|
||||
|
||||
@@ -46,7 +48,7 @@ class ReadFeedAction(BaseAction):
|
||||
if not await self._check_permission():
|
||||
_, reply_set, _ = await generator_api.generate_reply(
|
||||
chat_stream=self.chat_stream,
|
||||
action_data={"extra_info_block": "无权命令你阅读说说,请用符合你人格特点的方式拒绝请求"}
|
||||
action_data={"extra_info_block": "无权命令你阅读说说,请用符合你人格特点的方式拒绝请求"},
|
||||
)
|
||||
if reply_set and isinstance(reply_set, list):
|
||||
for reply_type, reply_content in reply_set:
|
||||
@@ -69,7 +71,9 @@ class ReadFeedAction(BaseAction):
|
||||
if result.get("success"):
|
||||
_, reply_set, _ = await generator_api.generate_reply(
|
||||
chat_stream=self.chat_stream,
|
||||
action_data={"extra_info_block": f"你刚刚看完了'{target_name}'的空间,并进行了互动。{result.get('message', '')}"}
|
||||
action_data={
|
||||
"extra_info_block": f"你刚刚看完了'{target_name}'的空间,并进行了互动。{result.get('message', '')}"
|
||||
},
|
||||
)
|
||||
if reply_set and isinstance(reply_set, list):
|
||||
for reply_type, reply_content in reply_set:
|
||||
@@ -78,9 +82,9 @@ class ReadFeedAction(BaseAction):
|
||||
return True, "阅读成功"
|
||||
else:
|
||||
await self.send_text(f"看'{target_name}'的空间时好像失败了:{result.get('message', '未知错误')}")
|
||||
return False, result.get('message', '未知错误')
|
||||
|
||||
return False, result.get("message", "未知错误")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行阅读说说动作时发生未知异常: {e}", exc_info=True)
|
||||
await self.send_text("糟糕,在看说说的过程中网络好像出问题了...")
|
||||
return False, "动作执行异常"
|
||||
return False, "动作执行异常"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
发送说说动作组件
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from src.common.logger import get_logger
|
||||
@@ -17,6 +18,7 @@ class SendFeedAction(BaseAction):
|
||||
"""
|
||||
当检测到用户意图是发送说说时,此动作被激活。
|
||||
"""
|
||||
|
||||
action_name: str = "send_feed"
|
||||
action_description: str = "发送一条关于特定主题的说说"
|
||||
activation_type: ActionActivationType = ActionActivationType.KEYWORD
|
||||
@@ -35,7 +37,7 @@ class SendFeedAction(BaseAction):
|
||||
"""检查当前用户是否有权限执行此动作"""
|
||||
platform = self.chat_stream.platform
|
||||
user_id = self.chat_stream.user_info.user_id
|
||||
|
||||
|
||||
# 使用权限API检查用户是否有发送说说的权限
|
||||
return permission_api.check_permission(platform, user_id, "plugin.maizone.send_feed")
|
||||
|
||||
@@ -46,7 +48,7 @@ class SendFeedAction(BaseAction):
|
||||
if not await self._check_permission():
|
||||
_, reply_set, _ = await generator_api.generate_reply(
|
||||
chat_stream=self.chat_stream,
|
||||
action_data={"extra_info_block": "无权命令你发送说说,请用符合你人格特点的方式拒绝请求"}
|
||||
action_data={"extra_info_block": "无权命令你发送说说,请用符合你人格特点的方式拒绝请求"},
|
||||
)
|
||||
if reply_set and isinstance(reply_set, list):
|
||||
for reply_type, reply_content in reply_set:
|
||||
@@ -64,7 +66,9 @@ class SendFeedAction(BaseAction):
|
||||
if result.get("success"):
|
||||
_, reply_set, _ = await generator_api.generate_reply(
|
||||
chat_stream=self.chat_stream,
|
||||
action_data={"extra_info_block": f"你刚刚成功发送了一条关于“{topic or '随机'}”的说说,内容是:{result.get('message', '')}"}
|
||||
action_data={
|
||||
"extra_info_block": f"你刚刚成功发送了一条关于“{topic or '随机'}”的说说,内容是:{result.get('message', '')}"
|
||||
},
|
||||
)
|
||||
if reply_set and isinstance(reply_set, list):
|
||||
for reply_type, reply_content in reply_set:
|
||||
@@ -75,9 +79,9 @@ class SendFeedAction(BaseAction):
|
||||
return True, "发送成功"
|
||||
else:
|
||||
await self.send_text(f"发送失败了呢,原因好像是:{result.get('message', '未知错误')}")
|
||||
return False, result.get('message', '未知错误')
|
||||
return False, result.get("message", "未知错误")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行发送说说动作时发生未知异常: {e}", exc_info=True)
|
||||
await self.send_text("糟糕,发送的时候网络好像波动了一下...")
|
||||
return False, "动作执行异常"
|
||||
return False, "动作执行异常"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
发送说说命令 await self.send_text(f"收到!正在为你生成关于"{topic or '随机'}"的说说,请稍候...【热重载测试成功】")件
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from src.common.logger import get_logger
|
||||
@@ -18,6 +19,7 @@ class SendFeedCommand(PlusCommand):
|
||||
响应用户通过 `/send_feed` 命令发送说说的请求。
|
||||
测试热重载功能 - 这是一个测试注释,现在应该可以正常工作了!
|
||||
"""
|
||||
|
||||
command_name: str = "send_feed"
|
||||
command_description: str = "发一条QQ空间说说"
|
||||
command_aliases = ["发空间"]
|
||||
@@ -48,9 +50,9 @@ class SendFeedCommand(PlusCommand):
|
||||
return True, "发送成功", True
|
||||
else:
|
||||
await self.send_text(f"哎呀,发送失败了:{result.get('message', '未知错误')}")
|
||||
return False, result.get('message', '未知错误'), True
|
||||
return False, result.get("message", "未知错误"), True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行发送说说命令时发生未知异常: {e}", exc_info=True)
|
||||
await self.send_text("呜... 发送过程中好像出了点问题。")
|
||||
return False, "命令执行异常", True
|
||||
return False, "命令执行异常", True
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
"""
|
||||
MaiZone(麦麦空间)- 重构版
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Type
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_system import (
|
||||
BasePlugin,
|
||||
ComponentInfo,
|
||||
register_plugin
|
||||
)
|
||||
from src.plugin_system import BasePlugin, ComponentInfo, register_plugin
|
||||
from src.plugin_system.base.config_types import ConfigField
|
||||
from src.plugin_system.apis.permission_api import permission_api
|
||||
|
||||
@@ -29,6 +26,7 @@ from .services.manager import register_service
|
||||
|
||||
logger = get_logger("MaiZone.Plugin")
|
||||
|
||||
|
||||
@register_plugin
|
||||
class MaiZoneRefactoredPlugin(BasePlugin):
|
||||
plugin_name: str = "MaiZoneRefactored"
|
||||
@@ -49,17 +47,19 @@ class MaiZoneRefactoredPlugin(BasePlugin):
|
||||
},
|
||||
"send": {
|
||||
"permission": ConfigField(type=list, default=[], description="发送权限QQ号列表"),
|
||||
"permission_type": ConfigField(type=str, default='whitelist', description="权限类型"),
|
||||
"permission_type": ConfigField(type=str, default="whitelist", description="权限类型"),
|
||||
"enable_image": ConfigField(type=bool, default=False, description="是否启用说说配图"),
|
||||
"enable_ai_image": ConfigField(type=bool, default=False, description="是否启用AI生成配图"),
|
||||
"enable_reply": ConfigField(type=bool, default=True, description="完成后是否回复"),
|
||||
"ai_image_number": ConfigField(type=int, default=1, description="AI生成图片数量"),
|
||||
"image_number": ConfigField(type=int, default=1, description="本地配图数量(1-9张)"),
|
||||
"image_directory": ConfigField(type=str, default=str(Path(__file__).parent / "images"), description="图片存储目录")
|
||||
"image_directory": ConfigField(
|
||||
type=str, default=str(Path(__file__).parent / "images"), description="图片存储目录"
|
||||
),
|
||||
},
|
||||
"read": {
|
||||
"permission": ConfigField(type=list, default=[], description="阅读权限QQ号列表"),
|
||||
"permission_type": ConfigField(type=str, default='blacklist', description="权限类型"),
|
||||
"permission_type": ConfigField(type=str, default="blacklist", description="权限类型"),
|
||||
"read_number": ConfigField(type=int, default=5, description="一次读取的说说数量"),
|
||||
"like_possibility": ConfigField(type=float, default=1.0, description="点赞概率"),
|
||||
"comment_possibility": ConfigField(type=float, default=0.3, description="评论概率"),
|
||||
@@ -77,7 +77,9 @@ class MaiZoneRefactoredPlugin(BasePlugin):
|
||||
"forbidden_hours_end": ConfigField(type=int, default=6, description="禁止发送的结束小时(24小时制)"),
|
||||
},
|
||||
"cookie": {
|
||||
"http_fallback_host": ConfigField(type=str, default="172.20.130.55", description="备用Cookie获取服务的主机地址"),
|
||||
"http_fallback_host": ConfigField(
|
||||
type=str, default="172.20.130.55", description="备用Cookie获取服务的主机地址"
|
||||
),
|
||||
"http_fallback_port": ConfigField(type=int, default=9999, description="备用Cookie获取服务的端口"),
|
||||
"napcat_token": ConfigField(type=str, default="", description="Napcat服务的认证Token(可选)"),
|
||||
},
|
||||
@@ -87,16 +89,10 @@ class MaiZoneRefactoredPlugin(BasePlugin):
|
||||
super().__init__(*args, **kwargs)
|
||||
# 注册权限节点
|
||||
permission_api.register_permission_node(
|
||||
"plugin.maizone.send_feed",
|
||||
"是否可以使用机器人发送QQ空间说说",
|
||||
"maiZone",
|
||||
False
|
||||
"plugin.maizone.send_feed", "是否可以使用机器人发送QQ空间说说", "maiZone", False
|
||||
)
|
||||
permission_api.register_permission_node(
|
||||
"plugin.maizone.read_feed",
|
||||
"是否可以使用机器人读取QQ空间说说",
|
||||
"maiZone",
|
||||
True
|
||||
"plugin.maizone.read_feed", "是否可以使用机器人读取QQ空间说说", "maiZone", True
|
||||
)
|
||||
content_service = ContentService(self.get_config)
|
||||
image_service = ImageService(self.get_config)
|
||||
@@ -105,20 +101,20 @@ class MaiZoneRefactoredPlugin(BasePlugin):
|
||||
qzone_service = QZoneService(self.get_config, content_service, image_service, cookie_service)
|
||||
scheduler_service = SchedulerService(self.get_config, qzone_service)
|
||||
monitor_service = MonitorService(self.get_config, qzone_service)
|
||||
|
||||
|
||||
register_service("qzone", qzone_service)
|
||||
register_service("reply_tracker", reply_tracker_service)
|
||||
register_service("get_config", self.get_config)
|
||||
|
||||
|
||||
# 保存服务引用以便后续启动
|
||||
self.scheduler_service = scheduler_service
|
||||
self.monitor_service = monitor_service
|
||||
|
||||
|
||||
logger.info("MaiZone重构版插件已加载,服务已注册。")
|
||||
|
||||
async def on_plugin_loaded(self):
|
||||
"""插件加载完成后的回调,启动异步服务"""
|
||||
if hasattr(self, 'scheduler_service') and hasattr(self, 'monitor_service'):
|
||||
if hasattr(self, "scheduler_service") and hasattr(self, "monitor_service"):
|
||||
asyncio.create_task(self.scheduler_service.start())
|
||||
asyncio.create_task(self.monitor_service.start())
|
||||
logger.info("MaiZone后台任务已启动。")
|
||||
@@ -128,4 +124,4 @@ class MaiZoneRefactoredPlugin(BasePlugin):
|
||||
(SendFeedAction.get_action_info(), SendFeedAction),
|
||||
(ReadFeedAction.get_action_info(), ReadFeedAction),
|
||||
(SendFeedCommand.get_plus_command_info(), SendFeedCommand),
|
||||
]
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
内容服务模块
|
||||
负责生成所有与QQ空间相关的文本内容,例如说说、评论等。
|
||||
"""
|
||||
|
||||
from typing import Callable, Optional
|
||||
import datetime
|
||||
|
||||
@@ -91,7 +92,7 @@ class ContentService:
|
||||
model_config=model_config,
|
||||
request_type="story.generate",
|
||||
temperature=0.3,
|
||||
max_tokens=1000
|
||||
max_tokens=1000,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -109,23 +110,16 @@ class ContentService:
|
||||
"""
|
||||
针对一条具体的说说内容生成评论。
|
||||
"""
|
||||
for i in range(3): # 重试3次
|
||||
for i in range(3): # 重试3次
|
||||
try:
|
||||
chat_manager = get_chat_manager()
|
||||
bot_platform = config_api.get_global_config('bot.platform')
|
||||
bot_qq = str(config_api.get_global_config('bot.qq_account'))
|
||||
bot_nickname = config_api.get_global_config('bot.nickname')
|
||||
|
||||
bot_user_info = UserInfo(
|
||||
platform=bot_platform,
|
||||
user_id=bot_qq,
|
||||
user_nickname=bot_nickname
|
||||
)
|
||||
bot_platform = config_api.get_global_config("bot.platform")
|
||||
bot_qq = str(config_api.get_global_config("bot.qq_account"))
|
||||
bot_nickname = config_api.get_global_config("bot.nickname")
|
||||
|
||||
chat_stream = await chat_manager.get_or_create_stream(
|
||||
platform=bot_platform,
|
||||
user_info=bot_user_info
|
||||
)
|
||||
bot_user_info = UserInfo(platform=bot_platform, user_id=bot_qq, user_nickname=bot_nickname)
|
||||
|
||||
chat_stream = await chat_manager.get_or_create_stream(platform=bot_platform, user_info=bot_user_info)
|
||||
|
||||
if not chat_stream:
|
||||
logger.error(f"无法为QQ号 {bot_qq} 创建聊天流")
|
||||
@@ -137,7 +131,7 @@ class ContentService:
|
||||
description = await self._describe_image(image_url)
|
||||
if description:
|
||||
image_descriptions.append(description)
|
||||
|
||||
|
||||
extra_info = "正在评论QQ空间的好友说说。"
|
||||
if image_descriptions:
|
||||
extra_info += "说说中包含的图片内容如下:\n" + "\n".join(image_descriptions)
|
||||
@@ -147,20 +141,17 @@ class ContentService:
|
||||
reply_to += f"\n[转发内容]: {rt_con}"
|
||||
|
||||
success, reply_set, _ = await generator_api.generate_reply(
|
||||
chat_stream=chat_stream,
|
||||
reply_to=reply_to,
|
||||
extra_info=extra_info,
|
||||
request_type="maizone.comment"
|
||||
chat_stream=chat_stream, reply_to=reply_to, extra_info=extra_info, request_type="maizone.comment"
|
||||
)
|
||||
|
||||
if success and reply_set:
|
||||
comment = "".join([content for type, content in reply_set if type == 'text'])
|
||||
comment = "".join([content for type, content in reply_set if type == "text"])
|
||||
logger.info(f"成功生成评论内容:'{comment}'")
|
||||
return comment
|
||||
else:
|
||||
# 如果生成失败,则进行重试
|
||||
if i < 2:
|
||||
logger.warning(f"生成评论失败,将在5秒后重试 (尝试 {i+1}/3)")
|
||||
logger.warning(f"生成评论失败,将在5秒后重试 (尝试 {i + 1}/3)")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
else:
|
||||
@@ -168,7 +159,7 @@ class ContentService:
|
||||
return ""
|
||||
except Exception as e:
|
||||
if i < 2:
|
||||
logger.warning(f"生成评论时发生异常,将在5秒后重试 (尝试 {i+1}/3): {e}")
|
||||
logger.warning(f"生成评论时发生异常,将在5秒后重试 (尝试 {i + 1}/3): {e}")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
else:
|
||||
@@ -180,23 +171,16 @@ class ContentService:
|
||||
"""
|
||||
针对自己说说的评论,生成回复。
|
||||
"""
|
||||
for i in range(3): # 重试3次
|
||||
for i in range(3): # 重试3次
|
||||
try:
|
||||
chat_manager = get_chat_manager()
|
||||
bot_platform = config_api.get_global_config('bot.platform')
|
||||
bot_qq = str(config_api.get_global_config('bot.qq_account'))
|
||||
bot_nickname = config_api.get_global_config('bot.nickname')
|
||||
bot_platform = config_api.get_global_config("bot.platform")
|
||||
bot_qq = str(config_api.get_global_config("bot.qq_account"))
|
||||
bot_nickname = config_api.get_global_config("bot.nickname")
|
||||
|
||||
bot_user_info = UserInfo(
|
||||
platform=bot_platform,
|
||||
user_id=bot_qq,
|
||||
user_nickname=bot_nickname
|
||||
)
|
||||
bot_user_info = UserInfo(platform=bot_platform, user_id=bot_qq, user_nickname=bot_nickname)
|
||||
|
||||
chat_stream = await chat_manager.get_or_create_stream(
|
||||
platform=bot_platform,
|
||||
user_info=bot_user_info
|
||||
)
|
||||
chat_stream = await chat_manager.get_or_create_stream(platform=bot_platform, user_info=bot_user_info)
|
||||
|
||||
if not chat_stream:
|
||||
logger.error(f"无法为QQ号 {bot_qq} 创建聊天流")
|
||||
@@ -209,16 +193,16 @@ class ContentService:
|
||||
chat_stream=chat_stream,
|
||||
reply_to=reply_to,
|
||||
extra_info=extra_info,
|
||||
request_type="maizone.comment_reply"
|
||||
request_type="maizone.comment_reply",
|
||||
)
|
||||
|
||||
if success and reply_set:
|
||||
reply = "".join([content for type, content in reply_set if type == 'text'])
|
||||
reply = "".join([content for type, content in reply_set if type == "text"])
|
||||
logger.info(f"成功为'{commenter_name}'的评论生成回复: '{reply}'")
|
||||
return reply
|
||||
else:
|
||||
if i < 2:
|
||||
logger.warning(f"生成评论回复失败,将在5秒后重试 (尝试 {i+1}/3)")
|
||||
logger.warning(f"生成评论回复失败,将在5秒后重试 (尝试 {i + 1}/3)")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
else:
|
||||
@@ -226,7 +210,7 @@ class ContentService:
|
||||
return ""
|
||||
except Exception as e:
|
||||
if i < 2:
|
||||
logger.warning(f"生成评论回复时发生异常,将在5秒后重试 (尝试 {i+1}/3): {e}")
|
||||
logger.warning(f"生成评论回复时发生异常,将在5秒后重试 (尝试 {i + 1}/3): {e}")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
else:
|
||||
@@ -238,7 +222,7 @@ class ContentService:
|
||||
"""
|
||||
使用LLM识别图片内容。
|
||||
"""
|
||||
for i in range(3): # 重试3次
|
||||
for i in range(3): # 重试3次
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(image_url, timeout=30) as resp:
|
||||
@@ -260,14 +244,10 @@ class ContentService:
|
||||
logger.error("未在插件配置中指定视觉模型")
|
||||
return None
|
||||
|
||||
vision_model_config = TaskConfig(
|
||||
model_list=[vision_model_name],
|
||||
temperature=0.3,
|
||||
max_tokens=1500
|
||||
)
|
||||
|
||||
vision_model_config = TaskConfig(model_list=[vision_model_name], temperature=0.3, max_tokens=1500)
|
||||
|
||||
llm_request = LLMRequest(model_set=vision_model_config, request_type="maizone.image_describe")
|
||||
|
||||
|
||||
prompt = config_api.get_global_config("custom_prompt.image_prompt", "请描述这张图片")
|
||||
|
||||
description, _ = await llm_request.generate_response_for_image(
|
||||
@@ -277,7 +257,7 @@ class ContentService:
|
||||
)
|
||||
return description
|
||||
except Exception as e:
|
||||
logger.error(f"识别图片时发生异常 (尝试 {i+1}/3): {e}")
|
||||
logger.error(f"识别图片时发生异常 (尝试 {i + 1}/3): {e}")
|
||||
await asyncio.sleep(2)
|
||||
return None
|
||||
|
||||
@@ -338,7 +318,7 @@ class ContentService:
|
||||
model_config=model_config,
|
||||
request_type="story.generate.activity",
|
||||
temperature=0.7, # 稍微提高创造性
|
||||
max_tokens=1000
|
||||
max_tokens=1000,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -347,7 +327,7 @@ class ContentService:
|
||||
else:
|
||||
logger.error("生成基于活动的说说内容失败")
|
||||
return ""
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成基于活动的说说内容异常: {e}")
|
||||
return ""
|
||||
return ""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Cookie服务模块
|
||||
负责从多种来源获取、缓存和管理QZone的Cookie。
|
||||
"""
|
||||
|
||||
import orjson
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Dict
|
||||
@@ -33,7 +34,7 @@ class CookieService:
|
||||
cookie_file_path = self._get_cookie_file_path(qq_account)
|
||||
try:
|
||||
with open(cookie_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(orjson.dumps(cookies, option=orjson.OPT_INDENT_2).decode('utf-8'))
|
||||
f.write(orjson.dumps(cookies, option=orjson.OPT_INDENT_2).decode("utf-8"))
|
||||
logger.info(f"Cookie已成功缓存至: {cookie_file_path}")
|
||||
except IOError as e:
|
||||
logger.error(f"无法写入Cookie文件 {cookie_file_path}: {e}")
|
||||
@@ -54,14 +55,20 @@ class CookieService:
|
||||
try:
|
||||
params = {"domain": "user.qzone.qq.com"}
|
||||
if stream_id:
|
||||
response = await send_api.adapter_command_to_stream(action="get_cookies", params=params, platform="qq", stream_id=stream_id, timeout=40.0)
|
||||
response = await send_api.adapter_command_to_stream(
|
||||
action="get_cookies", params=params, platform="qq", stream_id=stream_id, timeout=40.0
|
||||
)
|
||||
else:
|
||||
response = await send_api.adapter_command_to_stream(action="get_cookies", params=params, platform="qq", timeout=40.0)
|
||||
response = await send_api.adapter_command_to_stream(
|
||||
action="get_cookies", params=params, platform="qq", timeout=40.0
|
||||
)
|
||||
|
||||
if response and response.get("status") == "ok":
|
||||
cookie_str = response.get("data", {}).get("cookies", "")
|
||||
if cookie_str:
|
||||
return {k.strip(): v.strip() for k, v in (p.split('=', 1) for p in cookie_str.split('; ') if '=' in p)}
|
||||
return {
|
||||
k.strip(): v.strip() for k, v in (p.split("=", 1) for p in cookie_str.split("; ") if "=" in p)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"通过Adapter获取Cookie时发生异常: {e}")
|
||||
return None
|
||||
@@ -72,11 +79,13 @@ class CookieService:
|
||||
port = self.get_config("cookie.http_fallback_port", "9999")
|
||||
|
||||
if not host or not port:
|
||||
logger.warning("Cookie HTTP备用配置缺失:请在配置文件中设置 cookie.http_fallback_host 和 cookie.http_fallback_port")
|
||||
logger.warning(
|
||||
"Cookie HTTP备用配置缺失:请在配置文件中设置 cookie.http_fallback_host 和 cookie.http_fallback_port"
|
||||
)
|
||||
return None
|
||||
|
||||
http_url = f"http://{host}:{port}/get_cookies"
|
||||
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
# 根据更可靠的实现,这里应该使用POST并传递domain
|
||||
@@ -85,13 +94,16 @@ class CookieService:
|
||||
async with session.post(http_url, json=payload, timeout=timeout) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
|
||||
# 确保返回的数据格式被正确解析,兼容Adapter的返回结构
|
||||
cookie_str = data.get("data", {}).get("cookies")
|
||||
if cookie_str and isinstance(cookie_str, str):
|
||||
logger.info("从HTTP备用地址成功解析Cookie字符串。")
|
||||
return {k.strip(): v.strip() for k, v in (p.split('=', 1) for p in cookie_str.split('; ') if '=' in p)}
|
||||
|
||||
return {
|
||||
k.strip(): v.strip()
|
||||
for k, v in (p.split("=", 1) for p in cookie_str.split("; ") if "=" in p)
|
||||
}
|
||||
|
||||
logger.warning(f"从HTTP备用地址获取的Cookie格式不正确或为空: {data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
图片服务模块
|
||||
负责处理所有与图片相关的任务,特别是AI生成图片。
|
||||
"""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
@@ -74,12 +75,7 @@ class ImageService:
|
||||
"authorization": f"Bearer {api_key}",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"prompt": story,
|
||||
"n": batch_size,
|
||||
"response_format": "b64_json",
|
||||
"style": "cinematic-default"
|
||||
}
|
||||
payload = {"prompt": story, "n": batch_size, "response_format": "b64_json", "style": "cinematic-default"}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -101,4 +97,4 @@ class ImageService:
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"调用AI生图API时发生异常: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
服务管理器/定位器
|
||||
这是一个独立的模块,用于注册和获取插件内的全局服务实例,以避免循环导入。
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Callable
|
||||
from .qzone_service import QZoneService
|
||||
|
||||
@@ -22,4 +23,4 @@ def get_qzone_service() -> QZoneService:
|
||||
|
||||
def get_config_getter() -> Callable:
|
||||
"""全局可用的配置获取函数"""
|
||||
return _services["get_config"]
|
||||
return _services["get_config"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
好友动态监控服务
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Callable
|
||||
@@ -9,7 +10,7 @@ from typing import Callable
|
||||
from src.common.logger import get_logger
|
||||
from .qzone_service import QZoneService
|
||||
|
||||
logger = get_logger('MaiZone.MonitorService')
|
||||
logger = get_logger("MaiZone.MonitorService")
|
||||
|
||||
|
||||
class MonitorService:
|
||||
@@ -46,7 +47,7 @@ class MonitorService:
|
||||
"""监控任务主循环"""
|
||||
# 插件启动后,延迟一段时间再开始第一次监控
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
if not self.get_config("monitor.enable_auto_monitor", False):
|
||||
@@ -54,14 +55,14 @@ class MonitorService:
|
||||
continue
|
||||
|
||||
interval_minutes = self.get_config("monitor.interval_minutes", 10)
|
||||
|
||||
|
||||
await self.qzone_service.monitor_feeds()
|
||||
|
||||
|
||||
logger.info(f"本轮监控完成,将在 {interval_minutes} 分钟后进行下一次检查。")
|
||||
await asyncio.sleep(interval_minutes * 60)
|
||||
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"监控任务循环出错: {e}\n{traceback.format_exc()}")
|
||||
await asyncio.sleep(300)
|
||||
await asyncio.sleep(300)
|
||||
|
||||
@@ -64,7 +64,7 @@ class QZoneService:
|
||||
"""发送一条说说"""
|
||||
# --- 获取互通组上下文 ---
|
||||
context = await self._get_intercom_context(stream_id) if stream_id else None
|
||||
|
||||
|
||||
story = await self.content_service.generate_story(topic, context=context)
|
||||
if not story:
|
||||
return {"success": False, "message": "生成说说内容失败"}
|
||||
@@ -175,9 +175,9 @@ class QZoneService:
|
||||
logger.info(f"监控任务: 发现 {len(friend_feeds)} 条好友新动态,准备处理...")
|
||||
for feed in friend_feeds:
|
||||
target_qq = feed.get("target_qq")
|
||||
if not target_qq or str(target_qq) == str(qq_account): # 确保不重复处理自己的
|
||||
if not target_qq or str(target_qq) == str(qq_account): # 确保不重复处理自己的
|
||||
continue
|
||||
|
||||
|
||||
await self._process_single_feed(feed, api_client, target_qq, target_qq)
|
||||
await asyncio.sleep(random.uniform(5, 10))
|
||||
except Exception as e:
|
||||
@@ -200,18 +200,17 @@ class QZoneService:
|
||||
return None
|
||||
|
||||
chat_manager = get_chat_manager()
|
||||
bot_platform = config_api.get_global_config('bot.platform')
|
||||
bot_platform = config_api.get_global_config("bot.platform")
|
||||
|
||||
for group in intercom_config.groups:
|
||||
# 使用集合以优化查找效率
|
||||
group_stream_ids = {
|
||||
chat_manager.get_stream_id(bot_platform, chat_id, True)
|
||||
for chat_id in group.chat_ids
|
||||
}
|
||||
group_stream_ids = {chat_manager.get_stream_id(bot_platform, chat_id, True) for chat_id in group.chat_ids}
|
||||
|
||||
if stream_id in group_stream_ids:
|
||||
logger.debug(f"Stream ID '{stream_id}' 在互通组 '{getattr(group, 'name', 'Unknown')}' 中找到,正在构建上下文。")
|
||||
|
||||
logger.debug(
|
||||
f"Stream ID '{stream_id}' 在互通组 '{getattr(group, 'name', 'Unknown')}' 中找到,正在构建上下文。"
|
||||
)
|
||||
|
||||
all_messages = []
|
||||
end_time = time.time()
|
||||
start_time = end_time - (3 * 24 * 60 * 60) # 获取过去3天的消息
|
||||
@@ -222,8 +221,8 @@ class QZoneService:
|
||||
chat_id=chat_id,
|
||||
timestamp_start=start_time,
|
||||
timestamp_end=end_time,
|
||||
limit=20, # 每个聊天最多获取20条
|
||||
limit_mode="latest"
|
||||
limit=20, # 每个聊天最多获取20条
|
||||
limit_mode="latest",
|
||||
)
|
||||
all_messages.extend(messages)
|
||||
|
||||
@@ -232,7 +231,7 @@ class QZoneService:
|
||||
|
||||
# 按时间戳对所有消息进行排序
|
||||
all_messages.sort(key=lambda x: x.get("time", 0))
|
||||
|
||||
|
||||
# 限制总消息数,例如最多100条
|
||||
if len(all_messages) > 100:
|
||||
all_messages = all_messages[-100:]
|
||||
@@ -255,9 +254,9 @@ class QZoneService:
|
||||
return
|
||||
|
||||
# 1. 将评论分为用户评论和自己的回复
|
||||
user_comments = [c for c in comments if str(c.get('qq_account')) != str(qq_account)]
|
||||
my_replies = [c for c in comments if str(c.get('qq_account')) == str(qq_account)]
|
||||
|
||||
user_comments = [c for c in comments if str(c.get("qq_account")) != str(qq_account)]
|
||||
my_replies = [c for c in comments if str(c.get("qq_account")) == str(qq_account)]
|
||||
|
||||
if not user_comments:
|
||||
return
|
||||
|
||||
@@ -267,10 +266,10 @@ class QZoneService:
|
||||
# 3. 使用验证后的持久化记录来筛选未回复的评论
|
||||
comments_to_reply = []
|
||||
for comment in user_comments:
|
||||
comment_tid = comment.get('comment_tid')
|
||||
comment_tid = comment.get("comment_tid")
|
||||
if not comment_tid:
|
||||
continue
|
||||
|
||||
|
||||
# 检查是否已经在持久化记录中标记为已回复
|
||||
if not self.reply_tracker.has_replied(fid, comment_tid):
|
||||
comments_to_reply.append(comment)
|
||||
@@ -284,15 +283,11 @@ class QZoneService:
|
||||
comment_tid = comment.get("comment_tid")
|
||||
nickname = comment.get("nickname", "")
|
||||
comment_content = comment.get("content", "")
|
||||
|
||||
|
||||
try:
|
||||
reply_content = await self.content_service.generate_comment_reply(
|
||||
content, comment_content, nickname
|
||||
)
|
||||
reply_content = await self.content_service.generate_comment_reply(content, comment_content, nickname)
|
||||
if reply_content:
|
||||
success = await api_client["reply"](
|
||||
fid, qq_account, nickname, reply_content, comment_tid
|
||||
)
|
||||
success = await api_client["reply"](fid, qq_account, nickname, reply_content, comment_tid)
|
||||
if success:
|
||||
# 标记为已回复
|
||||
self.reply_tracker.mark_as_replied(fid, comment_tid)
|
||||
@@ -309,20 +304,20 @@ class QZoneService:
|
||||
"""验证并清理已删除的回复记录"""
|
||||
# 获取当前记录中该说说的所有已回复评论ID
|
||||
recorded_replied_comments = self.reply_tracker.get_replied_comments(fid)
|
||||
|
||||
|
||||
if not recorded_replied_comments:
|
||||
return
|
||||
|
||||
|
||||
# 从API返回的我的回复中提取parent_tid(即被回复的评论ID)
|
||||
current_replied_comments = set()
|
||||
for reply in my_replies:
|
||||
parent_tid = reply.get('parent_tid')
|
||||
parent_tid = reply.get("parent_tid")
|
||||
if parent_tid:
|
||||
current_replied_comments.add(parent_tid)
|
||||
|
||||
|
||||
# 找出记录中有但实际已不存在的回复
|
||||
deleted_replies = recorded_replied_comments - current_replied_comments
|
||||
|
||||
|
||||
if deleted_replies:
|
||||
logger.info(f"检测到 {len(deleted_replies)} 个回复已被删除,清理记录...")
|
||||
for comment_tid in deleted_replies:
|
||||
@@ -353,20 +348,23 @@ class QZoneService:
|
||||
|
||||
try:
|
||||
# 获取所有图片文件
|
||||
all_files = [f for f in os.listdir(image_dir)
|
||||
if os.path.isfile(os.path.join(image_dir, f))
|
||||
and f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp'))]
|
||||
|
||||
all_files = [
|
||||
f
|
||||
for f in os.listdir(image_dir)
|
||||
if os.path.isfile(os.path.join(image_dir, f))
|
||||
and f.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".bmp"))
|
||||
]
|
||||
|
||||
if not all_files:
|
||||
logger.warning(f"图片目录中没有找到图片文件: {image_dir}")
|
||||
return images
|
||||
|
||||
|
||||
# 检查是否启用配图
|
||||
enable_image = bool(self.get_config("send.enable_image", False))
|
||||
if not enable_image:
|
||||
logger.info("说说配图功能已关闭")
|
||||
return images
|
||||
|
||||
|
||||
# 根据配置选择图片数量
|
||||
config_image_number = self.get_config("send.image_number", 1)
|
||||
try:
|
||||
@@ -374,13 +372,13 @@ class QZoneService:
|
||||
except (ValueError, TypeError):
|
||||
config_image_number = 1
|
||||
logger.warning("配置项 image_number 值无效,使用默认值 1")
|
||||
|
||||
|
||||
max_images = min(min(config_image_number, 9), len(all_files)) # 最多9张,最少1张
|
||||
selected_count = max(1, max_images) # 确保至少选择1张
|
||||
selected_files = random.sample(all_files, selected_count)
|
||||
|
||||
|
||||
logger.info(f"从 {len(all_files)} 张图片中随机选择了 {selected_count} 张配图")
|
||||
|
||||
|
||||
for filename in selected_files:
|
||||
full_path = os.path.join(image_dir, filename)
|
||||
try:
|
||||
@@ -390,7 +388,7 @@ class QZoneService:
|
||||
logger.info(f"加载图片: {filename} ({len(image_data)} bytes)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载图片 {filename} 失败: {e}")
|
||||
|
||||
|
||||
return images
|
||||
except Exception as e:
|
||||
logger.error(f"加载本地图片失败: {e}")
|
||||
@@ -412,11 +410,13 @@ class QZoneService:
|
||||
host = self.get_config("cookie.http_fallback_host", "172.20.130.55")
|
||||
port = self.get_config("cookie.http_fallback_port", "9999")
|
||||
napcat_token = self.get_config("cookie.napcat_token", "")
|
||||
|
||||
|
||||
cookie_data = await self._fetch_cookies_http(host, port, napcat_token)
|
||||
if cookie_data and "cookies" in cookie_data:
|
||||
cookie_str = cookie_data["cookies"]
|
||||
parsed_cookies = {k.strip(): v.strip() for k, v in (p.split('=', 1) for p in cookie_str.split('; ') if '=' in p)}
|
||||
parsed_cookies = {
|
||||
k.strip(): v.strip() for k, v in (p.split("=", 1) for p in cookie_str.split("; ") if "=" in p)
|
||||
}
|
||||
with open(cookie_file_path, "wb") as f:
|
||||
f.write(orjson.dumps(parsed_cookies))
|
||||
logger.info(f"Cookie已更新并保存至: {cookie_file_path}")
|
||||
@@ -448,7 +448,7 @@ class QZoneService:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30.0)) as session:
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
if resp.status != 200:
|
||||
error_msg = f"Napcat服务返回错误状态码: {resp.status}"
|
||||
if resp.status == 403:
|
||||
@@ -476,15 +476,15 @@ class QZoneService:
|
||||
|
||||
async def _get_api_client(self, qq_account: str, stream_id: Optional[str]) -> Optional[Dict]:
|
||||
cookies = await self.cookie_service.get_cookies(qq_account, stream_id)
|
||||
if not cookies:
|
||||
if not cookies:
|
||||
return None
|
||||
|
||||
p_skey = cookies.get('p_skey') or cookies.get('p_skey'.upper())
|
||||
if not p_skey:
|
||||
|
||||
p_skey = cookies.get("p_skey") or cookies.get("p_skey".upper())
|
||||
if not p_skey:
|
||||
return None
|
||||
|
||||
|
||||
gtk = self._generate_gtk(p_skey)
|
||||
uin = cookies.get('uin', '').lstrip('o')
|
||||
uin = cookies.get("uin", "").lstrip("o")
|
||||
|
||||
async def _request(method, url, params=None, data=None, headers=None):
|
||||
final_headers = {"referer": f"https://user.qzone.qq.com/{uin}", "origin": "https://user.qzone.qq.com"}
|
||||
@@ -516,13 +516,13 @@ class QZoneService:
|
||||
"format": "json",
|
||||
"qzreferrer": f"https://user.qzone.qq.com/{uin}",
|
||||
}
|
||||
|
||||
|
||||
# 处理图片上传
|
||||
if images:
|
||||
logger.info(f"开始上传 {len(images)} 张图片...")
|
||||
pic_bos = []
|
||||
richvals = []
|
||||
|
||||
|
||||
for i, img_bytes in enumerate(images):
|
||||
try:
|
||||
# 上传图片到QQ空间
|
||||
@@ -530,18 +530,18 @@ class QZoneService:
|
||||
if upload_result:
|
||||
pic_bos.append(upload_result["pic_bo"])
|
||||
richvals.append(upload_result["richval"])
|
||||
logger.info(f"图片 {i+1} 上传成功")
|
||||
logger.info(f"图片 {i + 1} 上传成功")
|
||||
else:
|
||||
logger.error(f"图片 {i+1} 上传失败")
|
||||
logger.error(f"图片 {i + 1} 上传失败")
|
||||
except Exception as e:
|
||||
logger.error(f"上传图片 {i+1} 时发生异常: {e}")
|
||||
|
||||
logger.error(f"上传图片 {i + 1} 时发生异常: {e}")
|
||||
|
||||
if pic_bos and richvals:
|
||||
# 完全按照原版格式设置图片参数
|
||||
post_data['pic_bo'] = ','.join(pic_bos)
|
||||
post_data['richtype'] = '1'
|
||||
post_data['richval'] = '\t'.join(richvals) # 原版使用制表符分隔
|
||||
|
||||
post_data["pic_bo"] = ",".join(pic_bos)
|
||||
post_data["richtype"] = "1"
|
||||
post_data["richval"] = "\t".join(richvals) # 原版使用制表符分隔
|
||||
|
||||
logger.info(f"准备发布带图说说: {len(pic_bos)} 张图片")
|
||||
logger.info(f"pic_bo参数: {post_data['pic_bo']}")
|
||||
logger.info(f"richval参数长度: {len(post_data['richval'])} 字符")
|
||||
@@ -551,7 +551,7 @@ class QZoneService:
|
||||
res_text = await _request("POST", self.EMOTION_PUBLISH_URL, params={"g_tk": gtk}, data=post_data)
|
||||
result = orjson.loads(res_text)
|
||||
tid = result.get("tid", "")
|
||||
|
||||
|
||||
if tid:
|
||||
if images and pic_bos:
|
||||
logger.info(f"成功发布带图说说,tid: {tid},包含 {len(pic_bos)} 张图片")
|
||||
@@ -559,7 +559,7 @@ class QZoneService:
|
||||
logger.info(f"成功发布文本说说,tid: {tid}")
|
||||
else:
|
||||
logger.error(f"发布说说失败,API返回: {result}")
|
||||
|
||||
|
||||
return bool(tid), tid
|
||||
except Exception as e:
|
||||
logger.error(f"发布说说异常: {e}", exc_info=True)
|
||||
@@ -573,38 +573,38 @@ class QZoneService:
|
||||
def _get_picbo_and_richval(upload_result: dict) -> tuple:
|
||||
"""从上传结果中提取图片的picbo和richval值(仿照原版实现)"""
|
||||
json_data = upload_result
|
||||
|
||||
if 'ret' not in json_data:
|
||||
|
||||
if "ret" not in json_data:
|
||||
raise Exception("获取图片picbo和richval失败")
|
||||
|
||||
if json_data['ret'] != 0:
|
||||
|
||||
if json_data["ret"] != 0:
|
||||
raise Exception("上传图片失败")
|
||||
|
||||
|
||||
# 从URL中提取bo参数
|
||||
picbo_spt = json_data['data']['url'].split('&bo=')
|
||||
picbo_spt = json_data["data"]["url"].split("&bo=")
|
||||
if len(picbo_spt) < 2:
|
||||
raise Exception("上传图片失败")
|
||||
picbo = picbo_spt[1]
|
||||
|
||||
|
||||
# 构造richval - 完全按照原版格式
|
||||
richval = ",{},{},{},{},{},{},,{},{}".format(
|
||||
json_data['data']['albumid'],
|
||||
json_data['data']['lloc'],
|
||||
json_data['data']['sloc'],
|
||||
json_data['data']['type'],
|
||||
json_data['data']['height'],
|
||||
json_data['data']['width'],
|
||||
json_data['data']['height'],
|
||||
json_data['data']['width']
|
||||
json_data["data"]["albumid"],
|
||||
json_data["data"]["lloc"],
|
||||
json_data["data"]["sloc"],
|
||||
json_data["data"]["type"],
|
||||
json_data["data"]["height"],
|
||||
json_data["data"]["width"],
|
||||
json_data["data"]["height"],
|
||||
json_data["data"]["width"],
|
||||
)
|
||||
|
||||
|
||||
return picbo, richval
|
||||
|
||||
async def _upload_image(image_bytes: bytes, index: int) -> Optional[Dict[str, str]]:
|
||||
"""上传图片到QQ空间(完全按照原版实现)"""
|
||||
try:
|
||||
upload_url = "https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image"
|
||||
|
||||
|
||||
# 完全按照原版构建请求数据
|
||||
post_data = {
|
||||
"filename": "filename",
|
||||
@@ -616,7 +616,7 @@ class QZoneService:
|
||||
"zzpaneluin": uin,
|
||||
"p_uin": uin,
|
||||
"uin": uin,
|
||||
"p_skey": cookies.get('p_skey', ''),
|
||||
"p_skey": cookies.get("p_skey", ""),
|
||||
"output_type": "json",
|
||||
"qzonetoken": "",
|
||||
"refer": "shuoshuo",
|
||||
@@ -627,51 +627,40 @@ class QZoneService:
|
||||
"hd_height": "10000",
|
||||
"hd_quality": "96",
|
||||
"backUrls": "http://upbak.photo.qzone.qq.com/cgi-bin/upload/cgi_upload_image,"
|
||||
"http://119.147.64.75/cgi-bin/upload/cgi_upload_image",
|
||||
"http://119.147.64.75/cgi-bin/upload/cgi_upload_image",
|
||||
"url": f"https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk={gtk}",
|
||||
"base64": "1",
|
||||
"picfile": _image_to_base64(image_bytes),
|
||||
}
|
||||
|
||||
headers = {
|
||||
'referer': f'https://user.qzone.qq.com/{uin}',
|
||||
'origin': 'https://user.qzone.qq.com'
|
||||
}
|
||||
|
||||
logger.info(f"开始上传图片 {index+1}...")
|
||||
|
||||
|
||||
headers = {"referer": f"https://user.qzone.qq.com/{uin}", "origin": "https://user.qzone.qq.com"}
|
||||
|
||||
logger.info(f"开始上传图片 {index + 1}...")
|
||||
|
||||
async with aiohttp.ClientSession(cookies=cookies) as session:
|
||||
timeout = aiohttp.ClientTimeout(total=60)
|
||||
async with session.post(
|
||||
upload_url,
|
||||
data=post_data,
|
||||
headers=headers,
|
||||
timeout=timeout
|
||||
) as response:
|
||||
async with session.post(upload_url, data=post_data, headers=headers, timeout=timeout) as response:
|
||||
if response.status == 200:
|
||||
resp_text = await response.text()
|
||||
logger.info(f"图片上传响应状态码: {response.status}")
|
||||
logger.info(f"图片上传响应内容前500字符: {resp_text[:500]}")
|
||||
|
||||
|
||||
# 按照原版方式解析响应
|
||||
start_idx = resp_text.find('{')
|
||||
end_idx = resp_text.rfind('}') + 1
|
||||
start_idx = resp_text.find("{")
|
||||
end_idx = resp_text.rfind("}") + 1
|
||||
if start_idx != -1 and end_idx != -1:
|
||||
json_str = resp_text[start_idx:end_idx]
|
||||
upload_result = eval(json_str) # 与原版保持一致使用eval
|
||||
|
||||
|
||||
logger.info(f"图片上传解析结果: {upload_result}")
|
||||
|
||||
if upload_result.get('ret') == 0:
|
||||
|
||||
if upload_result.get("ret") == 0:
|
||||
# 使用原版的参数提取逻辑
|
||||
picbo, richval = _get_picbo_and_richval(upload_result)
|
||||
logger.info(f"图片 {index+1} 上传成功: picbo={picbo}")
|
||||
return {
|
||||
"pic_bo": picbo,
|
||||
"richval": richval
|
||||
}
|
||||
logger.info(f"图片 {index + 1} 上传成功: picbo={picbo}")
|
||||
return {"pic_bo": picbo, "richval": richval}
|
||||
else:
|
||||
logger.error(f"图片 {index+1} 上传失败: {upload_result}")
|
||||
logger.error(f"图片 {index + 1} 上传失败: {upload_result}")
|
||||
return None
|
||||
else:
|
||||
logger.error("无法解析上传响应")
|
||||
@@ -680,9 +669,9 @@ class QZoneService:
|
||||
error_text = await response.text()
|
||||
logger.error(f"图片上传HTTP请求失败,状态码: {response.status}, 响应: {error_text[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"上传图片 {index+1} 异常: {e}", exc_info=True)
|
||||
logger.error(f"上传图片 {index + 1} 异常: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _list_feeds(t_qq: str, num: int, is_monitoring_own_feeds: bool = False) -> List[Dict]:
|
||||
@@ -719,18 +708,20 @@ class QZoneService:
|
||||
if is_commented:
|
||||
continue
|
||||
|
||||
images = [pic['url1'] for pic in msg.get('pictotal', []) if 'url1' in pic]
|
||||
images = [pic["url1"] for pic in msg.get("pictotal", []) if "url1" in pic]
|
||||
|
||||
comments = []
|
||||
if 'commentlist' in msg:
|
||||
for c in msg['commentlist']:
|
||||
comments.append({
|
||||
'qq_account': c.get('uin'),
|
||||
'nickname': c.get('name'),
|
||||
'content': c.get('content'),
|
||||
'comment_tid': c.get('tid'),
|
||||
'parent_tid': c.get('parent_tid') # API直接返回了父ID
|
||||
})
|
||||
if "commentlist" in msg:
|
||||
for c in msg["commentlist"]:
|
||||
comments.append(
|
||||
{
|
||||
"qq_account": c.get("uin"),
|
||||
"nickname": c.get("name"),
|
||||
"content": c.get("content"),
|
||||
"comment_tid": c.get("tid"),
|
||||
"parent_tid": c.get("parent_tid"), # API直接返回了父ID
|
||||
}
|
||||
)
|
||||
|
||||
feeds_list.append(
|
||||
{
|
||||
@@ -743,7 +734,7 @@ class QZoneService:
|
||||
if isinstance(msg.get("rt_con"), dict)
|
||||
else "",
|
||||
"images": images,
|
||||
"comments": comments
|
||||
"comments": comments,
|
||||
}
|
||||
)
|
||||
return feeds_list
|
||||
@@ -820,136 +811,149 @@ class QZoneService:
|
||||
"""监控好友动态"""
|
||||
try:
|
||||
params = {
|
||||
"uin": uin, "scope": 0, "view": 1, "filter": "all", "flag": 1,
|
||||
"applist": "all", "pagenum": 1, "count": num, "format": "json",
|
||||
"g_tk": gtk, "useutf8": 1, "outputhtmlfeed": 1
|
||||
"uin": uin,
|
||||
"scope": 0,
|
||||
"view": 1,
|
||||
"filter": "all",
|
||||
"flag": 1,
|
||||
"applist": "all",
|
||||
"pagenum": 1,
|
||||
"count": num,
|
||||
"format": "json",
|
||||
"g_tk": gtk,
|
||||
"useutf8": 1,
|
||||
"outputhtmlfeed": 1,
|
||||
}
|
||||
res_text = await _request("GET", self.ZONE_LIST_URL, params=params)
|
||||
|
||||
|
||||
# 处理不同的响应格式
|
||||
json_str = ""
|
||||
stripped_res_text = res_text.strip()
|
||||
if stripped_res_text.startswith('_Callback(') and stripped_res_text.endswith(');'):
|
||||
json_str = stripped_res_text[len('_Callback('):-2]
|
||||
elif stripped_res_text.startswith('{') and stripped_res_text.endswith('}'):
|
||||
if stripped_res_text.startswith("_Callback(") and stripped_res_text.endswith(");"):
|
||||
json_str = stripped_res_text[len("_Callback(") : -2]
|
||||
elif stripped_res_text.startswith("{") and stripped_res_text.endswith("}"):
|
||||
json_str = stripped_res_text
|
||||
else:
|
||||
logger.warning(f"意外的响应格式: {res_text[:100]}...")
|
||||
return []
|
||||
|
||||
json_str = json_str.replace('undefined', 'null').strip()
|
||||
|
||||
|
||||
json_str = json_str.replace("undefined", "null").strip()
|
||||
|
||||
try:
|
||||
json_data = json5.loads(json_str)
|
||||
if not isinstance(json_data, dict):
|
||||
logger.warning(f"解析后的JSON数据不是字典类型: {type(json_data)}")
|
||||
return []
|
||||
|
||||
if json_data.get('code') != 0:
|
||||
error_code = json_data.get('code')
|
||||
error_msg = json_data.get('message', '未知错误')
|
||||
if json_data.get("code") != 0:
|
||||
error_code = json_data.get("code")
|
||||
error_msg = json_data.get("message", "未知错误")
|
||||
logger.warning(f"QQ空间API返回错误: code={error_code}, message={error_msg}")
|
||||
return []
|
||||
|
||||
|
||||
except Exception as parse_error:
|
||||
logger.error(f"JSON解析失败: {parse_error}, 原始数据: {json_str[:200]}...")
|
||||
return []
|
||||
|
||||
feeds_data = []
|
||||
if isinstance(json_data, dict):
|
||||
data_level1 = json_data.get('data')
|
||||
data_level1 = json_data.get("data")
|
||||
if isinstance(data_level1, dict):
|
||||
feeds_data = data_level1.get('data', [])
|
||||
|
||||
feeds_data = data_level1.get("data", [])
|
||||
|
||||
feeds_list = []
|
||||
for feed in feeds_data:
|
||||
if not feed or not isinstance(feed, dict):
|
||||
continue
|
||||
|
||||
if str(feed.get('appid', '')) != '311':
|
||||
if str(feed.get("appid", "")) != "311":
|
||||
continue
|
||||
|
||||
target_qq = str(feed.get('uin', ''))
|
||||
tid = feed.get('key', '')
|
||||
target_qq = str(feed.get("uin", ""))
|
||||
tid = feed.get("key", "")
|
||||
if not target_qq or not tid:
|
||||
continue
|
||||
|
||||
if target_qq == str(uin):
|
||||
continue
|
||||
|
||||
html_content = feed.get('html', '')
|
||||
html_content = feed.get("html", "")
|
||||
if not html_content:
|
||||
continue
|
||||
|
||||
soup = bs4.BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
like_btn = soup.find('a', class_='qz_like_btn_v3')
|
||||
soup = bs4.BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
like_btn = soup.find("a", class_="qz_like_btn_v3")
|
||||
is_liked = False
|
||||
if like_btn and isinstance(like_btn, bs4.Tag):
|
||||
is_liked = like_btn.get('data-islike') == '1'
|
||||
is_liked = like_btn.get("data-islike") == "1"
|
||||
|
||||
if is_liked:
|
||||
continue
|
||||
|
||||
text_div = soup.find('div', class_='f-info')
|
||||
text_div = soup.find("div", class_="f-info")
|
||||
text = text_div.get_text(strip=True) if text_div else ""
|
||||
|
||||
|
||||
# --- 借鉴原版插件的精确图片提取逻辑 ---
|
||||
image_urls = []
|
||||
img_box = soup.find('div', class_='img-box')
|
||||
img_box = soup.find("div", class_="img-box")
|
||||
if img_box:
|
||||
for img in img_box.find_all('img'):
|
||||
src = img.get('src')
|
||||
for img in img_box.find_all("img"):
|
||||
src = img.get("src")
|
||||
# 排除QQ空间的小图标和表情
|
||||
if src and 'qzonestyle.gtimg.cn' not in src:
|
||||
if src and "qzonestyle.gtimg.cn" not in src:
|
||||
image_urls.append(src)
|
||||
|
||||
|
||||
# 视频封面也视为图片
|
||||
video_thumb = soup.select_one('div.video-img img')
|
||||
if video_thumb and 'src' in video_thumb.attrs:
|
||||
image_urls.append(video_thumb['src'])
|
||||
video_thumb = soup.select_one("div.video-img img")
|
||||
if video_thumb and "src" in video_thumb.attrs:
|
||||
image_urls.append(video_thumb["src"])
|
||||
|
||||
# 去重
|
||||
images = list(set(image_urls))
|
||||
|
||||
|
||||
comments = []
|
||||
comment_divs = soup.find_all('div', class_='f-single-comment')
|
||||
comment_divs = soup.find_all("div", class_="f-single-comment")
|
||||
for comment_div in comment_divs:
|
||||
# --- 处理主评论 ---
|
||||
author_a = comment_div.find('a', class_='f-nick')
|
||||
content_span = comment_div.find('span', class_='f-re-con')
|
||||
|
||||
author_a = comment_div.find("a", class_="f-nick")
|
||||
content_span = comment_div.find("span", class_="f-re-con")
|
||||
|
||||
if author_a and content_span:
|
||||
comments.append({
|
||||
'qq_account': str(comment_div.get('data-uin', '')),
|
||||
'nickname': author_a.get_text(strip=True),
|
||||
'content': content_span.get_text(strip=True),
|
||||
'comment_tid': comment_div.get('data-tid', ''),
|
||||
'parent_tid': None # 主评论没有父ID
|
||||
})
|
||||
comments.append(
|
||||
{
|
||||
"qq_account": str(comment_div.get("data-uin", "")),
|
||||
"nickname": author_a.get_text(strip=True),
|
||||
"content": content_span.get_text(strip=True),
|
||||
"comment_tid": comment_div.get("data-tid", ""),
|
||||
"parent_tid": None, # 主评论没有父ID
|
||||
}
|
||||
)
|
||||
|
||||
# --- 处理这条主评论下的所有回复 ---
|
||||
reply_divs = comment_div.find_all('div', class_='f-single-re')
|
||||
reply_divs = comment_div.find_all("div", class_="f-single-re")
|
||||
for reply_div in reply_divs:
|
||||
reply_author_a = reply_div.find('a', class_='f-nick')
|
||||
reply_content_span = reply_div.find('span', class_='f-re-con')
|
||||
|
||||
if reply_author_a and reply_content_span:
|
||||
comments.append({
|
||||
'qq_account': str(reply_div.get('data-uin', '')),
|
||||
'nickname': reply_author_a.get_text(strip=True),
|
||||
'content': reply_content_span.get_text(strip=True).lstrip(': '), # 移除回复内容前多余的冒号和空格
|
||||
'comment_tid': reply_div.get('data-tid', ''),
|
||||
'parent_tid': reply_div.get('data-parent-tid', comment_div.get('data-tid', '')) # 如果没有父ID,则将父ID设为主评论ID
|
||||
})
|
||||
reply_author_a = reply_div.find("a", class_="f-nick")
|
||||
reply_content_span = reply_div.find("span", class_="f-re-con")
|
||||
|
||||
feeds_list.append({
|
||||
'target_qq': target_qq,
|
||||
'tid': tid,
|
||||
'content': text,
|
||||
'images': images,
|
||||
'comments': comments
|
||||
})
|
||||
if reply_author_a and reply_content_span:
|
||||
comments.append(
|
||||
{
|
||||
"qq_account": str(reply_div.get("data-uin", "")),
|
||||
"nickname": reply_author_a.get_text(strip=True),
|
||||
"content": reply_content_span.get_text(strip=True).lstrip(
|
||||
": "
|
||||
), # 移除回复内容前多余的冒号和空格
|
||||
"comment_tid": reply_div.get("data-tid", ""),
|
||||
"parent_tid": reply_div.get(
|
||||
"data-parent-tid", comment_div.get("data-tid", "")
|
||||
), # 如果没有父ID,则将父ID设为主评论ID
|
||||
}
|
||||
)
|
||||
|
||||
feeds_list.append(
|
||||
{"target_qq": target_qq, "tid": tid, "content": text, "images": images, "comments": comments}
|
||||
)
|
||||
logger.info(f"监控任务发现 {len(feeds_list)} 条未处理的新说说。")
|
||||
return feeds_list
|
||||
except Exception as e:
|
||||
|
||||
@@ -18,28 +18,28 @@ class ReplyTrackerService:
|
||||
评论回复跟踪服务
|
||||
使用本地JSON文件持久化存储已回复的评论ID
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
# 数据存储路径
|
||||
self.data_dir = Path(__file__).resolve().parent.parent / "data"
|
||||
self.data_dir.mkdir(exist_ok=True)
|
||||
self.reply_record_file = self.data_dir / "replied_comments.json"
|
||||
|
||||
|
||||
# 内存中的已回复评论记录
|
||||
# 格式: {feed_id: {comment_id: timestamp, ...}, ...}
|
||||
self.replied_comments: Dict[str, Dict[str, float]] = {}
|
||||
|
||||
|
||||
# 数据清理配置
|
||||
self.max_record_days = 30 # 保留30天的记录
|
||||
|
||||
|
||||
# 加载已有数据
|
||||
self._load_data()
|
||||
|
||||
|
||||
def _load_data(self):
|
||||
"""从文件加载已回复评论数据"""
|
||||
try:
|
||||
if self.reply_record_file.exists():
|
||||
with open(self.reply_record_file, 'r', encoding='utf-8') as f:
|
||||
with open(self.reply_record_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.replied_comments = data
|
||||
logger.info(f"已加载 {len(self.replied_comments)} 条说说的回复记录")
|
||||
@@ -48,71 +48,70 @@ class ReplyTrackerService:
|
||||
except Exception as e:
|
||||
logger.error(f"加载回复记录失败: {e}")
|
||||
self.replied_comments = {}
|
||||
|
||||
|
||||
def _save_data(self):
|
||||
"""保存已回复评论数据到文件"""
|
||||
try:
|
||||
# 清理过期数据
|
||||
self._cleanup_old_records()
|
||||
|
||||
with open(self.reply_record_file, 'w', encoding='utf-8') as f:
|
||||
|
||||
with open(self.reply_record_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.replied_comments, f, ensure_ascii=False, indent=2)
|
||||
logger.debug("回复记录已保存")
|
||||
except Exception as e:
|
||||
logger.error(f"保存回复记录失败: {e}")
|
||||
|
||||
|
||||
def _cleanup_old_records(self):
|
||||
"""清理超过保留期限的记录"""
|
||||
current_time = time.time()
|
||||
cutoff_time = current_time - (self.max_record_days * 24 * 60 * 60)
|
||||
|
||||
|
||||
feeds_to_remove = []
|
||||
total_removed = 0
|
||||
|
||||
|
||||
for feed_id, comments in self.replied_comments.items():
|
||||
comments_to_remove = []
|
||||
|
||||
|
||||
for comment_id, timestamp in comments.items():
|
||||
if timestamp < cutoff_time:
|
||||
comments_to_remove.append(comment_id)
|
||||
|
||||
|
||||
# 移除过期的评论记录
|
||||
for comment_id in comments_to_remove:
|
||||
del comments[comment_id]
|
||||
total_removed += 1
|
||||
|
||||
|
||||
# 如果该说说下没有任何记录了,标记删除整个说说记录
|
||||
if not comments:
|
||||
feeds_to_remove.append(feed_id)
|
||||
|
||||
|
||||
# 移除空的说说记录
|
||||
for feed_id in feeds_to_remove:
|
||||
del self.replied_comments[feed_id]
|
||||
|
||||
|
||||
if total_removed > 0:
|
||||
logger.info(f"清理了 {total_removed} 条过期的回复记录")
|
||||
|
||||
|
||||
def has_replied(self, feed_id: str, comment_id: str) -> bool:
|
||||
"""
|
||||
检查是否已经回复过指定的评论
|
||||
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
comment_id: 评论ID
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 如果已回复过返回True,否则返回False
|
||||
"""
|
||||
if not feed_id or not comment_id:
|
||||
return False
|
||||
|
||||
return (feed_id in self.replied_comments and
|
||||
comment_id in self.replied_comments[feed_id])
|
||||
|
||||
|
||||
return feed_id in self.replied_comments and comment_id in self.replied_comments[feed_id]
|
||||
|
||||
def mark_as_replied(self, feed_id: str, comment_id: str):
|
||||
"""
|
||||
标记指定评论为已回复
|
||||
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
comment_id: 评论ID
|
||||
@@ -120,76 +119,76 @@ class ReplyTrackerService:
|
||||
if not feed_id or not comment_id:
|
||||
logger.warning("feed_id 或 comment_id 为空,无法标记为已回复")
|
||||
return
|
||||
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
if feed_id not in self.replied_comments:
|
||||
self.replied_comments[feed_id] = {}
|
||||
|
||||
|
||||
self.replied_comments[feed_id][comment_id] = current_time
|
||||
|
||||
|
||||
# 保存到文件
|
||||
self._save_data()
|
||||
|
||||
|
||||
logger.info(f"已标记评论为已回复: feed_id={feed_id}, comment_id={comment_id}")
|
||||
|
||||
|
||||
def get_replied_comments(self, feed_id: str) -> Set[str]:
|
||||
"""
|
||||
获取指定说说下所有已回复的评论ID
|
||||
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
|
||||
|
||||
Returns:
|
||||
Set[str]: 已回复的评论ID集合
|
||||
"""
|
||||
if feed_id in self.replied_comments:
|
||||
return set(self.replied_comments[feed_id].keys())
|
||||
return set()
|
||||
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取回复记录统计信息
|
||||
|
||||
|
||||
Returns:
|
||||
Dict: 包含统计信息的字典
|
||||
"""
|
||||
total_feeds = len(self.replied_comments)
|
||||
total_replies = sum(len(comments) for comments in self.replied_comments.values())
|
||||
|
||||
|
||||
return {
|
||||
"total_feeds_with_replies": total_feeds,
|
||||
"total_replied_comments": total_replies,
|
||||
"data_file": str(self.reply_record_file),
|
||||
"max_record_days": self.max_record_days
|
||||
"max_record_days": self.max_record_days,
|
||||
}
|
||||
|
||||
|
||||
def remove_reply_record(self, feed_id: str, comment_id: str):
|
||||
"""
|
||||
移除指定评论的回复记录
|
||||
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
comment_id: 评论ID
|
||||
"""
|
||||
if feed_id in self.replied_comments and comment_id in self.replied_comments[feed_id]:
|
||||
del self.replied_comments[feed_id][comment_id]
|
||||
|
||||
|
||||
# 如果该说说下没有任何回复记录了,删除整个说说记录
|
||||
if not self.replied_comments[feed_id]:
|
||||
del self.replied_comments[feed_id]
|
||||
|
||||
|
||||
self._save_data()
|
||||
logger.debug(f"已移除回复记录: feed_id={feed_id}, comment_id={comment_id}")
|
||||
|
||||
|
||||
def remove_feed_records(self, feed_id: str):
|
||||
"""
|
||||
移除指定说说的所有回复记录
|
||||
|
||||
|
||||
Args:
|
||||
feed_id: 说说ID
|
||||
"""
|
||||
if feed_id in self.replied_comments:
|
||||
del self.replied_comments[feed_id]
|
||||
self._save_data()
|
||||
logger.info(f"已移除说说 {feed_id} 的所有回复记录")
|
||||
logger.info(f"已移除说说 {feed_id} 的所有回复记录")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
定时任务服务
|
||||
根据日程表定时发送说说。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import random
|
||||
@@ -16,14 +17,14 @@ from src.common.database.sqlalchemy_models import MaiZoneScheduleStatus
|
||||
|
||||
from .qzone_service import QZoneService
|
||||
|
||||
logger = get_logger('MaiZone.SchedulerService')
|
||||
logger = get_logger("MaiZone.SchedulerService")
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
"""
|
||||
定时任务管理器,负责根据全局日程表定时触发说说发送任务。
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, get_config: Callable, qzone_service: QZoneService):
|
||||
"""
|
||||
初始化定时任务服务。
|
||||
@@ -80,7 +81,7 @@ class SchedulerService:
|
||||
now = datetime.datetime.now()
|
||||
forbidden_start = self.get_config("schedule.forbidden_hours_start", 2)
|
||||
forbidden_end = self.get_config("schedule.forbidden_hours_end", 6)
|
||||
|
||||
|
||||
is_forbidden_time = False
|
||||
if forbidden_start < forbidden_end:
|
||||
# 例如,2点到6点
|
||||
@@ -90,26 +91,25 @@ class SchedulerService:
|
||||
is_forbidden_time = now.hour >= forbidden_start or now.hour < forbidden_end
|
||||
|
||||
if is_forbidden_time:
|
||||
logger.info(f"当前时间 {now.hour}点 处于禁止发送时段 ({forbidden_start}-{forbidden_end}),本次跳过。")
|
||||
logger.info(
|
||||
f"当前时间 {now.hour}点 处于禁止发送时段 ({forbidden_start}-{forbidden_end}),本次跳过。"
|
||||
)
|
||||
self.last_processed_activity = current_activity
|
||||
|
||||
|
||||
# 4. 检查活动是否是新的活动
|
||||
elif current_activity != self.last_processed_activity:
|
||||
logger.info(f"检测到新的日程活动: '{current_activity}',准备发送说说。")
|
||||
|
||||
|
||||
# 5. 调用QZoneService执行完整的发送流程
|
||||
result = await self.qzone_service.send_feed_from_activity(current_activity)
|
||||
|
||||
|
||||
# 6. 将处理结果记录到数据库
|
||||
now = datetime.datetime.now()
|
||||
hour_str = now.strftime("%Y-%m-%d %H")
|
||||
await self._mark_as_processed(
|
||||
hour_str,
|
||||
current_activity,
|
||||
result.get("success", False),
|
||||
result.get("message", "")
|
||||
hour_str, current_activity, result.get("success", False), result.get("message", "")
|
||||
)
|
||||
|
||||
|
||||
# 7. 更新上一个处理的活动
|
||||
self.last_processed_activity = current_activity
|
||||
else:
|
||||
@@ -121,7 +121,7 @@ class SchedulerService:
|
||||
wait_seconds = random.randint(min_minutes * 60, max_minutes * 60)
|
||||
logger.info(f"下一次检查将在 {wait_seconds / 60:.2f} 分钟后进行。")
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("定时任务循环被取消。")
|
||||
break
|
||||
@@ -139,10 +139,14 @@ class SchedulerService:
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
record = session.query(MaiZoneScheduleStatus).filter(
|
||||
MaiZoneScheduleStatus.datetime_hour == hour_str,
|
||||
MaiZoneScheduleStatus.is_processed == True # noqa: E712
|
||||
).first()
|
||||
record = (
|
||||
session.query(MaiZoneScheduleStatus)
|
||||
.filter(
|
||||
MaiZoneScheduleStatus.datetime_hour == hour_str,
|
||||
MaiZoneScheduleStatus.is_processed == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return record is not None
|
||||
except Exception as e:
|
||||
logger.error(f"检查日程处理状态时发生数据库错误: {e}")
|
||||
@@ -160,16 +164,16 @@ class SchedulerService:
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
# 查找是否已存在该记录
|
||||
record = session.query(MaiZoneScheduleStatus).filter(
|
||||
MaiZoneScheduleStatus.datetime_hour == hour_str
|
||||
).first()
|
||||
|
||||
record = (
|
||||
session.query(MaiZoneScheduleStatus).filter(MaiZoneScheduleStatus.datetime_hour == hour_str).first()
|
||||
)
|
||||
|
||||
if record:
|
||||
# 如果存在,则更新状态
|
||||
record.is_processed = True # type: ignore
|
||||
record.processed_at = datetime.datetime.now()# type: ignore
|
||||
record.send_success = success# type: ignore
|
||||
record.story_content = content# type: ignore
|
||||
record.is_processed = True # type: ignore
|
||||
record.processed_at = datetime.datetime.now() # type: ignore
|
||||
record.send_success = success # type: ignore
|
||||
record.story_content = content # type: ignore
|
||||
else:
|
||||
# 如果不存在,则创建新记录
|
||||
new_record = MaiZoneScheduleStatus(
|
||||
@@ -178,10 +182,10 @@ class SchedulerService:
|
||||
is_processed=True,
|
||||
processed_at=datetime.datetime.now(),
|
||||
story_content=content,
|
||||
send_success=success
|
||||
send_success=success,
|
||||
)
|
||||
session.add(new_record)
|
||||
session.commit()
|
||||
logger.info(f"已更新日程处理状态: {hour_str} - {activity} - 成功: {success}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新日程处理状态时发生数据库错误: {e}")
|
||||
logger.error(f"更新日程处理状态时发生数据库错误: {e}")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
历史记录工具模块
|
||||
提供用于获取QQ空间发送历史的功能。
|
||||
"""
|
||||
|
||||
import orjson
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -29,7 +30,7 @@ class _CookieManager:
|
||||
cookie_file = _CookieManager.get_cookie_file_path(qq_account)
|
||||
if os.path.exists(cookie_file):
|
||||
try:
|
||||
with open(cookie_file, 'r', encoding='utf-8') as f:
|
||||
with open(cookie_file, "r", encoding="utf-8") as f:
|
||||
return orjson.loads(f.read())
|
||||
except Exception as e:
|
||||
logger.error(f"加载Cookie文件失败: {e}")
|
||||
@@ -38,12 +39,13 @@ class _CookieManager:
|
||||
|
||||
class _SimpleQZoneAPI:
|
||||
"""极简的QZone API客户端,仅用于获取说说列表"""
|
||||
|
||||
LIST_URL = "https://user.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6"
|
||||
|
||||
def __init__(self, cookies_dict: Optional[Dict[str, str]] = None):
|
||||
self.cookies = cookies_dict or {}
|
||||
self.gtk2 = ''
|
||||
p_skey = self.cookies.get('p_skey') or self.cookies.get('p_skey'.upper())
|
||||
self.gtk2 = ""
|
||||
p_skey = self.cookies.get("p_skey") or self.cookies.get("p_skey".upper())
|
||||
if p_skey:
|
||||
self.gtk2 = self._generate_gtk(p_skey)
|
||||
|
||||
@@ -56,9 +58,17 @@ class _SimpleQZoneAPI:
|
||||
def get_feed_list(self, target_qq: str, num: int) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
params = {
|
||||
'g_tk': self.gtk2, "uin": target_qq, "ftype": 0, "sort": 0,
|
||||
"pos": 0, "num": num, "replynum": 100, "callback": "_preloadCallback",
|
||||
"code_version": 1, "format": "jsonp", "need_comment": 1
|
||||
"g_tk": self.gtk2,
|
||||
"uin": target_qq,
|
||||
"ftype": 0,
|
||||
"sort": 0,
|
||||
"pos": 0,
|
||||
"num": num,
|
||||
"replynum": 100,
|
||||
"callback": "_preloadCallback",
|
||||
"code_version": 1,
|
||||
"format": "jsonp",
|
||||
"need_comment": 1,
|
||||
}
|
||||
res = requests.get(self.LIST_URL, params=params, cookies=self.cookies, timeout=10)
|
||||
|
||||
@@ -66,7 +76,7 @@ class _SimpleQZoneAPI:
|
||||
return []
|
||||
|
||||
data = res.text
|
||||
json_str = data[len('_preloadCallback('):-2] if data.startswith('_preloadCallback(') else data
|
||||
json_str = data[len("_preloadCallback(") : -2] if data.startswith("_preloadCallback(") else data
|
||||
json_data = orjson.loads(json_str)
|
||||
|
||||
return json_data.get("msglist", [])
|
||||
@@ -111,4 +121,4 @@ async def get_send_history(qq_account: str) -> str:
|
||||
return "".join(history_lines)
|
||||
except Exception as e:
|
||||
logger.error(f"获取发送历史失败: {e}")
|
||||
return ""
|
||||
return ""
|
||||
|
||||
@@ -24,28 +24,22 @@ logger = get_logger("Permission")
|
||||
|
||||
class PermissionCommand(PlusCommand):
|
||||
"""权限管理命令 - 使用PlusCommand系统"""
|
||||
|
||||
|
||||
command_name = "permission"
|
||||
command_description = "权限管理命令,支持授权、撤销、查询等功能"
|
||||
command_aliases = ["perm", "权限"]
|
||||
priority = 10
|
||||
chat_type_allow = ChatType.ALL
|
||||
intercept_message = True
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# 注册权限节点
|
||||
permission_api.register_permission_node(
|
||||
"plugin.permission.manage",
|
||||
"权限管理:可以授权和撤销其他用户的权限",
|
||||
"permission_manager",
|
||||
False
|
||||
"plugin.permission.manage", "权限管理:可以授权和撤销其他用户的权限", "permission_manager", False
|
||||
)
|
||||
permission_api.register_permission_node(
|
||||
"plugin.permission.view",
|
||||
"权限查看:可以查看权限节点和用户权限信息",
|
||||
"permission_manager",
|
||||
True
|
||||
"plugin.permission.view", "权限查看:可以查看权限节点和用户权限信息", "permission_manager", True
|
||||
)
|
||||
|
||||
async def execute(self, args: CommandArgs) -> Tuple[bool, Optional[str], bool]:
|
||||
@@ -53,39 +47,39 @@ class PermissionCommand(PlusCommand):
|
||||
if args.is_empty:
|
||||
await self._show_help()
|
||||
return True, "显示帮助信息", True
|
||||
|
||||
|
||||
subcommand = args.get_first.lower()
|
||||
remaining_args = args.get_args()[1:] # 获取除第一个参数外的所有参数
|
||||
chat_stream = self.message.chat_stream
|
||||
|
||||
|
||||
if subcommand in ["grant", "授权", "give"]:
|
||||
await self._grant_permission(chat_stream, remaining_args)
|
||||
return True, "执行授权命令", True
|
||||
|
||||
|
||||
elif subcommand in ["revoke", "撤销", "remove"]:
|
||||
await self._revoke_permission(chat_stream, remaining_args)
|
||||
return True, "执行撤销命令", True
|
||||
|
||||
|
||||
elif subcommand in ["list", "列表", "ls"]:
|
||||
await self._list_permissions(chat_stream, remaining_args)
|
||||
return True, "执行列表命令", True
|
||||
|
||||
|
||||
elif subcommand in ["check", "检查"]:
|
||||
await self._check_permission(chat_stream, remaining_args)
|
||||
return True, "执行检查命令", True
|
||||
|
||||
|
||||
elif subcommand in ["nodes", "节点"]:
|
||||
await self._list_nodes(chat_stream, remaining_args)
|
||||
return True, "执行节点命令", True
|
||||
|
||||
|
||||
elif subcommand in ["allnodes", "全部节点", "all"]:
|
||||
await self._list_all_nodes_with_description(chat_stream)
|
||||
return True, "执行全部节点命令", True
|
||||
|
||||
|
||||
elif subcommand in ["help", "帮助"]:
|
||||
await self._show_help()
|
||||
return True, "显示帮助信息", True
|
||||
|
||||
|
||||
else:
|
||||
await self.send_text(f"❌ 未知的子命令: {subcommand}\n使用 /permission help 查看帮助")
|
||||
return True, "未知子命令", True
|
||||
@@ -114,59 +108,58 @@ class PermissionCommand(PlusCommand):
|
||||
• /permission allnodes
|
||||
|
||||
🔄 别名:可以使用 /perm 或 /权限 代替 /permission"""
|
||||
|
||||
|
||||
await self.send_text(help_text)
|
||||
|
||||
|
||||
def _parse_user_mention(self, mention: str) -> Optional[str]:
|
||||
"""解析用户提及,提取QQ号
|
||||
|
||||
|
||||
支持的格式:
|
||||
- @<用户名:QQ号> 格式
|
||||
- [CQ:at,qq=QQ号] 格式
|
||||
- [CQ:at,qq=QQ号] 格式
|
||||
- 直接的QQ号
|
||||
"""
|
||||
# 匹配 @<用户名:QQ号> 格式,提取QQ号
|
||||
at_match = re.search(r'@<[^:]+:(\d+)>', mention)
|
||||
at_match = re.search(r"@<[^:]+:(\d+)>", mention)
|
||||
if at_match:
|
||||
return at_match.group(1)
|
||||
|
||||
|
||||
# 直接是数字
|
||||
if mention.isdigit():
|
||||
return mention
|
||||
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def parse_user_from_args(args: CommandArgs, index: int = 0) -> Optional[str]:
|
||||
"""从CommandArgs中解析用户ID
|
||||
|
||||
|
||||
Args:
|
||||
args: 命令参数对象
|
||||
index: 参数索引,默认为0(第一个参数)
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[str]: 解析出的用户ID,如果解析失败返回None
|
||||
"""
|
||||
if index >= args.count():
|
||||
return None
|
||||
|
||||
|
||||
mention = args.get_arg(index)
|
||||
|
||||
|
||||
# 匹配 @<用户名:QQ号> 格式,提取QQ号
|
||||
at_match = re.search(r'@<[^:]+:(\d+)>', mention)
|
||||
at_match = re.search(r"@<[^:]+:(\d+)>", mention)
|
||||
if at_match:
|
||||
return at_match.group(1)
|
||||
|
||||
|
||||
# 匹配传统的 [CQ:at,qq=数字] 格式
|
||||
cq_match = re.search(r'\[CQ:at,qq=(\d+)\]', mention)
|
||||
cq_match = re.search(r"\[CQ:at,qq=(\d+)\]", mention)
|
||||
if cq_match:
|
||||
return cq_match.group(1)
|
||||
|
||||
|
||||
# 直接是数字
|
||||
if mention.isdigit():
|
||||
return mention
|
||||
|
||||
|
||||
return None
|
||||
|
||||
@require_permission("plugin.permission.manage", "❌ 你没有权限管理的权限")
|
||||
@@ -175,18 +168,18 @@ class PermissionCommand(PlusCommand):
|
||||
if len(args) < 2:
|
||||
await self.send_text("❌ 用法: /permission grant <@用户|QQ号> <权限节点>")
|
||||
return
|
||||
|
||||
|
||||
# 解析用户ID - 使用新的解析方法
|
||||
user_id = self._parse_user_mention(args[0])
|
||||
if not user_id:
|
||||
await self.send_text("❌ 无效的用户格式,请使用 @<用户名:QQ号> 或直接输入QQ号")
|
||||
return
|
||||
|
||||
|
||||
permission_node = args[1]
|
||||
|
||||
|
||||
# 执行授权
|
||||
success = permission_api.grant_permission(chat_stream.platform, user_id, permission_node)
|
||||
|
||||
|
||||
if success:
|
||||
await self.send_text(f"✅ 已授权用户 {user_id} 权限节点 `{permission_node}`")
|
||||
else:
|
||||
@@ -198,28 +191,28 @@ class PermissionCommand(PlusCommand):
|
||||
if len(args) < 2:
|
||||
await self.send_text("❌ 用法: /permission revoke <@用户|QQ号> <权限节点>")
|
||||
return
|
||||
|
||||
|
||||
# 解析用户ID - 使用新的解析方法
|
||||
user_id = self._parse_user_mention(args[0])
|
||||
if not user_id:
|
||||
await self.send_text("❌ 无效的用户格式,请使用 @<用户名:QQ号> 或直接输入QQ号")
|
||||
return
|
||||
|
||||
|
||||
permission_node = args[1]
|
||||
|
||||
|
||||
# 执行撤销
|
||||
success = permission_api.revoke_permission(chat_stream.platform, user_id, permission_node)
|
||||
|
||||
|
||||
if success:
|
||||
await self.send_text(f"✅ 已撤销用户 {user_id} 权限节点 `{permission_node}`")
|
||||
else:
|
||||
await self.send_text("❌ 撤销失败,请检查权限节点是否存在")
|
||||
|
||||
|
||||
@require_permission("plugin.permission.view", "❌ 你没有查看权限的权限")
|
||||
async def _list_permissions(self, chat_stream, args: List[str]):
|
||||
"""列出用户权限"""
|
||||
target_user_id = None
|
||||
|
||||
|
||||
if args:
|
||||
# 指定了用户 - 使用新的解析方法
|
||||
target_user_id = self._parse_user_mention(args[0])
|
||||
@@ -229,13 +222,13 @@ class PermissionCommand(PlusCommand):
|
||||
else:
|
||||
# 查看自己的权限
|
||||
target_user_id = chat_stream.user_info.user_id
|
||||
|
||||
|
||||
# 检查是否为Master用户
|
||||
is_master = permission_api.is_master(chat_stream.platform, target_user_id)
|
||||
|
||||
|
||||
# 获取用户权限
|
||||
permissions = permission_api.get_user_permissions(chat_stream.platform, target_user_id)
|
||||
|
||||
|
||||
if is_master:
|
||||
response = f"👑 用户 `{target_user_id}` 是Master用户,拥有所有权限"
|
||||
else:
|
||||
@@ -244,7 +237,7 @@ class PermissionCommand(PlusCommand):
|
||||
response = f"📋 用户 `{target_user_id}` 拥有的权限:\n{perm_list}"
|
||||
else:
|
||||
response = f"📋 用户 `{target_user_id}` 没有任何权限"
|
||||
|
||||
|
||||
await self.send_text(response)
|
||||
|
||||
@require_permission("plugin.permission.view", "❌ 你没有查看权限的权限")
|
||||
@@ -253,19 +246,19 @@ class PermissionCommand(PlusCommand):
|
||||
if len(args) < 2:
|
||||
await self.send_text("❌ 用法: /permission check <@用户|QQ号> <权限节点>")
|
||||
return
|
||||
|
||||
|
||||
# 解析用户ID - 使用新的解析方法
|
||||
user_id = self._parse_user_mention(args[0])
|
||||
if not user_id:
|
||||
await self.send_text("❌ 无效的用户格式,请使用 @<用户名:QQ号> 或直接输入QQ号")
|
||||
return
|
||||
|
||||
|
||||
permission_node = args[1]
|
||||
|
||||
|
||||
# 检查权限
|
||||
has_permission = permission_api.check_permission(chat_stream.platform, user_id, permission_node)
|
||||
is_master = permission_api.is_master(chat_stream.platform, user_id)
|
||||
|
||||
|
||||
if has_permission:
|
||||
if is_master:
|
||||
response = f"✅ 用户 `{user_id}` 拥有权限 `{permission_node}`(Master用户)"
|
||||
@@ -273,14 +266,14 @@ class PermissionCommand(PlusCommand):
|
||||
response = f"✅ 用户 `{user_id}` 拥有权限 `{permission_node}`"
|
||||
else:
|
||||
response = f"❌ 用户 `{user_id}` 没有权限 `{permission_node}`"
|
||||
|
||||
|
||||
await self.send_text(response)
|
||||
|
||||
@require_permission("plugin.permission.view", "❌ 你没有查看权限的权限")
|
||||
async def _list_nodes(self, chat_stream, args: List[str]):
|
||||
"""列出权限节点"""
|
||||
plugin_name = args[0] if args else None
|
||||
|
||||
|
||||
if plugin_name:
|
||||
# 获取指定插件的权限节点
|
||||
nodes = permission_api.get_plugin_permission_nodes(plugin_name)
|
||||
@@ -289,7 +282,7 @@ class PermissionCommand(PlusCommand):
|
||||
# 获取所有权限节点
|
||||
nodes = permission_api.get_all_permission_nodes()
|
||||
title = "📋 所有权限节点:"
|
||||
|
||||
|
||||
if not nodes:
|
||||
if plugin_name:
|
||||
response = f"📋 插件 {plugin_name} 没有注册任何权限节点"
|
||||
@@ -304,9 +297,9 @@ class PermissionCommand(PlusCommand):
|
||||
if not plugin_name:
|
||||
node_list.append(f" 🔌 插件: {node['plugin_name']}")
|
||||
node_list.append("") # 空行分隔
|
||||
|
||||
|
||||
response = title + "\n" + "\n".join(node_list)
|
||||
|
||||
|
||||
await self.send_text(response)
|
||||
|
||||
@require_permission("plugin.permission.view", "❌ 你没有查看权限的权限")
|
||||
@@ -314,12 +307,12 @@ class PermissionCommand(PlusCommand):
|
||||
"""列出所有插件的权限节点(带详细描述)"""
|
||||
# 获取所有权限节点
|
||||
all_nodes = permission_api.get_all_permission_nodes()
|
||||
|
||||
|
||||
if not all_nodes:
|
||||
response = "📋 系统中没有任何权限节点"
|
||||
await self.send_text(response)
|
||||
return
|
||||
|
||||
|
||||
# 按插件名分组节点
|
||||
plugins_dict = {}
|
||||
for node in all_nodes:
|
||||
@@ -327,55 +320,55 @@ class PermissionCommand(PlusCommand):
|
||||
if plugin_name not in plugins_dict:
|
||||
plugins_dict[plugin_name] = []
|
||||
plugins_dict[plugin_name].append(node)
|
||||
|
||||
|
||||
# 构建响应消息
|
||||
response_parts = ["📋 所有插件权限节点详情:\n"]
|
||||
|
||||
|
||||
for plugin_name in sorted(plugins_dict.keys()):
|
||||
nodes = plugins_dict[plugin_name]
|
||||
response_parts.append(f"🔌 **{plugin_name}** ({len(nodes)}个节点):")
|
||||
|
||||
|
||||
for node in nodes:
|
||||
default_text = "✅默认授权" if node["default_granted"] else "❌默认拒绝"
|
||||
response_parts.append(f" • `{node['node_name']}` - {default_text}")
|
||||
response_parts.append(f" 📄 {node['description']}")
|
||||
|
||||
|
||||
response_parts.append("") # 插件间空行分隔
|
||||
|
||||
|
||||
# 添加统计信息
|
||||
total_nodes = len(all_nodes)
|
||||
total_plugins = len(plugins_dict)
|
||||
response_parts.append(f"📊 统计:共 {total_plugins} 个插件,{total_nodes} 个权限节点")
|
||||
|
||||
|
||||
response = "\n".join(response_parts)
|
||||
|
||||
|
||||
# 如果消息太长,分段发送
|
||||
if len(response) > 4000: # 预留一些空间避免超出限制
|
||||
await self._send_long_message(response)
|
||||
else:
|
||||
await self.send_text(response)
|
||||
|
||||
|
||||
async def _send_long_message(self, message: str):
|
||||
"""发送长消息,自动分段"""
|
||||
lines = message.split('\n')
|
||||
lines = message.split("\n")
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
|
||||
for line in lines:
|
||||
line_length = len(line) + 1 # +1 for newline
|
||||
|
||||
|
||||
# 如果添加这一行会超出限制,先发送当前块
|
||||
if current_length + line_length > 3500 and current_chunk:
|
||||
await self.send_text('\n'.join(current_chunk))
|
||||
await self.send_text("\n".join(current_chunk))
|
||||
current_chunk = []
|
||||
current_length = 0
|
||||
|
||||
|
||||
current_chunk.append(line)
|
||||
current_length += line_length
|
||||
|
||||
|
||||
# 发送最后一块
|
||||
if current_chunk:
|
||||
await self.send_text('\n'.join(current_chunk))
|
||||
await self.send_text("\n".join(current_chunk))
|
||||
|
||||
|
||||
@register_plugin
|
||||
@@ -388,10 +381,10 @@ class PermissionManagerPlugin(BasePlugin):
|
||||
config_schema: dict = {
|
||||
"plugin": {
|
||||
"enabled": ConfigField(bool, default=True, description="是否启用插件"),
|
||||
"config_version": ConfigField(type=str, default="1.1.0", description="配置文件版本")
|
||||
"config_version": ConfigField(type=str, default="1.1.0", description="配置文件版本"),
|
||||
}
|
||||
}
|
||||
|
||||
def get_plugin_components(self) -> List[Tuple[PlusCommandInfo, Type[PlusCommand]]]:
|
||||
"""返回插件的PlusCommand组件"""
|
||||
return [(PermissionCommand.get_plus_command_info(), PermissionCommand)]
|
||||
return [(PermissionCommand.get_plus_command_info(), PermissionCommand)]
|
||||
|
||||
@@ -20,7 +20,7 @@ from src.plugin_system.core.plugin_hot_reload import hot_reload_manager
|
||||
|
||||
class ManagementCommand(PlusCommand):
|
||||
"""插件管理命令 - 使用PlusCommand系统"""
|
||||
|
||||
|
||||
command_name = "pm"
|
||||
command_description = "插件管理命令,支持插件和组件的管理操作"
|
||||
command_aliases = ["pluginmanage", "插件管理"]
|
||||
@@ -37,10 +37,10 @@ class ManagementCommand(PlusCommand):
|
||||
if args.is_empty():
|
||||
await self._show_help("all")
|
||||
return True, "显示帮助信息", True
|
||||
|
||||
|
||||
subcommand = args.get_first().lower()
|
||||
remaining_args = args.get_args()[1:] # 获取除第一个参数外的所有参数
|
||||
|
||||
|
||||
if subcommand in ["plugin", "插件"]:
|
||||
return await self._handle_plugin_commands(remaining_args)
|
||||
elif subcommand in ["component", "组件", "comp"]:
|
||||
@@ -57,9 +57,9 @@ class ManagementCommand(PlusCommand):
|
||||
if not args:
|
||||
await self._show_help("plugin")
|
||||
return True, "显示插件帮助", True
|
||||
|
||||
|
||||
action = args[0].lower()
|
||||
|
||||
|
||||
if action in ["help", "帮助"]:
|
||||
await self._show_help("plugin")
|
||||
elif action in ["list", "列表"]:
|
||||
@@ -85,7 +85,7 @@ class ManagementCommand(PlusCommand):
|
||||
else:
|
||||
await self.send_text("❌ 插件管理命令不合法\n使用 /pm plugin help 查看帮助")
|
||||
return False, "命令不合法", True
|
||||
|
||||
|
||||
return True, "插件命令执行完成", True
|
||||
|
||||
async def _handle_component_commands(self, args: List[str]) -> Tuple[bool, str, bool]:
|
||||
@@ -93,9 +93,9 @@ class ManagementCommand(PlusCommand):
|
||||
if not args:
|
||||
await self._show_help("component")
|
||||
return True, "显示组件帮助", True
|
||||
|
||||
|
||||
action = args[0].lower()
|
||||
|
||||
|
||||
if action in ["help", "帮助"]:
|
||||
await self._show_help("component")
|
||||
elif action in ["list", "列表"]:
|
||||
@@ -144,7 +144,7 @@ class ManagementCommand(PlusCommand):
|
||||
else:
|
||||
await self.send_text("❌ 组件管理命令不合法\n使用 /pm component help 查看帮助")
|
||||
return False, "命令不合法", True
|
||||
|
||||
|
||||
return True, "组件命令执行完成", True
|
||||
|
||||
async def _show_help(self, target: str):
|
||||
@@ -212,7 +212,7 @@ class ManagementCommand(PlusCommand):
|
||||
💡 示例:
|
||||
• `/pm component list type plus_command`
|
||||
• `/pm component enable global echo_command command`"""
|
||||
|
||||
|
||||
await self.send_text(help_msg)
|
||||
|
||||
async def _list_loaded_plugins(self):
|
||||
@@ -260,7 +260,7 @@ class ManagementCommand(PlusCommand):
|
||||
async def _force_reload_plugin(self, plugin_name: str):
|
||||
"""强制重载指定插件(深度清理)"""
|
||||
await self.send_text(f"🔄 开始强制重载插件: `{plugin_name}`...")
|
||||
|
||||
|
||||
try:
|
||||
success = hot_reload_manager.force_reload_plugin(plugin_name)
|
||||
if success:
|
||||
@@ -274,34 +274,34 @@ class ManagementCommand(PlusCommand):
|
||||
"""显示热重载状态"""
|
||||
try:
|
||||
status = hot_reload_manager.get_status()
|
||||
|
||||
|
||||
status_text = f"""🔄 **热重载系统状态**
|
||||
|
||||
🟢 **运行状态:** {'运行中' if status['is_running'] else '已停止'}
|
||||
📂 **监听目录:** {len(status['watch_directories'])} 个
|
||||
👁️ **活跃观察者:** {status['active_observers']} 个
|
||||
📦 **已加载插件:** {status['loaded_plugins']} 个
|
||||
❌ **失败插件:** {status['failed_plugins']} 个
|
||||
⏱️ **防抖延迟:** {status.get('debounce_delay', 0)} 秒
|
||||
🟢 **运行状态:** {"运行中" if status["is_running"] else "已停止"}
|
||||
📂 **监听目录:** {len(status["watch_directories"])} 个
|
||||
👁️ **活跃观察者:** {status["active_observers"]} 个
|
||||
📦 **已加载插件:** {status["loaded_plugins"]} 个
|
||||
❌ **失败插件:** {status["failed_plugins"]} 个
|
||||
⏱️ **防抖延迟:** {status.get("debounce_delay", 0)} 秒
|
||||
|
||||
📋 **监听的目录:**"""
|
||||
|
||||
for i, watch_dir in enumerate(status['watch_directories'], 1):
|
||||
|
||||
for i, watch_dir in enumerate(status["watch_directories"], 1):
|
||||
dir_type = "(内置插件)" if "src" in watch_dir else "(外部插件)"
|
||||
status_text += f"\n{i}. `{watch_dir}` {dir_type}"
|
||||
|
||||
if status.get('pending_reloads'):
|
||||
|
||||
if status.get("pending_reloads"):
|
||||
status_text += f"\n\n⏳ **待重载插件:** {', '.join([f'`{p}`' for p in status['pending_reloads']])}"
|
||||
|
||||
|
||||
await self.send_text(status_text)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
await self.send_text(f"❌ 获取热重载状态时发生错误: {str(e)}")
|
||||
|
||||
async def _clear_all_caches(self):
|
||||
"""清理所有模块缓存"""
|
||||
await self.send_text("🧹 开始清理所有Python模块缓存...")
|
||||
|
||||
|
||||
try:
|
||||
hot_reload_manager.clear_all_caches()
|
||||
await self.send_text("✅ 模块缓存清理完成!建议重载相关插件以确保生效。")
|
||||
@@ -432,10 +432,12 @@ class ManagementCommand(PlusCommand):
|
||||
"event_handler": ComponentType.EVENT_HANDLER,
|
||||
"plus_command": ComponentType.PLUS_COMMAND,
|
||||
}
|
||||
|
||||
|
||||
component_type = type_mapping.get(target_type.lower())
|
||||
if not component_type:
|
||||
await self.send_text(f"❌ 未知组件类型: `{target_type}`\n支持的类型: action, command, event_handler, plus_command")
|
||||
await self.send_text(
|
||||
f"❌ 未知组件类型: `{target_type}`\n支持的类型: action, command, event_handler, plus_command"
|
||||
)
|
||||
return
|
||||
|
||||
components_info = component_manage_api.get_components_info_by_type(component_type)
|
||||
@@ -456,12 +458,12 @@ class ManagementCommand(PlusCommand):
|
||||
"event_handler": ComponentType.EVENT_HANDLER,
|
||||
"plus_command": ComponentType.PLUS_COMMAND,
|
||||
}
|
||||
|
||||
|
||||
target_component_type = type_mapping.get(component_type.lower())
|
||||
if not target_component_type:
|
||||
await self.send_text(f"❌ 未知组件类型: `{component_type}`")
|
||||
return
|
||||
|
||||
|
||||
if component_manage_api.globally_enable_component(component_name, target_component_type):
|
||||
await self.send_text(f"✅ 全局启用组件成功: `{component_name}`")
|
||||
else:
|
||||
@@ -475,12 +477,12 @@ class ManagementCommand(PlusCommand):
|
||||
"event_handler": ComponentType.EVENT_HANDLER,
|
||||
"plus_command": ComponentType.PLUS_COMMAND,
|
||||
}
|
||||
|
||||
|
||||
target_component_type = type_mapping.get(component_type.lower())
|
||||
if not target_component_type:
|
||||
await self.send_text(f"❌ 未知组件类型: `{component_type}`")
|
||||
return
|
||||
|
||||
|
||||
success = await component_manage_api.globally_disable_component(component_name, target_component_type)
|
||||
if success:
|
||||
await self.send_text(f"✅ 全局禁用组件成功: `{component_name}`")
|
||||
@@ -495,12 +497,12 @@ class ManagementCommand(PlusCommand):
|
||||
"event_handler": ComponentType.EVENT_HANDLER,
|
||||
"plus_command": ComponentType.PLUS_COMMAND,
|
||||
}
|
||||
|
||||
|
||||
target_component_type = type_mapping.get(component_type.lower())
|
||||
if not target_component_type:
|
||||
await self.send_text(f"❌ 未知组件类型: `{component_type}`")
|
||||
return
|
||||
|
||||
|
||||
stream_id = self.message.chat_stream.stream_id
|
||||
if component_manage_api.locally_enable_component(component_name, target_component_type, stream_id):
|
||||
await self.send_text(f"✅ 本地启用组件成功: `{component_name}`")
|
||||
@@ -515,12 +517,12 @@ class ManagementCommand(PlusCommand):
|
||||
"event_handler": ComponentType.EVENT_HANDLER,
|
||||
"plus_command": ComponentType.PLUS_COMMAND,
|
||||
}
|
||||
|
||||
|
||||
target_component_type = type_mapping.get(component_type.lower())
|
||||
if not target_component_type:
|
||||
await self.send_text(f"❌ 未知组件类型: `{component_type}`")
|
||||
return
|
||||
|
||||
|
||||
stream_id = self.message.chat_stream.stream_id
|
||||
if component_manage_api.locally_disable_component(component_name, target_component_type, stream_id):
|
||||
await self.send_text(f"✅ 本地禁用组件成功: `{component_name}`")
|
||||
@@ -549,7 +551,7 @@ class PluginManagementPlugin(BasePlugin):
|
||||
"plugin.management.admin",
|
||||
"插件管理:可以管理插件和组件的加载、卸载、启用、禁用等操作",
|
||||
"plugin_management",
|
||||
False
|
||||
False,
|
||||
)
|
||||
|
||||
def get_plugin_components(self) -> List[Tuple[PlusCommandInfo, Type[PlusCommand]]]:
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.plugin_system.apis import generator_api
|
||||
|
||||
logger = get_logger("poke_plugin")
|
||||
|
||||
|
||||
# ===== Action组件 =====
|
||||
class PokeAction(BaseAction):
|
||||
"""发送戳一戳动作"""
|
||||
@@ -61,98 +62,95 @@ class PokeAction(BaseAction):
|
||||
return False, f"找不到名为 '{user_name}' 的用户"
|
||||
|
||||
user_id = user_info.get("user_id")
|
||||
|
||||
|
||||
for i in range(times):
|
||||
logger.info(f"正在向 {user_name} ({user_id}) 发送第 {i+1}/{times} 次戳一戳...")
|
||||
logger.info(f"正在向 {user_name} ({user_id}) 发送第 {i + 1}/{times} 次戳一戳...")
|
||||
await self.send_command(
|
||||
"SEND_POKE",
|
||||
args={"qq_id": user_id},
|
||||
display_message=f"戳了戳 {user_name} ({i+1}/{times})"
|
||||
"SEND_POKE", args={"qq_id": user_id}, display_message=f"戳了戳 {user_name} ({i + 1}/{times})"
|
||||
)
|
||||
# 添加一个小的延迟,以避免发送过快
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
success_message = f"已向 {user_name} 发送 {times} 次戳一戳。"
|
||||
await self.store_action_info(
|
||||
action_build_into_prompt=True,
|
||||
action_prompt_display=success_message,
|
||||
action_done=True
|
||||
action_build_into_prompt=True, action_prompt_display=success_message, action_done=True
|
||||
)
|
||||
return True, success_message
|
||||
|
||||
|
||||
# ===== Command组件 =====
|
||||
class PokeBackCommand(BaseCommand):
|
||||
"""反戳命令组件"""
|
||||
|
||||
|
||||
command_name = "poke_back"
|
||||
command_description = "检测到戳一戳时自动反戳回去"
|
||||
# 匹配戳一戳的正则表达式 - 匹配 "xxx戳了戳xxx" 的格式
|
||||
command_pattern = r"(?P<poker_name>\S+)\s*戳了戳\s*(?P<target_name>\S+)"
|
||||
|
||||
|
||||
async def execute(self) -> Tuple[bool, str, bool]:
|
||||
"""执行反戳逻辑"""
|
||||
# 检查反戳功能是否启用
|
||||
if not self.get_config("components.command_poke_back", True):
|
||||
return False, "", False
|
||||
|
||||
|
||||
# 获取匹配的用户名
|
||||
poker_name = self.matched_groups.get("poker_name", "")
|
||||
target_name = self.matched_groups.get("target_name", "")
|
||||
|
||||
|
||||
if not poker_name or not target_name:
|
||||
logger.debug("戳一戳消息格式不匹配,跳过反戳")
|
||||
return False, "", False
|
||||
|
||||
|
||||
# 只有当目标是机器人自己时才反戳
|
||||
if target_name not in ["我", "bot", "机器人", "麦麦"]:
|
||||
logger.debug(f"戳一戳目标不是机器人 ({target_name}), 跳过反戳")
|
||||
return False, "", False
|
||||
|
||||
|
||||
# 获取戳我的用户信息
|
||||
poker_info = await get_person_info_manager().get_person_info_by_name(poker_name)
|
||||
if not poker_info or not poker_info.get("user_id"):
|
||||
logger.info(f"找不到名为 '{poker_name}' 的用户信息,无法反戳")
|
||||
return False, "", False
|
||||
|
||||
|
||||
poker_id = poker_info.get("user_id")
|
||||
if not isinstance(poker_id, (int, str)):
|
||||
logger.error(f"获取到的用户ID类型不正确: {type(poker_id)}")
|
||||
return False, "", False
|
||||
|
||||
|
||||
# 确保poker_id是整数类型
|
||||
try:
|
||||
poker_id = int(poker_id)
|
||||
except (ValueError, TypeError):
|
||||
logger.error(f"无法将用户ID转换为整数: {poker_id}")
|
||||
return False, "", False
|
||||
|
||||
|
||||
# 检查反戳冷却时间(防止频繁反戳)
|
||||
cooldown_seconds = self.get_config("components.poke_back_cooldown", 5)
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
|
||||
|
||||
# 使用类变量存储上次反戳时间
|
||||
if not hasattr(PokeBackCommand, '_last_poke_back_time'):
|
||||
if not hasattr(PokeBackCommand, "_last_poke_back_time"):
|
||||
PokeBackCommand._last_poke_back_time = {}
|
||||
|
||||
|
||||
last_time = PokeBackCommand._last_poke_back_time.get(poker_id, 0)
|
||||
if current_time - last_time < cooldown_seconds:
|
||||
logger.info(f"反戳冷却中,跳过对 {poker_name} 的反戳")
|
||||
return False, "", False
|
||||
|
||||
|
||||
# 记录本次反戳时间
|
||||
PokeBackCommand._last_poke_back_time[poker_id] = current_time
|
||||
|
||||
|
||||
# 执行反戳
|
||||
logger.info(f"检测到 {poker_name} 戳了我,准备反戳回去")
|
||||
|
||||
|
||||
try:
|
||||
# 获取反戳模式
|
||||
poke_back_mode = self.get_config("components.poke_back_mode", "poke") # "poke", "reply", "random"
|
||||
|
||||
|
||||
if poke_back_mode == "random":
|
||||
# 随机选择模式
|
||||
poke_back_mode = random.choice(["poke", "reply"])
|
||||
|
||||
|
||||
if poke_back_mode == "poke":
|
||||
# 戳回去模式
|
||||
await self._poke_back(poker_id, poker_name)
|
||||
@@ -162,46 +160,49 @@ class PokeBackCommand(BaseCommand):
|
||||
else:
|
||||
logger.warning(f"未知的反戳模式: {poke_back_mode}")
|
||||
return False, "", False
|
||||
|
||||
|
||||
logger.info(f"成功反戳了 {poker_name} (模式: {poke_back_mode})")
|
||||
return True, f"反戳了 {poker_name}", False # 不拦截消息继续处理
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"反戳失败: {e}")
|
||||
return False, "", False
|
||||
|
||||
|
||||
async def _poke_back(self, poker_id: int, poker_name: str):
|
||||
"""执行戳一戳反击"""
|
||||
await self.send_command(
|
||||
"SEND_POKE",
|
||||
args={"qq_id": poker_id},
|
||||
display_message=f"反戳了 {poker_name}",
|
||||
storage_message=False # 不存储到消息历史中
|
||||
storage_message=False, # 不存储到消息历史中
|
||||
)
|
||||
|
||||
|
||||
# 可选:发送一个随机的反戳回复
|
||||
poke_back_messages = self.get_config("components.poke_back_messages", [
|
||||
"哼,戳回去!",
|
||||
"戳我干嘛~",
|
||||
"反戳!",
|
||||
"你戳我,我戳你!",
|
||||
"(戳回去)",
|
||||
])
|
||||
|
||||
poke_back_messages = self.get_config(
|
||||
"components.poke_back_messages",
|
||||
[
|
||||
"哼,戳回去!",
|
||||
"戳我干嘛~",
|
||||
"反戳!",
|
||||
"你戳我,我戳你!",
|
||||
"(戳回去)",
|
||||
],
|
||||
)
|
||||
|
||||
if poke_back_messages and self.get_config("components.send_poke_back_message", False):
|
||||
reply_message = random.choice(poke_back_messages)
|
||||
await self.send_text(reply_message)
|
||||
|
||||
|
||||
async def _reply_back(self, poker_name: str):
|
||||
"""生成AI回复"""
|
||||
# 构造回复上下文
|
||||
extra_info = f"{poker_name}戳了我一下,需要生成一个有趣的回应。"
|
||||
|
||||
|
||||
# 获取配置,确保类型正确
|
||||
enable_typo = self.get_config("components.enable_typo_in_reply", False)
|
||||
if not isinstance(enable_typo, bool):
|
||||
enable_typo = False
|
||||
|
||||
|
||||
# 使用generator_api生成回复
|
||||
success, reply_set, _ = await generator_api.generate_reply(
|
||||
chat_stream=self.message.chat_stream,
|
||||
@@ -211,7 +212,7 @@ class PokeBackCommand(BaseCommand):
|
||||
enable_chinese_typo=enable_typo,
|
||||
from_plugin=True,
|
||||
)
|
||||
|
||||
|
||||
if success and reply_set:
|
||||
# 发送生成的回复
|
||||
for reply_item in reply_set:
|
||||
@@ -222,13 +223,16 @@ class PokeBackCommand(BaseCommand):
|
||||
await self.send_type(message_type, content)
|
||||
else:
|
||||
# 如果AI回复失败,发送一个默认回复
|
||||
fallback_messages = self.get_config("components.fallback_reply_messages", [
|
||||
"被戳了!",
|
||||
"诶?",
|
||||
"做什么呢~",
|
||||
"怎么了?",
|
||||
])
|
||||
|
||||
fallback_messages = self.get_config(
|
||||
"components.fallback_reply_messages",
|
||||
[
|
||||
"被戳了!",
|
||||
"诶?",
|
||||
"做什么呢~",
|
||||
"怎么了?",
|
||||
],
|
||||
)
|
||||
|
||||
# 确保fallback_messages是列表
|
||||
if isinstance(fallback_messages, list) and fallback_messages:
|
||||
fallback_reply = random.choice(fallback_messages)
|
||||
@@ -236,6 +240,7 @@ class PokeBackCommand(BaseCommand):
|
||||
else:
|
||||
await self.send_text("被戳了!")
|
||||
|
||||
|
||||
# ===== 插件注册 =====
|
||||
@register_plugin
|
||||
class PokePlugin(BasePlugin):
|
||||
@@ -249,10 +254,7 @@ class PokePlugin(BasePlugin):
|
||||
config_file_name: str = "config.toml"
|
||||
|
||||
# 配置节描述
|
||||
config_section_descriptions = {
|
||||
"plugin": "插件基本信息",
|
||||
"components": "插件组件"
|
||||
}
|
||||
config_section_descriptions = {"plugin": "插件基本信息", "components": "插件组件"}
|
||||
|
||||
# 配置Schema定义
|
||||
config_schema: dict = {
|
||||
@@ -265,32 +267,34 @@ class PokePlugin(BasePlugin):
|
||||
"components": {
|
||||
"action_poke_user": ConfigField(type=bool, default=True, description="是否启用戳一戳功能"),
|
||||
"command_poke_back": ConfigField(type=bool, default=True, description="是否启用反戳功能"),
|
||||
"poke_back_mode": ConfigField(type=str, default="poke", description="反戳模式: poke(戳回去), reply(AI回复), random(随机)"),
|
||||
"poke_back_mode": ConfigField(
|
||||
type=str, default="poke", description="反戳模式: poke(戳回去), reply(AI回复), random(随机)"
|
||||
),
|
||||
"poke_back_cooldown": ConfigField(type=int, default=5, description="反戳冷却时间(秒)"),
|
||||
"send_poke_back_message": ConfigField(type=bool, default=False, description="戳回去时是否发送文字回复"),
|
||||
"enable_typo_in_reply": ConfigField(type=bool, default=False, description="AI回复时是否启用错字生成"),
|
||||
"poke_back_messages": ConfigField(
|
||||
type=list,
|
||||
default=["哼,戳回去!", "戳我干嘛~", "反戳!", "你戳我,我戳你!", "(戳回去)"],
|
||||
description="戳回去时的随机回复消息列表"
|
||||
type=list,
|
||||
default=["哼,戳回去!", "戳我干嘛~", "反戳!", "你戳我,我戳你!", "(戳回去)"],
|
||||
description="戳回去时的随机回复消息列表",
|
||||
),
|
||||
"fallback_reply_messages": ConfigField(
|
||||
type=list,
|
||||
default=["被戳了!", "诶?", "做什么呢~", "怎么了?"],
|
||||
description="AI回复失败时的备用回复消息列表"
|
||||
type=list,
|
||||
default=["被戳了!", "诶?", "做什么呢~", "怎么了?"],
|
||||
description="AI回复失败时的备用回复消息列表",
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]:
|
||||
components = []
|
||||
|
||||
|
||||
# 添加戳一戳动作组件
|
||||
if self.get_config("components.action_poke_user"):
|
||||
components.append((PokeAction.get_action_info(), PokeAction))
|
||||
|
||||
|
||||
# 添加反戳命令组件
|
||||
if self.get_config("components.command_poke_back"):
|
||||
components.append((PokeBackCommand.get_command_info(), PokeBackCommand))
|
||||
|
||||
return components
|
||||
|
||||
return components
|
||||
|
||||
@@ -31,7 +31,7 @@ class SetTypingStatusHandler(BaseEventHandler):
|
||||
return HandlerResult(success=False, continue_process=True, message="无法获取用户ID")
|
||||
|
||||
try:
|
||||
params = {"user_id": user_id,"event_type": 1}
|
||||
params = {"user_id": user_id, "event_type": 1}
|
||||
await send_api.adapter_command_to_stream(
|
||||
action="set_input_status",
|
||||
params=params,
|
||||
@@ -53,12 +53,12 @@ class SetTypingStatusPlugin(BasePlugin):
|
||||
dependencies = []
|
||||
python_dependencies = []
|
||||
config_file_name = ""
|
||||
|
||||
|
||||
config_schema = {}
|
||||
|
||||
def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]:
|
||||
"""注册插件的功能组件。"""
|
||||
return [(SetTypingStatusHandler.get_handler_info(), SetTypingStatusHandler)]
|
||||
|
||||
|
||||
def register_plugin(self) -> bool:
|
||||
return True
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "web_search_tool",
|
||||
"version": "1.0.0",
|
||||
"description": "一个用于在互联网上搜索信息的工具",
|
||||
"author": {
|
||||
"name": "MoFox-Studio",
|
||||
"url": "https://github.com/MoFox-Studio"
|
||||
},
|
||||
"license": "GPL-v3.0-or-later",
|
||||
|
||||
"host_application": {
|
||||
"min_version": "0.10.0"
|
||||
},
|
||||
"keywords": ["web_search", "url_parser"],
|
||||
"categories": ["web_search", "url_parser"],
|
||||
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
|
||||
"plugin_info": {
|
||||
"is_built_in": false,
|
||||
"plugin_type": "web_search"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
Search engines package
|
||||
"""
|
||||
@@ -1,31 +0,0 @@
|
||||
"""
|
||||
Base search engine interface
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
class BaseSearchEngine(ABC):
|
||||
"""
|
||||
搜索引擎基类
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def search(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
执行搜索
|
||||
|
||||
Args:
|
||||
args: 搜索参数,包含 query、num_results、time_range 等
|
||||
|
||||
Returns:
|
||||
搜索结果列表,每个结果包含 title、url、snippet、provider 字段
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""
|
||||
检查搜索引擎是否可用
|
||||
"""
|
||||
pass
|
||||
@@ -1,263 +0,0 @@
|
||||
"""
|
||||
Bing search engine implementation
|
||||
"""
|
||||
import asyncio
|
||||
import functools
|
||||
import random
|
||||
import traceback
|
||||
from typing import Dict, List, Any
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from .base import BaseSearchEngine
|
||||
|
||||
logger = get_logger("bing_engine")
|
||||
|
||||
ABSTRACT_MAX_LENGTH = 300 # abstract max length
|
||||
|
||||
user_agents = [
|
||||
# Edge浏览器
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0",
|
||||
# Chrome浏览器
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
# Firefox浏览器
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
|
||||
]
|
||||
|
||||
# 请求头信息
|
||||
HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"Cache-Control": "max-age=0",
|
||||
"Connection": "keep-alive",
|
||||
"Host": "www.bing.com",
|
||||
"Referer": "https://www.bing.com/",
|
||||
"Sec-Ch-Ua": '"Chromium";v="122", "Microsoft Edge";v="122", "Not-A.Brand";v="99"',
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": '"Windows"',
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0",
|
||||
}
|
||||
|
||||
bing_search_url = "https://www.bing.com/search?q="
|
||||
|
||||
|
||||
class BingSearchEngine(BaseSearchEngine):
|
||||
"""
|
||||
Bing搜索引擎实现
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers = HEADERS
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查Bing搜索引擎是否可用"""
|
||||
return True # Bing是免费搜索引擎,总是可用
|
||||
|
||||
async def search(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""执行Bing搜索"""
|
||||
query = args["query"]
|
||||
num_results = args.get("num_results", 3)
|
||||
time_range = args.get("time_range", "any")
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
func = functools.partial(self._search_sync, query, num_results, time_range)
|
||||
search_response = await loop.run_in_executor(None, func)
|
||||
return search_response
|
||||
except Exception as e:
|
||||
logger.error(f"Bing 搜索失败: {e}")
|
||||
return []
|
||||
|
||||
def _search_sync(self, keyword: str, num_results: int, time_range: str) -> List[Dict[str, Any]]:
|
||||
"""同步执行Bing搜索"""
|
||||
if not keyword:
|
||||
return []
|
||||
|
||||
list_result = []
|
||||
|
||||
# 构建搜索URL
|
||||
search_url = bing_search_url + keyword
|
||||
|
||||
# 如果指定了时间范围,添加时间过滤参数
|
||||
if time_range == "week":
|
||||
search_url += "&qft=+filterui:date-range-7"
|
||||
elif time_range == "month":
|
||||
search_url += "&qft=+filterui:date-range-30"
|
||||
|
||||
try:
|
||||
data = self._parse_html(search_url)
|
||||
if data:
|
||||
list_result.extend(data)
|
||||
logger.debug(f"Bing搜索 [{keyword}] 找到 {len(data)} 个结果")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bing搜索解析失败: {e}")
|
||||
return []
|
||||
|
||||
logger.debug(f"Bing搜索 [{keyword}] 完成,总共 {len(list_result)} 个结果")
|
||||
return list_result[:num_results] if len(list_result) > num_results else list_result
|
||||
|
||||
def _parse_html(self, url: str) -> List[Dict[str, Any]]:
|
||||
"""解析处理结果"""
|
||||
try:
|
||||
logger.debug(f"访问Bing搜索URL: {url}")
|
||||
|
||||
# 设置必要的Cookie
|
||||
cookies = {
|
||||
"SRCHHPGUSR": "SRCHLANG=zh-Hans", # 设置默认搜索语言为中文
|
||||
"SRCHD": "AF=NOFORM",
|
||||
"SRCHUID": "V=2&GUID=1A4D4F1C8844493F9A2E3DB0D1BC806C",
|
||||
"_SS": "SID=0D89D9A3C95C60B62E7AC80CC85461B3",
|
||||
"_EDGE_S": "ui=zh-cn", # 设置界面语言为中文
|
||||
"_EDGE_V": "1",
|
||||
}
|
||||
|
||||
# 为每次请求随机选择不同的用户代理,降低被屏蔽风险
|
||||
headers = HEADERS.copy()
|
||||
headers["User-Agent"] = random.choice(user_agents)
|
||||
|
||||
# 创建新的session
|
||||
session = requests.Session()
|
||||
session.headers.update(headers)
|
||||
session.cookies.update(cookies)
|
||||
|
||||
# 发送请求
|
||||
try:
|
||||
res = session.get(url=url, timeout=(3.05, 6), verify=True, allow_redirects=True)
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||
logger.warning(f"第一次请求超时,正在重试: {str(e)}")
|
||||
try:
|
||||
res = session.get(url=url, timeout=(5, 10), verify=False)
|
||||
except Exception as e2:
|
||||
logger.error(f"第二次请求也失败: {str(e2)}")
|
||||
return []
|
||||
|
||||
res.encoding = "utf-8"
|
||||
|
||||
# 检查响应状态
|
||||
if res.status_code == 403:
|
||||
logger.error("被禁止访问 (403 Forbidden),可能是IP被限制")
|
||||
return []
|
||||
|
||||
if res.status_code != 200:
|
||||
logger.error(f"必应搜索请求失败,状态码: {res.status_code}")
|
||||
return []
|
||||
|
||||
# 检查是否被重定向到登录页面或验证页面
|
||||
if "login.live.com" in res.url or "login.microsoftonline.com" in res.url:
|
||||
logger.error("被重定向到登录页面,可能需要登录")
|
||||
return []
|
||||
|
||||
if "https://www.bing.com/ck/a" in res.url:
|
||||
logger.error("被重定向到验证页面,可能被识别为机器人")
|
||||
return []
|
||||
|
||||
# 解析HTML
|
||||
try:
|
||||
root = BeautifulSoup(res.text, "lxml")
|
||||
except Exception:
|
||||
try:
|
||||
root = BeautifulSoup(res.text, "html.parser")
|
||||
except Exception as e:
|
||||
logger.error(f"HTML解析失败: {str(e)}")
|
||||
return []
|
||||
|
||||
list_data = []
|
||||
|
||||
# 尝试提取搜索结果
|
||||
# 方法1: 查找标准的搜索结果容器
|
||||
results = root.select("ol#b_results li.b_algo")
|
||||
|
||||
if results:
|
||||
for _rank, result in enumerate(results, 1):
|
||||
# 提取标题和链接
|
||||
title_link = result.select_one("h2 a")
|
||||
if not title_link:
|
||||
continue
|
||||
|
||||
title = title_link.get_text().strip()
|
||||
url = title_link.get("href", "")
|
||||
|
||||
# 提取摘要
|
||||
abstract = ""
|
||||
abstract_elem = result.select_one("div.b_caption p")
|
||||
if abstract_elem:
|
||||
abstract = abstract_elem.get_text().strip()
|
||||
|
||||
# 限制摘要长度
|
||||
if ABSTRACT_MAX_LENGTH and len(abstract) > ABSTRACT_MAX_LENGTH:
|
||||
abstract = abstract[:ABSTRACT_MAX_LENGTH] + "..."
|
||||
|
||||
list_data.append({
|
||||
"title": title,
|
||||
"url": url,
|
||||
"snippet": abstract,
|
||||
"provider": "Bing"
|
||||
})
|
||||
|
||||
if len(list_data) >= 10: # 限制结果数量
|
||||
break
|
||||
|
||||
# 方法2: 如果标准方法没找到结果,使用备用方法
|
||||
if not list_data:
|
||||
# 查找所有可能的搜索结果链接
|
||||
all_links = root.find_all("a")
|
||||
|
||||
for link in all_links:
|
||||
href = link.get("href", "")
|
||||
text = link.get_text().strip()
|
||||
|
||||
# 过滤有效的搜索结果链接
|
||||
if (href and text and len(text) > 10
|
||||
and not href.startswith("javascript:")
|
||||
and not href.startswith("#")
|
||||
and "http" in href
|
||||
and not any(x in href for x in [
|
||||
"bing.com/search", "bing.com/images", "bing.com/videos",
|
||||
"bing.com/maps", "bing.com/news", "login", "account",
|
||||
"microsoft", "javascript"
|
||||
])):
|
||||
|
||||
# 尝试获取摘要
|
||||
abstract = ""
|
||||
parent = link.parent
|
||||
if parent and parent.get_text():
|
||||
full_text = parent.get_text().strip()
|
||||
if len(full_text) > len(text):
|
||||
abstract = full_text.replace(text, "", 1).strip()
|
||||
|
||||
# 限制摘要长度
|
||||
if ABSTRACT_MAX_LENGTH and len(abstract) > ABSTRACT_MAX_LENGTH:
|
||||
abstract = abstract[:ABSTRACT_MAX_LENGTH] + "..."
|
||||
|
||||
list_data.append({
|
||||
"title": text,
|
||||
"url": href,
|
||||
"snippet": abstract,
|
||||
"provider": "Bing"
|
||||
})
|
||||
|
||||
if len(list_data) >= 10:
|
||||
break
|
||||
|
||||
logger.debug(f"从Bing解析到 {len(list_data)} 个搜索结果")
|
||||
return list_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析Bing页面时出错: {str(e)}")
|
||||
logger.debug(traceback.format_exc())
|
||||
return []
|
||||
@@ -1,42 +0,0 @@
|
||||
"""
|
||||
DuckDuckGo search engine implementation
|
||||
"""
|
||||
from typing import Dict, List, Any
|
||||
from asyncddgs import aDDGS
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from .base import BaseSearchEngine
|
||||
|
||||
logger = get_logger("ddg_engine")
|
||||
|
||||
|
||||
class DDGSearchEngine(BaseSearchEngine):
|
||||
"""
|
||||
DuckDuckGo搜索引擎实现
|
||||
"""
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查DuckDuckGo搜索引擎是否可用"""
|
||||
return True # DuckDuckGo不需要API密钥,总是可用
|
||||
|
||||
async def search(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""执行DuckDuckGo搜索"""
|
||||
query = args["query"]
|
||||
num_results = args.get("num_results", 3)
|
||||
|
||||
try:
|
||||
async with aDDGS() as ddgs:
|
||||
search_response = await ddgs.text(query, max_results=num_results)
|
||||
|
||||
return [
|
||||
{
|
||||
"title": r.get("title"),
|
||||
"url": r.get("href"),
|
||||
"snippet": r.get("body"),
|
||||
"provider": "DuckDuckGo"
|
||||
}
|
||||
for r in search_response
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"DuckDuckGo 搜索失败: {e}")
|
||||
return []
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
Exa search engine implementation
|
||||
"""
|
||||
import asyncio
|
||||
import functools
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
from exa_py import Exa
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_system.apis import config_api
|
||||
from .base import BaseSearchEngine
|
||||
from ..utils.api_key_manager import create_api_key_manager_from_config
|
||||
|
||||
logger = get_logger("exa_engine")
|
||||
|
||||
|
||||
class ExaSearchEngine(BaseSearchEngine):
|
||||
"""
|
||||
Exa搜索引擎实现
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._initialize_clients()
|
||||
|
||||
def _initialize_clients(self):
|
||||
"""初始化Exa客户端"""
|
||||
# 从主配置文件读取API密钥
|
||||
exa_api_keys = config_api.get_global_config("web_search.exa_api_keys", None)
|
||||
|
||||
# 创建API密钥管理器
|
||||
self.api_manager = create_api_key_manager_from_config(
|
||||
exa_api_keys,
|
||||
lambda key: Exa(api_key=key),
|
||||
"Exa"
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查Exa搜索引擎是否可用"""
|
||||
return self.api_manager.is_available()
|
||||
|
||||
async def search(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""执行Exa搜索"""
|
||||
if not self.is_available():
|
||||
return []
|
||||
|
||||
query = args["query"]
|
||||
num_results = args.get("num_results", 3)
|
||||
time_range = args.get("time_range", "any")
|
||||
|
||||
exa_args = {"num_results": num_results, "text": True, "highlights": True}
|
||||
if time_range != "any":
|
||||
today = datetime.now()
|
||||
start_date = today - timedelta(days=7 if time_range == "week" else 30)
|
||||
exa_args["start_published_date"] = start_date.strftime('%Y-%m-%d')
|
||||
|
||||
try:
|
||||
# 使用API密钥管理器获取下一个客户端
|
||||
exa_client = self.api_manager.get_next_client()
|
||||
if not exa_client:
|
||||
logger.error("无法获取Exa客户端")
|
||||
return []
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
func = functools.partial(exa_client.search_and_contents, query, **exa_args)
|
||||
search_response = await loop.run_in_executor(None, func)
|
||||
|
||||
return [
|
||||
{
|
||||
"title": res.title,
|
||||
"url": res.url,
|
||||
"snippet": " ".join(getattr(res, 'highlights', [])) or (getattr(res, 'text', '')[:250] + '...'),
|
||||
"provider": "Exa"
|
||||
}
|
||||
for res in search_response.results
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Exa 搜索失败: {e}")
|
||||
return []
|
||||
@@ -1,90 +0,0 @@
|
||||
"""
|
||||
Tavily search engine implementation
|
||||
"""
|
||||
import asyncio
|
||||
import functools
|
||||
from typing import Dict, List, Any
|
||||
from tavily import TavilyClient
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_system.apis import config_api
|
||||
from .base import BaseSearchEngine
|
||||
from ..utils.api_key_manager import create_api_key_manager_from_config
|
||||
|
||||
logger = get_logger("tavily_engine")
|
||||
|
||||
|
||||
class TavilySearchEngine(BaseSearchEngine):
|
||||
"""
|
||||
Tavily搜索引擎实现
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._initialize_clients()
|
||||
|
||||
def _initialize_clients(self):
|
||||
"""初始化Tavily客户端"""
|
||||
# 从主配置文件读取API密钥
|
||||
tavily_api_keys = config_api.get_global_config("web_search.tavily_api_keys", None)
|
||||
|
||||
# 创建API密钥管理器
|
||||
self.api_manager = create_api_key_manager_from_config(
|
||||
tavily_api_keys,
|
||||
lambda key: TavilyClient(api_key=key),
|
||||
"Tavily"
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查Tavily搜索引擎是否可用"""
|
||||
return self.api_manager.is_available()
|
||||
|
||||
async def search(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""执行Tavily搜索"""
|
||||
if not self.is_available():
|
||||
return []
|
||||
|
||||
query = args["query"]
|
||||
num_results = args.get("num_results", 3)
|
||||
time_range = args.get("time_range", "any")
|
||||
|
||||
try:
|
||||
# 使用API密钥管理器获取下一个客户端
|
||||
tavily_client = self.api_manager.get_next_client()
|
||||
if not tavily_client:
|
||||
logger.error("无法获取Tavily客户端")
|
||||
return []
|
||||
|
||||
# 构建Tavily搜索参数
|
||||
search_params = {
|
||||
"query": query,
|
||||
"max_results": num_results,
|
||||
"search_depth": "basic",
|
||||
"include_answer": False,
|
||||
"include_raw_content": False
|
||||
}
|
||||
|
||||
# 根据时间范围调整搜索参数
|
||||
if time_range == "week":
|
||||
search_params["days"] = 7
|
||||
elif time_range == "month":
|
||||
search_params["days"] = 30
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
func = functools.partial(tavily_client.search, **search_params)
|
||||
search_response = await loop.run_in_executor(None, func)
|
||||
|
||||
results = []
|
||||
if search_response and "results" in search_response:
|
||||
for res in search_response["results"]:
|
||||
results.append({
|
||||
"title": res.get("title", "无标题"),
|
||||
"url": res.get("url", ""),
|
||||
"snippet": res.get("content", "")[:300] + "..." if res.get("content") else "无摘要",
|
||||
"provider": "Tavily"
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tavily 搜索失败: {e}")
|
||||
return []
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
Web Search Tool Plugin
|
||||
|
||||
一个功能强大的网络搜索和URL解析插件,支持多种搜索引擎和解析策略。
|
||||
"""
|
||||
from typing import List, Tuple, Type
|
||||
|
||||
from src.plugin_system import (
|
||||
BasePlugin,
|
||||
register_plugin,
|
||||
ComponentInfo,
|
||||
ConfigField,
|
||||
PythonDependency
|
||||
)
|
||||
from src.plugin_system.apis import config_api
|
||||
from src.common.logger import get_logger
|
||||
|
||||
from .tools.web_search import WebSurfingTool
|
||||
from .tools.url_parser import URLParserTool
|
||||
|
||||
logger = get_logger("web_search_plugin")
|
||||
|
||||
|
||||
@register_plugin
|
||||
class WEBSEARCHPLUGIN(BasePlugin):
|
||||
"""
|
||||
网络搜索工具插件
|
||||
|
||||
提供网络搜索和URL解析功能,支持多种搜索引擎:
|
||||
- Exa (需要API密钥)
|
||||
- Tavily (需要API密钥)
|
||||
- DuckDuckGo (免费)
|
||||
- Bing (免费)
|
||||
"""
|
||||
|
||||
# 插件基本信息
|
||||
plugin_name: str = "web_search_tool" # 内部标识符
|
||||
enable_plugin: bool = True
|
||||
dependencies: List[str] = [] # 插件依赖列表
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""初始化插件,立即加载所有搜索引擎"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# 立即初始化所有搜索引擎,触发API密钥管理器的日志输出
|
||||
logger.info("🚀 正在初始化所有搜索引擎...")
|
||||
try:
|
||||
from .engines.exa_engine import ExaSearchEngine
|
||||
from .engines.tavily_engine import TavilySearchEngine
|
||||
from .engines.ddg_engine import DDGSearchEngine
|
||||
from .engines.bing_engine import BingSearchEngine
|
||||
|
||||
# 实例化所有搜索引擎,这会触发API密钥管理器的初始化
|
||||
exa_engine = ExaSearchEngine()
|
||||
tavily_engine = TavilySearchEngine()
|
||||
ddg_engine = DDGSearchEngine()
|
||||
bing_engine = BingSearchEngine()
|
||||
|
||||
# 报告每个引擎的状态
|
||||
engines_status = {
|
||||
"Exa": exa_engine.is_available(),
|
||||
"Tavily": tavily_engine.is_available(),
|
||||
"DuckDuckGo": ddg_engine.is_available(),
|
||||
"Bing": bing_engine.is_available()
|
||||
}
|
||||
|
||||
available_engines = [name for name, available in engines_status.items() if available]
|
||||
unavailable_engines = [name for name, available in engines_status.items() if not available]
|
||||
|
||||
if available_engines:
|
||||
logger.info(f"✅ 可用搜索引擎: {', '.join(available_engines)}")
|
||||
if unavailable_engines:
|
||||
logger.info(f"❌ 不可用搜索引擎: {', '.join(unavailable_engines)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 搜索引擎初始化失败: {e}", exc_info=True)
|
||||
|
||||
# Python包依赖列表
|
||||
python_dependencies: List[PythonDependency] = [
|
||||
PythonDependency(
|
||||
package_name="asyncddgs",
|
||||
description="异步DuckDuckGo搜索库",
|
||||
optional=False
|
||||
),
|
||||
PythonDependency(
|
||||
package_name="exa_py",
|
||||
description="Exa搜索API客户端库",
|
||||
optional=True # 如果没有API密钥,这个是可选的
|
||||
),
|
||||
PythonDependency(
|
||||
package_name="tavily",
|
||||
install_name="tavily-python", # 安装时使用这个名称
|
||||
description="Tavily搜索API客户端库",
|
||||
optional=True # 如果没有API密钥,这个是可选的
|
||||
),
|
||||
PythonDependency(
|
||||
package_name="httpx",
|
||||
version=">=0.20.0",
|
||||
install_name="httpx[socks]", # 安装时使用这个名称(包含可选依赖)
|
||||
description="支持SOCKS代理的HTTP客户端库",
|
||||
optional=False
|
||||
)
|
||||
]
|
||||
config_file_name: str = "config.toml" # 配置文件名
|
||||
|
||||
# 配置节描述
|
||||
config_section_descriptions = {
|
||||
"plugin": "插件基本信息",
|
||||
"proxy": "链接本地解析代理配置"
|
||||
}
|
||||
|
||||
# 配置Schema定义
|
||||
# 注意:EXA配置和组件设置已迁移到主配置文件(bot_config.toml)的[exa]和[web_search]部分
|
||||
config_schema: dict = {
|
||||
"plugin": {
|
||||
"name": ConfigField(type=str, default="WEB_SEARCH_PLUGIN", description="插件名称"),
|
||||
"version": ConfigField(type=str, default="1.0.0", description="插件版本"),
|
||||
"enabled": ConfigField(type=bool, default=False, description="是否启用插件"),
|
||||
},
|
||||
"proxy": {
|
||||
"http_proxy": ConfigField(
|
||||
type=str,
|
||||
default=None,
|
||||
description="HTTP代理地址,格式如: http://proxy.example.com:8080"
|
||||
),
|
||||
"https_proxy": ConfigField(
|
||||
type=str,
|
||||
default=None,
|
||||
description="HTTPS代理地址,格式如: http://proxy.example.com:8080"
|
||||
),
|
||||
"socks5_proxy": ConfigField(
|
||||
type=str,
|
||||
default=None,
|
||||
description="SOCKS5代理地址,格式如: socks5://proxy.example.com:1080"
|
||||
),
|
||||
"enable_proxy": ConfigField(
|
||||
type=bool,
|
||||
default=False,
|
||||
description="是否启用代理"
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]:
|
||||
"""
|
||||
获取插件组件列表
|
||||
|
||||
Returns:
|
||||
组件信息和类型的元组列表
|
||||
"""
|
||||
enable_tool = []
|
||||
|
||||
# 从主配置文件读取组件启用配置
|
||||
if config_api.get_global_config("web_search.enable_web_search_tool", True):
|
||||
enable_tool.append((WebSurfingTool.get_tool_info(), WebSurfingTool))
|
||||
|
||||
if config_api.get_global_config("web_search.enable_url_tool", True):
|
||||
enable_tool.append((URLParserTool.get_tool_info(), URLParserTool))
|
||||
|
||||
return enable_tool
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
Tools package
|
||||
"""
|
||||
@@ -1,234 +0,0 @@
|
||||
"""
|
||||
URL parser tool implementation
|
||||
"""
|
||||
import asyncio
|
||||
import functools
|
||||
from typing import Any, Dict
|
||||
from exa_py import Exa
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_system import BaseTool, ToolParamType, llm_api
|
||||
from src.plugin_system.apis import config_api
|
||||
|
||||
from ..utils.formatters import format_url_parse_results
|
||||
from ..utils.url_utils import parse_urls_from_input, validate_urls
|
||||
from ..utils.api_key_manager import create_api_key_manager_from_config
|
||||
|
||||
logger = get_logger("url_parser_tool")
|
||||
|
||||
|
||||
class URLParserTool(BaseTool):
|
||||
"""
|
||||
一个用于解析和总结一个或多个网页URL内容的工具。
|
||||
"""
|
||||
name: str = "parse_url"
|
||||
description: str = "当需要理解一个或多个特定网页链接的内容时,使用此工具。例如:'这些网页讲了什么?[https://example.com, https://example2.com]' 或 '帮我总结一下这些文章'"
|
||||
available_for_llm: bool = True
|
||||
parameters = [
|
||||
("urls", ToolParamType.STRING, "要理解的网站", True, None),
|
||||
]
|
||||
|
||||
# --- 新的缓存配置 ---
|
||||
enable_cache: bool = True
|
||||
cache_ttl: int = 86400 # 缓存24小时
|
||||
semantic_cache_query_key: str = "urls"
|
||||
# --------------------
|
||||
|
||||
def __init__(self, plugin_config=None):
|
||||
super().__init__(plugin_config)
|
||||
self._initialize_exa_clients()
|
||||
|
||||
def _initialize_exa_clients(self):
|
||||
"""初始化Exa客户端"""
|
||||
# 优先从主配置文件读取,如果没有则从插件配置文件读取
|
||||
exa_api_keys = config_api.get_global_config("exa.api_keys", None)
|
||||
if exa_api_keys is None:
|
||||
# 从插件配置文件读取
|
||||
exa_api_keys = self.get_config("exa.api_keys", [])
|
||||
|
||||
# 创建API密钥管理器
|
||||
from typing import cast, List
|
||||
self.api_manager = create_api_key_manager_from_config(
|
||||
cast(List[str], exa_api_keys),
|
||||
lambda key: Exa(api_key=key),
|
||||
"Exa URL Parser"
|
||||
)
|
||||
|
||||
async def _local_parse_and_summarize(self, url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
使用本地库(httpx, BeautifulSoup)解析URL,并调用LLM进行总结。
|
||||
"""
|
||||
try:
|
||||
# 读取代理配置
|
||||
enable_proxy = self.get_config("proxy.enable_proxy", False)
|
||||
proxies = None
|
||||
|
||||
if enable_proxy:
|
||||
socks5_proxy = self.get_config("proxy.socks5_proxy", None)
|
||||
http_proxy = self.get_config("proxy.http_proxy", None)
|
||||
https_proxy = self.get_config("proxy.https_proxy", None)
|
||||
|
||||
# 优先使用SOCKS5代理(全协议代理)
|
||||
if socks5_proxy:
|
||||
proxies = socks5_proxy
|
||||
logger.info(f"使用SOCKS5代理: {socks5_proxy}")
|
||||
elif http_proxy or https_proxy:
|
||||
proxies = {}
|
||||
if http_proxy:
|
||||
proxies["http://"] = http_proxy
|
||||
if https_proxy:
|
||||
proxies["https://"] = https_proxy
|
||||
logger.info(f"使用HTTP/HTTPS代理配置: {proxies}")
|
||||
|
||||
client_kwargs = {"timeout": 15.0, "follow_redirects": True}
|
||||
if proxies:
|
||||
client_kwargs["proxies"] = proxies
|
||||
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
title = soup.title.string if soup.title else "无标题"
|
||||
for script in soup(["script", "style"]):
|
||||
script.extract()
|
||||
text = soup.get_text(separator="\n", strip=True)
|
||||
|
||||
if not text:
|
||||
return {"error": "无法从页面提取有效文本内容。"}
|
||||
|
||||
summary_prompt = f"请根据以下网页内容,生成一段不超过300字的中文摘要,保留核心信息和关键点:\n\n---\n\n标题: {title}\n\n内容:\n{text[:4000]}\n\n---\n\n摘要:"
|
||||
|
||||
text_model = str(self.get_config("models.text_model", "replyer_1"))
|
||||
models = llm_api.get_available_models()
|
||||
model_config = models.get(text_model)
|
||||
if not model_config:
|
||||
logger.error("未配置LLM模型")
|
||||
return {"error": "未配置LLM模型"}
|
||||
|
||||
success, summary, reasoning, model_name = await llm_api.generate_with_model(
|
||||
prompt=summary_prompt,
|
||||
model_config=model_config,
|
||||
request_type="story.generate",
|
||||
temperature=0.3,
|
||||
max_tokens=1000
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.info(f"生成摘要失败: {summary}")
|
||||
return {"error": "发生ai错误"}
|
||||
|
||||
logger.info(f"成功生成摘要内容:'{summary}'")
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"url": url,
|
||||
"snippet": summary,
|
||||
"source": "local"
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(f"本地解析URL '{url}' 失败 (HTTP {e.response.status_code})")
|
||||
return {"error": f"请求失败,状态码: {e.response.status_code}"}
|
||||
except Exception as e:
|
||||
logger.error(f"本地解析或总结URL '{url}' 时发生未知异常: {e}", exc_info=True)
|
||||
return {"error": f"发生未知错误: {str(e)}"}
|
||||
|
||||
async def execute(self, function_args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
执行URL内容提取和总结。优先使用Exa,失败后尝试本地解析。
|
||||
"""
|
||||
urls_input = function_args.get("urls")
|
||||
if not urls_input:
|
||||
return {"error": "URL列表不能为空。"}
|
||||
|
||||
# 处理URL输入,确保是列表格式
|
||||
urls = parse_urls_from_input(urls_input)
|
||||
if not urls:
|
||||
return {"error": "提供的字符串中未找到有效的URL。"}
|
||||
|
||||
# 验证URL格式
|
||||
valid_urls = validate_urls(urls)
|
||||
if not valid_urls:
|
||||
return {"error": "未找到有效的URL。"}
|
||||
|
||||
urls = valid_urls
|
||||
logger.info(f"准备解析 {len(urls)} 个URL: {urls}")
|
||||
|
||||
successful_results = []
|
||||
error_messages = []
|
||||
urls_to_retry_locally = []
|
||||
|
||||
# 步骤 1: 尝试使用 Exa API 进行解析
|
||||
contents_response = None
|
||||
if self.api_manager.is_available():
|
||||
logger.info(f"开始使用 Exa API 解析URL: {urls}")
|
||||
try:
|
||||
# 使用API密钥管理器获取下一个客户端
|
||||
exa_client = self.api_manager.get_next_client()
|
||||
if not exa_client:
|
||||
logger.error("无法获取Exa客户端")
|
||||
else:
|
||||
loop = asyncio.get_running_loop()
|
||||
exa_params = {"text": True, "summary": True, "highlights": True}
|
||||
func = functools.partial(exa_client.get_contents, urls, **exa_params)
|
||||
contents_response = await loop.run_in_executor(None, func)
|
||||
except Exception as e:
|
||||
logger.error(f"执行 Exa URL解析时发生严重异常: {e}", exc_info=True)
|
||||
contents_response = None # 确保异常后为None
|
||||
|
||||
# 步骤 2: 处理Exa的响应
|
||||
if contents_response and hasattr(contents_response, 'statuses'):
|
||||
results_map = {res.url: res for res in contents_response.results} if hasattr(contents_response, 'results') else {}
|
||||
if contents_response.statuses:
|
||||
for status in contents_response.statuses:
|
||||
if status.status == 'success':
|
||||
res = results_map.get(status.id)
|
||||
if res:
|
||||
summary = getattr(res, 'summary', '')
|
||||
highlights = " ".join(getattr(res, 'highlights', []))
|
||||
text_snippet = (getattr(res, 'text', '')[:300] + '...') if getattr(res, 'text', '') else ''
|
||||
snippet = summary or highlights or text_snippet or '无摘要'
|
||||
|
||||
successful_results.append({
|
||||
"title": getattr(res, 'title', '无标题'),
|
||||
"url": getattr(res, 'url', status.id),
|
||||
"snippet": snippet,
|
||||
"source": "exa"
|
||||
})
|
||||
else:
|
||||
error_tag = getattr(status, 'error', '未知错误')
|
||||
logger.warning(f"Exa解析URL '{status.id}' 失败: {error_tag}。准备本地重试。")
|
||||
urls_to_retry_locally.append(status.id)
|
||||
else:
|
||||
# 如果Exa未配置、API调用失败或返回无效响应,则所有URL都进入本地重试
|
||||
urls_to_retry_locally.extend(url for url in urls if url not in [res['url'] for res in successful_results])
|
||||
|
||||
# 步骤 3: 对失败的URL进行本地解析
|
||||
if urls_to_retry_locally:
|
||||
logger.info(f"开始本地解析以下URL: {urls_to_retry_locally}")
|
||||
local_tasks = [self._local_parse_and_summarize(url) for url in urls_to_retry_locally]
|
||||
local_results = await asyncio.gather(*local_tasks)
|
||||
|
||||
for i, res in enumerate(local_results):
|
||||
url = urls_to_retry_locally[i]
|
||||
if "error" in res:
|
||||
error_messages.append(f"URL: {url} - 解析失败: {res['error']}")
|
||||
else:
|
||||
successful_results.append(res)
|
||||
|
||||
if not successful_results:
|
||||
return {"error": "无法从所有给定的URL获取内容。", "details": error_messages}
|
||||
|
||||
formatted_content = format_url_parse_results(successful_results)
|
||||
|
||||
result = {
|
||||
"type": "url_parse_result",
|
||||
"content": formatted_content,
|
||||
"errors": error_messages
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -1,155 +0,0 @@
|
||||
"""
|
||||
Web search tool implementation
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_system import BaseTool, ToolParamType
|
||||
from src.plugin_system.apis import config_api
|
||||
|
||||
from ..engines.exa_engine import ExaSearchEngine
|
||||
from ..engines.tavily_engine import TavilySearchEngine
|
||||
from ..engines.ddg_engine import DDGSearchEngine
|
||||
from ..engines.bing_engine import BingSearchEngine
|
||||
from ..utils.formatters import format_search_results, deduplicate_results
|
||||
|
||||
logger = get_logger("web_search_tool")
|
||||
|
||||
|
||||
class WebSurfingTool(BaseTool):
|
||||
"""
|
||||
网络搜索工具
|
||||
"""
|
||||
name: str = "web_search"
|
||||
description: str = "用于执行网络搜索。当用户明确要求搜索,或者需要获取关于公司、产品、事件的最新信息、新闻或动态时,必须使用此工具"
|
||||
available_for_llm: bool = True
|
||||
parameters = [
|
||||
("query", ToolParamType.STRING, "要搜索的关键词或问题。", True, None),
|
||||
("num_results", ToolParamType.INTEGER, "期望每个搜索引擎返回的搜索结果数量,默认为5。", False, None),
|
||||
("time_range", ToolParamType.STRING, "指定搜索的时间范围,可以是 'any', 'week', 'month'。默认为 'any'。", False, ["any", "week", "month"])
|
||||
] # type: ignore
|
||||
|
||||
# --- 新的缓存配置 ---
|
||||
enable_cache: bool = True
|
||||
cache_ttl: int = 7200 # 缓存2小时
|
||||
semantic_cache_query_key: str = "query"
|
||||
# --------------------
|
||||
|
||||
def __init__(self, plugin_config=None):
|
||||
super().__init__(plugin_config)
|
||||
# 初始化搜索引擎
|
||||
self.engines = {
|
||||
"exa": ExaSearchEngine(),
|
||||
"tavily": TavilySearchEngine(),
|
||||
"ddg": DDGSearchEngine(),
|
||||
"bing": BingSearchEngine()
|
||||
}
|
||||
|
||||
async def execute(self, function_args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
query = function_args.get("query")
|
||||
if not query:
|
||||
return {"error": "搜索查询不能为空。"}
|
||||
|
||||
# 读取搜索配置
|
||||
enabled_engines = config_api.get_global_config("web_search.enabled_engines", ["ddg"])
|
||||
search_strategy = config_api.get_global_config("web_search.search_strategy", "single")
|
||||
|
||||
logger.info(f"开始搜索,策略: {search_strategy}, 启用引擎: {enabled_engines}, 参数: '{function_args}'")
|
||||
|
||||
# 根据策略执行搜索
|
||||
if search_strategy == "parallel":
|
||||
result = await self._execute_parallel_search(function_args, enabled_engines)
|
||||
elif search_strategy == "fallback":
|
||||
result = await self._execute_fallback_search(function_args, enabled_engines)
|
||||
else: # single
|
||||
result = await self._execute_single_search(function_args, enabled_engines)
|
||||
|
||||
return result
|
||||
|
||||
async def _execute_parallel_search(self, function_args: Dict[str, Any], enabled_engines: List[str]) -> Dict[str, Any]:
|
||||
"""并行搜索策略:同时使用所有启用的搜索引擎"""
|
||||
search_tasks = []
|
||||
|
||||
for engine_name in enabled_engines:
|
||||
engine = self.engines.get(engine_name)
|
||||
if engine and engine.is_available():
|
||||
custom_args = function_args.copy()
|
||||
custom_args["num_results"] = custom_args.get("num_results", 5)
|
||||
search_tasks.append(engine.search(custom_args))
|
||||
|
||||
if not search_tasks:
|
||||
return {"error": "没有可用的搜索引擎。"}
|
||||
|
||||
try:
|
||||
search_results_lists = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
all_results = []
|
||||
for result in search_results_lists:
|
||||
if isinstance(result, list):
|
||||
all_results.extend(result)
|
||||
elif isinstance(result, Exception):
|
||||
logger.error(f"搜索时发生错误: {result}")
|
||||
|
||||
# 去重并格式化
|
||||
unique_results = deduplicate_results(all_results)
|
||||
formatted_content = format_search_results(unique_results)
|
||||
|
||||
return {
|
||||
"type": "web_search_result",
|
||||
"content": formatted_content,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行并行网络搜索时发生异常: {e}", exc_info=True)
|
||||
return {"error": f"执行网络搜索时发生严重错误: {str(e)}"}
|
||||
|
||||
async def _execute_fallback_search(self, function_args: Dict[str, Any], enabled_engines: List[str]) -> Dict[str, Any]:
|
||||
"""回退搜索策略:按顺序尝试搜索引擎,失败则尝试下一个"""
|
||||
for engine_name in enabled_engines:
|
||||
engine = self.engines.get(engine_name)
|
||||
if not engine or not engine.is_available():
|
||||
continue
|
||||
|
||||
try:
|
||||
custom_args = function_args.copy()
|
||||
custom_args["num_results"] = custom_args.get("num_results", 5)
|
||||
|
||||
results = await engine.search(custom_args)
|
||||
|
||||
if results: # 如果有结果,直接返回
|
||||
formatted_content = format_search_results(results)
|
||||
return {
|
||||
"type": "web_search_result",
|
||||
"content": formatted_content,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"{engine_name} 搜索失败,尝试下一个引擎: {e}")
|
||||
continue
|
||||
|
||||
return {"error": "所有搜索引擎都失败了。"}
|
||||
|
||||
async def _execute_single_search(self, function_args: Dict[str, Any], enabled_engines: List[str]) -> Dict[str, Any]:
|
||||
"""单一搜索策略:只使用第一个可用的搜索引擎"""
|
||||
for engine_name in enabled_engines:
|
||||
engine = self.engines.get(engine_name)
|
||||
if not engine or not engine.is_available():
|
||||
continue
|
||||
|
||||
try:
|
||||
custom_args = function_args.copy()
|
||||
custom_args["num_results"] = custom_args.get("num_results", 5)
|
||||
|
||||
results = await engine.search(custom_args)
|
||||
formatted_content = format_search_results(results)
|
||||
return {
|
||||
"type": "web_search_result",
|
||||
"content": formatted_content,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{engine_name} 搜索失败: {e}")
|
||||
return {"error": f"{engine_name} 搜索失败: {str(e)}"}
|
||||
|
||||
return {"error": "没有可用的搜索引擎。"}
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
Web search tool utilities package
|
||||
"""
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
API密钥管理器,提供轮询机制
|
||||
"""
|
||||
import itertools
|
||||
from typing import List, Optional, TypeVar, Generic, Callable
|
||||
from src.common.logger import get_logger
|
||||
|
||||
logger = get_logger("api_key_manager")
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class APIKeyManager(Generic[T]):
|
||||
"""
|
||||
API密钥管理器,支持轮询机制
|
||||
"""
|
||||
|
||||
def __init__(self, api_keys: List[str], client_factory: Callable[[str], T], service_name: str = "Unknown"):
|
||||
"""
|
||||
初始化API密钥管理器
|
||||
|
||||
Args:
|
||||
api_keys: API密钥列表
|
||||
client_factory: 客户端工厂函数,接受API密钥参数并返回客户端实例
|
||||
service_name: 服务名称,用于日志记录
|
||||
"""
|
||||
self.service_name = service_name
|
||||
self.clients: List[T] = []
|
||||
self.client_cycle: Optional[itertools.cycle] = None
|
||||
|
||||
if api_keys:
|
||||
# 过滤有效的API密钥,排除None、空字符串、"None"字符串等
|
||||
valid_keys = []
|
||||
for key in api_keys:
|
||||
if isinstance(key, str) and key.strip() and key.strip().lower() not in ("none", "null", ""):
|
||||
valid_keys.append(key.strip())
|
||||
|
||||
if valid_keys:
|
||||
try:
|
||||
self.clients = [client_factory(key) for key in valid_keys]
|
||||
self.client_cycle = itertools.cycle(self.clients)
|
||||
logger.info(f"🔑 {service_name} 成功加载 {len(valid_keys)} 个 API 密钥")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 初始化 {service_name} 客户端失败: {e}")
|
||||
self.clients = []
|
||||
self.client_cycle = None
|
||||
else:
|
||||
logger.warning(f"⚠️ {service_name} API Keys 配置无效(包含None或空值),{service_name} 功能将不可用")
|
||||
else:
|
||||
logger.warning(f"⚠️ {service_name} API Keys 未配置,{service_name} 功能将不可用")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查是否有可用的客户端"""
|
||||
return bool(self.clients and self.client_cycle)
|
||||
|
||||
def get_next_client(self) -> Optional[T]:
|
||||
"""获取下一个客户端(轮询)"""
|
||||
if not self.is_available():
|
||||
return None
|
||||
return next(self.client_cycle)
|
||||
|
||||
def get_client_count(self) -> int:
|
||||
"""获取可用客户端数量"""
|
||||
return len(self.clients)
|
||||
|
||||
|
||||
def create_api_key_manager_from_config(
|
||||
config_keys: Optional[List[str]],
|
||||
client_factory: Callable[[str], T],
|
||||
service_name: str
|
||||
) -> APIKeyManager[T]:
|
||||
"""
|
||||
从配置创建API密钥管理器的便捷函数
|
||||
|
||||
Args:
|
||||
config_keys: 从配置读取的API密钥列表
|
||||
client_factory: 客户端工厂函数
|
||||
service_name: 服务名称
|
||||
|
||||
Returns:
|
||||
API密钥管理器实例
|
||||
"""
|
||||
api_keys = config_keys if isinstance(config_keys, list) else []
|
||||
return APIKeyManager(api_keys, client_factory, service_name)
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
Formatters for web search results
|
||||
"""
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def format_search_results(results: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
格式化搜索结果为字符串
|
||||
"""
|
||||
if not results:
|
||||
return "没有找到相关的网络信息。"
|
||||
|
||||
formatted_string = "根据网络搜索结果:\n\n"
|
||||
for i, res in enumerate(results, 1):
|
||||
title = res.get("title", '无标题')
|
||||
url = res.get("url", '#')
|
||||
snippet = res.get("snippet", '无摘要')
|
||||
provider = res.get("provider", "未知来源")
|
||||
|
||||
formatted_string += f"{i}. **{title}** (来自: {provider})\n"
|
||||
formatted_string += f" - 摘要: {snippet}\n"
|
||||
formatted_string += f" - 来源: {url}\n\n"
|
||||
|
||||
return formatted_string
|
||||
|
||||
|
||||
def format_url_parse_results(results: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
将成功解析的URL结果列表格式化为一段简洁的文本。
|
||||
"""
|
||||
formatted_parts = []
|
||||
for res in results:
|
||||
title = res.get('title', '无标题')
|
||||
url = res.get('url', '#')
|
||||
snippet = res.get('snippet', '无摘要')
|
||||
source = res.get('source', '未知')
|
||||
|
||||
formatted_string = f"**{title}**\n"
|
||||
formatted_string += f"**内容摘要**:\n{snippet}\n"
|
||||
formatted_string += f"**来源**: {url} (由 {source} 解析)\n"
|
||||
formatted_parts.append(formatted_string)
|
||||
|
||||
return "\n---\n".join(formatted_parts)
|
||||
|
||||
|
||||
def deduplicate_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
根据URL去重搜索结果
|
||||
"""
|
||||
unique_urls = set()
|
||||
unique_results = []
|
||||
for res in results:
|
||||
if isinstance(res, dict) and res.get("url") and res["url"] not in unique_urls:
|
||||
unique_urls.add(res["url"])
|
||||
unique_results.append(res)
|
||||
return unique_results
|
||||
@@ -1,39 +0,0 @@
|
||||
"""
|
||||
URL processing utilities
|
||||
"""
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
|
||||
def parse_urls_from_input(urls_input) -> List[str]:
|
||||
"""
|
||||
从输入中解析URL列表
|
||||
"""
|
||||
if isinstance(urls_input, str):
|
||||
# 如果是字符串,尝试解析为URL列表
|
||||
# 提取所有HTTP/HTTPS URL
|
||||
url_pattern = r'https?://[^\s\],]+'
|
||||
urls = re.findall(url_pattern, urls_input)
|
||||
if not urls:
|
||||
# 如果没有找到标准URL,将整个字符串作为单个URL
|
||||
if urls_input.strip().startswith(('http://', 'https://')):
|
||||
urls = [urls_input.strip()]
|
||||
else:
|
||||
return []
|
||||
elif isinstance(urls_input, list):
|
||||
urls = [url.strip() for url in urls_input if isinstance(url, str) and url.strip()]
|
||||
else:
|
||||
return []
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def validate_urls(urls: List[str]) -> List[str]:
|
||||
"""
|
||||
验证URL格式,返回有效的URL列表
|
||||
"""
|
||||
valid_urls = []
|
||||
for url in urls:
|
||||
if url.startswith(('http://', 'https://')):
|
||||
valid_urls.append(url)
|
||||
return valid_urls
|
||||
Reference in New Issue
Block a user