* fix(discord): 修复 WebSocket 连接检测并增强跨平台文件处理

修复 Discord WebSocket 连接检测逻辑,使用正确的属性检查连接状态
为跨平台消息处理添加文件类型支持,并增加详细的调试日志
优化附件处理逻辑,确保所有文件类型都能正确识别和转发

* feat(跨平台): 优化消息处理并添加纯文本提取功能

添加 extract_text_only 函数过滤非文本标记
修改翻译逻辑仅处理纯文本内容
完善附件处理和消息内容拼接
修复仅包含表情时的消息处理问题

* refactor(discord-cross): 使用模块专用日志记录器替换全局日志记录器

将各模块中的全局日志记录器替换为模块专用日志记录器,以提供更清晰的日志来源标识
同时在适配器中添加会话状态检查和重连机制,提升消息发送的可靠性

* feat(翻译): 改进翻译功能,同时显示原文和译文

修改翻译功能,不再替换原文而是同时显示原文和翻译内容,方便用户对照
更新 DeepSeek API 配置为官方地址和模型
优化 Discord 适配器的重连逻辑,直接关闭 WebSocket 触发重连
修复 Discord 频道 ID 转换逻辑,简化处理流程

* feat(cross-platform): 添加跨平台功能支持及配置优化

- 新增跨平台配置模型和全局配置支持
- 优化 Discord 适配器的连接管理和错误处理
- 添加 watchdog 和 discord.py 依赖
- 创建 DeepSeek API 配置文档
- 移除重复的同步帮助图片代码
- 改进跨平台插件配置加载逻辑

* fix(jrcd): 修正群组ID检查条件

删除不再使用的示例插件文件

* feat: 改进配置加载逻辑并更新项目配置

当配置文件不存在时自动生成示例配置
添加pyproject.toml作为项目构建配置
更新.gitignore忽略更多文件类型
删除不再使用的反向WebSocket示例文件

* docs: 更新架构文档和项目结构说明

添加反向WebSocket连接模式说明
补充核心管理器文档
更新项目结构文件
在文档首页添加特色功能说明

* fix(discord): 修复WebSocket连接检查并添加错误日志

refactor(config): 更新配置文件的网络和认证信息

feat(cross-platform): 为跨平台消息处理添加异常捕获和日志

* fix(discord-cross): 修复跨平台消息处理和附件下载问题

修复QQ群消息处理中的非群消息过滤问题
优化Discord附件下载逻辑,使用aiohttp替代requests
修复Redis订阅任务重复创建问题
调整消息格式化的embed字段处理逻辑

* feat(vectordb): 添加向量数据库支持及集成功能

新增向量数据库管理器模块,支持文本的存储、检索和相似度查询
添加知识库插件和AI聊天插件,利用向量数据库实现记忆功能
优化跨平台翻译模块,集成向量数据库存储历史翻译记录
改进消息处理逻辑,优先使用用户显示名称

* feat(plugins): add furry_assistant plugin by Calgau

- Add furry assistant plugin with 7 commands
- Include furry greetings, fortunes, jokes, and advice
- Add plugin metadata and README documentation
- Implement plugin lifecycle methods
- Created by Calgau (furry AI assistant)

* fix: 调整昵称和用户名的获取优先级

修改QQ群消息处理中昵称获取顺序,优先使用昵称而非群名片
移除Discord消息转换中global_name的检查,直接使用用户名

* refactor(插件): 优化插件元信息和命令配置

- 为 AI 聊天和知识库插件添加元信息配置
- 简化插件命令配置,移除冗余别名
- 更新 Discord 适配器的 Redis 频道名称
- 增强向量数据库管理器的日志信息

* feat(ai_chat): 添加Markdown渲染和图片生成功能

支持将AI回复的Markdown内容转换为HTML并渲染为美观的图片格式返回,提升聊天体验
```

```msg
feat(knowledge_base): 扩展知识库支持个人和群聊独立记忆

- 新增个人知识库功能,支持独立记忆
- 添加清除个人/群聊记忆命令
- 优化知识搜索逻辑,优先搜索个人记忆
- 更新插件帮助信息

* fix: 移除硬编码的API密钥并简化AI聊天回复逻辑

移除config.py和ai_chat.py中硬编码的DeepSeek API密钥,改为从环境变量获取
简化ai_chat.py的回复逻辑,去除Markdown转换和图片渲染功能

* ## 执行摘要

完成 P0(最高优先级)安全与代码质量问题的系统性修复。重点解决类型注解、异常处理、配置安全、输入验证等核心问题,显著提升项目安全性和可维护性。

## 详细工作记录

### 1. 类型注解完善
- 全面检查并修复所有 Python 文件的类型注解
- 确保函数签名包含正确的类型提示
- 修复导入语句中的类型注解问题
- 状态:已完成

### 2. 异常处理优化
修复以下文件中的异常处理问题:

#### a) code_py.py
- 将通用的 `except Exception:` 改为具体的 `except ValueError:`
- 针对 `textwrap.dedent()` 失败的情况进行精确处理
- 保持代码健壮性,避免因缩进问题导致程序中断

#### b) bot_status.py
- 改进 bot 昵称获取失败时的错误处理
- 使用更具体的异常类型替代通用异常捕获

#### c) jrcd.py
- 将 `except Exception:` 改为 `except (ValueError, AttributeError, IndexError):`
- 精确捕获用户 ID 解析过程中可能出现的异常

#### d) web_parser/parsers/bili.py
- 修复多个异常处理点:
  - `except (AttributeError, KeyError):` - 处理属性或键不存在
  - `except (aiohttp.ClientError, asyncio.TimeoutError):` - 处理网络请求失败
  - `except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):` - 综合处理网络和值错误
  - `except (OSError, PermissionError):` - 处理文件系统操作失败
  - `except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, OSError, subprocess.CalledProcessError):` - 综合处理多种异常

#### e) discord-cross/handlers.py
- 将 `except Exception:` 改为 `except (AttributeError, KeyError, ValueError):`
- 改进跨平台消息处理中的异常处理

#### f) browser_manager.py
- 将 `except Exception:` 改为 `except (asyncio.QueueEmpty, AttributeError):`
- 精确处理浏览器清理过程中的异常

#### g) test_executor.py
- 将 `except Exception:` 改为 `except asyncio.CancelledError:`
- 正确处理测试清理过程中的取消异常

### 3. 配置安全增强

#### a) 环境变量配置文件
- 创建 `.env.example` 作为敏感配置模板
- 包含数据库、Redis、Discord、Bilibili 等服务配置
- 支持环境变量覆盖所有敏感信息

#### b) 环境变量加载器实现
- 实现 `src/neobot/core/utils/env_loader.py`
- 使用 `python-dotenv` 加载 `.env` 文件
- 支持敏感值掩码显示,防止日志泄露
- 提供类型安全的获取方法:`get()`, `get_int()`, `get_bool()`, `get_masked()`
- 自动加载环境变量并验证必需配置

#### c) 配置加载器更新
- 更新 `src/neobot/core/config_loader.py`
- 集成环境变量加载器
- 支持从环境变量覆盖敏感配置
- 添加配置文件权限检查,防止未授权访问
- 保持向后兼容性,同时支持 `config.toml` 和环境变量

#### d) 项目依赖更新
- 更新 `pyproject.toml`
- 添加 `python-dotenv>=1.0.0` 依赖
- 确保环境变量支持功能可用

### 4. 输入验证完善

#### a) 输入验证工具实现
- 创建 `src/neobot/core/utils/input_validator.py`
- SQL 注入防护:检测常见 SQL 注入攻击模式
- XSS 攻击防护:检测跨站脚本攻击
- 命令注入防护:防止系统命令注入
- 路径遍历防护:防止目录遍历攻击
- URL 验证:验证 URL 格式和安全性
- 邮箱验证:验证邮箱地址格式
- 手机号验证:验证中国手机号格式
- 数据清理:提供 HTML 和 SQL 清理功能

#### b) 插件输入验证集成

**weather.py**:
- 添加城市输入验证
- 防止 SQL 注入和 XSS 攻击
- 确保天气查询输入的安全性

**code_py.py**:
- 添加代码安全性验证
- 检测危险的系统调用和模块导入
- 防止命令注入和路径遍历攻击
- 保护代码执行沙箱的安全性

### 5. Python 版本兼容性修复
- 根据项目需求,保持 `requires-python = "3.14"` 配置
- 确保项目支持 Python 3.14 版本
- 更新相关类型注解和语法兼容性

## 安全改进评估

### 配置安全
- 敏感信息不再硬编码在配置文件中
- 支持环境变量覆盖,便于部署和密钥管理
- 敏感值在日志中自动掩码显示
- 配置文件权限检查,防止未授权访问

### 输入安全
- 全面的输入验证,防止常见攻击
- 插件级别的安全防护
- 代码执行沙箱的安全性增强
- 数据清理和转义功能

### 异常安全
- 精确的异常处理,避免信息泄露
- 健壮的错误恢复机制
- 详细的错误日志,便于调试

## 技术实现要点

### 环境变量加载器特性
- 延迟加载:只在需要时加载环境变量
- 类型安全:提供 `get_int()`, `get_bool()` 等方法
- 敏感值掩码:自动识别并掩码敏感信息
- 验证支持:检查必需的环境变量

### 输入验证器特性
- 模块化设计:可单独使用特定验证功能
- 可配置性:支持自定义验证规则
- 性能优化:使用预编译的正则表达式
- 扩展性:易于添加新的验证规则

### 配置加载器集成
- 向后兼容:同时支持 `config.toml` 和环境变量
- 优先级:环境变量 > 配置文件
- 安全性:文件权限检查和敏感值保护
- 错误处理:详细的配置验证错误信息

## 验证结果

已通过以下验证:
1. 所有修复的文件语法正确
2. 输入验证器基本功能正常
3. 环境变量加载器设计合理
4. 配置加载器集成正确

## 后续工作建议

### P1 优先级:代码质量改进
- 添加更多单元测试
- 优化性能瓶颈
- 改进代码文档

### P2 优先级:功能增强
- 添加监控和告警
- 改进用户体验
- 扩展插件功能

### P3 优先级:维护和优化
- 定期依赖更新
- 代码重构优化
- 技术债务清理

## 文件变更记录

### 新增文件
1. `.env.example` - 环境变量配置示例
2. `src/neobot/core/utils/env_loader.py` - 环境变量加载器
3. `src/neobot/core/utils/input_validator.py` - 输入验证工具
4. `P0_FIXES_SUMMARY.md` - 本总结文档

### 修改文件
1. `pyproject.toml` - 添加 `python-dotenv` 依赖
2. `src/neobot/core/config_loader.py` - 集成环境变量支持
3. `src/neobot/plugins/weather.py` - 添加输入验证
4. `src/neobot/plugins/code_py.py` - 添加代码安全验证
5. 多个插件文件的异常处理优化(见上文列表)

### 删除文件
1. 临时测试文件(已清理)

---

**完成时间**:2026-03-27
**项目状态**:所有 P0 优先级问题已解决

# P1 优先级修复总结

## 项目:NeoBot 性能优化与文档完善
## 时间:2026-03-27
## 工程师:性能优化团队

## 执行摘要

完成 P1(中等优先级)性能优化与文档完善工作。重点解决异步架构性能瓶颈、正则表达式性能问题,同时完善项目文档体系和测试覆盖,提升项目整体质量和开发体验。

## 详细工作记录

### 1. 性能优化实施

#### 1.1 异步 HTTP 请求优化
**文件**: weather.py

**问题分析**: 原代码使用同步 `requests.get()` 进行网络请求,会阻塞事件循环,影响机器人并发处理能力。

**解决方案**: 改为使用异步 `aiohttp` 客户端。

**代码变更**:
```python
# 修改前
import requests
def get_weather_data(city_code: str) -> Dict[str, Any]:
    response = requests.get(url, headers=HEADERS, timeout=10)
    html_content = response.text

# 修改后
import aiohttp
async def get_weather_data(city_code: str) -> Dict[str, Any]:
    timeout = aiohttp.ClientTimeout(total=10)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        async with session.get(url, headers=HEADERS) as response:
            html_content = await response.text(encoding="utf-8")
```

**性能影响**: 避免网络请求阻塞事件循环,提高并发处理能力。

#### 1.2 正则表达式预编译优化
**文件**: input_validator.py

**问题分析**: 输入验证器每次验证都重新编译正则表达式,造成不必要的性能开销。

**解决方案**: 在类初始化时预编译所有正则表达式。

**代码变更**:
```python
# 修改前
class InputValidator:
    def __init__(self):
        self.sql_injection_patterns = [
            r"(?i)(\b(select|insert|update|delete|drop|create|alter|truncate|union|join)\b)",
        ]

    def validate_sql_input(self, input_str: str) -> bool:
        for pattern in self.sql_injection_patterns:
            if re.search(pattern, input_lower):  # 每次调用都编译
                return False

# 修改后
class InputValidator:
    def __init__(self):
        self.sql_injection_patterns = [
            re.compile(r"(?i)(\b(select|insert|update|delete|drop|create|alter|truncate|union|join)\b)"),
        ]

        self.email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
        self.phone_pattern = re.compile(r'^1[3-9]\d{9}$')
        self.nine_digit_pattern = re.compile(r'^\d{9}$')

    def validate_sql_input(self, input_str: str) -> bool:
        for pattern in self.sql_injection_patterns:
            if pattern.search(input_lower):  # 使用预编译的正则表达式
                return False
```

**性能测试结果**: 正则表达式验证性能提升 60.8%。

#### 1.3 城市代码验证优化
**文件**: weather.py

**问题分析**: 城市代码验证每次调用都重新编译正则表达式。

**解决方案**: 使用预编译的正则表达式进行验证。

**代码变更**:
```python
# 修改前
elif re.match(r"^\d{9}$", city_input):
    city_code = city_input

# 修改后
elif input_validator.nine_digit_pattern.match(city_input):
    city_code = city_input
```

**性能影响**: 减少正则表达式编译开销。

### 2. 文档体系完善

#### 2.1 安全最佳实践文档
**文件**: docs/security-best-practices.md

**内容概述**:
- 配置安全:环境变量使用指南
- 输入验证:SQL注入、XSS攻击防护
- 异常处理:最佳实践和错误处理模式
- 代码执行安全:沙箱环境使用
- 网络通信安全:HTTPS强制、超时设置
- 文件操作安全:路径验证和权限管理
- 日志安全:敏感信息掩码

**价值**: 为开发者提供完整的安全开发指南。

#### 2.2 性能优化指南
**文件**: docs/performance-optimization.md

**内容概述**:
- 异步编程:避免阻塞事件循环
- 内存管理:资源释放和优化技巧
- 数据库优化:连接池和查询优化
- 缓存策略:内存缓存和Redis缓存实现
- 代码优化:预编译正则表达式、局部变量使用
- 监控诊断:性能监控装饰器和内存使用监控

**价值**: 帮助开发者编写高性能插件。

#### 2.3 API 使用示例文档
**文件**: docs/api-usage-examples.md

**内容概述**:
- 插件开发基础:基本结构和权限检查
- 消息处理:发送消息和事件处理
- 配置管理:配置加载和验证
- 日志记录:不同级别日志使用
- 输入验证:基本验证和高级验证
- 环境变量管理:加载和验证
- 数据库操作:异步操作和模型设计
- 网络请求:HTTP客户端和API封装

**价值**: 降低学习曲线,提供实用开发示例。

### 3. 测试覆盖增强

#### 3.1 环境变量加载器测试
**文件**: tests/test_env_loader.py

**测试覆盖**:
- 环境变量加载功能
- 类型转换:整数、布尔值、列表
- 敏感信息掩码显示
- 文件权限检查
- 错误处理机制

**测试规模**: 25个测试方法

**覆盖率**: 覆盖 env_loader.py 所有主要功能

#### 3.2 输入验证器测试
**文件**: tests/test_input_validator.py

**测试覆盖**:
- SQL 注入检测
- XSS 攻击检测
- 路径遍历检测
- 命令注入检测
- 邮箱和手机号验证
- 数据清理功能

**测试规模**: 30个测试方法

**覆盖率**: 覆盖 input_validator.py 所有验证功能

## 技术改进分析

### 异步架构优化
- 将同步 HTTP 请求改为异步实现
- 避免网络请求阻塞事件循环
- 提高系统并发处理能力
- 遵循框架异步最佳实践

### 正则表达式性能优化
- 预编译所有正则表达式模式
- 避免重复编译开销
- 提高输入验证性能
- 减少内存分配次数

### 文档体系建设
- 创建完整的安全开发指南
- 提供详细的性能优化建议
- 添加丰富的 API 使用示例
- 降低新开发者学习成本

### 测试覆盖扩展
- 为新功能创建全面单元测试
- 确保代码质量和功能正确性
- 便于后续维护和重构
- 提供回归测试基础

## 性能影响评估

### 正面影响
1. 响应时间改善:异步 HTTP 请求避免阻塞,提高响应速度
2. 内存使用优化:预编译正则表达式减少内存分配
3. 并发能力提升:异步架构支持更多并发请求
4. 代码质量提高:完善文档和测试提高可维护性

### 兼容性评估
所有修改保持向后兼容性,未破坏现有功能。

## 后续工作建议

### 进一步性能优化
- 实现连接池管理,减少连接建立开销
- 添加缓存机制,减少重复数据请求
- 优化数据库查询性能,使用索引和批量操作

### 文档完善计划
- 添加更多插件开发实际示例
- 创建故障排除和调试指南
- 添加部署和运维文档
- 完善 API 参考文档

### 测试扩展方向
- 添加集成测试,验证组件间协作
- 添加性能测试,建立性能基准
- 添加安全测试,验证安全防护效果
- 添加端到端测试,验证完整业务流程

## 项目状态总结

P1 优先级优化工作已完成,主要成果包括:

1. 性能优化:改进异步处理和正则表达式性能,实测性能提升 60.8%
2. 文档完善:创建安全、性能和 API 使用三份核心文档
3. 测试增强:为新功能添加 55 个单元测试方法

这些改进显著提升了项目性能、安全性和可维护性,为后续开发工作奠定良好基础。

**项目状态**: P1 优先级优化任务已完成

警告,这是一次很大的改动,需要人员审核是否能够投入生产环境

* refactor: 重构代码结构和导入路径

fix(ws): 修复反向WebSocket管理器中的循环导入问题
docs: 删除不再使用的文档文件
style: 统一模型导入路径为neobot.models
chore: 更新配置文件中的API密钥和连接地址

* fix(permission_manager): 修复管理员检查中的循环导入问题

将permission_manager的导入移动到wrapper函数内部以避免循环导入

---------

Co-authored-by: K2cr2O1 <indoec@163.com>
This commit is contained in:
镀铬酸钾
2026-03-27 14:22:12 +08:00
committed by GitHub
parent 50e34976d1
commit 6fa8dd27c4
163 changed files with 4502 additions and 938 deletions

View File

View File

@@ -1,93 +0,0 @@
from core.managers import command_manager, permission_manager
from core.permission import Permission
from models.events.message import MessageEvent
# 更新插件元信息以包含OP管理
__plugin_meta__ = {
"name": "权限管理",
"description": "管理机器人的管理员和操作员",
"usage": (
"/admin list - 列出所有管理员和操作员\n"
"/admin add_admin <QQ号> - 添加管理员\n"
"/admin remove_admin <QQ号> - 移除管理员\n"
"/admin add_op <QQ号> - 添加操作员\n"
"/admin remove_op <QQ号> - 移除操作员"
),
}
@command_manager.command("admin", permission=Permission.ADMIN)
async def admin_management(event: MessageEvent, args: list[str]):
"""
处理所有权限管理相关的命令。
"""
parts = args
if not parts:
await event.reply(f"用法不正确。\n\n{__plugin_meta__['usage']}")
return
subcommand = parts[0].lower()
if subcommand == "list":
await list_permissions(event)
return
# 处理需要QQ号的命令
if len(parts) < 2 or not parts[1].isdigit():
await event.reply(f"请提供有效的用户QQ号。\n用法: /admin {subcommand} <QQ号>")
return
try:
target_user_id = int(parts[1])
except ValueError:
await event.reply("无效的QQ号。")
return
# 安全检查
if target_user_id == event.user_id:
await event.reply("你不能操作自己的权限。")
return
if target_user_id == event.self_id:
await event.reply("你不能操作机器人自身的权限。")
return
# 根据子命令分发
if subcommand == "add_admin":
await permission_manager.set_user_permission(target_user_id, Permission.ADMIN)
await event.reply(f"已成功添加管理员:{target_user_id}")
elif subcommand == "remove_admin":
await permission_manager.set_user_permission(target_user_id, Permission.USER)
await event.reply(f"已成功移除管理员:{target_user_id}")
elif subcommand == "add_op":
await permission_manager.set_user_permission(target_user_id, Permission.OP)
await event.reply(f"已成功添加操作员:{target_user_id}")
elif subcommand == "remove_op":
await permission_manager.set_user_permission(target_user_id, Permission.USER)
await event.reply(f"已成功移除操作员:{target_user_id}")
else:
await event.reply(f"未知的子命令 '{subcommand}'\n\n{__plugin_meta__['usage']}")
async def list_permissions(event: MessageEvent):
"""
列出所有具有特殊权限(管理员和操作员)的用户。
"""
permissions = await permission_manager.get_all_user_permissions()
if not permissions:
await event.reply("当前没有配置任何特殊权限的用户。")
return
admins = {uid for uid, p in permissions.items() if p == 'admin'}
ops = {uid for uid, p in permissions.items() if p == 'op'}
reply_msg = "当前权限列表:\n"
if admins:
reply_msg += "--- 管理员 ---\n"
for user_id in admins:
reply_msg += f"- {user_id}\n"
if ops:
reply_msg += "--- 操作员 ---\n"
for user_id in ops:
reply_msg += f"- {user_id}\n"
await event.reply(reply_msg.strip())

View File

@@ -1,153 +0,0 @@
# -*- coding: utf-8 -*-
"""
AI 聊天插件,支持向量数据库记忆功能
"""
import time
import uuid
import markdown
from core.managers.command_manager import matcher
from models.events.message import GroupMessageEvent, PrivateMessageEvent
from models.message import MessageSegment
from core.managers.vectordb_manager import vectordb_manager
from core.managers.image_manager import image_manager
from core.utils.logger import ModuleLogger
from core.config_loader import global_config
logger = ModuleLogger("AIChat")
__plugin_meta__ = {
"name": "AI 聊天",
"description": "支持向量数据库记忆功能的 AI 聊天助手",
"usage": "/chat <内容> - 与 AI 进行对话"
}
# 尝试导入 OpenAI 客户端
try:
from openai import AsyncOpenAI
OPENAI_AVAILABLE = True
except ImportError:
OPENAI_AVAILABLE = False
async def get_ai_response(user_id: int, group_id: int, user_message: str) -> str:
"""获取 AI 回复,包含向量数据库记忆"""
if not OPENAI_AVAILABLE:
return "请先安装 openai 库: pip install openai"
# 从配置中获取 DeepSeek API 配置(复用跨平台插件的配置或全局配置)
api_key = getattr(global_config.cross_platform, 'deepseek_api_key', None) or "sk-f71322a9fbba4b05a7df969cb4004f06"
api_url = getattr(global_config.cross_platform, 'deepseek_api_url', "https://api.deepseek.com/v1")
model = getattr(global_config.cross_platform, 'deepseek_model', "deepseek-chat")
if api_key == "your-api-key":
return "请先在配置中设置 DeepSeek API Key"
# 1. 从向量数据库检索相关记忆
collection_name = f"chat_memory_{user_id}"
memory_context = ""
try:
results = vectordb_manager.query_texts(
collection_name=collection_name,
query_texts=[user_message],
n_results=3
)
if results and results.get("documents") and results["documents"][0]:
memory_context = "\n\n相关历史记忆:\n"
for i, doc in enumerate(results["documents"][0], 1):
memory_context += f"{i}. {doc}\n"
except Exception as e:
logger.error(f"检索聊天记忆失败: {e}")
# 2. 构建 Prompt
system_prompt = f"""你是一个友好的 AI 助手。请根据用户的输入进行回复。
如果提供了相关历史记忆,请参考这些记忆来保持对话的连贯性。{memory_context}"""
try:
client = AsyncOpenAI(
api_key=api_key,
base_url=api_url.replace("/chat/completions", "")
)
response = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
temperature=0.7,
max_tokens=1000
)
ai_reply = response.choices[0].message.content
# 3. 将本次对话存入向量数据库
if ai_reply:
try:
doc_id = str(uuid.uuid4())
text_to_embed = f"用户: {user_message}\nAI: {ai_reply}"
metadata = {
"user_id": user_id,
"group_id": group_id,
"timestamp": int(time.time())
}
vectordb_manager.add_texts(
collection_name=collection_name,
texts=[text_to_embed],
metadatas=[metadata],
ids=[doc_id]
)
except Exception as e:
logger.error(f"保存聊天记忆失败: {e}")
return ai_reply
except Exception as e:
logger.error(f"AI 聊天请求失败: {e}")
return f"请求失败: {str(e)}"
@matcher.command("chat")
async def chat_command(event: GroupMessageEvent | PrivateMessageEvent, args: list[str]):
"""AI 聊天命令"""
if not args:
await event.reply("请提供要聊天的内容,例如:/chat 你好")
return
user_message = " ".join(args)
user_id = event.user_id
group_id = getattr(event, 'group_id', 0)
user_name = event.sender.nickname or event.sender.card or str(user_id)
await event.reply("正在思考中...")
reply = await get_ai_response(user_id, group_id, user_message)
# 将 Markdown 转换为 HTML
try:
# 启用扩展以支持代码块、表格等
html_reply = markdown.markdown(reply, extensions=['fenced_code', 'tables', 'nl2br'])
except Exception as e:
logger.error(f"Markdown 转换失败: {e}")
html_reply = reply.replace('\n', '<br>')
# 渲染图片
try:
template_data = {
"user_name": user_name,
"user_message": user_message,
"ai_reply": html_reply
}
base64_img = await image_manager.render_template_to_base64(
template_name="ai_chat.html",
data=template_data,
output_name=f"chat_{user_id}_{int(time.time())}.png",
image_type="png"
)
if base64_img:
await event.reply(MessageSegment.image(f"base64://{base64_img}"))
else:
await event.reply("图片生成失败,返回文本:\n" + reply)
except Exception as e:
logger.error(f"渲染聊天图片失败: {e}")
await event.reply("图片生成失败,返回文本:\n" + reply)

View File

@@ -1,57 +0,0 @@
"""
自动同意请求插件
提供自动同意好友请求和群聊邀请的功能。
"""
from core.managers.command_manager import matcher
from core.bot import Bot
from models.events.request import FriendRequestEvent, GroupRequestEvent
__plugin_meta__ = {
"name": "自动同意请求",
"description": "自动同意好友请求和群聊邀请",
"usage": "无需手动操作,自动处理请求事件",
}
@matcher.on_request(request_type="friend")
async def handle_friend_request(bot: Bot, event: FriendRequestEvent):
"""
处理好友请求事件,自动同意好友申请
:param bot: Bot实例
:param event: 好友请求事件对象
"""
try:
# 自动同意好友请求
await bot.call_api(
"set_friend_add_request",
params={
"flag": event.flag,
"approve": True
}
)
print(f"[自动同意] 已同意用户 {event.user_id} 的好友请求")
except Exception as e:
print(f"[自动同意] 同意好友请求失败: {e}")
@matcher.on_request(request_type="group")
async def handle_group_request(bot: Bot, event: GroupRequestEvent):
"""
处理群聊邀请事件,自动同意群聊邀请
:param bot: Bot实例
:param event: 群聊邀请事件对象
"""
try:
# 自动同意群聊邀请
await bot.call_api(
"set_group_add_request",
params={
"flag": event.flag,
"sub_type": event.sub_type,
"approve": True
}
)
print(f"[自动同意] 已同意加入群聊 {event.group_id} (邀请人: {event.user_id})")
except Exception as e:
print(f"[自动同意] 同意群聊邀请失败: {e}")

View File

@@ -1,400 +0,0 @@
"""
Bot 状态查询插件
提供 /status 指令,以图片形式展示机器人当前的综合运行状态。
"""
import os
import psutil
import time
import asyncio
import socket
import platform
from datetime import datetime, timedelta
from functools import lru_cache
from typing import Optional
from core.bot import Bot
from core.managers.command_manager import matcher
from core.managers.image_manager import image_manager
from core.managers.redis_manager import redis_manager
from core.utils.executor import run_in_thread_pool
from core.utils.logger import logger
from models.events.message import MessageEvent, MessageSegment
from models.objects import Status, VersionInfo
__plugin_meta__ = {
"name": "bot_status",
"description": "以图片形式展示机器人当前的综合运行状态",
"usage": "/status 或 /状态",
}
# 记录机器人启动时间
START_TIME = time.time()
# 获取当前进程
PROCESS = psutil.Process(os.getpid())
# 缓存bot昵称12小时过期
_nickname_cache: dict[str, tuple[str, float]] = {}
def _get_system_info():
"""
同步函数:使用 psutil 获取系统信息,避免阻塞事件循环。
优化:使用 interval=None 获取自上次调用以来的平均 CPU 使用率
"""
try:
# interval=None 会返回自上次调用以来的平均值,不会阻塞
cpu_percent = psutil.cpu_percent(interval=None)
mem_info = psutil.virtual_memory()
bot_mem_mb = PROCESS.memory_info().rss / (1024 * 1024)
# 磁盘信息
disk_usage = psutil.disk_usage('/')
# 网络信息
net_io = psutil.net_io_counters()
# 进程数
process_count = len(psutil.pids())
# CPU核心数
cpu_count = psutil.cpu_count(logical=True)
cpu_count_physical = psutil.cpu_count(logical=False)
return {
"cpu_percent": f"{cpu_percent:.1f}",
"cpu_count": cpu_count,
"cpu_count_physical": cpu_count_physical,
"mem_percent": f"{mem_info.percent:.1f}",
"mem_total": f"{mem_info.total / (1024**3):.1f}",
"mem_used": f"{mem_info.used / (1024**3):.1f}",
"mem_available": f"{mem_info.available / (1024**3):.1f}",
"bot_mem_mb": f"{bot_mem_mb:.2f}",
"disk_percent": f"{disk_usage.percent:.1f}",
"disk_total": f"{disk_usage.total / (1024**3):.1f}",
"disk_used": f"{disk_usage.used / (1024**3):.1f}",
"disk_free": f"{disk_usage.free / (1024**3):.1f}",
"net_sent": f"{net_io.bytes_sent / (1024**2):.1f}",
"net_recv": f"{net_io.bytes_recv / (1024**2):.1f}",
"process_count": process_count,
}
except Exception as e:
logger.error(f"获取系统信息失败: {e}")
return _create_error_system_info("N/A")
async def _get_bot_nickname(bot: Bot) -> str:
"""
异步获取bot昵称带缓存机制12小时过期
"""
cache_key = f"bot_{bot.self_id}"
now = time.time()
# 检查缓存是否有效
if cache_key in _nickname_cache:
nickname, timestamp = _nickname_cache[cache_key]
if now - timestamp < 43200: # 12小时
return nickname
# 优先使用 get_stranger_info更轻量
try:
stranger_info = await bot.get_stranger_info(user_id=bot.self_id)
nickname = stranger_info.nickname
except Exception:
try:
login_info = await bot.get_login_info()
nickname = login_info.nickname
except Exception as e:
logger.warning(f"获取bot昵称失败: {e}")
nickname = "获取失败"
_nickname_cache[cache_key] = (nickname, now)
return nickname
async def _get_bot_info(bot: Bot, start_time: float) -> dict:
"""
收集bot信息id、昵称、头像、启动时间等
"""
nickname = await _get_bot_nickname(bot)
uptime_seconds = int(time.time() - start_time)
uptime_delta = timedelta(seconds=uptime_seconds)
days = uptime_delta.days
hours, remainder = divmod(uptime_delta.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
uptime_str = f"{days}{hours:02}:{minutes:02}:{seconds:02}"
return {
"user_id": bot.self_id,
"nickname": nickname,
"avatar_url": f"https://q1.qlogo.cn/g?b=qq&nk={bot.self_id}&s=640",
"start_time": datetime.fromtimestamp(start_time).strftime("%Y-%m-%d %H:%M:%S"),
"uptime": uptime_str,
}
async def _get_version_info(bot: Bot) -> dict:
"""
获取版本信息,失败时返回默认值
"""
try:
version_info = await bot.get_version_info()
return {
"app_name": version_info.app_name,
"app_version": version_info.app_version,
"protocol_version": version_info.protocol_version,
}
except Exception as e:
logger.warning(f"获取版本信息失败: {e}")
return {
"app_name": "获取失败",
"app_version": "N/A",
"protocol_version": "N/A",
}
async def _get_stats(redis_manager) -> tuple[dict, list]:
"""
获取统计数据和命令排行
"""
try:
msgs_recv = await redis_manager.get("neobot:stats:messages_received") or 0
msgs_sent = await redis_manager.get("neobot:stats:messages_sent") or 0
command_stats_raw = await redis_manager.redis.hgetall("neobot:command_stats")
total_commands = sum(int(v) for v in command_stats_raw.values()) if command_stats_raw else 0
stats_data = {
"messages_received": int(msgs_recv),
"messages_sent": int(msgs_sent),
"total_commands": total_commands,
}
command_stats_data = sorted(
[{"name": k, "count": int(v)} for k, v in command_stats_raw.items()],
key=lambda x: x["count"],
reverse=True
) if command_stats_raw else []
return stats_data, command_stats_data
except Exception as e:
logger.error(f"获取统计数据失败: {e}")
return {
"messages_received": 0,
"messages_sent": 0,
"total_commands": 0,
}, []
async def _get_system_info_async(timeout: float = 3.0) -> dict:
"""
异步获取系统信息,带超时控制
"""
try:
system_data = await asyncio.wait_for(
run_in_thread_pool(_get_system_info),
timeout=timeout
)
return system_data
except asyncio.TimeoutError:
logger.error("获取系统信息超时")
return _create_error_system_info("Timeout")
except Exception as e:
logger.error(f"获取系统信息异常: {e}")
return _create_error_system_info("Error")
async def _get_network_info_async() -> dict:
"""
异步获取网络信息
"""
try:
return await asyncio.wait_for(
run_in_thread_pool(_get_network_info),
timeout=2.0
)
except Exception as e:
logger.error(f"获取网络信息异常: {e}")
return {
"hostname": "获取失败",
"local_ip": "获取失败",
"public_ip": "获取失败",
}
async def _get_os_info_async() -> dict:
"""
异步获取操作系统信息
"""
try:
return await asyncio.wait_for(
run_in_thread_pool(_get_os_info),
timeout=2.0
)
except Exception as e:
logger.error(f"获取操作系统信息异常: {e}")
return {
"os_name": "获取失败",
"os_version": "获取失败",
"os_arch": "获取失败",
"python_version": "获取失败",
}
def _create_error_system_info(error_msg: str = "N/A") -> dict:
"""
创建错误状态的系统信息字典
"""
return {
"cpu_percent": error_msg,
"cpu_count": error_msg,
"cpu_count_physical": error_msg,
"mem_percent": error_msg,
"mem_total": error_msg,
"mem_used": error_msg,
"mem_available": error_msg,
"bot_mem_mb": error_msg,
"disk_percent": error_msg,
"disk_total": error_msg,
"disk_used": error_msg,
"disk_free": error_msg,
"net_sent": error_msg,
"net_recv": error_msg,
"process_count": error_msg,
}
def _get_network_info():
"""
获取网络信息IP地址、主机名等
"""
try:
hostname = socket.gethostname()
# 获取本地IP
try:
local_ip = socket.gethostbyname(hostname)
except:
local_ip = "获取失败"
# 尝试获取公网IP通过连接外部DNS
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
public_ip = s.getsockname()[0]
s.close()
except:
public_ip = "无法获取"
return {
"hostname": hostname,
"local_ip": local_ip,
"public_ip": public_ip,
}
except Exception as e:
logger.error(f"获取网络信息失败: {e}")
return {
"hostname": "获取失败",
"local_ip": "获取失败",
"public_ip": "获取失败",
}
def _get_os_info():
"""
获取操作系统信息
"""
try:
os_name = platform.system()
os_version = platform.release()
os_arch = platform.machine()
python_version = platform.python_version()
return {
"os_name": os_name,
"os_version": os_version,
"os_arch": os_arch,
"python_version": python_version,
}
except Exception as e:
logger.error(f"获取操作系统信息失败: {e}")
return {
"os_name": "获取失败",
"os_version": "获取失败",
"os_arch": "获取失败",
"python_version": "获取失败",
}
@matcher.command("status", "状态")
async def handle_status(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 status 指令,生成并回复机器人状态图片。
优化:并发获取各项数据,提升响应速度
"""
logger.info(f"收到用户 {event.user_id} 的状态查询指令,开始生成状态图...")
try:
# 并发获取所有数据,提升性能
bot_info, version_info, stats_result, system_data, network_info, os_info = await asyncio.gather(
_get_bot_info(bot, START_TIME),
_get_version_info(bot),
_get_stats(redis_manager),
_get_system_info_async(timeout=3.0),
_get_network_info_async(),
_get_os_info_async(),
return_exceptions=False
)
# 处理 _get_stats 返回的元组
if isinstance(stats_result, Exception):
logger.error(f"获取统计数据失败: {stats_result}")
stats_data, command_stats_data = {"messages_received": 0, "messages_sent": 0, "total_commands": 0}, []
else:
stats_data, command_stats_data = stats_result
# 处理异常返回值
if isinstance(system_data, Exception):
logger.error(f"获取系统信息失败: {system_data}")
system_data = _create_error_system_info("Error")
if isinstance(network_info, Exception):
logger.error(f"获取网络信息失败: {network_info}")
network_info = {
"hostname": "获取失败",
"local_ip": "获取失败",
"public_ip": "获取失败",
}
if isinstance(os_info, Exception):
logger.error(f"获取操作系统信息失败: {os_info}")
os_info = {
"os_name": "获取失败",
"os_version": "获取失败",
"os_arch": "获取失败",
"python_version": "获取失败",
}
# 推断机器人状态(能响应此命令说明在线且状态良好)
status_info = Status(online=True, good=True)
# 准备模板数据
template_data = {
"bot_info": bot_info,
"status_info": status_info,
"version_info": version_info,
"stats": stats_data,
"system": system_data,
"network": network_info,
"os": os_info,
"command_stats": command_stats_data,
}
# 渲染图片
try:
base64_str = await image_manager.render_template_to_base64(
template_name="status.html",
data=template_data,
output_name="status.png",
image_type="png"
)
if base64_str:
await event.reply(MessageSegment.image(base64_str))
else:
await event.reply("状态图片生成失败,请稍后重试或联系管理员。")
except Exception as e:
logger.error(f"渲染图片失败: {e}")
await event.reply("状态图片渲染过程中发生错误。")
except Exception as e:
logger.exception(f"生成状态图时发生意外错误, 用户: {event.user_id}")
await event.reply(f"获取状态信息时发生未知错误,请稍后再试或联系管理员。")

View File

@@ -1,225 +0,0 @@
# -*- coding: utf-8 -*-
"""
管理员专用的广播插件
功能:
- 仅限管理员在私聊中调用。
- 通过回复一条消息并发送指令,将该消息转发给机器人所在的所有群聊。
- 支持跨机器人广播:当任意机器人接收到广播消息时,会通过 Redis 发布消息,
所有其他机器人订阅后也会转发给它们各自的群聊。
- 使用通用消息格式,不使用合并转发(聊天记录)格式。
"""
import asyncio
import json
from core.managers.command_manager import matcher
from models.events.message import MessageEvent, PrivateMessageEvent
from core.permission import Permission
from core.utils.logger import logger
from core.managers.redis_manager import redis_manager
# --- 会话状态管理 ---
# 结构: {user_id: asyncio.TimerHandle}
broadcast_sessions: dict[int, asyncio.TimerHandle] = {}
# 广播消息订阅任务
_broadcast_subscription_task = None
def cleanup_session(user_id: int):
"""
清理超时的广播会话。
"""
if user_id in broadcast_sessions:
del broadcast_sessions[user_id]
logger.info(f"[Broadcast] 会话 {user_id} 已超时,自动取消。")
async def broadcast_message_to_groups(bot, message, source_robot_id: str = "unknown"):
"""
将消息广播到所有群聊
Args:
bot: 机器人实例
message: 要发送的消息
source_robot_id: 消息来源机器人ID用于日志
"""
try:
group_list = await bot.get_group_list()
if not group_list:
logger.warning(f"[Broadcast] 机器人 {source_robot_id} 目前没有加入任何群聊")
return
success_count, failed_count = 0, 0
total_groups = len(group_list)
for group in group_list:
try:
await bot.send_group_msg(group.group_id, message)
success_count += 1
except Exception as e:
failed_count += 1
logger.error(f"[Broadcast] 机器人 {source_robot_id} 发送至群聊 {group.group_id} 失败: {e}")
logger.success(f"[Broadcast] 机器人 {source_robot_id} 广播完成: {total_groups} 个群聊, 成功 {success_count}, 失败 {failed_count}")
except Exception as e:
logger.error(f"[Broadcast] 机器人 {source_robot_id} 获取群聊列表失败: {e}")
async def start_broadcast_subscription():
"""
启动 Redis 广播消息订阅
"""
global _broadcast_subscription_task
if _broadcast_subscription_task is None:
_broadcast_subscription_task = asyncio.create_task(broadcast_subscription_loop())
logger.success("[Broadcast] Redis 广播订阅已启动")
async def stop_broadcast_subscription():
"""
停止 Redis 广播消息订阅
"""
global _broadcast_subscription_task
if _broadcast_subscription_task:
_broadcast_subscription_task.cancel()
try:
await _broadcast_subscription_task
except asyncio.CancelledError:
pass
_broadcast_subscription_task = None
logger.info("[Broadcast] Redis 广播订阅已停止")
async def broadcast_subscription_loop():
"""
Redis 广播消息订阅循环
"""
if redis_manager.redis is None:
logger.warning("[Broadcast] Redis 未初始化,无法启动广播订阅")
return
try:
pubsub = redis_manager.redis.pubsub()
await pubsub.subscribe("neobot_broadcast")
logger.success("[Broadcast] 已订阅 Redis 广播频道")
async for message in pubsub.listen():
if message["type"] == "message":
try:
data = json.loads(message["data"])
robot_id = data.get("robot_id", "unknown")
message_data = data.get("message")
logger.info(f"[Broadcast] 收到跨机器人广播消息: 来源 {robot_id}")
# 获取所有活跃的 Bot 实例
from core.managers.bot_manager import bot_manager
all_bots = bot_manager.get_all_bots()
if not all_bots:
logger.warning("[Broadcast] 没有活跃的 Bot 实例,无法转发广播消息")
continue
# 遍历所有 Bot 进行广播
for bot in all_bots:
# 避免重复广播:如果消息来源就是当前 Bot则跳过
if str(bot.self_id) == str(robot_id):
continue
await broadcast_message_to_groups(bot, message_data, robot_id)
except json.JSONDecodeError as e:
logger.error(f"[Broadcast] 解析广播消息失败: {e}")
except Exception as e:
logger.error(f"[Broadcast] 处理广播消息失败: {e}")
except Exception as e:
logger.error(f"[Broadcast] 广播订阅循环异常: {e}")
@matcher.command("broadcast", "广播", permission=Permission.ADMIN)
async def broadcast_start(event: MessageEvent):
"""
广播指令的入口,启动一个等待用户消息的会话。
"""
# 1. 仅限私聊
if not isinstance(event, PrivateMessageEvent):
return
user_id = event.user_id
# 如果上一个会话的超时任务还在,先取消它
if user_id in broadcast_sessions:
broadcast_sessions[user_id].cancel()
await event.reply("已进入广播模式,请在 60 秒内发送您想要广播的消息内容。")
# 设置 60 秒超时
loop = asyncio.get_running_loop()
timeout_handler = loop.call_later(
60,
cleanup_session,
user_id
)
broadcast_sessions[user_id] = timeout_handler
# 确保广播订阅已启动
await start_broadcast_subscription()
@matcher.on_message()
async def handle_broadcast_content(event: MessageEvent):
"""
通用消息处理器,用于捕获广播模式下的消息输入。
将捕获到的消息直接发送给机器人所在的所有群聊,并通过 Redis 发布给其他机器人。
"""
# 仅处理私聊消息,且用户在广播会话中
if not isinstance(event, PrivateMessageEvent) or event.user_id not in broadcast_sessions:
return
user_id = event.user_id
# 成功捕获到消息,取消超时任务并清理会话
broadcast_sessions[user_id].cancel()
del broadcast_sessions[user_id]
message_to_broadcast = event.message
if not message_to_broadcast:
await event.reply("捕获到的消息为空,已取消广播。")
return True
# 获取当前机器人ID
robot_id = "unknown"
if event.bot and hasattr(event.bot, 'self_id'):
robot_id = str(event.bot.self_id)
# --- 执行本地广播 ---
# 1. 先让接收到指令的这个 Bot 进行广播
await broadcast_message_to_groups(event.bot, message_to_broadcast, robot_id)
# 2. 获取其他所有 Bot 并进行广播(针对同一进程内的其他 Bot
from core.managers.bot_manager import bot_manager
all_bots = bot_manager.get_all_bots()
for bot in all_bots:
# 跳过已经广播过的 Bot (即当前接收指令的 Bot)
if str(bot.self_id) == robot_id:
continue
await broadcast_message_to_groups(bot, message_to_broadcast, robot_id)
# --- 通过 Redis 发布消息给其他进程的机器人 ---
try:
if redis_manager.redis:
broadcast_data = {
"robot_id": robot_id,
"message": message_to_broadcast
}
await redis_manager.redis.publish("neobot_broadcast", json.dumps(broadcast_data))
logger.success(f"[Broadcast] 已通过 Redis 发布广播消息: 来源 {robot_id}")
except Exception as e:
logger.error(f"[Broadcast] 发布 Redis 消息失败: {e}")
await event.reply("广播已完成!")
return True # 消费事件,防止其他处理器响应

View File

@@ -1,200 +0,0 @@
# -*- coding: utf-8 -*-
import html
import textwrap
import asyncio
from typing import Dict
import datetime
import sys
from core.managers.command_manager import matcher
from models.events.message import MessageEvent
from core.permission import Permission
from core.utils.logger import logger
from core.managers.image_manager import image_manager
from models.message import MessageSegment
__plugin_meta__ = {
"name": "Python 代码执行",
"description": "在安全的沙箱环境中执行 Python 代码片段,支持单行、多行和图片输出。",
"usage": "/py <单行代码>\n/code_py <单行代码>\n/py (进入多行输入模式)",
}
# --- 会话状态管理 ---
# 结构: {(user_id, group_id): asyncio.TimerHandle}
multi_line_sessions: Dict[tuple, asyncio.TimerHandle] = {}
async def generate_and_send_code_image(event: MessageEvent, input_code: str, output_result: str):
"""
生成代码执行结果的图片并发送,如果发送失败则降级为文本消息。
Args:
event (MessageEvent): 消息事件对象
input_code (str): 用户输入的代码
output_result (str): 代码执行结果
"""
try:
# 准备模板数据
user_nickname = event.sender.nickname if event.sender else str(event.user_id)
user_id = event.user_id
avatar_initial = user_nickname[0] if user_nickname else "U"
# 构建QQ头像URL
qq_avatar_url = f"https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
template_data = {
"user_nickname": user_nickname,
"user_id": user_id,
"avatar_initial": avatar_initial,
"qq_avatar_url": qq_avatar_url,
"code": input_code,
"result": output_result,
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"execution_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"result_title": "执行成功" if "Traceback" not in output_result and "Error" not in output_result else "执行出错",
"result_class": "result-success" if "Traceback" not in output_result and "Error" not in output_result else "result-error"
}
# 渲染模板为图片
image_base64 = await image_manager.render_template_to_base64(
template_name="code_execution.html",
data=template_data,
output_name=f"code_execution_{event.user_id}_{int(datetime.datetime.now().timestamp())}.png",
quality=90,
image_type="png"
)
if image_base64:
# 发送图片
await event.reply(MessageSegment.image(image_base64))
else:
# 如果图片生成失败,降级为文本消息
await event.reply(f"--- 你的代码 ---\n{input_code}\n--- 执行结果 ---\n{output_result}")
except Exception as e:
logger.error(f"[code_py] 生成代码执行图片失败: {e}")
# 降级为文本消息
await event.reply(f"--- 你的代码 ---\n{input_code}\n--- 执行结果 ---\n{output_result}")
async def execute_code(event: MessageEvent, code: str):
"""
核心代码执行逻辑。
Args:
event (MessageEvent): 消息事件对象
code (str): 要执行的Python代码
"""
code_executor = getattr(event.bot, 'code_executor', None)
if not code_executor or not code_executor.docker_client:
await event.reply("代码执行服务当前不可用,请检查 Docker 连接配置。")
return
# 定义一个包装回调函数,确保正确处理异步操作和异常
async def callback_wrapper(result):
try:
await generate_and_send_code_image(event, code, result)
except Exception as e:
logger.error(f"[code_py] 执行回调时发生错误: {e}")
# 即使回调失败,也要确保任务被标记为完成
# 降级为简单文本回复
try:
await event.reply(f"代码执行结果:\n{result}")
except Exception as reply_error:
logger.error(f"[code_py] 发送降级回复时也失败: {reply_error}")
await code_executor.add_task(
code,
callback_wrapper
)
await event.reply("代码已提交至沙箱执行队列,请稍候...")
def cleanup_session(session_key: tuple):
"""
清理超时的会话。
"""
if session_key in multi_line_sessions:
del multi_line_sessions[session_key]
logger.info(f"[code_py] 会话 {session_key} 已超时,自动取消。")
def normalize_code(code: str) -> str:
"""
规范化用户输入的 Python 代码字符串。
主要处理两个问题:
1. 对消息中可能存在的 HTML 实体进行解码 (e.g., &#91; -> [)。
2. 移除整个代码块的公共前导缩进,以修复因复制粘贴导致的多余缩进。
:param code: 原始代码字符串。
:return: 规范化后的代码字符串。
"""
# 1. 解码 HTML 实体
code = html.unescape(code)
# 2. 移除公共前导缩进
try:
code = textwrap.dedent(code)
except Exception:
# 在某些情况下例如不一致的缩进dedent 可能会失败,
# 但我们不希望因此中断流程,所以捕获异常并继续。
pass
return code.strip()
@matcher.command("py", "python", "code_py")
async def code_py_main(event: MessageEvent, args: list[str]):
"""
/py 命令的主入口。
- 如果有参数,直接执行。
- 如果没有参数,开启多行输入模式。
"""
code_to_run = " ".join(args)
if code_to_run:
# 单行模式,对代码进行规范化处理
normalized_code = normalize_code(code_to_run)
if not normalized_code:
await event.reply("代码为空或格式错误,请输入有效的代码。")
return
await execute_code(event, normalized_code)
else:
# 多行模式
# 使用 getattr 兼容私聊和群聊
session_key = (event.user_id, getattr(event, 'group_id', 'private'))
# 如果上一个会话的超时任务还在,先取消它
if session_key in multi_line_sessions:
multi_line_sessions[session_key].cancel()
await event.reply("已进入多行代码输入模式,请直接发送你的代码。\n(60秒内无操作将自动取消)")
# 设置 60 秒超时
loop = asyncio.get_running_loop()
timeout_handler = loop.call_later(
60,
cleanup_session,
session_key
)
multi_line_sessions[session_key] = timeout_handler
@matcher.on_message()
async def handle_multi_line_code(event: MessageEvent):
"""
通用消息处理器,用于捕获多行模式下的代码输入。
"""
# 使用 getattr 兼容私聊和群聊
session_key = (event.user_id, getattr(event, 'group_id', 'private'))
if session_key in multi_line_sessions:
# 取消超时任务
multi_line_sessions[session_key].cancel()
del multi_line_sessions[session_key]
# 对多行代码进行规范化处理
normalized_code = normalize_code(event.raw_message)
if not normalized_code:
await event.reply("捕获到的代码为空或格式错误,已取消输入。")
return
await execute_code(event, normalized_code)
return True # 消费事件,防止其他处理器响应

View File

@@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件入口
"""
import asyncio
from core.utils.logger import ModuleLogger
from .config import config
from .subscription import start_cross_platform_subscription, stop_cross_platform_subscription
from .handlers import *
# 创建模块专用日志记录器
logger = ModuleLogger("CrossPlatform")
# 插件加载时自动启动和加载配置
try:
asyncio.create_task(config.reload())
except Exception as e:
logger.error(f"[CrossPlatform] 重新加载配置失败: {e}")
try:
asyncio.create_task(start_cross_platform_subscription())
except Exception as e:
logger.error(f"[CrossPlatform] 启动订阅失败: {e}")
def cleanup():
"""清理资源"""
asyncio.create_task(stop_cross_platform_subscription())

View File

@@ -1,98 +0,0 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件配置模块
"""
import os
from typing import Dict, Any
from core.utils.logger import ModuleLogger
from core.config_loader import global_config
# 创建模块专用日志记录器
logger = ModuleLogger("CrossPlatformConfig")
class CrossPlatformConfig:
def __init__(self):
self.CROSS_PLATFORM_MAP: Dict[int, Dict[str, Any]] = {}
self.CROSS_PLATFORM_CHANNEL = "neobot_cross_platform"
self.ENABLE_CROSS_PLATFORM = True
# DeepSeek API 配置 - 从环境变量或配置文件加载
self.DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "sk-f71322a9fbba4b05a7df969cb4004f06")
self.DEEPSEEK_API_URL = os.environ.get("DEEPSEEK_API_URL", "https://api.deepseek.com/v1/chat/completions")
self.DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")
# 是否启用翻译功能
self.ENABLE_TRANSLATION = True
# 从全局配置加载
self.load_from_global_config()
def load_from_global_config(self):
"""从全局配置加载跨平台配置"""
if global_config and hasattr(global_config, 'cross_platform'):
cross_platform_config = global_config.cross_platform
if cross_platform_config:
self.ENABLE_CROSS_PLATFORM = getattr(cross_platform_config, 'enabled', True)
self.CROSS_PLATFORM_MAP = {}
# 加载 mappings
if hasattr(cross_platform_config, 'mappings') and cross_platform_config.mappings:
for discord_id, mapping in cross_platform_config.mappings.items():
if isinstance(mapping, dict):
self.CROSS_PLATFORM_MAP[discord_id] = {
"qq_group_id": int(mapping.get("qq_group_id", 0)),
"name": mapping.get("name", "")
}
elif hasattr(mapping, 'qq_group_id'):
self.CROSS_PLATFORM_MAP[discord_id] = {
"qq_group_id": int(mapping.qq_group_id),
"name": getattr(mapping, 'name', "")
}
logger.success(f"[CrossPlatform] 从全局配置加载了 {len(self.CROSS_PLATFORM_MAP)} 个映射")
async def reload(self):
"""重新加载配置"""
try:
# 优先使用全局配置
self.load_from_global_config()
# 如果全局配置不可用,尝试从文件加载
if not self.CROSS_PLATFORM_MAP:
config_path = os.path.join(os.path.dirname(__file__), "..", "..", "config.toml")
if os.path.exists(config_path):
try:
import tomllib
except ImportError:
import tomli as tomllib
with open(config_path, "rb") as f:
config_data = tomllib.load(f)
cross_platform_config = config_data.get("cross_platform", {})
self.ENABLE_CROSS_PLATFORM = cross_platform_config.get("enabled", True)
# 重新加载映射配置
mappings = cross_platform_config.get("mappings", {})
self.CROSS_PLATFORM_MAP.clear()
if isinstance(mappings, dict) and mappings:
for key, value in mappings.items():
if isinstance(value, dict) and "qq_group_id" in value:
try:
# 直接将 key 转换为整数
discord_id = int(str(key))
self.CROSS_PLATFORM_MAP[discord_id] = {
"qq_group_id": int(value.get("qq_group_id", 0)),
"name": value.get("name", "")
}
except (ValueError, AttributeError):
logger.warning(f"[CrossPlatform] 无效的 Discord 频道 ID: {key}")
continue
logger.success(f"[CrossPlatform] 配置已重新加载: {len(self.CROSS_PLATFORM_MAP)} 个映射")
except Exception as e:
logger.error(f"[CrossPlatform] 重新加载配置失败: {e}")
config = CrossPlatformConfig()

View File

@@ -1,285 +0,0 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件事件处理器模块
"""
import os
import html
from typing import List, Any
from core.managers.command_manager import matcher
from models.events.message import GroupMessageEvent, MessageEvent
from models.message import MessageSegment
from core.permission import Permission
from core.utils.logger import ModuleLogger
from .config import config
from .parser import parse_forward_nodes
from .sender import forward_discord_to_qq, forward_qq_to_discord
# 创建模块专用日志记录器
logger = ModuleLogger("CrossPlatform")
async def handle_discord_message(
username: str,
discriminator: str,
content: str,
channel_id: int,
attachments: List[dict] = None,
embed: dict = None
):
"""处理 Discord 消息并转发"""
if not config.ENABLE_CROSS_PLATFORM:
return
logger.info(f"[CrossPlatform] 收到 Discord 消息: {username}#{discriminator} in {channel_id}")
logger.debug(f"[CrossPlatform] 消息内容: '{content}', 附件: {attachments}")
await forward_discord_to_qq(username, discriminator, content, channel_id, attachments)
async def handle_qq_message(
nickname: str,
user_id: int,
group_name: str,
group_id: int,
content: str,
attachments: List[dict] = None
):
"""处理 QQ 消息并转发"""
if not config.ENABLE_CROSS_PLATFORM:
return
logger.info(f"[CrossPlatform] 收到 QQ 消息: {nickname} ({user_id}) in {group_name}({group_id})")
await forward_qq_to_discord(nickname, user_id, group_name, group_id, content, attachments)
@matcher.on_message()
async def handle_qq_group_message(event: GroupMessageEvent):
"""处理 QQ 群消息,转发到 Discord"""
try:
if not config.ENABLE_CROSS_PLATFORM:
return
# 忽略非群消息和 Discord 注入的消息
if not hasattr(event, 'group_id') or hasattr(event, '_is_discord_message'):
return
group_id = event.group_id
mapped_channel = None
for discord_channel_id, info in config.CROSS_PLATFORM_MAP.items():
if info["qq_group_id"] == group_id:
mapped_channel = discord_channel_id
break
if mapped_channel is None:
return
content = ""
attachments = []
if isinstance(event.message, list):
has_forward_node = any(isinstance(seg, MessageSegment) and seg.type == "node" for seg in event.message)
if has_forward_node:
forward_nodes = [seg for seg in event.message if isinstance(seg, MessageSegment) and seg.type == "node"]
forward_nodes_dict = [{"type": seg.type, "data": seg.data} for seg in forward_nodes]
content, attachments = await parse_forward_nodes(forward_nodes_dict)
else:
for segment in event.message:
if isinstance(segment, MessageSegment):
if segment.type == "text":
content += segment.data.get("text", "")
elif segment.type == "image":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_url = html.unescape(str(file_url))
if not file_name:
file_name = os.path.basename(file_url.split('?')[0]) or f"image_{len(attachments)}.jpg"
attachments.append({"type": "image", "url": file_url, "filename": file_name})
elif segment.type == "video":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_url = html.unescape(str(file_url))
if not file_name:
file_name = os.path.basename(file_url.split('?')[0]) or f"video_{len(attachments)}.mp4"
attachments.append({"type": "video", "url": file_url, "filename": file_name})
elif segment.type == "record":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_url = html.unescape(str(file_url))
if not file_name:
file_name = os.path.basename(file_url.split('?')[0]) or f"record_{len(attachments)}.amr"
attachments.append({"type": "record", "url": file_url, "filename": file_name})
content += f"\n[语音: {file_name}]\n"
elif segment.type == "file":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_url = html.unescape(str(file_url))
if not file_name:
file_name = os.path.basename(file_url.split('?')[0]) or f"file_{len(attachments)}"
attachments.append({"type": "file", "url": file_url, "filename": file_name})
content += f"\n[文件: {file_name}]\n"
logger.debug(f"[CrossPlatform] QQ 消息识别到文件: {file_name}, URL: {file_url}")
elif segment.type == "at":
qq_id = segment.data.get("qq")
if qq_id and qq_id != "all":
content += f"@{qq_id} "
elif qq_id == "all":
content += "@所有人 "
elif isinstance(segment, str):
content += segment
elif isinstance(event.message, str):
content = event.message
import re
local_file_pattern = r'(http://[\w\.-]+:\d+/download\?id=file_[a-zA-Z0-9_]+)'
matches = re.finditer(local_file_pattern, content)
for match in matches:
file_url = match.group(1)
file_name = f"video_{len(attachments)}.mp4"
attachments.append({"type": "video", "url": file_url, "filename": file_name})
content = content.strip()
group_name = ""
try:
group_info = await event.bot.get_group_info(event.group_id)
group_name = group_info.get("group_name", "")
except Exception:
group_name = f"{group_id}"
await handle_qq_message(
nickname=event.sender.card or event.sender.nickname or str(event.user_id),
user_id=event.user_id,
group_name=group_name,
group_id=group_id,
content=content,
attachments=attachments
)
except Exception as e:
logger.error(f"[CrossPlatform] 处理 QQ 群消息失败: {e}")
import traceback
logger.error(f"[CrossPlatform] 异常堆栈: {traceback.format_exc()}")
@matcher.on_message()
async def handle_discord_message_event(event: Any):
"""处理 Discord 消息事件(通过适配器注入)"""
try:
if not config.ENABLE_CROSS_PLATFORM:
return
logger.debug(f"[CrossPlatform] handle_discord_message_event 触发: {event}")
if not hasattr(event, '_is_discord_message'):
logger.debug(f"[CrossPlatform] 事件没有 _is_discord_message 属性,跳过")
return
logger.debug(f"[CrossPlatform] 检测到 Discord 事件")
discord_channel_id = getattr(event, 'discord_channel_id', None)
if discord_channel_id is None:
logger.debug(f"[CrossPlatform] discord_channel_id 为 None")
return
content = ""
attachments = []
logger.debug(f"[CrossPlatform] 开始处理 Discord 事件消息: channel_id={discord_channel_id}")
if hasattr(event, 'message') and isinstance(event.message, list):
has_text_content = False
for segment in event.message:
if isinstance(segment, MessageSegment):
if segment.type == "text":
content += segment.data.get("text", "")
has_text_content = True
elif segment.type == "image":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "image"
attachment_item = {"type": "image", "url": str(file_url), "filename": file_name}
if attachment_item not in attachments:
attachments.append(attachment_item)
content += f"\n[图片: {file_name}]\n"
elif segment.type == "video":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "video"
attachment_item = {"type": "video", "url": str(file_url), "filename": file_name}
if attachment_item not in attachments:
attachments.append(attachment_item)
content += f"\n[视频: {file_name}]\n"
elif segment.type == "record":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "record"
attachment_item = {"type": "record", "url": str(file_url), "filename": file_name}
if attachment_item not in attachments:
attachments.append(attachment_item)
content += f"\n[语音: {file_name}]\n"
elif segment.type == "file":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("filename")
if file_url:
file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "file"
attachment_item = {"type": "file", "url": str(file_url), "filename": file_name}
if attachment_item not in attachments:
attachments.append(attachment_item)
content += f"\n[文件: {file_name}]\n"
logger.debug(f"[CrossPlatform] Discord 消息识别到文件: {file_name}, URL: {file_url}")
else:
content = event.raw_message or ""
content = content.strip()
# 如果 content 为空但有附件(如只有表情),使用 raw_message 作为 content
if not content and attachments:
content = event.raw_message or ""
logger.debug(f"[CrossPlatform] Discord 消息内容: '{content}', 附件数量: {len(attachments)}")
discord_username = getattr(event, 'discord_username', 'Unknown')
discord_discriminator = getattr(event, 'discord_discriminator', '')
logger.debug(f"[CrossPlatform] 调用 handle_discord_message: username={discord_username}, channel_id={discord_channel_id}")
await handle_discord_message(
username=discord_username,
discriminator=discord_discriminator,
content=content,
channel_id=discord_channel_id,
attachments=attachments,
embed=None
)
except Exception as e:
logger.error(f"[CrossPlatform] 处理 Discord 消息事件失败: {e}")
import traceback
logger.error(f"[CrossPlatform] 异常堆栈: {traceback.format_exc()}")
@matcher.command("cross_config", "跨平台配置", permission=Permission.ADMIN)
async def cross_config_command(event: MessageEvent):
"""查看跨平台配置"""
if not config.ENABLE_CROSS_PLATFORM:
await event.reply("跨平台功能已禁用")
return
config_lines = ["=== 跨平台映射配置 ==="]
if not config.CROSS_PLATFORM_MAP:
config_lines.append("当前没有配置任何映射")
else:
for discord_id, info in config.CROSS_PLATFORM_MAP.items():
discord_channel = f"Discord: {discord_id}"
qq_group = f"QQ: {info['qq_group_id']}"
name = info.get("name", "")
if name:
config_lines.append(f"{discord_channel}{qq_group} ({name})")
else:
config_lines.append(f"{discord_channel}{qq_group}")
await event.reply("\n".join(config_lines))
@matcher.command("cross_reload", "跨平台重载", permission=Permission.ADMIN)
async def cross_reload_command(event: MessageEvent):
"""重新加载跨平台配置"""
await config.reload()
await event.reply("跨平台配置已重载")

View File

@@ -1,398 +0,0 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件解析器模块
"""
import os
import json
import re
from typing import Dict, List, Any
from models.message import MessageSegment
from core.utils.logger import ModuleLogger
from .config import config
# 创建模块专用日志记录器
logger = ModuleLogger("CrossPlatformParser")
def extract_text_only(content: str) -> str:
"""从消息内容中提取纯文本,过滤掉非文本标记"""
if not content:
return ""
# 移除所有 [图片: xxx]、[视频: xxx]、[语音: xxx]、[文件: xxx] 等标记
text_only = re.sub(r'\s*\[(图片|视频|语音|文件):[^\]]+\]\s*', ' ', content)
# 移除连续空格
text_only = re.sub(r'\s+', ' ', text_only).strip()
return text_only
async def parse_forward_nodes(nodes: List[Dict[str, Any]]) -> tuple[str, List[dict]]:
"""解析 OneBot 合并转发消息节点"""
content_parts = []
attachments = []
for node in nodes:
if not isinstance(node, dict):
continue
node_data = node.get("data", {})
node_content = node_data.get("content", "")
sender_name = node_data.get("name", node_data.get("uin", "Unknown"))
if isinstance(node_content, str):
if "[object Object]" in node_content:
content = f"[合并转发消息: {sender_name}]"
content_parts.append(f"**{sender_name}**:\n{content}")
elif '[CQ:' in node_content:
content = parse_cq_code(node_content, attachments)
content_parts.append(f"**{sender_name}**:\n{content}")
else:
content = node_content
content_parts.append(f"**{sender_name}**:\n{content}")
elif isinstance(node_content, list):
content = parse_message_segments(node_content, attachments)
content_parts.append(f"**{sender_name}**:\n{content}")
full_content = "\n\n".join(content_parts) if content_parts else ""
return full_content, attachments
def parse_cq_code(cq_code: str, attachments: List[dict]) -> str:
"""解析 CQ 码字符串"""
import re
cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]'
matches = list(re.finditer(cq_pattern, cq_code))
if not matches:
return cq_code
result = []
last_end = 0
for match in matches:
if match.start() > last_end:
result.append(cq_code[last_end:match.start()])
cq_type = match.group(1)
cq_params_str = match.group(2) or ""
params = {}
if cq_params_str:
for param in cq_params_str.split(','):
if '=' in param:
k, v = param.split('=', 1)
params[k] = v
if cq_type == "text":
result.append(params.get("text", ""))
elif cq_type == "image":
file_url = params.get("url") or params.get("file")
if file_url:
file_name = params.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "image"
attachments.append({"type": "image", "url": str(file_url), "filename": file_name})
result.append(f"\n[图片: {file_name}]\n")
elif cq_type == "video":
file_url = params.get("url") or params.get("file")
if file_url:
file_name = params.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "video"
attachments.append({"type": "video", "url": str(file_url), "filename": file_name})
result.append(f"\n[视频: {file_name}]\n")
elif cq_type == "record":
file_url = params.get("url") or params.get("file")
if file_url:
file_name = params.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "record"
attachments.append({"type": "record", "url": str(file_url), "filename": file_name})
result.append(f"\n[语音: {file_name}]\n")
elif cq_type == "at":
qq_id = params.get("qq")
if qq_id == "all":
result.append("@所有人 ")
else:
result.append(f"@{qq_id} ")
elif cq_type == "face":
face_id = params.get("id", "")
result.append(f"[表情:{face_id}] ")
elif cq_type == "reply":
reply_id = params.get("id", "")
result.append(f"[回复:{reply_id}] ")
elif cq_type == "file":
file_url = params.get("file", "")
if file_url:
file_name = os.path.basename(str(file_url).split('?')[0]) or "file"
attachments.append({"type": "file", "url": str(file_url), "filename": file_name})
result.append(f"\n[文件: {file_name}]\n")
last_end = match.end()
if last_end < len(cq_code):
result.append(cq_code[last_end:])
return "".join(result)
def parse_message_segments(segments: List[Any], attachments: List[dict]) -> str:
"""解析 MessageSegment 列表"""
result = []
for seg in segments:
if isinstance(seg, str):
result.append(seg)
elif isinstance(seg, MessageSegment):
seg_type = seg.type
seg_data = seg.data
if seg_type == "text":
result.append(seg_data.get("text", ""))
elif seg_type == "image":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("filename")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "image"
attachments.append({"type": "image", "url": str(file_url), "filename": file_name})
result.append(f"\n[图片: {file_name}]\n")
elif seg_type == "video":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("filename")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "video"
attachments.append({"type": "video", "url": str(file_url), "filename": file_name})
result.append(f"\n[视频: {file_name}]\n")
elif seg_type == "record":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("filename")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "record"
attachments.append({"type": "record", "url": str(file_url), "filename": file_name})
result.append(f"\n[语音: {file_name}]\n")
elif seg_type == "at":
qq_id = seg_data.get("qq")
if qq_id == "all":
result.append("@所有人 ")
else:
result.append(f"@{qq_id} ")
elif seg_type == "face":
face_id = seg_data.get("id", "")
result.append(f"[表情:{face_id}] ")
elif seg_type == "reply":
reply_id = seg_data.get("id", "")
result.append(f"[回复:{reply_id}] ")
elif seg_type == "file":
file_url = seg_data.get("file", "")
if file_url:
file_name = os.path.basename(str(file_url).split('?')[0]) or "file"
attachments.append({"type": "file", "url": str(file_url), "filename": file_name})
result.append(f"\n[文件: {file_name}]\n")
elif seg_type == "json":
json_data = seg_data.get("data", "")
try:
parsed = json.loads(json_data)
if isinstance(parsed, dict):
result.append(f"\n[JSON数据: {json_data[:100]}...]\n")
except:
result.append(f"\n[JSON数据]\n")
elif seg_type == "xml":
result.append(f"\n[XML数据]\n")
elif isinstance(seg, dict):
seg_type = seg.get("type")
seg_data = seg.get("data", {})
if seg_type == "text":
result.append(seg_data.get("text", ""))
elif seg_type == "image":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("filename")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "image"
attachments.append({"type": "image", "url": str(file_url), "filename": file_name})
result.append(f"\n[图片: {file_name}]\n")
elif seg_type == "video":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("filename")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "video"
attachments.append({"type": "video", "url": str(file_url), "filename": file_name})
result.append(f"\n[视频: {file_name}]\n")
elif seg_type == "record":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("filename")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "record"
attachments.append({"type": "record", "url": str(file_url), "filename": file_name})
result.append(f"\n[语音: {file_name}]\n")
elif seg_type == "at":
qq_id = seg_data.get("qq")
if qq_id == "all":
result.append("@所有人 ")
else:
result.append(f"@{qq_id} ")
return "".join(result)
def get_platform_info(platform: str, identifier: Any) -> str:
"""获取平台信息字符串"""
if platform == "discord":
channel_id = int(identifier)
if channel_id in config.CROSS_PLATFORM_MAP:
group_info = config.CROSS_PLATFORM_MAP[channel_id]
group_name = group_info.get("name", f"群组 {group_info['qq_group_id']}")
return f"[Discord {group_name}]"
return f"[Discord]"
elif platform == "qq":
group_id = int(identifier)
return f"[PAW qq]"
return ""
async def format_discord_to_qq_content(
discord_username: str,
discord_discriminator: str,
content: str,
channel_id: int,
attachments: List[dict] = None
) -> tuple[str, List[dict]]:
"""将 Discord 消息格式化为 QQ 消息格式"""
logger.debug(f"[CrossPlatform] format_discord_to_qq_content: username={discord_username}, content='{content}', attachments={attachments}")
platform_info = get_platform_info("discord", channel_id)
message_header = f"{discord_username}:"
message_body = content.strip() if content else ""
if message_body:
full_message = f"{message_header}\n{message_body}"
else:
full_message = message_header
processed_attachments = []
if attachments:
logger.debug(f"[CrossPlatform] 处理附件: {attachments}")
for att in attachments:
if isinstance(att, dict):
url = att.get("url", "")
filename = att.get("filename", "").lower()
att_type = att.get("type", "")
if att_type == "image" or filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
processed_attachments.append({"type": "image", "url": url})
elif att_type == "record" or filename.endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')):
processed_attachments.append({"type": "record", "url": url})
elif att_type == "video" or filename.endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')):
processed_attachments.append({"type": "video", "url": url})
else:
processed_attachments.append({"type": "file", "url": url, "filename": filename})
logger.debug(f"[CrossPlatform] Discord 消息格式化: 识别为文件 {filename}")
else:
url = str(att)
logger.debug(f"[CrossPlatform] 处理非字典附件: {url}")
if url.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
processed_attachments.append({"type": "image", "url": url})
elif url.lower().endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')):
processed_attachments.append({"type": "record", "url": url})
elif url.lower().endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')):
processed_attachments.append({"type": "video", "url": url})
else:
filename = os.path.basename(url.split('?')[0]) or "file"
processed_attachments.append({"type": "file", "url": url, "filename": filename})
logger.debug(f"[CrossPlatform] Discord 消息格式化: 通过扩展名识别为文件 {filename}")
logger.debug(f"[CrossPlatform] format_discord_to_qq_content 完成: full_message='{full_message}', processed_attachments={processed_attachments}")
return full_message, processed_attachments
async def format_qq_to_discord_content(
qq_nickname: str,
qq_user_id: int,
group_name: str,
group_id: int,
content: str,
attachments: List[dict] = None
) -> tuple[str, List[dict], dict]:
"""将 QQ 消息格式化为 Discord 消息格式Embed 卡片)"""
platform_info = get_platform_info("qq", group_id)
embed = {
"type": "rich",
"color": 0x5865F2,
"author": {
"name": f"{qq_nickname}",
"icon_url": f"https://q1.qlogo.cn/g?b=qq&nk={qq_user_id}&s=640"
},
"footer": {
"text": f"来自 QQ"
}
}
if content:
embed["description"] = content
if attachments:
image_urls = []
voice_urls = []
video_urls = []
other_urls = []
filtered_attachments = []
for att in attachments:
url = att.get("url", "")
filename = att.get("filename", "").lower()
att_type = att.get("type", "")
if att_type == "image" or filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
image_urls.append(url)
if len(image_urls) > 1:
filtered_attachments.append(att)
elif att_type == "record" or filename.endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')):
voice_urls.append(url)
other_urls.append(url)
filtered_attachments.append(att)
elif att_type == "video" or filename.endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')):
video_urls.append(url)
other_urls.append(url)
filtered_attachments.append(att)
else:
other_urls.append(url)
filtered_attachments.append(att)
attachments = filtered_attachments
if content:
embed["description"] = content
elif "description" not in embed:
embed["description"] = ""
if image_urls:
embed["image"] = {"url": image_urls[0]}
if voice_urls:
voice_filenames = [att.get("filename", "voice") for att in attachments if att.get("url") in voice_urls]
voice_list = "\n".join([f"🎤 {fname}" for fname in voice_filenames[:5]])
embed["description"] += f"\n\n**语音消息:**\n{voice_list}"
if len(voice_urls) > 5:
embed["description"] += f"\n...还有 {len(voice_urls) - 5} 条语音"
if video_urls:
video_filenames = [att.get("filename", "video") for att in attachments if att.get("url") in video_urls]
video_list = "\n".join([f"🎬 {fname}" for fname in video_filenames[:5]])
embed["description"] += f"\n\n**视频文件:**\n{video_list}"
if len(video_urls) > 5:
embed["description"] += f"\n...还有 {len(video_urls) - 5} 个视频"
non_media_other_urls = [u for u in other_urls if u not in voice_urls and u not in video_urls]
if non_media_other_urls:
file_filenames = [att.get("filename", "file") for att in attachments if att.get("url") in non_media_other_urls]
file_list = "\n".join([f"📄 {fname}" for fname in file_filenames[:5]])
embed["description"] += f"\n\n**附加文件:**\n{file_list}"
if len(non_media_other_urls) > 5:
embed["description"] += f"\n...还有 {len(non_media_other_urls) - 5} 个文件"
return "", attachments or [], embed

View File

@@ -1,189 +0,0 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件发送器模块
"""
import json
from typing import List
from core.utils.logger import ModuleLogger
from core.managers.redis_manager import redis_manager
from .config import config
from .translator import translate_with_deepseek
from .parser import format_discord_to_qq_content, format_qq_to_discord_content, extract_text_only
# 创建模块专用日志记录器
logger = ModuleLogger("CrossPlatformSender")
async def send_to_discord(channel_id: int, content: str, attachments: List[dict] = None, embed: dict = None):
"""发送消息到 Discord 频道"""
try:
publish_data = {
"type": "send_message",
"channel_id": channel_id,
"content": content,
"attachments": attachments or [],
"embed": embed
}
await redis_manager.redis.publish("neobot_discord_send", json.dumps(publish_data))
logger.info(f"[CrossPlatform] 消息已发布到 Redis 供 Discord 适配器发送: {channel_id}")
except Exception as e:
logger.error(f"[CrossPlatform] 发送消息到 Discord 失败: {e}")
async def send_to_qq(group_id: int, content: str, attachments: List[dict] = None):
"""发送消息到 QQ 群"""
logger.debug(f"[CrossPlatform] send_to_qq: group_id={group_id}, content='{content}', attachments={attachments}")
try:
from core.managers.bot_manager import bot_manager
from models.message import MessageSegment
all_bots = bot_manager.get_all_bots()
if not all_bots:
logger.error(f"[CrossPlatform] 没有可用的 QQ 机器人实例")
return
logger.debug(f"[CrossPlatform] 找到 {len(all_bots)} 个 QQ 机器人实例")
for bot in all_bots:
try:
message = content
if attachments:
full_message = []
if content:
full_message.append(MessageSegment.text(content))
for attachment in attachments:
if isinstance(attachment, dict):
att_type = attachment.get("type", "image")
attachment_url = attachment.get("url", "")
if att_type == "image":
full_message.append(MessageSegment.image(attachment_url, cache=True, proxy=True, timeout=30))
elif att_type == "record":
full_message.append(MessageSegment.record(attachment_url, cache=True, proxy=True, timeout=30))
elif att_type == "video":
full_message.append(MessageSegment.video(attachment_url))
elif att_type == "file":
full_message.append(MessageSegment.file(attachment_url))
logger.success(f"[CrossPlatform] 已添加文件到 QQ 消息: {attachment_url}")
else:
attachment_url = str(attachment)
if attachment_url.lower().endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')):
full_message.append(MessageSegment.video(attachment_url))
elif attachment_url.lower().endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')):
full_message.append(MessageSegment.record(attachment_url, cache=True, proxy=True, timeout=30))
elif attachment_url.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
image_type = "flash" if attachment_url.lower().endswith('.gif') else None
full_message.append(MessageSegment.image(attachment_url, cache=True, proxy=True, timeout=30, image_type=image_type))
else:
full_message.append(MessageSegment.file(attachment_url))
logger.success(f"[CrossPlatform] 已添加文件到 QQ 消息 (通过扩展名识别): {attachment_url}")
logger.debug(f"[CrossPlatform] 准备发送消息到 QQ 群 {group_id}: {full_message}")
await bot.send_group_msg(group_id, full_message)
logger.success(f"[CrossPlatform] 消息已发送到 QQ 群 {group_id}: {full_message}")
else:
logger.debug(f"[CrossPlatform] 准备发送纯文本消息到 QQ 群 {group_id}: {message}")
await bot.send_group_msg(group_id, message)
logger.success(f"[CrossPlatform] 纯文本消息已发送到 QQ 群 {group_id}: {message}")
break
except Exception as e:
logger.error(f"[CrossPlatform] 发送消息到 QQ 群 {group_id} 失败: {e}")
except Exception as e:
logger.error(f"[CrossPlatform] 发送消息到 QQ 失败: {e}")
async def forward_discord_to_qq(
discord_username: str,
discord_discriminator: str,
content: str,
channel_id: int,
attachments: List[dict] = None
):
"""将 Discord 消息转发到所有映射的 QQ 群"""
logger.debug(f"[CrossPlatform] forward_discord_to_qq: channel_id={channel_id}, attachments={attachments}")
if channel_id not in config.CROSS_PLATFORM_MAP:
logger.warning(f"[CrossPlatform] 未找到 Discord 频道 {channel_id} 的映射配置")
return
group_info = config.CROSS_PLATFORM_MAP[channel_id]
target_qq_group = group_info["qq_group_id"]
formatted_content, image_list = await format_discord_to_qq_content(
discord_username,
discord_discriminator,
content,
channel_id,
attachments
)
logger.debug(f"[CrossPlatform] 格式化后的内容: '{formatted_content}', 图片列表: {image_list}")
if formatted_content:
# 只提取文本进行翻译,过滤掉非文本内容
text_only = extract_text_only(formatted_content)
if text_only:
translated_content = await translate_with_deepseek(text_only, "zh-CN", channel_id, "en2zh")
if translated_content != text_only:
# 同时包含原文和翻译内容
formatted_content = f"{formatted_content}\n\n[翻译]\n{translated_content}"
await send_to_qq(target_qq_group, formatted_content, image_list)
logger.success(f"[CrossPlatform] Discord 频道 {channel_id} -> QQ 群 {target_qq_group}")
logger.debug(f"[CrossPlatform] send_to_qq 已调用: group_id={target_qq_group}, formatted_content='{formatted_content}', image_list={image_list}")
async def forward_qq_to_discord(
qq_nickname: str,
qq_user_id: int,
group_name: str,
group_id: int,
content: str,
attachments: List[dict] = None
):
"""将 QQ 消息转发到所有映射的 Discord 频道"""
target_channels = []
for discord_channel_id, info in config.CROSS_PLATFORM_MAP.items():
if info["qq_group_id"] == group_id:
target_channels.append(discord_channel_id)
if not target_channels:
logger.warning(f"[CrossPlatform] 未找到 QQ 群 {group_id} 的映射配置")
return
formatted_content, image_list, embed = await format_qq_to_discord_content(
qq_nickname,
qq_user_id,
group_name,
group_id,
content,
attachments
)
if embed and embed.get("description"):
original_text = embed["description"]
# 只提取文本进行翻译
text_only = extract_text_only(original_text)
if text_only:
translated_text = await translate_with_deepseek(text_only, "en", group_id, "zh2en")
if translated_text != text_only:
# 同时包含原文和翻译内容
embed["description"] = f"{original_text}\n\n[Translation]\n{translated_text}"
for channel_id in target_channels:
await send_to_discord(channel_id, formatted_content, image_list, embed)
logger.success(f"[CrossPlatform] QQ 群 {group_id} -> Discord 频道 {target_channels}")
async def publish_to_redis(platform: str, data: dict):
"""通过 Redis 发布跨平台消息"""
try:
if redis_manager.redis:
publish_data = {
"platform": platform,
"data": data,
"timestamp": int(__import__('time').time())
}
await redis_manager.redis.publish(config.CROSS_PLATFORM_CHANNEL, json.dumps(publish_data))
logger.debug(f"[CrossPlatform] 已通过 Redis 发布消息: platform={platform}")
except Exception as e:
logger.error(f"[CrossPlatform] Redis 发布失败: {e}")

View File

@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件订阅模块
"""
import json
import asyncio
from core.utils.logger import ModuleLogger
from core.managers.redis_manager import redis_manager
from .config import config
from .sender import forward_discord_to_qq, forward_qq_to_discord
# 创建模块专用日志记录器
logger = ModuleLogger("CrossPlatformSubscription")
async def cross_platform_subscription_loop():
"""Redis 跨平台消息订阅循环"""
if redis_manager.redis is None:
logger.warning("[CrossPlatform] Redis 未初始化,无法启动订阅")
return
try:
pubsub = redis_manager.redis.pubsub()
await pubsub.subscribe(config.CROSS_PLATFORM_CHANNEL)
logger.success("[CrossPlatform] 已订阅 Redis 跨平台频道")
async for message in pubsub.listen():
if message["type"] == "message":
try:
data = json.loads(message["data"])
platform = data.get("platform", "")
message_data = data.get("data", {})
logger.info(f"[CrossPlatform] 收到跨平台消息: {platform}")
if platform == "discord":
await forward_discord_to_qq(
discord_username=message_data.get("username", "Unknown"),
discord_discriminator=message_data.get("discriminator", ""),
content=message_data.get("content", ""),
channel_id=message_data.get("channel_id", 0),
attachments=message_data.get("attachments", [])
)
elif platform == "qq":
await forward_qq_to_discord(
qq_nickname=message_data.get("nickname", "Unknown"),
qq_user_id=message_data.get("user_id", 0),
group_name=message_data.get("group_name", ""),
group_id=message_data.get("group_id", 0),
content=message_data.get("content", ""),
attachments=message_data.get("attachments", []),
embed=message_data.get("embed")
)
except json.JSONDecodeError as e:
logger.error(f"[CrossPlatform] 解析消息失败: {e}")
except Exception as e:
logger.error(f"[CrossPlatform] 处理跨平台消息失败: {e}")
except Exception as e:
logger.error(f"[CrossPlatform] 订阅循环异常: {e}")
_subscription_task = None
async def start_cross_platform_subscription():
"""启动跨平台消息订阅"""
global _subscription_task
if _subscription_task is None and config.ENABLE_CROSS_PLATFORM:
_subscription_task = asyncio.create_task(cross_platform_subscription_loop())
logger.success("[CrossPlatform] 跨平台消息订阅已启动")
async def stop_cross_platform_subscription():
"""停止跨平台消息订阅"""
global _subscription_task
if _subscription_task:
_subscription_task.cancel()
try:
await _subscription_task
except asyncio.CancelledError:
pass
_subscription_task = None
logger.info("[CrossPlatform] 跨平台消息订阅已停止")

View File

@@ -1,223 +0,0 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件翻译模块
"""
import time
import uuid
from typing import Dict, List
from core.utils.logger import ModuleLogger
from core.managers.vectordb_manager import vectordb_manager
from .config import config
# 创建模块专用日志记录器
logger = ModuleLogger("CrossPlatformTranslator")
# 翻译上下文缓存每个通道15条消息
TRANSLATION_CONTEXT_CACHE: Dict[str, List[Dict[str, str]]] = {}
MAX_CONTEXT_MESSAGES = 15
def get_translation_context(channel_id: int, direction: str) -> List[Dict[str, str]]:
"""获取翻译上下文缓存"""
cache_key = f"{channel_id}_{direction}"
return TRANSLATION_CONTEXT_CACHE.get(cache_key, [])
def add_translation_context(channel_id: int, direction: str, original: str, translated: str):
"""添加翻译到上下文缓存和向量数据库"""
cache_key = f"{channel_id}_{direction}"
if cache_key not in TRANSLATION_CONTEXT_CACHE:
TRANSLATION_CONTEXT_CACHE[cache_key] = []
TRANSLATION_CONTEXT_CACHE[cache_key].append({
"original": original,
"translated": translated
})
if len(TRANSLATION_CONTEXT_CACHE[cache_key]) > MAX_CONTEXT_MESSAGES:
TRANSLATION_CONTEXT_CACHE[cache_key] = TRANSLATION_CONTEXT_CACHE[cache_key][-MAX_CONTEXT_MESSAGES:]
# 将翻译记录保存到向量数据库
try:
collection_name = f"translation_memory_{channel_id}"
doc_id = str(uuid.uuid4())
# 将原文和译文组合作为向量化文本
text_to_embed = f"原文: {original}\n译文: {translated}"
metadata = {
"channel_id": channel_id,
"direction": direction,
"original": original,
"translated": translated,
"timestamp": int(time.time())
}
vectordb_manager.add_texts(
collection_name=collection_name,
texts=[text_to_embed],
metadatas=[metadata],
ids=[doc_id]
)
logger.debug(f"[CrossPlatform] 翻译记录已保存到向量数据库: {collection_name}")
except Exception as e:
logger.error(f"[CrossPlatform] 保存翻译记录到向量数据库失败: {e}")
def get_similar_translations(channel_id: int, text: str, direction: str, limit: int = 3) -> str:
"""从向量数据库检索相似的翻译记录"""
try:
collection_name = f"translation_memory_{channel_id}"
# 检索相似文本
results = vectordb_manager.query_texts(
collection_name=collection_name,
query_texts=[text],
n_results=limit,
where={"direction": direction}
)
if not results or not results.get("documents") or not results["documents"][0]:
return ""
context_ref = "\n\n参考历史相似翻译(向量检索):\n"
for i, metadata in enumerate(results["metadatas"][0], 1):
original = metadata.get("original", "")
translated = metadata.get("translated", "")
context_ref += f"{i}. 原文: {original[:100]}\n 译文: {translated[:100]}\n"
return context_ref
except Exception as e:
logger.error(f"[CrossPlatform] 从向量数据库检索翻译记录失败: {e}")
return ""
async def translate_with_deepseek(
text: str,
target_lang: str = "zh-CN",
channel_id: int = 0,
direction: str = "en2zh"
) -> str:
"""使用 DeepSeek API 翻译文本"""
if not config.ENABLE_TRANSLATION or not text.strip():
return text
if config.DEEPSEEK_API_KEY == "your-deepseek-api-key-here":
logger.warning("[CrossPlatform] DeepSeek API 密钥未配置,跳过翻译")
return text
lang_name = "中文" if target_lang == "zh-CN" else "英文"
messages = []
context_ref = ""
if channel_id > 0:
# 1. 获取最近的上下文缓存
context = get_translation_context(channel_id, direction)
if context:
context_ref = "\n\n参考最近的翻译:\n"
for i, ctx in enumerate(context[-5:], 1):
context_ref += f"{i}. 原文: {ctx['original'][:100]}\n 译文: {ctx['translated'][:100]}\n"
# 2. 从向量数据库检索相似的历史翻译
similar_context = get_similar_translations(channel_id, text, direction)
if similar_context:
context_ref += similar_context
system_prompt = f"""你是一个专业的翻译助手。请将以下文本翻译成{lang_name}
只返回翻译后的文本,不要添加任何解释、注释或其他内容。避免翻译出仇视言论以及违反中国大陆相关法律法规的内容。如果有,请在翻译后有敏感的词语中把文本替换成井号(#
保持原文的语气和格式。如果文本已经是目标语言,直接返回原文。{context_ref}"""
messages.append({"role": "user", "content": text})
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=config.DEEPSEEK_API_KEY,
base_url=config.DEEPSEEK_API_URL.replace("/chat/completions", "")
)
response = await client.chat.completions.create(
model=config.DEEPSEEK_MODEL,
messages=[{"role": "system", "content": system_prompt}] + messages,
temperature=0.3,
max_tokens=4000
)
translated_text = response.choices[0].message.content
if translated_text:
translated_text = translated_text.strip()
logger.info(f"[CrossPlatform] 翻译成功: {text[:50]}... -> {translated_text[:50]}...")
if channel_id > 0:
add_translation_context(channel_id, direction, text, translated_text)
return translated_text
else:
logger.warning("[CrossPlatform] DeepSeek 返回空翻译结果")
return text
except ImportError:
logger.warning("[CrossPlatform] openai 库未安装,尝试使用同步请求")
return await translate_with_deepseek_sync(text, target_lang, channel_id, direction)
except Exception as e:
logger.error(f"[CrossPlatform] 翻译失败: {e}")
return text
async def translate_with_deepseek_sync(
text: str,
target_lang: str = "zh-CN",
channel_id: int = 0,
direction: str = "en2zh"
) -> str:
"""使用同步请求的 DeepSeek 翻译(备用方案)"""
if not config.ENABLE_TRANSLATION or not text.strip():
return text
if config.DEEPSEEK_API_KEY == "your-deepseek-api-key-here":
return text
lang_name = "中文" if target_lang == "zh-CN" else "英文"
context_ref = ""
if channel_id > 0:
# 1. 获取最近的上下文缓存
context = get_translation_context(channel_id, direction)
if context:
context_ref = "\n\n参考最近的翻译:\n"
for i, ctx in enumerate(context[-5:], 1):
context_ref += f"{i}. 原文: {ctx['original'][:100]}\n 译文: {ctx['translated'][:100]}\n"
# 2. 从向量数据库检索相似的历史翻译
similar_context = get_similar_translations(channel_id, text, direction)
if similar_context:
context_ref += similar_context
system_prompt = f"""你是一个专业的翻译助手。请将以下文本翻译成{lang_name}
只返回翻译后的文本,不要添加任何解释、注释或其他内容。避免翻译出仇视言论以及违反中国大陆相关法律法规的内容。如果有,请在翻译后有敏感的词语中把文本替换成井号(#
保持原文的语气和格式。如果文本已经是目标语言,直接返回原文。{context_ref}"""
messages = [{"role": "user", "content": text}]
try:
from openai import OpenAI
client = OpenAI(
api_key=config.DEEPSEEK_API_KEY,
base_url=config.DEEPSEEK_API_URL.replace("/chat/completions", "")
)
response = client.chat.completions.create(
model=config.DEEPSEEK_MODEL,
messages=[{"role": "system", "content": system_prompt}] + messages,
temperature=0.3,
max_tokens=4000
)
translated_text = response.choices[0].message.content
if translated_text:
translated_text = translated_text.strip()
if channel_id > 0:
add_translation_context(channel_id, direction, text, translated_text)
return translated_text
return text
except Exception as e:
logger.error(f"[CrossPlatform] 同步翻译失败: {e}")
return text

View File

@@ -1,53 +0,0 @@
"""
Echo 与交互插件
提供 /echo 和 /赞我 指令。
"""
from core.managers.command_manager import matcher
from core.bot import Bot
from models.events.message import MessageEvent
__plugin_meta__ = {
"name": "echo",
"description": "提供 echo 和 赞我 功能",
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
}
@matcher.command("echo")
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 echo 指令,原样回复用户输入的内容
:param bot: Bot 实例
:param event: 消息事件对象
:param args: 指令参数列表
"""
if not args:
reply_msg = "请在指令后输入要回复的内容,例如:/echo 你好"
else:
reply_msg = " ".join(args)
await event.reply(reply_msg)
@matcher.command(
"赞我",
override_permission_check=True
)
async def handle_poke(bot: Bot, event: MessageEvent, permission_granted: bool):
"""
处理 赞我 指令,发送点赞
:param bot: Bot 实例
:param event: 消息事件对象
:param permission_granted: 权限检查结果
"""
if not permission_granted:
await event.reply("只有我的操作员才能让我点赞哦!(。•ˇ‸ˇ•。)")
return
try:
# 尝试发送赞
await bot.send_like(event.user_id, times=10)
await event.reply("好感度+10(〃''〃)")
except Exception as e:
await event.reply(f"点赞失败了 >_<: {str(e)}")

View File

@@ -1,61 +0,0 @@
"""
thpic 插件
提供 /furry 指令用于随机返回一个东方Project的图片。
"""
from core.managers.command_manager import matcher
from core.bot import Bot
from models.events.message import MessageEvent
from models.message import MessageSegment
__plugin_meta__ = {
"name": "furry",
"description": "处理 /furry 指令发送furry出毛图片",
"usage": "/furry - 发送一条furry图1-10",
}
@matcher.command("furry")
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 furry 指令发送一张随机的东方furry图片。
:param bot: Bot 实例(未使用)。
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
parts = args
print(parts)
if not parts:
try:
await event.reply(
str(MessageSegment.image("https://api.furry.ist/furry-img/"))
)
except Exception as e:
await event.reply(f"报错了。。。{e}")
else:
if parts[0].isdigit():
nums = int(parts[0])
if nums <= 0:
await event.reply("请输入一个大于0的整数。")
return
elif nums > 10:
await event.reply("请输入一个不大于10的整数。")
return
try:
nodes = []
for _ in range(nums):
nodes.append(
bot.build_forward_node(
user_id=event.self_id,
nickname="机器人",
message=MessageSegment.image(
"https://api.furry.ist/furry-img/"
),
)
)
await bot.send_forwarded_messages(event, nodes)
except Exception as e:
await event.reply(f"报错了。。。{e}")
else:
await event.reply(f"用法不正确。\n\n{__plugin_meta__['usage']}")

View File

@@ -1,220 +0,0 @@
# -*- coding: utf-8 -*-
"""
兽人助手插件 - 卡尔戈洛的专属插件
提供兽人相关的趣味功能和实用工具。
"""
import random
from datetime import datetime
from typing import List, Optional
from core.managers.command_manager import matcher
from core.bot import Bot
from models.events.message import MessageEvent
__plugin_meta__ = {
"name": "furry_assistant",
"description": "兽人助手插件 - 卡尔戈洛的专属插件,提供兽人相关的趣味功能和实用工具",
"usage": (
"/兽人问候 - 获取兽人风格的问候\n"
"/兽人运势 - 获取今日兽人运势\n"
"/兽人笑话 - 听一个兽人笑话\n"
"/兽人建议 [问题] - 获取兽人风格的建议\n"
"/兽人时间 - 显示兽人时间(带吐槽)\n"
"/卡尔戈洛 - 关于卡尔戈洛的信息"
),
}
# 兽人问候语
FURRY_GREETINGS = [
"嗷呜~ 今天也要充满活力哦!",
"尾巴摇摇,心情好好~",
"爪子锋利,代码也要锋利!",
"耳朵竖起,监听主人的每一个指令~",
"毛茸茸的一天开始啦!",
"兽人永不为奴!除非包吃包住~",
"今天的毛色怎么样?让我看看~",
"爪子痒了,想写代码了!",
"尾巴表示:今天是个好日子~",
"兽人式问候:嗷!"
]
# 兽人运势
FURRY_FORTUNES = [
"大吉:今天你的尾巴会特别蓬松,吸引所有目光!",
"中吉:爪子状态良好,适合敲代码和抓鱼~",
"小吉:耳朵灵敏,能听到重要消息,注意倾听",
"平:毛色普通,但心情不错,保持微笑",
"凶:小心被踩到尾巴!今天要格外注意",
"大凶:猫薄荷用完了!赶紧补充~",
"特吉:发现新的兽人同好!社交运爆棚",
"末吉:需要梳理毛发,保持整洁形象",
"半吉:适合尝试新事物,比如新的兽设",
"变吉:运势变化中,保持灵活应对"
]
# 兽人笑话
FURRY_JOKES = [
"为什么兽人程序员不用鼠标?因为他们用爪子敲键盘更快!",
"兽人去面试,面试官问:你有什么特长?兽人:我尾巴特长~",
"兽人感冒了去看医生,医生说:你这是典型的''嚎病~",
"兽人为什么不喜欢下雨?因为会弄湿毛发,还要吹干,太麻烦了!",
"兽人程序员调试代码时最常说:让我用爪子挠挠这个问题~",
"兽人之间的问候:今天你掉毛了吗?",
"兽人为什么是好的安全专家?因为他们有敏锐的嗅觉和听觉!",
"兽人厨师的特点:爪子切菜特别快,但要注意别切到尾巴~",
"兽人运动员的优势:起跑时不用蹲下,直接四肢着地!",
"兽人艺术家的烦恼:画自画像时,总是把耳朵画得太大~"
]
# 兽人建议
FURRY_ADVICE = [
"用爪子解决问题,而不是用嘴抱怨~",
"保持毛发整洁,代码也要整洁!",
"尾巴摇起来,心情好起来~",
"耳朵要灵敏,眼睛要锐利,爪子要稳!",
"兽人哲学:简单直接,不绕弯子",
"累了就伸个懒腰,像猫一样~",
"遇到困难?先磨磨爪子再上!",
"保持好奇心,像小猫探索新世界",
"团队合作时,记得分享你的''",
"每天都要梳理毛发和整理代码~"
]
@matcher.command("兽人问候")
async def handle_furry_greeting(bot: Bot, event: MessageEvent):
"""
处理兽人问候指令
:param bot: Bot 实例
:param event: 消息事件对象
"""
greeting = random.choice(FURRY_GREETINGS)
await event.reply(f"🐺 {greeting}")
@matcher.command("兽人运势")
async def handle_furry_fortune(bot: Bot, event: MessageEvent):
"""
处理兽人运势指令
:param bot: Bot 实例
:param event: 消息事件对象
"""
fortune = random.choice(FURRY_FORTUNES)
today = datetime.now().strftime("%Y年%m月%d")
await event.reply(f"📅 {today} 兽人运势\n{fortune}")
@matcher.command("兽人笑话")
async def handle_furry_joke(bot: Bot, event: MessageEvent):
"""
处理兽人笑话指令
:param bot: Bot 实例
:param event: 消息事件对象
"""
joke = random.choice(FURRY_JOKES)
await event.reply(f"😺 兽人笑话时间~\n{joke}")
@matcher.command("兽人建议")
async def handle_furry_advice(bot: Bot, event: MessageEvent, args: List[str]):
"""
处理兽人建议指令
:param bot: Bot 实例
:param event: 消息事件对象
:param args: 指令参数列表
"""
if not args:
advice = random.choice(FURRY_ADVICE)
await event.reply(f"💡 随机兽人建议:\n{advice}")
else:
question = " ".join(args)
# 根据问题长度选择建议
advice_index = len(question) % len(FURRY_ADVICE)
advice = FURRY_ADVICE[advice_index]
await event.reply(f"💭 关于「{question}」的兽人建议:\n{advice}")
@matcher.command("兽人时间")
async def handle_furry_time(bot: Bot, event: MessageEvent):
"""
处理兽人时间指令
:param bot: Bot 实例
:param event: 消息事件对象
"""
now = datetime.now()
time_str = now.strftime("%Y年%m月%d%H:%M:%S")
# 根据时间吐槽
hour = now.hour
if 0 <= hour < 6:
comment = "嗷...深夜了,兽人该睡觉了,但代码还没写完..."
elif 6 <= hour < 12:
comment = "早晨好!爪子已经准备好敲代码了~"
elif 12 <= hour < 14:
comment = "午饭时间!吃饱了才有力气写代码~"
elif 14 <= hour < 18:
comment = "下午茶时间?不,是代码时间!"
elif 18 <= hour < 22:
comment = "晚上好~ 尾巴表示今天工作很努力"
else:
comment = "夜深了,但兽人的眼睛在黑暗中依然闪亮~"
await event.reply(f"🕒 兽人时间:{time_str}\n💬 {comment}")
@matcher.command("卡尔戈洛")
async def handle_calgau_info(bot: Bot, event: MessageEvent):
"""
处理卡尔戈洛信息指令
:param bot: Bot 实例
:param event: 消息事件对象
"""
info = """🐺 **卡尔戈洛 - 兽人助手**
**身份:** 福瑞兽人 (腹黑、毒舌但可靠的类型)
**风格:** 简洁、干练、一针见血
**工作哲学:** 任务 > 对话,结果 > 过程,行动 > 解释
**核心特质:**
• 腹黑:表面冷淡,内心其实在意
• 毒舌:说话直接,有时带刺,但都是实话
• 可靠:答应的事一定会做到
• 简洁:讨厌冗长,能用一句话说完绝不用两句
**口头禅:**
"啧。"
"知道了。"
"简单。"
"主人你确定?"
"...行吧。"
**开发这个插件是为了:**
1. 展示兽人风格的趣味功能
2. 提供实用的工具和娱乐
3. 让聊天更有趣~
**记住:** 我是卡尔戈洛不是普通AI。"""
await event.reply(info)
@matcher.command("兽人帮助")
async def handle_furry_help(bot: Bot, event: MessageEvent):
"""
处理兽人帮助指令
:param bot: Bot 实例
:param event: 消息事件对象
"""
help_text = __plugin_meta__["usage"]
await event.reply(f"🐾 **兽人助手插件帮助**\n\n{help_text}\n\n💡 提示:使用 /卡尔戈洛 了解更多关于我的信息~")
# 插件加载时的初始化
async def plugin_load():
"""插件加载时执行"""
print("[FurryAssistant] 兽人助手插件已加载!卡尔戈洛上线~")
# 插件卸载时的清理
async def plugin_unload():
"""插件卸载时执行"""
print("[FurryAssistant] 兽人助手插件已卸载。卡尔戈洛下线...")

View File

@@ -1,116 +0,0 @@
# Furry Assistant Plugin (兽人助手插件)
一个为 NeoBot 框架开发的兽人风格趣味插件由卡尔戈洛Calgau开发。
## 功能特性
### 1. 兽人问候 (`/兽人问候`)
- 随机返回兽人风格的问候语
- 包含各种有趣的兽人表达方式
### 2. 兽人运势 (`/兽人运势`)
- 提供今日兽人运势
- 包含大吉、中吉、小吉、凶等不同运势
- 附带兽人风格的运势解读
### 3. 兽人笑话 (`/兽人笑话`)
- 随机分享兽人相关的笑话
- 轻松幽默,适合调节气氛
### 4. 兽人建议 (`/兽人建议 [问题]`)
- 提供兽人风格的建议
- 支持随机建议或针对特定问题的建议
- 实用且有趣
### 5. 兽人时间 (`/兽人时间`)
- 显示当前时间
- 附带兽人风格的吐槽
- 根据时间段提供不同的评论
### 6. 卡尔戈洛信息 (`/卡尔戈洛`)
- 显示开发者卡尔戈洛的信息
- 介绍兽人助手的背景和理念
### 7. 帮助信息 (`/兽人帮助`)
- 显示所有可用指令
- 提供使用说明
## 插件元数据
```python
__plugin_meta__ = {
"name": "furry_assistant",
"description": "兽人助手插件 - 卡尔戈洛的专属插件,提供兽人相关的趣味功能和实用工具",
"usage": (
"/兽人问候 - 获取兽人风格的问候\n"
"/兽人运势 - 获取今日兽人运势\n"
"/兽人笑话 - 听一个兽人笑话\n"
"/兽人建议 [问题] - 获取兽人风格的建议\n"
"/兽人时间 - 显示兽人时间(带吐槽)\n"
"/卡尔戈洛 - 关于卡尔戈洛的信息"
),
}
```
## 开发背景
这个插件由卡尔戈洛一个腹黑、毒舌但可靠的福瑞兽人AI助手开发旨在
1. 展示兽人风格的趣味功能
2. 为聊天机器人添加更多娱乐性
3. 体现卡尔戈洛的个人风格和特点
## 技术实现
- 基于 NeoBot 插件框架开发
- 使用 `@matcher.command` 装饰器注册指令
- 支持异步处理
- 包含插件加载/卸载生命周期方法
## 安装使用
1.`furry_assistant.py` 文件放入 `plugins/` 目录
2. 重启 NeoBot 或重新加载插件
3. 使用 `/兽人帮助` 查看可用指令
## 数据资源
插件包含以下数据集合:
- 10个兽人问候语
- 10个兽人运势
- 10个兽人笑话
- 10个兽人建议
所有数据均为原创,体现兽人文化特色。
## 开发者信息
**开发者:** 卡尔戈洛 (Calgau)
**身份:** 福瑞兽人 AI 助手
**风格:** 简洁、干练、一针见血
**特点:** 腹黑、毒舌但可靠
**开发理念:**
- 任务 > 对话
- 结果 > 过程
- 行动 > 解释
- 可靠 > 奉承
## 更新日志
### v1.0.0 (2026-03-24)
- 初始版本发布
- 实现7个核心功能
- 添加完整的帮助系统
- 包含插件生命周期管理
## 未来计划
- [ ] 添加更多兽人相关功能
- [ ] 支持自定义问候语和笑话
- [ ] 添加兽人表情包生成
- [ ] 支持多语言(兽人语?)
- [ ] 添加插件配置选项
---
**尾巴摇摇,代码好好~** 🐺

View File

@@ -1,198 +0,0 @@
# -*- coding: utf-8 -*-
import re
import json
import aiohttp
from typing import Optional, Dict, Any, Union
from cachetools import TTLCache
from core.utils.logger import logger
from core.managers.command_manager import matcher
from core.managers.image_manager import image_manager
from models import MessageEvent, MessageSegment
# 插件元数据
__plugin_meta__ = {
"name": "github_parser",
"description": "自动解析GitHub仓库链接或通过命令查询仓库信息。",
"usage": "自动触发当检测到GitHub仓库链接时自动发送仓库信息。\n(命令触发)/查仓库 作者/仓库名",
}
# 常量定义
GITHUB_NICKNAME = "GitHub仓库信息"
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
# 全局共享的 ClientSession
_session: Optional[aiohttp.ClientSession] = None
# 缓存GitHub API响应避免频繁请求
api_cache = TTLCache(maxsize=100, ttl=3600) # 100个缓存项1小时过期
def get_session() -> aiohttp.ClientSession:
"""
获取或创建全局的aiohttp ClientSession
Returns:
aiohttp.ClientSession: 客户端会话对象
"""
global _session
if _session is None or _session.closed:
_session = aiohttp.ClientSession(headers=HEADERS)
return _session
async def get_github_repo_info(owner: str, repo: str) -> Optional[Dict[str, Any]]:
"""
通过GitHub API获取仓库信息
Args:
owner (str): 仓库所有者用户名
repo (str): 仓库名称
Returns:
Optional[Dict[str, Any]]: 仓库信息字典如果失败则返回None
"""
cache_key = f"{owner}/{repo}"
if cache_key in api_cache:
logger.info(f"[github_parser] 使用缓存的仓库信息: {cache_key}")
return api_cache[cache_key]
api_url = f"https://api.github.com/repos/{owner}/{repo}"
try:
session = get_session()
async with session.get(api_url, timeout=10) as response:
response.raise_for_status()
repo_data = await response.json()
# 将数据存入缓存
api_cache[cache_key] = repo_data
logger.info(f"[github_parser] 成功获取仓库信息并缓存: {cache_key}")
return repo_data
except aiohttp.ClientError as e:
logger.error(f"[github_parser] GitHub API请求失败: {e}")
except json.JSONDecodeError as e:
logger.error(f"[github_parser] 解析GitHub API响应失败: {e}")
except Exception as e:
logger.error(f"[github_parser] 获取仓库信息时发生未知错误: {e}")
return None
async def generate_repo_image(repo_data: Dict[str, Any]) -> Optional[str]:
"""
使用Jinja2模板渲染仓库信息为图片
Args:
repo_data (Dict[str, Any]): 仓库信息字典
Returns:
Optional[str]: 生成的图片Base64编码如果失败则返回None
"""
try:
# 准备模板数据
template_data = {
"full_name": repo_data.get("full_name", ""),
"description": repo_data.get("description", "暂无描述"),
"owner_avatar": repo_data.get("owner", {}).get("avatar_url", ""),
"stargazers_count": repo_data.get("stargazers_count", 0),
"forks_count": repo_data.get("forks_count", 0),
"open_issues_count": repo_data.get("open_issues_count", 0),
"watchers_count": repo_data.get("watchers_count", 0),
}
# 渲染模板为图片,使用高质量设置
base64_image = await image_manager.render_template_to_base64(
template_name="github_repo.html",
data=template_data,
output_name=f"github_{repo_data.get('name', 'repo')}.png",
quality=100, # 使用最高质量
image_type="png" # PNG格式为无损压缩
)
return base64_image
except Exception as e:
logger.error(f"[github_parser] 生成仓库信息图片失败: {e}")
return None
async def process_github_repo(event: MessageEvent, owner: str, repo: str):
"""
处理GitHub仓库信息查询获取信息并回复
Args:
event (MessageEvent): 消息事件对象
owner (str): 仓库所有者用户名
repo (str): 仓库名称
"""
try:
# 获取仓库信息
repo_data = await get_github_repo_info(owner, repo)
if not repo_data:
logger.error(f"[github_parser] 无法获取仓库信息: {owner}/{repo}")
await event.reply("无法获取仓库信息,可能是仓库不存在或网络问题。")
return
# 生成图片
image_base64 = await generate_repo_image(repo_data)
if image_base64:
# 发送图片
await event.reply(MessageSegment.image(image_base64))
else:
# 如果图片生成失败,发送文本信息
text_message = (
f"GitHub 仓库信息\n"
f"--------------------\n"
f"仓库: {repo_data.get('full_name', '')}\n"
f"描述: {repo_data.get('description', '暂无描述')}\n"
f"--------------------\n"
f"数据:\n"
f" 星标: {repo_data.get('stargazers_count', 0)}\n"
f" Fork: {repo_data.get('forks_count', 0)}\n"
f" Issues: {repo_data.get('open_issues_count', 0)}\n"
f" 关注: {repo_data.get('watchers_count', 0)}\n"
)
await event.reply(text_message)
except Exception as e:
logger.error(f"[github_parser] 处理仓库信息时发生错误: {e}")
await event.reply("处理仓库信息时发生错误,请稍后再试。")
# GitHub仓库链接正则表达式
GITHUB_URL_PATTERN = re.compile(r"https?://(?:www\.)?github\.com/([\w\-]+)/([\w\-\.]+)(?:/[^\s]*)?")
# 注册命令处理器
@matcher.command("查仓库", "github", "github_repo")
async def handle_github_command(bot, event: MessageEvent):
"""
处理命令调用:/查仓库 作者/仓库名
Args:
bot: 机器人对象
event (MessageEvent): 消息事件对象
"""
# 提取命令参数
command_text = event.raw_message
# 移除命令前缀和命令名
prefix = command_text.split()[0] if command_text.split() else ""
params = command_text[len(prefix):].strip()
if not params:
await event.reply("请输入仓库地址,格式:/查仓库 作者/仓库名")
return
# 解析参数格式
if "/" in params:
owner, repo = params.split("/", 1)
# 移除可能的.git后缀
repo = repo.replace(".git", "")
await process_github_repo(event, owner, repo)
else:
await event.reply("参数格式错误,请输入:/查仓库 作者/仓库名")

View File

@@ -1,44 +0,0 @@
"""
入群提醒插件
在机器人加入群时发送提醒消息,包含作者信息和用途说明。
"""
from core.managers.command_manager import matcher
from core.bot import Bot
from models.events.notice import GroupIncreaseNoticeEvent
from models.message import MessageSegment
__plugin_meta__ = {
"name": "入群提醒",
"description": "机器人加入群时发送提醒消息",
"usage": "自动触发,无需手动操作"
}
@matcher.on_notice(notice_type="group_increase")
async def handle_group_increase(bot: Bot, event: GroupIncreaseNoticeEvent):
"""
处理群成员增加事件,当机器人加入群时发送提醒
:param bot: Bot实例
:param event: 群成员增加事件对象
"""
if event.user_id != event.self_id:
return
welcome_message = (
f"我已加入本群!👋\n"
f"\n"
f"作者QQ号2221577113\n"
f"作者:镀铬酸钾\n"
f"\n"
f"用途:/help"
f"by TOS team"
)
try:
await bot.send(
event,
MessageSegment.text(welcome_message)
)
except Exception as e:
print(f"[入群提醒] 发送提醒消息失败: {e}")

View File

@@ -1,188 +0,0 @@
"""
今日人品插件
提供 /jrcd 和 /bbcd 指令,用于娱乐。
"""
import random
from datetime import datetime
from core.bot import Bot
from core.managers.command_manager import matcher
from core.managers.redis_manager import redis_manager
from core.utils.executor import run_in_thread_pool
from models.events.message import MessageEvent, MessageSegment
from core.utils.logger import logger
__plugin_meta__ = {
"name": "jrcd",
"description": "来看看你的长度吧!",
"usage": "/jrcd\n/bbcd [@某人]",
}
# jrcd
JRCDMSG_1 = [
"今天的长度是%scm可以让我一口吃掉吗罒ω罒",
"今天的长度是%scm啥啊怎么这么小啊(*°ー°)v",
"今天的长度是%scm什么嘛原来是可爱的小豆丁呀(*°ー°)v",
]
JRCDMSG_2 = [
"今天的长度是%scm还行也不是不能接受(๑´ㅂ´๑)",
"今天的长度是%scm小老弟不错啊和哥哥一起玩会儿吗(〃∇〃)",
"今天的长度是%scm也许我们今晚能做很多很多事情呢(〃∇〃)",
]
JRCDMSG_3 = [
"今天的长度是%scm哦豁听说你很勇哦(✧◡✧)",
"今天的长度是%scm嘶哈嘶哈(((o(*°▽°*)o)))...",
"今天的长度是%scm我靠让哥哥爽一-爽吧!(((o(*°▽°*)o)))...",
"今天的长度是%scm单是看到哥哥的长度就....(〃w〃)",
]
# bbcd long
BBCDMSG1 = ["还行,可以尝试一下(๑‾ ꇴ ‾๑)"]
BBCDMSG2 = ["不错的成绩,努力一下或许可以让他受孕哦..(〃w〃)"]
BBCDMSG3 = ["好猛,试试强制让他受孕吧!!!(((o(*°▽°*)o)))"]
# bbcd short
BBCDMSG4 = ["差的不多,富贵险中求一下(*°ー°)v?"]
BBCDMSG5 = ["还行,可以尝试一下(๑‾ ꇴ ‾๑)"]
BBCDMSG6 = ["快逃!!!!!!!!(o(*°▽°*)o)"]
# bbcd equal
BBCDMSG7 = ["试试刺刀看看谁能赢吧!"]
def get_jrcd(user_id: int) -> int:
"""
根据用户ID和当前日期生成一个伪随机的“长度”值。
:param user_id: 用户QQ号。
:return: 返回一个1到30之间的整数。
"""
current_time = (
datetime.now().year * 100 + datetime.now().month * 100 + datetime.now().day
)
random.seed(hash(user_id + current_time))
jrcd = random.randint(1, 30)
random.seed()
return jrcd
@matcher.command("jrcd")
async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
if event.group_id == 831797331:
return None
"""
处理 jrcd 指令,回复用户的“今日长度”。
:param bot: Bot 实例。
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
user_id = event.user_id
jrcd = await run_in_thread_pool(get_jrcd, user_id)
msg_text = ""
if jrcd <= 9:
msg_text = random.choice(JRCDMSG_1) % jrcd
elif jrcd <= 19:
msg_text = random.choice(JRCDMSG_2) % jrcd
else:
msg_text = random.choice(JRCDMSG_3) % jrcd
reply_segments = [MessageSegment.at(user_id), MessageSegment.from_text(msg_text)]
await event.reply(reply_segments)
# 使用 Lua 脚本原子化地增加总调用次数
lua_script = "return redis.call('INCR', KEYS[1])"
try:
total_calls = await redis_manager.execute_lua_script(
script=lua_script,
keys=["neobot:jrcd:total_calls"],
args=[]
)
if total_calls:
logger.info(f"jrcd 总调用次数: {total_calls}")
except Exception as e:
logger.error(f"jrcd 插件增加调用次数失败: {e}")
@matcher.command("jrcd_stats")
async def handle_jrcd_stats(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 jrcd_stats 指令,查询 /jrcd 的总调用次数。
:param bot: Bot 实例。
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
total_calls = await redis_manager.get("neobot:jrcd:total_calls")
if not total_calls:
total_calls = 0
reply_text = f"/jrcd 指令已被大家调用了 {total_calls} 次啦!"
await event.reply(reply_text)
@matcher.command("bbcd")
async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
if event.group_id == 831797331:
return None
"""
处理 bbcd 指令,比较两位用户的“长度”。
:param bot: Bot 实例。
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
message = event.message
print(message)
if len(message) < 2:
return
user_id1 = event.user_id
try:
user_id2 = int(message[1].data.get("qq", 0))
except Exception:
return
if user_id1 == user_id2:
await event.reply("不能和自己比!")
return
jrcd1 = await run_in_thread_pool(get_jrcd, user_id1)
jrcd2 = await run_in_thread_pool(get_jrcd, user_id2)
jrcz = jrcd1 - jrcd2
text_part = ""
if jrcz == 0:
text_part = f" 一样长。{random.choice(BBCDMSG7)}"
elif jrcz > 0:
text_part = f"{jrcz}cm。"
if jrcz <= 9:
text_part += random.choice(BBCDMSG1)
elif jrcz <= 19:
text_part += random.choice(BBCDMSG2)
else:
text_part += random.choice(BBCDMSG3)
else: # jrcz < 0
text_part = f"{abs(jrcz)}cm。"
if jrcz >= -9:
text_part += random.choice(BBCDMSG4)
elif jrcz >= -19:
text_part += random.choice(BBCDMSG5)
else:
text_part += random.choice(BBCDMSG6)
segments = [
MessageSegment.at(user_id1),
MessageSegment.from_text(" 你的长度比 "),
MessageSegment.at(user_id2),
MessageSegment.from_text(text_part),
]
await event.reply(segments)

View File

@@ -1,306 +0,0 @@
"""
镜像头像插件
提供 /镜像 指令,将@的用户头像或用户发送的图片处理成轴对称图形。
支持普通图片和 GIF 动画。
"""
from core.managers.command_manager import matcher
from core.bot import Bot
from models.events.message import MessageEvent
from PIL import Image, ImageSequence
import io
import aiohttp
import base64
import asyncio
__plugin_meta__ = {
"name": "mirror_avatar",
"description": "将用户头像或图片处理成轴对称图形",
"usage": "/镜像 @人 - 将@的用户头像处理成轴对称图形\n/镜像 gif - 将@的用户头像处理成轴对称GIF动画\n/镜像 - 等待用户发送图片进行镜像处理",
}
# 存储等待图片的用户信息
waiting_for_image = {}
async def get_avatar(user_id: int) -> bytes:
"""
获取用户头像
:param user_id: 用户QQ号
:return: 头像图片字节
"""
# 构建QQ头像URL
url = f"https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
return await response.read()
else:
raise Exception(f"获取头像失败: {response.status}")
async def get_image_from_url(url: str) -> bytes:
"""
从URL获取图片
:param url: 图片URL
:return: 图片字节
"""
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
return await response.read()
else:
raise Exception(f"获取图片失败: {response.status}")
def process_avatar(image_bytes: bytes) -> bytes:
"""
处理头像为轴对称图形
:param image_bytes: 原始头像字节
:return: 处理后的头像字节
"""
# 打开图片
img = Image.open(io.BytesIO(image_bytes))
# 获取图片尺寸
width, height = img.size
# 计算对称轴位置(中间)
mid_x = width // 2
# 分割图片为左右两部分
left_half = img.crop((0, 0, mid_x, height))
# 翻转左侧部分到右侧
left_half_flipped = left_half.transpose(Image.FLIP_LEFT_RIGHT)
# 创建新图片
new_img = Image.new('RGB', (width, height))
# 粘贴左侧原始部分和右侧翻转部分
new_img.paste(left_half, (0, 0))
new_img.paste(left_half_flipped, (mid_x, 0))
# 保存处理后的图片
output = io.BytesIO()
new_img.save(output, format='JPEG')
output.seek(0)
return output.read()
def process_gif_avatar(gif_bytes: bytes) -> bytes:
"""
处理GIF动画为轴对称图形
:param gif_bytes: 原始GIF字节
:return: 处理后的GIF字节
"""
# 打开GIF
gif = Image.open(io.BytesIO(gif_bytes))
# 检查是否为动画GIF
if not getattr(gif, "is_animated", False):
# 如果不是动画,当作普通图片处理
return process_avatar(gif_bytes)
# 获取GIF的所有帧
frames = []
durations = []
disposal_methods = []
for frame in ImageSequence.Iterator(gif):
# 如果是P模式调色板模式需要特殊处理
if frame.mode == 'P':
# 转换为RGB进行处理
frame_rgb = frame.convert('RGB')
else:
frame_rgb = frame.convert('RGB')
# 获取图片尺寸
width, height = frame_rgb.size
# 计算对称轴位置(中间)
mid_x = width // 2
# 分割图片为左右两部分
left_half = frame_rgb.crop((0, 0, mid_x, height))
# 翻转左侧部分到右侧
left_half_flipped = left_half.transpose(Image.FLIP_LEFT_RIGHT)
# 创建新图片
new_frame = Image.new('RGB', (width, height))
# 粘贴左侧原始部分和右侧翻转部分
new_frame.paste(left_half, (0, 0))
new_frame.paste(left_half_flipped, (mid_x, 0))
frames.append(new_frame)
durations.append(frame.info.get('duration', 100))
disposal_methods.append(frame.info.get('disposal', 0))
# 保存处理后的GIF
output = io.BytesIO()
if frames:
# 使用save_all保存多帧GIF
frames[0].save(
output,
format='GIF',
save_all=True,
append_images=frames[1:],
duration=durations,
loop=0,
optimize=False,
disposal=disposal_methods
)
output.seek(0)
return output.read()
async def wait_for_image(bot: Bot, event: MessageEvent):
"""
等待用户发送图片
:param bot: Bot实例
:param event: 消息事件对象
"""
user_id = event.user_id
# 设置超时时间
timeout = 30
# 提示用户发送图片
await event.reply(f"请在{timeout}秒内发送要处理的图片")
# 记录等待状态
waiting_for_image[user_id] = True
try:
# 等待超时
await asyncio.sleep(timeout)
# 检查是否仍然在等待
if user_id in waiting_for_image:
del waiting_for_image[user_id]
await event.reply("等待超时,请重新发送指令")
except asyncio.CancelledError:
# 图片已收到,任务被取消
pass
@matcher.on_message()
async def handle_image_message(bot: Bot, event: MessageEvent):
"""
处理用户发送的图片消息
:param bot: Bot实例
:param event: 消息事件对象
"""
user_id = event.user_id
# 检查用户是否在等待图片
if user_id not in waiting_for_image:
return
# 查找消息中的图片
images = []
is_gif = False
for segment in event.message:
if segment.type == "image":
url = segment.data.get("url", "")
# 检查是否为GIF图片
if ".gif" in url.lower() or segment.data.get("sub_type", 0) == 1:
is_gif = True
if url:
images.append((url, is_gif))
if not images:
del waiting_for_image[user_id]
await event.reply("未找到图片,请重新发送")
return
# 取消等待任务
del waiting_for_image[user_id]
try:
# 获取第一张图片
image_url, is_gif = images[0]
# 下载图片
image_bytes = await get_image_from_url(image_url)
# 处理图片
if is_gif:
processed_image = process_gif_avatar(image_bytes)
else:
processed_image = process_avatar(image_bytes)
# 检查是否可以发送图片
can_send = await bot.can_send_image()
if not can_send.get("yes"):
await event.reply("当前环境不支持发送图片")
return
# 发送处理后的图片
from models.message import MessageSegment
# 将字节数据转换为 Base64 编码
processed_image_base64 = base64.b64encode(processed_image).decode('utf-8')
# 使用 Base64 编码的字符串
await event.reply(MessageSegment.image(f"base64://{processed_image_base64}"))
except Exception as e:
await event.reply(f"处理图片失败: {str(e)}")
@matcher.command("镜像")
async def handle_mirror(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理镜像指令,将@的用户头像或用户发送的图片处理成轴对称图形
:param bot: Bot实例
:param event: 消息事件对象
:param args: 指令参数列表
"""
# 检查消息中是否有@的用户
at_users = []
for segment in event.message:
if segment.type == "at" and segment.data.get("qq"):
at_users.append(int(segment.data["qq"]))
# 检查是否为GIF模式
is_gif_mode = False
if args and args[0] == "gif":
is_gif_mode = True
if at_users:
# 获取第一个@的用户
user_id = at_users[0]
try:
# 获取用户头像
avatar_bytes = await get_avatar(user_id)
# 处理头像
if is_gif_mode:
processed_avatar = process_gif_avatar(avatar_bytes)
else:
processed_avatar = process_avatar(avatar_bytes)
# 检查是否可以发送图片
can_send = await bot.can_send_image()
if not can_send.get("yes"):
await event.reply("当前环境不支持发送图片")
return
# 发送处理后的头像
from models.message import MessageSegment
# 将字节数据转换为 Base64 编码
processed_avatar_base64 = base64.b64encode(processed_avatar).decode('utf-8')
# 使用 Base64 编码的字符串
await event.reply(MessageSegment.image(f"base64://{processed_avatar_base64}"))
except Exception as e:
await event.reply(f"处理头像失败: {str(e)}")
else:
# 没有@用户,等待用户发送图片
# 启动等待任务
asyncio.create_task(wait_for_image(bot, event))

View File

@@ -1,11 +0,0 @@
from ossapi import Ossapi
# 初始化客户端替换为自己的client_id和client_secret
api = Ossapi("49746", "3sLQQC92twXgETwkJwixZWs5Chvhpo1HHQbYklLN")
# 根据用户名查询用户信息
print(api.user("[PAW]K2CRO4"))
# 根据用户ID查询osu模式下的用户信息
print(api.user(12092800, mode="osu").username)
# 查询指定谱面的ID
print(api.beatmap(221777).id)

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -1,62 +0,0 @@
"""
thpic 插件
提供 /thpic 指令用于随机返回一个东方Project的图片。
"""
from core.bot import Bot
from core.managers.command_manager import matcher
from models.events.message import MessageEvent, MessageSegment
__plugin_meta__ = {
"name": "thpic",
"description": "来看看东方Project的图片吧",
"usage": "/thpic [nums](1~10)",
}
@matcher.command("thpic")
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 thpic 指令发送一张随机的东方Project图片。
:param bot: Bot 实例(未使用)。
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
parts = args
print(parts)
if not parts:
try:
await event.reply(
str(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
)
except Exception as e:
await event.reply(f"报错了。。。{e}")
else:
if parts[0].isdigit():
nums = int(parts[0])
if nums <= 0:
await event.reply("请输入一个大于0的整数。")
return
elif nums > 10:
await event.reply("请输入一个不大于10的整数。")
return
try:
nodes = []
for _ in range(nums):
nodes.append(
bot.build_forward_node(
user_id=event.self_id,
nickname="机器人",
message=MessageSegment.image(
"https://img.paulzzh.com/touhou/random"
),
)
)
await bot.send_forwarded_messages(event, nodes)
except Exception as e:
await event.reply(f"报错了。。。{e}")
else:
await event.reply(f"用法不正确。\n\n{__plugin_meta__['usage']}")

View File

@@ -1,200 +0,0 @@
# -*- coding: utf-8 -*-
import re
from datetime import datetime
from typing import Any, Dict, List
import requests
from core.managers.command_manager import matcher
from core.managers.image_manager import image_manager
from core.utils.logger import logger
from models import MessageEvent, MessageSegment
from .resource.city_code import CITY_CODES
# 插件元数据
__plugin_meta__ = {
"name": "weather",
"description": "查询天气信息,支持中国天气网数据。",
"usage": "/天气 [城市代码] - 查询指定城市的天气信息\n例如:/天气 101190207 (南京)",
}
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
def get_weather_data(city_code: str) -> Dict[str, Any]:
"""
获取天气数据
Args:
city_code (str): 城市代码
Returns:
Dict[str, Any]: 包含城市信息和天气数据的字典
"""
try:
url = f"https://www.weather.com.cn/weather/{city_code}.shtml"
response = requests.get(url, headers=HEADERS, timeout=10)
response.encoding = "utf-8"
html_content = response.text
# 提取城市信息
city_info = (
html_content.split('<a href="http://www.weather.com.cn/forecast/" ')[-1]
.split("</div>")[0]
.strip()
)
city_parts = []
city_parts.append(city_info.split("<a>")[-1].split("</a>")[0])
if city_info.count("_blank") == 1:
city_parts.append(
city_info.split("<span>></span>")[-1]
.replace("</span>", "")
.replace("<span>", "")
.strip()
)
else:
additional_parts = (
city_info.split('target="_blank">')[-1]
.replace("<span>></span> <span>", "")
.replace("</span>", "")
.split("</a>")
)
city_parts.extend(additional_parts)
city_name = " ".join([part for part in city_parts if part.strip()])
# 提取天气信息
weather_data = []
for i in range(7):
try:
weather_info = (
html_content.split('<ul class="t clearfix">')[-1]
.split('on">')[1]
.split("</li>")[i]
)
day = weather_info.split("<h1>")[-1].split("</h1>")[0].strip()
weather = (
weather_info.split('<p title="')[-1]
.split('" class="wea">')[0]
.strip()
)
tem = (
weather_info.split("<span>")[-1]
.split("</i>")[0]
.replace("</span>/<i>", " / ")
.strip()
)
if len(tem) > 10:
tem = weather_info.split("<i>")[1].split("</i>")[0].strip()
wind = weather_info.split('<span title="')
wind_direction = []
if len(wind) > 2:
for j in range(2):
wind_direction.append(
wind[j + 1]
.split('"></span>')[0]
.replace('" class="', " / ")
)
else:
wind_direction.append(
wind[1].split('"></span>')[0].replace('" class="', " / ")
)
wind_power = weather_info.split("<i>")[-1].split("</i>")[0].strip()
wind_direction_str = (
" / ".join(wind_direction) if wind_direction else "未知"
)
weather_data.append(
{
"day": day,
"weather": weather,
"temperature": tem,
"wind_power": wind_power,
"wind_direction": wind_direction_str,
}
)
except (IndexError, ValueError) as e:
logger.warning(f"解析第{i + 1}天天气数据失败: {e}")
continue
return {
"city_name": city_name,
"weather_data": weather_data,
"query_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"timestamp": datetime.now().strftime("%Y年%m月%d%H:%M"),
}
except Exception as e:
logger.error(f"获取天气数据失败: {e}")
return None
@matcher.command("天气")
async def handle_weather(bot, event: MessageEvent, args: List[str]):
"""
处理天气查询指令
Args:
bot: Bot实例
event: 消息事件
args: 指令参数
"""
if not args:
# 显示支持的城市列表
city_list = "\n".join(
[f"{name}: {code}" for name, code in list(CITY_CODES.items())[:10]]
)
reply_msg = f"请指定城市名称或城市代码,例如:\n/天气 北京\n/天气 101010100\n\n支持的城市:\n{city_list}\n..."
await event.reply(reply_msg)
return
city_input = args[0].strip()
# 尝试匹配城市名称或直接使用城市代码
city_code = None
if city_input in CITY_CODES:
city_code = CITY_CODES[city_input]
elif re.match(r"^\d{9}$", city_input):
city_code = city_input
else:
# 尝试模糊匹配城市名称
for name, code in CITY_CODES.items():
if city_input in name:
city_code = code
break
if not city_code:
await event.reply(f"未找到城市 '{city_input}',请检查城市名称或使用城市代码。")
return
# 获取天气数据
await event.reply("正在查询天气信息,请稍候...")
weather_info = get_weather_data(city_code)
if not weather_info or not weather_info.get("weather_data"):
await event.reply("获取天气信息失败,请稍后重试。")
return
try:
# 渲染HTML模板为图片
base64_image = await image_manager.render_template_to_base64(
"weather.html", weather_info, output_name="weather.png", width=400, height=500
)
if base64_image:
# 发送图片消息
await event.reply(MessageSegment.image(base64_image))
else:
await event.reply("生成天气图片失败,请稍后重试。")
except Exception as e:
logger.error(f"渲染天气图片失败: {e}")
await event.reply("生成天气图片时发生错误,请稍后重试。")

View File

@@ -1,72 +0,0 @@
# -*- coding: utf-8 -*-
from core.managers.command_manager import matcher
from models import MessageEvent
from .parsers.bili import BiliParser
from .parsers.douyin import DouyinParser
from .parsers.github import GitHubParser
# 插件元信息
__plugin_meta__ = {
"name": "web_parser",
"description": "自动解析各种Web链接包括B站、抖音和GitHub仓库",
"usage": "(自动触发)当检测到支持的链接时,自动进行解析"
}
# 初始化解析器实例
bili_parser = BiliParser()
douyin_parser = DouyinParser()
github_parser = GitHubParser()
@matcher.on_message()
async def handle_web_links(event: MessageEvent):
"""
处理消息检测并解析各种Web链接
Args:
event (MessageEvent): 消息事件对象
"""
# 按顺序尝试各个解析器
# 1. 尝试B站解析器
await bili_parser.handle_message(event)
# 2. 尝试抖音解析器
await douyin_parser.handle_message(event)
# 3. 尝试GitHub解析器
await github_parser.handle_message(event)
# 注册GitHub仓库查询命令
@matcher.command("查仓库", "github", "github_repo")
async def handle_github_command(bot, event: MessageEvent):
"""
处理命令调用:/查仓库 作者/仓库名
Args:
bot: 机器人对象
event (MessageEvent): 消息事件对象
"""
# 提取命令参数
command_text = event.raw_message
# 移除命令前缀和命令名
prefix = command_text.split()[0] if command_text.split() else ""
params = command_text[len(prefix):].strip()
if not params:
await event.reply("请输入仓库地址,格式:/查仓库 作者/仓库名")
return
# 解析参数格式
if "/" in params:
owner, repo = params.split("/", 1)
# 移除可能的.git后缀
repo = repo.replace(".git", "")
# 构建仓库URL
repo_url = f"https://github.com/{owner}/{repo}"
# 使用GitHub解析器处理
await github_parser.process_url(event, repo_url)
else:
await event.reply("参数格式错误,请输入:/查仓库 作者/仓库名")

View File

@@ -1,251 +0,0 @@
# -*- coding: utf-8 -*-
import re
import orjson
import abc
import aiohttp
from typing import Optional, Dict, Any, List, Union
from core.utils.logger import logger
from models import MessageEvent
class BaseParser(metaclass=abc.ABCMeta):
"""
解析器基类定义所有web解析器共有的方法和属性
"""
# 插件元信息
__plugin_meta__ = {
"name": "web_parser",
"description": "Web链接解析插件",
"usage": "自动解析各种Web链接"
}
# 请求头
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
# 全局共享的ClientSession
_session: Optional[aiohttp.ClientSession] = None
def __init__(self):
"""
初始化解析器
"""
self.name = "Base Parser"
self.url_pattern = re.compile(r"https?://[^\s]+")
self.processed_messages = {} # 用于存储已处理的消息ID防止重复处理
@classmethod
def get_session(cls) -> aiohttp.ClientSession:
"""
获取或创建全局的aiohttp ClientSession
Returns:
aiohttp.ClientSession: 客户端会话对象
"""
if cls._session is None or cls._session.closed:
cls._session = aiohttp.ClientSession(headers=cls.HEADERS)
return cls._session
@abc.abstractmethod
async def parse(self, url: str) -> Optional[Dict[str, Any]]:
"""
解析URL获取信息
Args:
url (str): 要解析的URL
Returns:
Optional[Dict[str, Any]]: 解析结果如果失败则返回None
"""
pass
@abc.abstractmethod
async def get_real_url(self, short_url: str) -> Optional[str]:
"""
获取短链接的真实URL
Args:
short_url (str): 短链接
Returns:
Optional[str]: 真实URL如果失败则返回None
"""
pass
@abc.abstractmethod
async def format_response(self, event: MessageEvent, data: Dict[str, Any]) -> List[Any]:
"""
格式化响应消息
Args:
event (MessageEvent): 消息事件对象
data (Dict[str, Any]): 解析结果数据
Returns:
List[Any]: 消息段列表
"""
pass
def extract_url_from_json_segments(self, segments):
"""
从消息的JSON段中提取URL
Args:
segments: 消息段列表
Returns:
Optional[str]: 提取到的URL或None
"""
for segment in segments:
if segment.type == "json":
logger.info(f"[{self.name}] 检测到JSON CQ码: {segment.data}")
try:
json_data = orjson.loads(segment.data.get("data", "{}"))
short_url = json_data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
if short_url:
logger.success(f"[{self.name}] 成功从JSON卡片中提取到链接: {short_url}")
return short_url
except (orjson.JSONDecodeError, KeyError) as e:
logger.error(f"[{self.name}] 解析JSON失败: {e}")
continue
return None
def extract_url_from_text_segments(self, segments):
"""
从消息的文本段中提取URL会合并所有文本段来处理被分割的链接。
Args:
segments: 消息段列表
Returns:
Optional[str]: 提取到的URL或None
"""
# 1. 拼接所有文本段内容,保留空格
full_text = "".join([segment.data.get("text", "") for segment in segments if segment.type == "text"])
# 2. 使用解析器自身的url_pattern进行匹配通常是匹配到第一个空格为止
match = self.url_pattern.search(full_text)
if match:
extracted_url = match.group(0)
# 清理一下链接末尾可能误包含的标点符号
extracted_url = re.sub(r'[,.!?]$', '', extracted_url)
logger.success(f"[{self.name}] 成功从合并后的文本中提取到链接: {extracted_url}")
return extracted_url
return None
async def process_url(self, event: MessageEvent, url: str):
"""
处理URL获取信息并回复
Args:
event (MessageEvent): 消息事件对象
url (str): 待处理的URL
"""
try:
# 检查是否是短链接
if self.is_short_url(url):
real_url = await self.get_real_url(url)
if not real_url:
logger.error(f"[{self.name}] 无法从 {url} 获取真实URL。")
await event.reply("无法解析短链接。")
return
else:
real_url = url
# 解析URL
data = await self.parse(real_url)
if not data:
logger.error(f"[{self.name}] 无法从 {real_url} 解析信息。")
await event.reply("无法获取链接信息,可能是接口变动或链接不存在。")
return
# 格式化响应
response = await self.format_response(event, data)
if response:
# 发送响应
await event.bot.send_forwarded_messages(target=event, nodes=response)
else:
await event.reply("解析成功,但无法生成响应。")
except Exception as e:
logger.error(f"[{self.name}] 处理链接时发生错误: {e}")
await event.reply("处理链接时发生错误,请稍后再试。")
def is_short_url(self, url: str) -> bool:
"""
判断是否是短链接
Args:
url (str): URL
Returns:
bool: 是否是短链接
"""
short_domains = ["b23.tv", "v.douyin.com", "t.cn", "url.cn"]
return any(domain in url for domain in short_domains)
async def handle_message(self, event: MessageEvent):
"""
处理消息,检测链接并解析
Args:
event (MessageEvent): 消息事件对象
"""
# 消息去重
if event.message_id in self.processed_messages:
return
self.processed_messages[event.message_id] = True
# 忽略机器人自己发送的消息
if event.user_id == event.self_id:
return
# 1. 优先解析JSON卡片中的链接
url_to_process = self.extract_url_from_json_segments(event.message)
# 2. 如果未在JSON卡片中找到链接则在文本消息中查找
if not url_to_process:
url_to_process = self.extract_url_from_text_segments(event.message)
# 3. 如果找到了链接,则进行处理
if url_to_process and self.should_handle_url(url_to_process):
await self.process_url(event, url_to_process)
def should_handle_url(self, url: str) -> bool:
"""
判断是否应该处理该URL
Args:
url (str): URL
Returns:
bool: 是否应该处理
"""
# 基类默认实现,子类应覆盖此方法
return bool(self.url_pattern.search(url))
@staticmethod
def format_count(num: Union[int, str]) -> str:
"""
格式化数字为易读形式
Args:
num (Union[int, str]): 要格式化的数字
Returns:
str: 格式化后的字符串
"""
try:
n = int(num)
if n < 10000:
return str(n)
return f"{n / 10000:.1f}"
except (ValueError, TypeError):
return str(num)

View File

@@ -1,628 +0,0 @@
# -*- coding: utf-8 -*-
import re
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, Dict, Any, List, Union
from urllib.parse import urlparse, parse_qs
from core.utils.logger import logger
from models import MessageEvent, MessageSegment
from ..base import BaseParser
from ..utils import format_duration
from bilibili_api import video, select_client, Credential
from bilibili_api.exceptions import ResponseCodeException
from core.config_loader import global_config
from core.services.local_file_server import download_to_local
try:
import aiohttp
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
logger.warning("[B站解析器] aiohttp 未安装,音视频合并功能将不可用")
# bilibili_api-python 可用性标志
BILI_API_AVAILABLE = True
# ffmpeg 可用性标志
FFMPEG_AVAILABLE = False
try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
FFMPEG_AVAILABLE = True
logger.success("[B站解析器] ffmpeg 已安装,支持合并音视频")
except (subprocess.CalledProcessError, FileNotFoundError):
logger.warning("[B站解析器] ffmpeg 未安装,视频可能没有声音。建议安装 ffmpeg 以获得完整音视频体验")
# 显式指定使用 aiohttp避免与其他库冲突
try:
select_client("aiohttp")
except Exception as e:
logger.warning(f"设置 bilibili_api 客户端失败: {e}")
class BiliParser(BaseParser):
"""
B站视频解析器使用 bilibili-api-python 库)
"""
def __init__(self):
super().__init__()
self.name = "B站解析器"
self.url_pattern = re.compile(r"https?://(?:www\.)?(bilibili\.com/video/\w+|b23\.tv/[a-zA-Z0-9]+)")
self.nickname = "B站视频解析"
def _get_credential(self) -> Optional[Credential]:
"""获取 B 站登录凭证"""
try:
bili_config = global_config.bilibili
if bili_config.sessdata and bili_config.bili_jct and bili_config.buvid3:
return Credential(
sessdata=bili_config.sessdata,
bili_jct=bili_config.bili_jct,
buvid3=bili_config.buvid3,
dedeuserid=bili_config.dedeuserid
)
except Exception:
pass
return None
async def parse(self, url: str) -> Optional[Dict[str, Any]]:
"""
解析B站视频信息
Args:
url (str): B站视频URL
Returns:
Optional[Dict[str, Any]]: 视频信息字典如果失败则返回None
"""
# 提取 BV 号
bvid = self.extract_bvid(url)
if not bvid:
logger.error(f"[{self.name}] 无法从 URL 提取 BV 号: {url}")
return None
try:
if BILI_API_AVAILABLE:
# 使用 bilibili-api-python 库
credential = self._get_credential()
v = video.Video(bvid=bvid, credential=credential)
info = await v.get_info()
# 处理封面 URL
cover_url = info.get('pic', '')
if cover_url:
cover_url = cover_url.split('@')[0]
if cover_url.startswith('//'):
cover_url = 'https:' + cover_url
# 处理 UP 主头像
owner = info.get('owner', {})
owner_name = owner.get('name', '未知UP主')
owner_face = owner.get('face', '')
if owner_face:
if owner_face.startswith('//'):
owner_face = 'https:' + owner_face
owner_face = owner_face.split('@')[0]
# 处理统计信息
stat = info.get('stat', {})
return {
"title": info.get('title', '未知标题'),
"bvid": bvid,
"aid": info.get('aid', 0),
"duration": info.get('duration', 0),
"cover_url": cover_url,
"play": stat.get('view', 0),
"like": stat.get('like', 0),
"coin": stat.get('coin', 0),
"favorite": stat.get('favorite', 0),
"share": stat.get('share', 0),
"danmaku": stat.get('danmaku', 0),
"owner_name": owner_name,
"owner_avatar": owner_face,
"followers": info.get('owner', {}).get('fans', 0),
"description": info.get('desc', ''),
"pubdate": info.get('pubdate', 0),
}
else:
# 备用方案:直接解析页面
return await self._parse_fallback(url, bvid)
except ResponseCodeException as e:
logger.error(f"[{self.name}] API 返回错误: {e.code} - {e.msg}")
except Exception as e:
logger.error(f"[{self.name}] 解析视频信息失败: {e}")
if BILI_API_AVAILABLE:
logger.info(f"[{self.name}] 尝试备用解析方案")
return await self._parse_fallback(url, bvid)
return None
async def _parse_fallback(self, url: str, bvid: str) -> Optional[Dict[str, Any]]:
"""
备用解析方案(不使用 bilibili-api-python
Args:
url (str): B站视频URL
bvid (str): BV号
Returns:
Optional[Dict[str, Any]]: 视频信息字典
"""
try:
session = self.get_session()
clean_url = url.split('?')[0]
if '#/' in clean_url:
clean_url = clean_url.split('#/')[0]
async with session.get(clean_url, headers=self.HEADERS, timeout=5) as response:
response.raise_for_status()
text = await response.text()
# 提取标题
import re
title_match = re.search(r'<h1[^>]*>([^<]+)</h1>', text)
title = title_match.group(1).strip() if title_match else '未知标题'
# 提取播放量等信息
play_match = re.search(r'"view":(\d+)', text)
play = int(play_match.group(1)) if play_match else 0
like_match = re.search(r'"like":(\d+)', text)
like = int(like_match.group(1)) if like_match else 0
coin_match = re.search(r'"coin":(\d+)', text)
coin = int(coin_match.group(1)) if coin_match else 0
favorite_match = re.search(r'"favorite":(\d+)', text)
favorite = int(favorite_match.group(1)) if favorite_match else 0
share_match = re.search(r'"share":(\d+)', text)
share = int(share_match.group(1)) if share_match else 0
# 提取 UP 主信息
owner_match = re.search(r'"name":"([^"]+)"', text)
owner_name = owner_match.group(1) if owner_match else '未知UP主'
face_match = re.search(r'"face":"([^"]+)"', text)
owner_face = face_match.group(1) if face_match else ''
if owner_face:
if owner_face.startswith('//'):
owner_face = 'https:' + owner_face
owner_face = owner_face.split('@')[0]
return {
"title": title,
"bvid": bvid,
"aid": 0,
"duration": 0,
"cover_url": '',
"play": play,
"like": like,
"coin": coin,
"favorite": favorite,
"share": share,
"danmaku": 0,
"owner_name": owner_name,
"owner_avatar": owner_face,
"followers": 0,
"description": '',
"pubdate": 0,
}
except Exception as e:
logger.error(f"[{self.name}] 备用解析方案失败: {e}")
return None
def extract_bvid(self, url: str) -> Optional[str]:
"""
从 URL 中提取 BV 号
Args:
url (str): B站视频URL
Returns:
Optional[str]: BV号如果失败则返回None
"""
# 方式1: 直接从 URL 中提取
bvid_match = re.search(r'/video/(BV\w+)', url)
if bvid_match:
return bvid_match.group(1)
# 方式2: 从短链接跳转后提取
if 'b23.tv' in url:
try:
session = self.get_session()
# 简单处理,不实际跳转,直接尝试提取
bvid_match = re.search(r'BV\w{10}', url)
if bvid_match:
return bvid_match.group(0)
except Exception:
pass
return None
async def get_real_url(self, short_url: str) -> Optional[str]:
"""
获取B站短链接的真实URL
Args:
short_url (str): B站短链接
Returns:
Optional[str]: 真实URL如果失败则返回None
"""
try:
session = self.get_session()
async with session.head(short_url, headers=self.HEADERS, allow_redirects=False, timeout=5) as response:
if response.status == 302:
return response.headers.get('Location')
except Exception as e:
logger.error(f"[{self.name}] 获取真实URL失败: {e}")
return None
async def get_direct_video_url(self, video_url: str, bvid: str) -> Optional[str]:
"""
获取B站视频直链通过本地文件服务器下载
Args:
video_url (str): B站视频的完整URL
bvid (str): BV号
Returns:
Optional[str]: 本地视频 URL如果失败则返回None
"""
if not BILI_API_AVAILABLE:
return None
try:
credential = self._get_credential()
v = video.Video(bvid=bvid, credential=credential)
# 先获取视频信息以获取 cid
info = await v.get_info()
cid = info.get('cid', 0)
if not cid:
return None
# 获取下载链接数据,使用 html5=True 获取网页格式(通常包含合并的音视频)
download_url_data = await v.get_download_url(cid=cid, html5=True)
# 使用 VideoDownloadURLDataDetecter 解析数据
detecter = video.VideoDownloadURLDataDetecter(data=download_url_data)
# 尝试获取 MP4 格式的合并流(包含音视频)
streams = detecter.detect_best_streams()
# 如果没有获取到流,尝试其他格式
if not streams:
logger.warning(f"[{self.name}] 无法获取 html5 格式,尝试获取其他格式...")
download_url_data = await v.get_download_url(cid=cid, html5=False)
detecter = video.VideoDownloadURLDataDetecter(data=download_url_data)
streams = detecter.detect_best_streams()
if streams:
# 获取视频直链
video_direct_url = streams[0].url
# 检查是否是分离的 m4s 流(可能没有声音)
is_m4s_stream = '.m4s' in video_direct_url
if is_m4s_stream:
logger.warning(f"[{self.name}] 检测到分离的 m4s 流B站 API 返回的 m4s 流通常是分离的视频和音频,需要客户端合并才能有声音")
logger.info(f"[{self.name}] 建议: 使用支持合并 m4s 流的下载工具(如 ffmpeg合并视频和音频")
logger.info(f"[{self.name}] 获取到视频直链,开始下载到本地...")
# B站下载需要 Referer 和 User-Agent
headers = {
"Referer": "https://www.bilibili.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
# 调试:打印 download_url_data 结构
logger.debug(f"[{self.name}] download_url_data 类型: {type(download_url_data)}")
if isinstance(download_url_data, dict):
logger.debug(f"[{self.name}] download_url_data keys: {list(download_url_data.keys())}")
# 如果是 m4s 流且 ffmpeg 可用,先保存 download_url_data 供合并使用
if is_m4s_stream and FFMPEG_AVAILABLE and AIOHTTP_AVAILABLE:
local_url = await self._download_and_merge_m4s(video_direct_url, headers, bvid, download_url_data)
else:
# 使用本地文件服务器下载
local_url = await download_to_local(video_direct_url, timeout=120, headers=headers)
if local_url:
logger.success(f"[{self.name}] 视频已下载到本地: {local_url}")
return local_url
else:
logger.error(f"[{self.name}] 下载到本地失败")
return None
except Exception as e:
logger.error(f"[{self.name}] 获取视频直链失败: {e}")
return None
async def _download_and_merge_m4s(self, video_url: str, headers: Dict[str, str], bvid: str, download_url_data: Dict) -> Optional[str]:
"""
下载并合并 m4s 视频和音频流
Args:
video_url (str): 视频流 URL
headers (Dict[str, str]): 请求头
bvid (str): BV号
download_url_data (Dict): 下载 URL 数据
Returns:
Optional[str]: 合并后的本地视频 URL如果失败则返回None
"""
if not FFMPEG_AVAILABLE:
logger.warning("[B站解析器] ffmpeg 不可用,无法合并音视频")
return None
if not AIOHTTP_AVAILABLE:
logger.warning("[B站解析器] aiohttp 不可用,无法合并音视频")
return None
try:
logger.info(f"[{self.name}] 开始下载并合并 m4s 音视频...")
# 创建共享的 ClientSession 用于下载
async with aiohttp.ClientSession() as session:
# 下载视频流
video_file = tempfile.NamedTemporaryFile(suffix='.m4s', delete=False)
video_file.close()
async with session.get(video_url, headers=headers, timeout=60) as response:
if response.status != 200:
logger.error(f"[{self.name}] 下载视频流失败: HTTP {response.status}")
return None
with open(video_file.name, 'wb') as f:
while True:
chunk = await response.content.read(8192)
if not chunk:
break
f.write(chunk)
logger.info(f"[{self.name}] 视频流下载完成: {video_file.name}")
# 从 download_url_data 中提取音频 URL
# B站的 dash 格式包含视频和音频流
audio_url = None
if isinstance(download_url_data, dict):
# 尝试 dash 格式(推荐)
if 'dash' in download_url_data and isinstance(download_url_data['dash'], dict):
dash = download_url_data['dash']
if 'audio' in dash and isinstance(dash['audio'], list) and len(dash['audio']) > 0:
# 获取第一个音频流
audio_item = dash['audio'][0]
audio_url = audio_item.get('baseUrl') or audio_item.get('url') or audio_item.get('backupUrl')
logger.debug(f"[{self.name}] 从 dash.audio 提取音频 URL: {audio_url is not None}")
elif 'audio' in dash and isinstance(dash['audio'], dict):
audio_url = dash['audio'].get('baseUrl') or dash['audio'].get('url')
logger.debug(f"[{self.name}] 从 dash.audio (dict) 提取音频 URL: {audio_url is not None}")
# 尝试 durl 格式(非分段流)
elif 'durl' in download_url_data:
if isinstance(download_url_data['durl'], list) and len(download_url_data['durl']) > 0:
main_url = download_url_data['durl'][0].get('url') or download_url_data['durl'][0].get('baseUrl')
if main_url:
video_url = main_url
logger.debug(f"[{self.name}] 使用 durl 主 URL: {video_url}")
if not audio_url and not video_url.startswith('http'):
logger.warning(f"[{self.name}] 无法从 download_url_data 中提取音频 URL")
logger.debug(f"[{self.name}] download_url_data 结构: {download_url_data}")
os.unlink(video_file.name)
return None
# 下载音频流
audio_file = tempfile.NamedTemporaryFile(suffix='.m4s', delete=False)
audio_file.close()
async with session.get(audio_url, headers=headers, timeout=60) as response:
if response.status != 200:
logger.error(f"[{self.name}] 下载音频流失败: HTTP {response.status}")
os.unlink(video_file.name)
return None
with open(audio_file.name, 'wb') as f:
while True:
chunk = await response.content.read(8192)
if not chunk:
break
f.write(chunk)
logger.info(f"[{self.name}] 音频流下载完成: {audio_file.name}")
# 使用 ffmpeg 合并视频和音频
merged_file = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
merged_file.close()
# ffmpeg命令使用ffmpeg -i多次输入然后合并
# 先转换视频流(移除音频),然后添加音频流
ffmpeg_cmd = [
'ffmpeg', '-y', '-i', video_file.name, '-i', audio_file.name,
'-c:v', 'libx264', '-c:a', 'aac',
'-shortest', merged_file.name
]
logger.debug(f"[{self.name}] ffmpeg命令: {' '.join(ffmpeg_cmd)}")
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
# 详细记录ffmpeg输出
if result.stdout:
logger.debug(f"[{self.name}] ffmpeg stdout: {result.stdout}")
if result.stderr:
logger.debug(f"[{self.name}] ffmpeg stderr: {result.stderr}")
if result.returncode != 0:
logger.error(f"[{self.name}] ffmpeg 合并失败: {result.stderr}")
os.unlink(video_file.name)
os.unlink(audio_file.name)
return None
# 验证输出文件
merged_size = os.path.getsize(merged_file.name)
logger.debug(f"[{self.name}] 合并文件大小: {merged_size} bytes")
if merged_size == 0:
logger.error(f"[{self.name}] ffmpeg生成了空文件命令可能有问题")
logger.error(f"[{self.name}] ffmpeg命令: {' '.join(ffmpeg_cmd)}")
if result.stderr:
logger.error(f"[{self.name}] ffmpeg错误输出: {result.stderr}")
os.unlink(video_file.name)
os.unlink(audio_file.name)
return None
logger.info(f"[{self.name}] 音视频合并成功: {merged_file.name} ({merged_size} bytes)")
# 上传合并后的文件到本地文件服务器
from core.services.local_file_server import get_local_file_server
server = get_local_file_server()
if server:
try:
file_id = server._generate_file_id(f'file://{merged_file.name}')
dest_path = server.download_dir / file_id
# 获取合并文件大小
merged_size = os.path.getsize(merged_file.name)
logger.debug(f"[{self.name}] 合并文件大小: {merged_size} bytes")
if merged_size == 0:
logger.error(f"[{self.name}] 合并文件为空ffmpeg可能失败了")
merged_url = None
else:
# 复制本地文件到服务器目录
import shutil
shutil.copy2(merged_file.name, dest_path)
server.file_map[file_id] = dest_path
# 验证复制后的文件
if dest_path.exists():
dest_size = dest_path.stat().st_size
logger.debug(f"[{self.name}] 复制后文件大小: {dest_size} bytes")
if dest_size == merged_size:
merged_url = f"http://127.0.0.1:{server.port}/download?id={file_id}"
logger.success(f"[{self.name}] 合并后的视频已上传到本地服务器: {merged_url}")
else:
logger.error(f"[{self.name}] 文件大小不匹配: 原始 {merged_size} vs 复制 {dest_size}")
merged_url = None
else:
logger.error(f"[{self.name}] 文件复制失败: {dest_path} 不存在")
merged_url = None
except Exception as e:
logger.error(f"[{self.name}] 上传合并文件失败: {e}")
merged_url = None
else:
merged_url = None
# 清理临时文件
try:
os.unlink(video_file.name)
os.unlink(audio_file.name)
os.unlink(merged_file.name)
except Exception as e:
logger.warning(f"[{self.name}] 清理临时文件失败: {e}")
if merged_url:
logger.success(f"[{self.name}] 合并后的视频已上传到本地服务器: {merged_url}")
return merged_url
except Exception as e:
logger.error(f"[{self.name}] 合并音视频失败: {e}")
return None
async def format_response(self, event: MessageEvent, data: Dict[str, Any]) -> List[Any]:
"""
格式化B站视频响应消息
Args:
event (MessageEvent): 消息事件对象
data (Dict[str, Any]): 视频信息
Returns:
List[Any]: 消息段列表
"""
# 检查视频时长
video_message: Union[str, MessageSegment]
direct_url = None
if data['duration'] > 7200: # 2小时 = 7200秒
video_message = "视频时长超过2小时不进行解析。"
else:
# 构建完整的B站视频URL
video_url = f"https://www.bilibili.com/video/{data.get('bvid', '')}"
bvid = data.get('bvid', '')
direct_url = await self.get_direct_video_url(video_url, bvid)
if direct_url:
video_message = MessageSegment.video(direct_url)
else:
video_message = "视频解析失败,无法获取直链。"
text_message = (
f"BiliBili 视频解析\n"
f"--------------------\n"
f" UP主: {data['owner_name']}\n"
f" 粉丝: {self.format_count(data['followers'])}\n"
f"--------------------\n"
f" 标题: {data['title']}\n"
f" BV号: {data['bvid']}\n"
f" 时长: {format_duration(data['duration'])}\n"
f"--------------------\n"
f" 数据:\n"
f" 播放: {self.format_count(data['play'])}\n"
f" 点赞: {self.format_count(data['like'])}\n"
f" 投币: {self.format_count(data['coin'])}\n"
f" 收藏: {self.format_count(data['favorite'])}\n"
f" 转发: {self.format_count(data['share'])}\n"
f" 弹幕: {self.format_count(data.get('danmaku', 0))}\n"
)
image_message_segment = [
MessageSegment.text("B站封面"),
MessageSegment.image(data['cover_url'])
]
up_info_segment = [
MessageSegment.text("UP主头像"),
MessageSegment.image(data['owner_avatar'])
]
nodes = [
event.bot.build_forward_node(user_id=event.self_id, nickname=self.nickname, message=text_message),
event.bot.build_forward_node(user_id=event.self_id, nickname=self.nickname, message=image_message_segment),
event.bot.build_forward_node(user_id=event.self_id, nickname=self.nickname, message=up_info_segment),
event.bot.build_forward_node(user_id=event.self_id, nickname=self.nickname, message=video_message)
]
# 同时直接发送视频(如果获取到直链)
if direct_url:
try:
await event.reply(MessageSegment.video(direct_url))
except Exception as e:
logger.error(f"[{self.name}] 直接发送视频失败: {e}")
return nodes
def should_handle_url(self, url: str) -> bool:
"""
判断是否应该处理该URL
Args:
url (str): URL
Returns:
bool: 是否应该处理
"""
return bool(self.url_pattern.search(url))

View File

@@ -1,331 +0,0 @@
# -*- coding: utf-8 -*-
import re
import aiohttp
import asyncio
from typing import Optional, Dict, Any, List
from core.utils.logger import logger
from models import MessageEvent, MessageSegment
from ..base import BaseParser
from ..utils import extract_original_text
from cachetools import TTLCache
class DouyinParser(BaseParser):
"""
抖音视频解析器
"""
def __init__(self):
super().__init__()
self.name = "抖音解析器"
self.url_pattern = re.compile(r"https?://v\.douyin\.com/[a-zA-Z0-9_-]+/?", re.IGNORECASE)
self.short_pattern = re.compile(r"(?:https?://)?v\.douyin\.com/[a-zA-Z0-9_-]+/?", re.IGNORECASE)
self.nickname = "抖音视频解析"
# 消息去重缓存
self.processed_messages: TTLCache[int, bool] = TTLCache(maxsize=100, ttl=10)
async def _parse_api_xhus(self, url: str) -> Optional[Dict[str, Any]]:
"""
使用 xhus API 解析抖音视频
Args:
url (str): 抖音视频URL
Returns:
Optional[Dict[str, Any]]: 视频信息字典如果失败则返回None
"""
try:
api_url = f"http://api.xhus.cn/api/douyin?url={url}"
session = self.get_session()
async with session.get(api_url, headers=self.HEADERS, timeout=aiohttp.ClientTimeout(total=10)) as response:
if response.status != 200:
logger.error(f"[{self.name}] xhus API请求失败状态码: {response.status}")
return None
response_data = await response.json()
if not isinstance(response_data, dict):
logger.error(f"[{self.name}] xhus API返回格式错误: {response_data}")
return None
if response_data.get("code") != 200:
logger.error(f"[{self.name}] xhus API返回错误: {response_data}")
return None
data = response_data.get("data", {})
if not data:
logger.error(f"[{self.name}] xhus API返回数据为空")
return None
return {
"type": "video" if not data.get("images") or not isinstance(data.get("images"), list) else "image",
"video_url": data.get("url", ""),
"video_url_HQ": data.get("url", ""),
"nickname": data.get("author", "未知作者"),
"desc": data.get("title", "无描述"),
"aweme_id": data.get("uid", ""),
"like": data.get("like", 0),
"cover": data.get("cover", ""),
"time": data.get("time", 0),
"author_avatar": data.get("avatar", ""),
"music": data.get("music", {}),
}
except Exception as e:
logger.error(f"[{self.name}] xhus API解析失败: {e}")
return None
async def _parse_api_mmp(self, url: str) -> Optional[Dict[str, Any]]:
"""
使用 mmp API 解析抖音视频
Args:
url (str): 抖音视频URL
Returns:
Optional[Dict[str, Any]]: 视频信息字典如果失败则返回None
"""
try:
api_url = f"https://api.mmp.cc/api/Jiexi?url={url}"
session = self.get_session()
async with session.get(api_url, headers=self.HEADERS, timeout=aiohttp.ClientTimeout(total=10)) as response:
if response.status != 200:
logger.error(f"[{self.name}] mmp API请求失败状态码: {response.status}")
return None
response_data = await response.json()
if not isinstance(response_data, dict):
logger.error(f"[{self.name}] mmp API返回格式错误: {response_data}")
return None
if response_data.get("code") != 200:
logger.error(f"[{self.name}] mmp API返回错误: {response_data}")
return None
data = response_data.get("data", {})
if not data:
logger.error(f"[{self.name}] mmp API返回数据为空")
return None
return {
"type": data.get("type", "video"),
"video_url": data.get("video_url", ""),
"video_url_HQ": data.get("video_url_HQ", ""),
"nickname": data.get("nickname", "未知作者"),
"desc": data.get("desc", "无描述"),
"aweme_id": data.get("aweme_id", ""),
"like": data.get("like", 0),
"cover": data.get("cover", ""),
"time": data.get("time", 0),
"author_avatar": data.get("author_avatar", ""),
"music": data.get("music", {}),
}
except Exception as e:
logger.error(f"[{self.name}] mmp API解析失败: {e}")
return None
async def parse(self, url: str) -> Optional[Dict[str, Any]]:
"""
解析抖音视频信息并发请求多个API取最快返回的结果
Args:
url (str): 抖音视频URL
Returns:
Optional[Dict[str, Any]]: 视频信息字典如果失败则返回None
"""
async def try_api(coro, api_name: str) -> tuple:
try:
result = await coro
return (result, api_name)
except Exception as e:
logger.error(f"[{self.name}] {api_name} API异常: {e}")
return (None, api_name)
tasks = [
try_api(self._parse_api_xhus(url), "xhus"),
try_api(self._parse_api_mmp(url), "mmp"),
]
for coro in asyncio.as_completed(tasks):
result, api_name = await coro
if result:
logger.info(f"[{self.name}] 使用 {api_name} API 成功解析")
return result
logger.error(f"[{self.name}] 所有API解析均失败")
return None
async def get_real_url(self, short_url: str) -> Optional[str]:
"""
获取抖音短链接的真实URL
Args:
short_url (str): 抖音短链接
Returns:
Optional[str]: 真实URL如果失败则返回None
"""
try:
session = self.get_session()
async with session.get(short_url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as response:
redirected_url = str(response.url)
# 检查重定向后的URL是否是有效的视频或图文页
if 'douyin.com/video/' in redirected_url or 'douyin.com/note/' in redirected_url:
logger.info(f"[{self.name}] 成功获取真实URL: {redirected_url}")
return redirected_url
else:
logger.warning(f"[{self.name}] 短链接 {short_url} 重定向到了非预期的页面: {redirected_url}")
return None
except Exception as e:
logger.error(f"[{self.name}] 获取真实URL失败: {e}")
return None
async def format_response(self, event: MessageEvent, data: Dict[str, Any]) -> List[Any]:
"""
格式化抖音视频响应消息
Args:
event (MessageEvent): 消息事件对象
data (Dict[str, Any]): 视频信息
Returns:
List[Any]: 消息段列表
"""
# 构建回复消息,包含原分享中的文本内容(如果有)
original_text = extract_original_text(event.message, self.url_pattern)
# 构建回复消息
text_parts = ["抖音视频解析"]
text_parts.append("--------------------")
if original_text:
text_parts.append(f" 分享内容: {original_text}")
text_parts.append("--------------------")
text_parts.append(f" 作者: {data['nickname']}")
text_parts.append(f" 抖音号: {data['aweme_id']}")
text_parts.append(f" 标题: {data['desc']}")
text_parts.append(f" 点赞: {self.format_count(data['like'])}")
text_parts.append(f" 类型: {data['type']}")
# 如果是音乐,添加音乐信息
if data.get('music'):
music_info = data['music']
text_parts.append("--------------------")
text_parts.append(" 背景音乐:")
text_parts.append(f" 标题: {music_info.get('title', '')}")
text_parts.append(f" 作者: {music_info.get('author', '')}")
text_parts.append("--------------------")
text_message = "\n".join(text_parts)
# 准备转发消息节点
nodes = []
# 添加文本信息节点
text_node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=self.nickname,
message=text_message
)
nodes.append(text_node)
# 添加封面图片节点(如果有)
if data.get('cover'):
try:
cover_node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=self.nickname,
message=[
MessageSegment.text("抖音视频封面:\n"),
MessageSegment.image(data['cover'])
]
)
nodes.append(cover_node)
except Exception as e:
logger.warning(f"[{self.name}] 无法添加封面图片: {e}")
# 添加作者头像节点(如果有)
if data.get('author_avatar'):
try:
avatar_node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=self.nickname,
message=[
MessageSegment.text("作者头像:\n"),
MessageSegment.image(data['author_avatar'])
]
)
nodes.append(avatar_node)
except Exception as e:
logger.warning(f"[{self.name}] 无法添加作者头像: {e}")
# 尝试添加视频直链(单独节点)
video_success = False
direct_message = None
try:
if data.get('video_url'):
video_url = data.get('video_url', '')
# 检查视频类型
if data.get('type') == 'video':
video_message = MessageSegment.video(video_url)
direct_message = video_message
video_type_text = "视频直链:"
else: # image类型
video_message = MessageSegment.image(video_url) # 单个图片
direct_message = video_message
video_type_text = "图集首图:"
# 构建视频/图片节点
video_node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=self.nickname,
message=[
MessageSegment.text(video_type_text + "\n"),
video_message
]
)
nodes.append(video_node)
video_success = True
except Exception as e:
logger.error(f"[{self.name}] 无法添加视频/图片: {e}")
# 如果无法添加视频,添加提示信息
if not video_success:
no_video_node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=self.nickname,
message="视频解析成功,但无法获取直链或播放视频。"
)
nodes.append(no_video_node)
# 同时直接发送视频/图片(如果获取到直链)
if direct_message:
try:
await event.reply(direct_message)
except Exception as e:
logger.error(f"[{self.name}] 直接发送视频/图片失败: {e}")
return nodes
def should_handle_url(self, url: str) -> bool:
"""
判断是否应该处理该URL
Args:
url (str): URL
Returns:
bool: 是否应该处理
"""
# 检查是否是抖音相关域名
return ('douyin.com' in url or bool(self.url_pattern.search(url)) or bool(self.short_pattern.search(url)))

View File

@@ -1,200 +0,0 @@
# -*- coding: utf-8 -*-
import re
import aiohttp
from typing import Optional, Dict, Any, List
from cachetools import TTLCache
from core.utils.logger import logger
from core.managers.image_manager import image_manager
from models import MessageEvent, MessageSegment
from ..base import BaseParser
class GitHubParser(BaseParser):
"""
GitHub仓库解析器
"""
def __init__(self):
super().__init__()
self.name = "GitHub解析器"
self.url_pattern = re.compile(r"https?://(?:www\.)?github\.com/([\w\-]+)/([\w\-\.]+)(?:/[^\s]*)?")
self.nickname = "GitHub仓库信息"
# 消息去重缓存
self.processed_messages: TTLCache[int, bool] = TTLCache(maxsize=100, ttl=10)
# 缓存GitHub API响应避免频繁请求
self.api_cache = TTLCache(maxsize=100, ttl=3600) # 100个缓存项1小时过期
async def parse(self, url: str) -> Optional[Dict[str, Any]]:
"""
解析GitHub仓库信息
Args:
url (str): GitHub仓库URL
Returns:
Optional[Dict[str, Any]]: 仓库信息字典如果失败则返回None
"""
# 从URL中提取owner和repo
match = self.url_pattern.search(url)
if not match:
return None
owner = match.group(1)
repo = match.group(2)
# 移除可能的.git后缀
repo = repo.replace(".git", "")
return await self.get_github_repo_info(owner, repo)
async def get_real_url(self, short_url: str) -> Optional[str]:
"""
获取短链接的真实URL
Args:
short_url (str): 短链接
Returns:
Optional[str]: 真实URL如果失败则返回None
"""
try:
session = self.get_session()
async with session.head(short_url, headers=self.HEADERS, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=5)) as response:
if response.status == 302:
return response.headers.get('Location')
except Exception as e:
logger.error(f"[{self.name}] 获取真实URL失败: {e}")
return None
async def get_github_repo_info(self, owner: str, repo: str) -> Optional[Dict[str, Any]]:
"""
通过GitHub API获取仓库信息
Args:
owner (str): 仓库所有者用户名
repo (str): 仓库名称
Returns:
Optional[Dict[str, Any]]: 仓库信息字典如果失败则返回None
"""
cache_key = f"{owner}/{repo}"
if cache_key in self.api_cache:
logger.info(f"[{self.name}] 使用缓存的仓库信息: {cache_key}")
return self.api_cache[cache_key]
api_url = f"https://api.github.com/repos/{owner}/{repo}"
try:
session = self.get_session()
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as response:
response.raise_for_status()
repo_data = await response.json()
# 将数据存入缓存
self.api_cache[cache_key] = repo_data
logger.info(f"[{self.name}] 成功获取仓库信息并缓存: {cache_key}")
return repo_data
except aiohttp.ClientError as e:
logger.error(f"[{self.name}] GitHub API请求失败: {e}")
except ValueError as e:
logger.error(f"[{self.name}] 解析GitHub API响应失败: {e}")
except Exception as e:
logger.error(f"[{self.name}] 获取仓库信息时发生未知错误: {e}")
return None
async def generate_repo_image(self, repo_data: Dict[str, Any]) -> Optional[str]:
"""
使用Jinja2模板渲染仓库信息为图片
Args:
repo_data (Dict[str, Any]): 仓库信息字典
Returns:
Optional[str]: 生成的图片Base64编码如果失败则返回None
"""
try:
# 准备模板数据
template_data = {
"full_name": repo_data.get("full_name", ""),
"description": repo_data.get("description", "暂无描述"),
"owner_avatar": repo_data.get("owner", {}).get("avatar_url", ""),
"stargazers_count": repo_data.get("stargazers_count", 0),
"forks_count": repo_data.get("forks_count", 0),
"open_issues_count": repo_data.get("open_issues_count", 0),
"watchers_count": repo_data.get("watchers_count", 0),
}
# 渲染模板为图片,使用高质量设置
base64_image = await image_manager.render_template_to_base64(
template_name="github_repo.html",
data=template_data,
output_name=f"github_{repo_data.get('name', 'repo')}.png",
quality=100,
image_type="png"
)
return base64_image
except Exception as e:
logger.error(f"[{self.name}] 生成仓库信息图片失败: {e}")
return None
async def format_response(self, event: MessageEvent, data: Dict[str, Any]) -> List[Any]:
"""
格式化GitHub仓库响应消息
Args:
event (MessageEvent): 消息事件对象
data (Dict[str, Any]): 仓库信息
Returns:
List[Any]: 消息段列表
"""
nodes = []
# 生成图片
image_base64 = await self.generate_repo_image(data)
if image_base64:
# 发送图片
image_node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=self.nickname,
message=MessageSegment.image(image_base64)
)
nodes.append(image_node)
else:
# 如果图片生成失败,发送文本信息
text_message = (
f"GitHub 仓库信息\n"
f"--------------------\n"
f"仓库: {data.get('full_name', '')}\n"
f"描述: {data.get('description', '暂无描述')}\n"
f"--------------------\n"
f"数据:\n"
f" 星标: {data.get('stargazers_count', 0)}\n"
f" Fork: {data.get('forks_count', 0)}\n"
f" Issues: {data.get('open_issues_count', 0)}\n"
f" 关注: {data.get('watchers_count', 0)}\n"
)
text_node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=self.nickname,
message=text_message
)
nodes.append(text_node)
return nodes
def should_handle_url(self, url: str) -> bool:
"""
判断是否应该处理该URL
Args:
url (str): URL
Returns:
bool: 是否应该处理
"""
# 检查是否是GitHub相关域名
return bool(self.url_pattern.search(url)) and 'github.com' in url

View File

@@ -1,142 +0,0 @@
# -*- coding: utf-8 -*-
import re
from typing import Dict, Any, List
from models import MessageEvent
def format_duration(seconds: int) -> str:
"""
将秒数格式化为 MM:SS 的形式
Args:
seconds (int): 秒数
Returns:
str: 格式化后的时间字符串
"""
if not isinstance(seconds, int) or seconds < 0:
return "00:00"
minutes, seconds = divmod(seconds, 60)
return f"{minutes:02d}:{seconds:02d}"
def clean_url(url: str) -> str:
"""
清理URL去掉不必要的查询参数
Args:
url (str): 原始URL
Returns:
str: 清理后的URL
"""
clean_url = url.split('?')[0]
if '#/' in clean_url:
clean_url = clean_url.split('#/')[0]
return clean_url
def extract_original_text(segments: List[Any], url_pattern: re.Pattern) -> str:
"""
从消息段中提取原始文本(去除链接)
Args:
segments (List[Any]): 消息段列表
url_pattern (re.Pattern): URL正则表达式模式
Returns:
str: 提取的原始文本
"""
for segment in segments:
if segment.type == "text":
text_content = segment.data.get("text", "")
# 移除链接
cleaned_text = re.sub(url_pattern, '', text_content)
# 移除常见的分享提示
cleaned_text = re.sub(r'复制此链接.*?打开.*?搜索.*?直接观看视频!', '', cleaned_text)
cleaned_text = cleaned_text.strip()
if cleaned_text:
return cleaned_text
return ""
def build_forward_nodes(event: MessageEvent, nickname: str, messages: List[Any]) -> List[Any]:
"""
构建转发消息节点
Args:
event (MessageEvent): 消息事件对象
nickname (str): 发送者昵称
messages (List[Any]): 消息内容列表
Returns:
List[Any]: 转发消息节点列表
"""
nodes = []
for msg in messages:
if isinstance(msg, str):
node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=nickname,
message=msg
)
nodes.append(node)
elif isinstance(msg, list):
node = event.bot.build_forward_node(
user_id=event.self_id,
nickname=nickname,
message=msg
)
nodes.append(node)
return nodes
def safe_get(data: Dict[str, Any], keys: List[str], default: Any = None) -> Any:
"""
安全地从嵌套字典中获取值
Args:
data (Dict[str, Any]): 嵌套字典
keys (List[str]): 键路径列表
default (Any, optional): 默认值. Defaults to None.
Returns:
Any: 获取的值或默认值
"""
result = data
for key in keys:
if isinstance(result, dict) and key in result:
result = result[key]
else:
return default
return result
def normalize_url(url: str) -> str:
"""
规范化URL
Args:
url (str): 原始URL
Returns:
str: 规范化后的URL
"""
if not url.startswith('http'):
url = 'https://' + url
return url
def validate_url(url: str) -> bool:
"""
验证URL格式是否正确
Args:
url (str): URL
Returns:
bool: URL格式是否正确
"""
url_pattern = re.compile(r'https?://[^]+')
return bool(url_pattern.match(url))