diff --git a/Dockerfile b/Dockerfile index 1f62311..12a4ddd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN mkdir -p /app/templates \ COPY --chown=appuser:appuser . . -VOLUME [ "/app/config" ] +VOLUME [ "/app/templates" ] USER appuser diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..4c995c4 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,33 @@ +# Copyright 2025 AptS-1547 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Onebot GitHub Webhook API 模块 +本模块包含应用的所有 API 路由 +作者:AptS:1547 +版本:0.1.0-alpha +日期:2025-04-17 +本程序遵循 Apache License 2.0 许可证 +""" + +from fastapi import APIRouter + +from .github_webhook import router as github_webhook_router + +# 主路由 +api_router = APIRouter() + +# 注册子路由 +api_router.include_router(github_webhook_router, prefix="/github-webhook", tags=["github"]) + +__all__ = ["api_router"] diff --git a/app/api/github_webhook.py b/app/api/github_webhook.py new file mode 100644 index 0000000..4653e9f --- /dev/null +++ b/app/api/github_webhook.py @@ -0,0 +1,137 @@ +# Copyright 2025 AptS-1547 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +GitHub Webhook 路由模块 +本模块用于处理 GitHub Webhook 事件,并将其转发到配置的 OneBot 目标。 +作者:AptS:1547 +版本:0.1.0-alpha +日期:2025-04-17 +本程序遵循 Apache License 2.0 许可证 +""" + +import logging +from fastapi import APIRouter, Request, HTTPException + +from app.core import GitHubWebhookHandler +from app.onebot import text, get_onebot_client +from app.models.config import get_settings + +router = APIRouter() +logger = logging.getLogger(__name__) + +config = get_settings() + +@router.post("") +async def github_webhook( + request: Request, +): # pylint: disable=too-many-return-statements + """处理 GitHub webhook 请求""" + + onebot_client = get_onebot_client() + + if not onebot_client: + logger.error("OneBot 客户端未初始化,无法处理请求") + raise HTTPException(status_code=503, detail="OneBot 客户端未初始化") + + content_type = request.headers.get("Content-Type") + if content_type != "application/json": + logger.info("收到非 JSON 格式的请求,忽略") + return {"status": "ignored", "message": "只处理 application/json 格式的请求"} + + event_type = request.headers.get("X-GitHub-Event", "") + if not event_type: + logger.info("缺少 X-GitHub-Event 头,忽略") + return {"status": "ignored", "message": "缺少 X-GitHub-Event 头"} + + try: + await GitHubWebhookHandler.verify_signature( + request, + config.GITHUB_WEBHOOK, + request.headers.get("X-Hub-Signature-256") + ) + except HTTPException as e: + logger.info("签名验证失败: %s", e.detail) + return {"status": "ignored", "message": f"签名验证失败: {e.detail}"} + + payload = await request.json() + if not payload: + logger.info("请求体为空,忽略") + return {"status": "ignored", "message": "请求体为空"} + + repo_name = payload.get("repository", {}).get("full_name") + branch = payload.get("ref", "").replace("refs/heads/", "") + + matched_webhook = GitHubWebhookHandler.find_matching_webhook( + repo_name, + branch, + event_type, + config.GITHUB_WEBHOOK + ) + + if not matched_webhook: + logger.info("找不到匹配的 webhook 配置: 仓库 %s, 分支 %s, 事件类型 %s", repo_name, branch, event_type) + return {"status": "ignored", "message": "找不到匹配的 webhook 配置"} + + # 处理不同类型的事件,暂时只支持 push 事件 + if event_type == "push": + push_data = GitHubWebhookHandler.extract_push_data(payload) + + logger.info("发现新的 push 事件,来自 %s 仓库", push_data["repo_name"]) + logger.info("分支: %s,推送者: %s,提交数量: %s", + push_data["branch"], + push_data["pusher"], + push_data["commit_count"]) + + # 向配置的所有 OneBot 目标发送通知 + for target in matched_webhook.ONEBOT: + logger.info("正在发送消息到 QQ %s %s", target.type, target.id) + + message = format_github_push_message( + repo_name=push_data["repo_name"], + branch=push_data["branch"], + pusher=push_data["pusher"], + commit_count=push_data["commit_count"], + commits=push_data["commits"] + ) + + # 使用已有的客户端发送消息 + await onebot_client.send_message(target.type, target.id, message) + + return {"status": "success", "message": "处理 push 事件成功"} + + logger.info("收到 %s 事件,但尚未实现处理逻辑", event_type) + return {"status": "ignored", "message": f"暂不处理 {event_type} 类型的事件"} + + +def format_github_push_message(repo_name, branch, pusher, commit_count, commits): + """格式化 GitHub 推送消息""" + + message = [ + text("📢 GitHub 推送通知\n"), + text(f"仓库:{repo_name}\n"), + text(f"分支:{branch}\n"), + text(f"推送者:{pusher}\n"), + text(f"提交数量:{commit_count}\n\n"), + text("最新提交:\n") + ] + + # 最多展示3条最新提交 + for commit in commits[:3]: + short_id = commit["id"][:7] + commit_message = commit["message"].split("\n")[0] # 只取第一行 + author = commit.get("author", {}).get("name", "未知") + + message.append(text(f"[{short_id}] {commit_message} (by {author})\n")) + + return message diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..c0c7a90 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,27 @@ +# Copyright 2025 AptS-1547 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OneBot GitHub Webhook core 模块 +本模块用于处理 GitHub Webhook 事件的匹配逻辑,包括验证签名、查找匹配的 webhook 配置和提取 push 事件数据。 +作者:AptS:1547 +版本:0.1.0-alpha +日期:2025-04-17 +本程序遵循 Apache License 2.0 许可证 +""" + +from .github import GitHubWebhookHandler + +__all__ = [ + "GitHubWebhookHandler", +] diff --git a/app/core/github.py b/app/core/github.py index 9232e28..7059d9d 100644 --- a/app/core/github.py +++ b/app/core/github.py @@ -31,96 +31,104 @@ logger = logging.getLogger(__name__) -async def verify_signature( - request: Request, - webhooks: List, - x_hub_signature_256: Optional[str] = Header(None) - ) -> bool: - """验证 GitHub webhook 签名""" - - try: - body = await request.body() - payload = await request.json() - repo_name = payload.get("repository", {}).get("full_name") - except Exception as e: - logger.error("解析请求体失败: %s", str(e)) - raise HTTPException( - status_code=400, detail="Invalid JSON payload") from e - - webhook_secret = None - for webhook in webhooks: - if any(match_pattern(repo_name, repo_pattern) for repo_pattern in webhook.REPO): - webhook_secret = webhook.SECRET - break - - if not webhook_secret: - logger.warning("仓库 %s 未配置 webhook 密钥,跳过签名验证", repo_name) - return True - - if not x_hub_signature_256: - raise HTTPException( - status_code=401, detail="Missing X-Hub-Signature-256 header") - - signature = hmac.new( - key=webhook_secret.encode(), - msg=body, - digestmod=hashlib.sha256 - ).hexdigest() - - expected_signature = f"sha256={signature}" - if not hmac.compare_digest(expected_signature, x_hub_signature_256): - raise HTTPException(status_code=401, detail="Invalid signature") - - return True - -def find_matching_webhook( - repo_name: str, - branch: str, - event_type: str, - webhooks: List - ) -> Optional[Any]: +class GitHubWebhookHandler: """ - 查找匹配的 webhook 配置 - - Args: - repo_name: 仓库名 - branch: 分支名 - event_type: 事件类型 - webhooks: webhook 配置列表 - - Returns: - 匹配的 webhook 配置或 None + GitHub Webhook 处理类 + 用于处理 GitHub Webhook 事件,包括验证签名、查找匹配的 webhook 配置和提取 push 事件数据。 """ - for webhook in webhooks: - # 检查仓库名是否匹配配置中的任一模式 - repo_matches = any(match_pattern(repo_name, repo_pattern) - for repo_pattern in webhook.REPO) - # 检查分支名是否匹配配置中的任一模式 - branch_matches = any(match_pattern(branch, branch_pattern) - for branch_pattern in webhook.BRANCH) + @staticmethod + async def verify_signature( + request: Request, + webhooks: List, + x_hub_signature_256: Optional[str] = Header(None) + ) -> bool: + """验证 GitHub webhook 签名""" + + try: + body = await request.body() + payload = await request.json() + repo_name = payload.get("repository", {}).get("full_name") + except Exception as e: + logger.error("解析请求体失败: %s", str(e)) + raise HTTPException( + status_code=400, detail="Invalid JSON payload") from e + + webhook_secret = None + for webhook in webhooks: + if any(match_pattern(repo_name, repo_pattern) for repo_pattern in webhook.REPO): + webhook_secret = webhook.SECRET + break + + if not webhook_secret: + logger.warning("仓库 %s 未配置 webhook 密钥,跳过签名验证", repo_name) + return True + + if not x_hub_signature_256: + raise HTTPException( + status_code=401, detail="Missing X-Hub-Signature-256 header") + + signature = hmac.new( + key=webhook_secret.encode(), + msg=body, + digestmod=hashlib.sha256 + ).hexdigest() + + expected_signature = f"sha256={signature}" + if not hmac.compare_digest(expected_signature, x_hub_signature_256): + raise HTTPException(status_code=401, detail="Invalid signature") - # 检查事件类型是否匹配 - if (repo_matches and branch_matches and event_type in webhook.EVENTS): - return webhook - - return None - - -def extract_push_data(payload: Dict[str, Any]) -> Dict[str, Any]: - """ - 从 push 事件中提取相关数据 - - Args: - payload: GitHub webhook 负载 + return True - Returns: - 包含提取数据的字典 - """ - return { - "repo_name": payload.get("repository", {}).get("full_name"), - "branch": payload.get("ref", "").replace("refs/heads/", ""), - "pusher": payload.get("pusher", {}).get("name"), - "commits": payload.get("commits", []), - "commit_count": len(payload.get("commits", [])) - } + @staticmethod + def find_matching_webhook( + repo_name: str, + branch: str, + event_type: str, + webhooks: List + ) -> Optional[Any]: + """ + 查找匹配的 webhook 配置 + + Args: + repo_name: 仓库名 + branch: 分支名 + event_type: 事件类型 + webhooks: webhook 配置列表 + + Returns: + 匹配的 webhook 配置或 None + """ + for webhook in webhooks: + # 检查仓库名是否匹配配置中的任一模式 + repo_matches = any(match_pattern(repo_name, repo_pattern) + for repo_pattern in webhook.REPO) + + # 检查分支名是否匹配配置中的任一模式 + branch_matches = any(match_pattern(branch, branch_pattern) + for branch_pattern in webhook.BRANCH) + + # 检查事件类型是否匹配 + if (repo_matches and branch_matches and event_type in webhook.EVENTS): + return webhook + + return None + + @staticmethod + def extract_push_data(payload: Dict[str, Any]) -> Dict[str, Any]: + """ + 从 push 事件中提取相关数据 + + Args: + payload: GitHub webhook 负载 + + Returns: + 包含提取数据的字典 + """ + return { + "repo_name": payload.get("repository", {}).get("full_name"), + "branch": payload.get("ref", "").replace("refs/heads/", ""), + "pusher": payload.get("pusher", {}).get("name"), + "commits": payload.get("commits", []), + "commit_count": len(payload.get("commits", [])) + } diff --git a/app/core/onebot.py b/app/core/onebot.py deleted file mode 100644 index bd6a0a0..0000000 --- a/app/core/onebot.py +++ /dev/null @@ -1,270 +0,0 @@ -# Copyright 2025 AptS-1547 -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -OneBot V11 客户端 -本模块提供了一个 OneBot V11 客户端的实现,支持通过 WebSocket 和 HTTP 发送消息。 -作者:AptS:1547 -版本:0.1.0-alpha -日期:2025-04-17 -本程序遵循 Apache License 2.0 许可证 -""" - -import json -import logging -from typing import Union, List, Dict, Any -import aiohttp - -logger = logging.getLogger(__name__) - -class WSConnectionException(Exception): - """WebSocket 连接异常""" - -class OnebotClient: # pylint: disable=too-few-public-methods - """OneBot 客户端基类""" - def __init__(self, onebot_url: str, access_token: str = ""): - """ - 初始化 OneBot 客户端 - - 参数: - onebot_url: OneBot 实现的 URL 地址 - access_token: 鉴权 token,如果有的话 - """ - self.onebot_url = onebot_url - self.access_token = access_token - - async def send_message( - self, - onebot_type: str, - onebot_id: int, - message: Union[str, List[Dict[str, Any]]], - auto_escape: bool = False - ) -> Dict[str, Any]: - """ - 发送消息的抽象方法,子类需要实现 - - 参数: - onebot_type: 消息类型,例如 "group" 或 "private" - onebot_id: 消息接收者的 ID - message: 要发送的消息,可以是字符串或消息段列表 - auto_escape: 是否转义 CQ 码,默认为 False - - 返回: - API 响应 - """ - raise NotImplementedError("send_message 方法需要在子类中实现") - -class OneBotWebSocketClient(OnebotClient): # pylint: disable=too-few-public-methods - """基于 aiohttp 的 OneBot V11 WebSocket 客户端,仅用于发送消息""" - - async def send_message( - self, - onebot_type: str, - onebot_id: int, - message: Union[str, List[Dict[str, Any]]], - auto_escape: bool = False - ) -> Dict[str, Any]: - """ - 发送群消息 - - 参数: - user_id: 发送消息的用户 ID - onebot_id: 发送消息的群 ID - message: 要发送的消息,可以是字符串或消息段列表 - auto_escape: 是否转义 CQ 码,默认为 False - - 返回: - API 响应 - """ - request = { - "action": "send_msg", - "params": { - "message_type": onebot_type, - "message": message, - "auto_escape": auto_escape - }, - "echo": "The_ESAP_Project_Github_Notification" - } - - if onebot_type == "group": - request["params"]["group_id"] = onebot_id - elif onebot_type == "private": - request["params"]["user_id"] = onebot_id - - # 准备 headers - headers = {} - if self.access_token: - headers["Authorization"] = f"Bearer {self.access_token}" - - try: - # 使用 aiohttp 的 ClientSession - async with aiohttp.ClientSession() as session: - # 创建 WebSocket 连接 - async with session.ws_connect( - self.onebot_url, - headers=headers - ) as ws: - # 发送请求 - await ws.send_str(json.dumps(request)) - - # 接收响应 - response = await ws.receive() - - # 处理消息类型 - if response.type == aiohttp.WSMsgType.TEXT: - return json.loads(response.data) - if response.type == aiohttp.WSMsgType.CLOSED: - logger.debug("WebSocket 连接已关闭") - return {"status": "ok", "retcode": 0, "data": {"message_id": -1}} - if response.type == aiohttp.WSMsgType.ERROR: - logger.error("WebSocket 连接错误: %s", ws.exception()) - raise WSConnectionException("WebSocket 连接错误") - - logger.warning("收到未知类型的消息: %s", response.type) - return {"status": "error", "retcode": -1, "message": f"未知响应类型: {response.type}"} # pylint: disable=line-too-long - - except aiohttp.ClientError as e: - logger.error("aiohttp 客户端错误: %s", e) - raise - except Exception as e: - logger.error("发送群消息时出错: %s", e) - raise - -class OneBotHTTPClient(OnebotClient): # pylint: disable=too-few-public-methods - """基于 aiohttp 的 OneBot V11 HTTP 客户端,仅用于发送消息""" - - async def send_message( - self, - onebot_type: str, - onebot_id: int, - message: Union[str, List[Dict[str, Any]]], - auto_escape: bool = False - ) -> Dict[str, Any]: - """ - 发送群消息 - - 参数: - user_id: 发送消息的用户 ID - onebot_id: 发送消息的群 ID - message: 要发送的消息,可以是字符串或消息段列表 - auto_escape: 是否转义 CQ 码,默认为 False - - 返回: - API 响应 - """ - request = { - "message_type": onebot_type, - "message": message, - "auto_escape": auto_escape - } - - if onebot_type == "group": - request["group_id"] = onebot_id - elif onebot_type == "private": - request["user_id"] = onebot_id - - # 准备 headers - headers = {} - if self.access_token: - headers["Authorization"] = f"Bearer {self.access_token}" - headers["Content-Type"] = "application/json" - - try: - # 使用 aiohttp 的 ClientSession - async with aiohttp.ClientSession() as session: - # 发送请求 - async with session.post( - self.onebot_url + "/send_msg", - json=request, - headers=headers - ) as response: - # 检查响应状态码 - if response.status != 200: - logger.error("HTTP 请求失败,状态码: %s", response.status) - return {"status": "error", "retcode": -1, "message": f"HTTP 错误: {response.status}"} # pylint: disable=line-too-long - - # 解析 JSON 响应 - data = await response.json() - return data - - except aiohttp.ClientError as e: - logger.error("aiohttp 客户端错误: %s", e) - raise - except Exception as e: - logger.error("发送群消息时出错: %s", e) - raise - -# 消息段工具函数 -def text(content: str) -> Dict[str, Any]: - """纯文本消息""" - return {"type": "text", "data": {"text": content}} - -def at(qq: int) -> Dict[str, Any]: - """@某人""" - return {"type": "at", "data": {"qq": str(qq)}} - -def image(file: str) -> Dict[str, Any]: - """图片消息""" - return {"type": "image", "data": {"file": file}} - -async def send_github_notification( # pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments - onebot_type: str, - onebot_url: str, - access_token: str, - repo_name: str, - branch: str, - pusher: str, - commit_count: int, - commits: List[Dict], - onebot_send_type: str = "group", - onebot_id: int = 0 -): - """发送 GitHub 推送通知""" - - if onebot_type == "ws": - client = OneBotWebSocketClient(onebot_url, access_token) - elif onebot_type == "http": - client = OneBotHTTPClient(onebot_url, access_token) - else: - logger.error("不支持的 OneBot 连接类型: %s", onebot_type) - return None - - # 构建消息 - message = [ - text("📢 GitHub 推送通知\n"), - text(f"仓库:{repo_name}\n"), - text(f"分支:{branch}\n"), - text(f"推送者:{pusher}\n"), - text(f"提交数量:{commit_count}\n\n") - ] - - # 添加最近的提交信息(最多3条) - for i, commit in enumerate(commits[:3]): - commit_id = commit.get("id", "")[:7] - commit_msg = commit.get("message", "").split("\n")[0] # 只取第一行 - author = commit.get("author", {}).get("name", "") - - message.append(text(f"[{i+1}] {commit_id} by {author}\n")) - message.append(text(f" {commit_msg}\n")) - - # 发送消息 - try: - if onebot_send_type in ["group", "private"]: - result = await client.send_message(onebot_send_type, onebot_id, message) - else: - logger.error("不支持的 OneBot 类型: %s", onebot_send_type) - return None - return result - except Exception as e: # pylint: disable=broad-except - logger.error("发送 GitHub 通知失败: %s", e) - return None diff --git a/app/models/__init__.py b/app/models/__init__.py index 7e1bcb6..1e6f54c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -19,8 +19,8 @@ 本程序遵循 Apache License 2.0 许可证 """ -from .config import Config +from .config import get_settings __all__ = [ - "Config", + "get_settings", ] diff --git a/app/models/config.py b/app/models/config.py index e64e16d..60b639a 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -21,10 +21,14 @@ """ import pathlib +import logging from typing import List, Literal +from functools import lru_cache import yaml -from pydantic import model_validator, BaseModel +from pydantic import model_validator, BaseModel, ValidationError + +logger = logging.getLogger(__name__) class OnebotTarget(BaseModel): """OneBot 目标类型""" @@ -64,7 +68,6 @@ def from_yaml(cls, yaml_file: str = "config.yaml"): """从YAML文件加载配置""" config_path = pathlib.Path.cwd() / yaml_file - # 使用默认值初始化 config = cls() # 如果配置文件存在,则加载 @@ -85,16 +88,17 @@ def from_yaml(cls, yaml_file: str = "config.yaml"): # 使用 model_validate 创建实例 try: return cls.model_validate(config_data) - except Exception as e: # pylint: disable=broad-except - print(f"配置验证失败: {e}") - print("使用部分配置和默认值") - - # 更新基本配置 - for key, value in config_data.items(): - if key != "WEBHOOK" and hasattr(config, key): - setattr(config, key, value) + except yaml.YAMLError as e: + logger.error("YAML配置加载失败: %s", e) + raise + except ValidationError as e: + logger.error("Pydantic配置验证失败: %s", e) + for error in e.errors(): + logger.error(" %s: %s", error['loc'], error['msg']) + raise + else: - print(f"警告:配置文件 {yaml_file} 不存在,使用默认配置") + logger.warning("警告:配置文件 %s 不存在,使用默认配置", config_path) # 创建默认配置文件 default_config = { @@ -125,6 +129,11 @@ def from_yaml(cls, yaml_file: str = "config.yaml"): allow_unicode=True ) - print(f"已创建默认配置文件:{config_path}") + logger.info("已创建默认配置文件 %s,请根据需要修改", config_path) return config + +@lru_cache() +def get_settings(): + """获取应用配置(缓存结果)""" + return Config().from_yaml() diff --git a/app/onebot/__init__.py b/app/onebot/__init__.py new file mode 100644 index 0000000..90e4f9c --- /dev/null +++ b/app/onebot/__init__.py @@ -0,0 +1,39 @@ +# Copyright 2025 AptS-1547 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OneBot GitHub Webhook onebot 模块 +本模块用于处理 OneBot GitHub Webhook 事件的发送逻辑,包括 WebSocket 和 HTTP 客户端的实现。 +作者:AptS:1547 +版本:0.1.0-alpha +日期:2025-04-17 +本程序遵循 Apache License 2.0 许可证 +""" + +from .onebot import ( + OneBotWebSocketClient, + OneBotHTTPClient, + text, + init_onebot_client, + get_onebot_client, + shutdown_onebot_client +) + +__all__ = [ + "OneBotWebSocketClient", + "OneBotHTTPClient", + "text", + "init_onebot_client", + "get_onebot_client", + "shutdown_onebot_client" +] diff --git a/app/onebot/onebot.py b/app/onebot/onebot.py new file mode 100644 index 0000000..85acb89 --- /dev/null +++ b/app/onebot/onebot.py @@ -0,0 +1,492 @@ +# Copyright 2025 AptS-1547 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OneBot V11 客户端 +本模块提供了一个 OneBot V11 客户端的实现,支持通过 WebSocket 和 HTTP 发送消息。 +作者:AptS:1547 +版本:0.1.0-alpha +日期:2025-04-17 +本程序遵循 Apache License 2.0 许可证 +""" + +import json +import uuid +import asyncio +import logging +from typing import Union, List, Dict, Any +import aiohttp + +logger = logging.getLogger(__name__) + +class OneBotException(Exception): + """OneBot 基础异常类""" + +class WSConnectionException(OneBotException): + """WebSocket 连接异常""" + +class OneBotWebSocketManager: # pylint: disable=too-many-instance-attributes + """管理 OneBot WebSocket 连接的单例类""" + _instance = None + _initialized = False + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, onebot_url: str, access_token: str = ""): + if self._initialized: + return + + self.onebot_url = onebot_url + self.access_token = access_token + self.ws = None + self.session = None + self.response_futures = {} + self.running = False + self._initialized = True + self._lock = asyncio.Lock() + self._receiver_task = None + + async def start(self, max_retries: int = 5, retry_delay: float = 2.0): + """ + 启动 WebSocket 连接,支持重试机制 + + 参数: + max_retries: 最大重试次数 + retry_delay: 重试间隔(秒) + """ + async with self._lock: + if self.running: + return + + retries = 0 + current_delay = retry_delay + + while retries <= max_retries: + try: + if retries > 0: + logger.info("尝试重新连接 WebSocket (第 %d 次)...", retries) + + self.session = aiohttp.ClientSession() + + headers = {} + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + + self.ws = await self.session.ws_connect(self.onebot_url, headers=headers) + self.running = True + + # 启动消息接收器 + self._receiver_task = asyncio.create_task(self._message_receiver()) + logger.info("WebSocket 连接已建立") + return # 成功连接,退出循环 + + except Exception as e: # pylint: disable=broad-except + if self.session: + await self.session.close() + self.session = None + + retries += 1 + + if retries > max_retries: + logger.error("WebSocket 连接失败,已达到最大重试次数: %d", max_retries) + raise WSConnectionException(f"无法建立 WebSocket 连接: {e}") from e + + logger.warning("WebSocket 连接失败,将在 %.1f 秒后重试: %s", current_delay, e) + await asyncio.sleep(current_delay) + current_delay = min(current_delay * 1.5, 30) # 指数退避,但最多等待30秒 + + async def stop(self): + """关闭 WebSocket 连接""" + async with self._lock: + if not self.running: + return + + self.running = False + + # 取消接收器任务 + if self._receiver_task and not self._receiver_task.done(): + self._receiver_task.cancel() + try: + await self._receiver_task + except asyncio.CancelledError: + pass + self._receiver_task = None + + # 关闭所有等待中的 Future + for _, future in self.response_futures.items(): + if not future.done(): + future.set_exception(WSConnectionException("WebSocket 连接已关闭")) + self.response_futures.clear() + + # 关闭WebSocket连接 + if self.ws: + await self.ws.close() + self.ws = None + + # 关闭HTTP会话 + if self.session: + await self.session.close() + self.session = None + + logger.info("WebSocket 连接已关闭") + + async def _message_receiver(self): # pylint: disable=too-many-branches, too-many-statements + """接收并处理 WebSocket 消息的异步任务""" + try: # pylint: disable=too-many-nested-blocks + while self.running and self.ws: + try: + response = await self.ws.receive() + + if response.type == aiohttp.WSMsgType.TEXT: + data = json.loads(response.data) + echo = data.get("echo") + + if echo and echo in self.response_futures: + future = self.response_futures.pop(echo) + if not future.done(): + future.set_result(data) + else: + logger.debug("收到无匹配 echo 的响应或不含 echo: %s", data) + + elif response.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): + logger.warning("WebSocket 连接已关闭: %s", response.data) + break + + elif response.type == aiohttp.WSMsgType.ERROR: + logger.error("WebSocket 连接错误: %s", response.data) + break + + else: + logger.warning("收到未知类型的消息: %s", response.type) + + except asyncio.CancelledError: + logger.info("WebSocket 接收任务被取消") + except aiohttp.ClientError as e: + logger.error("处理 WebSocket 消息时出错: %s", str(e)) + if self.ws: + await self.ws.close() + break + except Exception as e: # pylint: disable=broad-except + logger.error("处理 WebSocket 消息时出错: %s", str(e)) + break + + except asyncio.CancelledError: + logger.info("WebSocket 接收任务被取消") + + finally: + if self.running: + logger.info("WebSocket 连接中断,正在重新连接...") + asyncio.create_task(self._attempt_reconnect()) + + async def _attempt_reconnect(self, retry_delay: float = 5.0, max_delay: float = 60.0): + """尝试重新连接WebSocket""" + # 确保先停止现有连接 + await self.stop() + + delay = retry_delay + while self.running: + try: + logger.info("尝试重新连接 WebSocket (延迟 %.1f 秒)...", delay) + await asyncio.sleep(delay) + await self.start(max_retries=0) + return # 如果成功连接则退出 + except Exception as e: # pylint: disable=broad-except + logger.error("重新连接失败: %s", e) + delay = min(delay * 1.5, max_delay) + + async def send_request(self, request: dict, timeout: float = 30.0) -> Dict[str, Any]: + """ + 发送请求并等待响应 + + 参数: + request: 请求数据 + timeout: 超时时间(秒) + + 返回: + API 响应 + """ + if not self.running: + # 如果尚未运行,则启动连接 + await self.start() + + if not self.ws: + raise WSConnectionException("WebSocket 连接不可用") + + echo_id = str(uuid.uuid4()) + request["echo"] = echo_id + + future = asyncio.Future() + self.response_futures[echo_id] = future + + try: + await self.ws.send_str(json.dumps(request)) + + try: + response = await asyncio.wait_for(future, timeout) + return response + except asyncio.TimeoutError: + self.response_futures.pop(echo_id, None) + logger.error("请求超时:%s", echo_id) + return { + "status": "error", + "retcode": -1, + "message": "请求超时", + "echo": echo_id + } + + except asyncio.TimeoutError: + self.response_futures.pop(echo_id, None) + logger.error("请求超时:%s", echo_id) + return { + "status": "error", + "retcode": -1, + "message": "请求超时", + "echo": echo_id + } + except aiohttp.ClientError as e: + self.response_futures.pop(echo_id, None) + logger.error("发送请求失败: %s", str(e)) + raise + except Exception as e: + self.response_futures.pop(echo_id, None) + logger.error("发送请求失败: %s", str(e)) + raise + +class OnebotClient: # pylint: disable=too-few-public-methods + """OneBot 客户端基类""" + def __init__(self, onebot_url: str, access_token: str = ""): + """ + 初始化 OneBot 客户端 + + 参数: + onebot_url: OneBot 实现的 URL 地址 + access_token: 鉴权 token,如果有的话 + """ + self.onebot_url = onebot_url + self.access_token = access_token + + async def send_message( + self, + onebot_type: str, + onebot_id: int, + message: Union[str, List[Dict[str, Any]]], + auto_escape: bool = False + ) -> Dict[str, Any]: + """ + 发送消息的抽象方法,子类需要实现 + + 参数: + onebot_type: 消息类型,例如 "group" 或 "private" + onebot_id: 消息接收者的 ID + message: 要发送的消息,可以是字符串或消息段列表 + auto_escape: 是否转义 CQ 码,默认为 False + + 返回: + API 响应 + """ + raise NotImplementedError("send_message 方法需要在子类中实现") + +class OneBotWebSocketClient(OnebotClient): + """基于 WebSocket 的 OneBot V11 客户端""" + + def __init__(self, onebot_url: str, access_token: str = ""): + super().__init__(onebot_url, access_token) + self.manager = OneBotWebSocketManager(onebot_url, access_token) + + async def start(self, max_retries: int = 5, retry_delay: float = 2.0): + """启动 WebSocket 连接""" + await self.manager.start(max_retries=max_retries, retry_delay=retry_delay) + + async def stop(self): + """停止 WebSocket 连接""" + await self.manager.stop() + + async def send_message( + self, + onebot_type: str, + onebot_id: int, + message: Union[str, List[Dict[str, Any]]], + auto_escape: bool = False + ) -> Dict[str, Any]: + """发送消息""" + if not self._validate_message_type(onebot_type): + raise ValueError(f"不支持的消息类型: {onebot_type}") + + request = { + "action": "send_msg", + "params": { + "message_type": onebot_type, + "message": message, + "auto_escape": auto_escape + } + } + + if onebot_type == "group": + request["params"]["group_id"] = onebot_id + else: # private + request["params"]["user_id"] = onebot_id + + try: + return await self.manager.send_request(request) + except Exception as e: + logger.error("发送消息时出错: %s", e) + raise + + def _validate_message_type(self, onebot_type: str) -> bool: + """验证消息类型是否有效""" + if onebot_type not in ["group", "private"]: + logger.error("不支持的消息类型: {onebot_type}") + return False + return True + +class OneBotHTTPClient(OnebotClient): # pylint: disable=too-few-public-methods + """基于 HTTP 的 OneBot V11 客户端""" + + async def send_message( + self, + onebot_type: str, + onebot_id: int, + message: Union[str, List[Dict[str, Any]]], + auto_escape: bool = False + ) -> Dict[str, Any]: + """发送消息""" + if onebot_type not in ["group", "private"]: + logger.error("不支持的消息类型: {onebot_type}") + return { + "status": "error", + "retcode": -1, + "message": f"不支持的消息类型: {onebot_type}" + } + + request = { + "message_type": onebot_type, + "message": message, + "auto_escape": auto_escape + } + + if onebot_type == "group": + request["group_id"] = onebot_id + else: # private + request["user_id"] = onebot_id + + headers = {"Content-Type": "application/json"} + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.onebot_url}/send_msg", + json=request, + headers=headers + ) as response: + if response.status != 200: + logger.error("HTTP 请求失败,状态码: %d", response.status) + return { + "status": "error", + "retcode": -1, + "message": f"HTTP 错误: {response.status}" + } + + data = await response.json() + return data + except aiohttp.ClientError as e: + logger.error("aiohttp 客户端错误: %s", e) + raise + except Exception as e: + logger.error("发送消息时出错: %s", e) + raise + +# 消息段工具函数 +def text(content: str) -> Dict[str, Any]: + """纯文本消息""" + return {"type": "text", "data": {"text": content}} + +# 全局客户端实例 +_ONEBOT_CLIENT = None + +async def init_onebot_client( + client_type: str, + url: str, + access_token: str = "", + max_retries: int = 5, + retry_delay: float = 2.0 + ) -> OnebotClient: + """ + 初始化全局 OneBot 客户端 + + 参数: + client_type: 客户端类型,"ws" 或 "http" + url: OneBot 实现的 URL 地址 + access_token: 鉴权 token,如果有的话 + max_retries: 最大重试次数 (仅用于 WebSocket) + retry_delay: 重试间隔(秒)(仅用于 WebSocket) + + 返回: + 已初始化的客户端实例 + """ + global _ONEBOT_CLIENT # pylint: disable=global-statement + + if _ONEBOT_CLIENT is not None: + logger.warning("OneBot 客户端已经初始化,将返回现有实例") + return _ONEBOT_CLIENT + + if client_type == "ws": + _ONEBOT_CLIENT = OneBotWebSocketClient(url, access_token) + # 启动 WebSocket 连接 + logger.info("正在初始化 WebSocket 连接...") + try: + await _ONEBOT_CLIENT.manager.start(max_retries=max_retries, retry_delay=retry_delay) + logger.info("WebSocket 连接已成功建立") + except Exception as e: # pylint: disable=broad-except + logger.error("建立 WebSocket 连接失败: %s", e) + raise + elif client_type == "http": + _ONEBOT_CLIENT = OneBotHTTPClient(url, access_token) + else: + raise ValueError(f"不支持的客户端类型: {client_type}") + + return _ONEBOT_CLIENT + +def get_onebot_client(): + """ + 获取全局 OneBot 客户端实例 + 如果客户端尚未初始化,会抛出异常 + + 返回: + OneBot 客户端实例 + """ + if _ONEBOT_CLIENT is None: + raise RuntimeError("OneBot 客户端尚未初始化,请先调用 init_onebot_client") + + return _ONEBOT_CLIENT + +async def shutdown_onebot_client(): + """关闭 OneBot 客户端连接""" + global _ONEBOT_CLIENT # pylint: disable=global-statement + + if _ONEBOT_CLIENT is None: + return + + if isinstance(_ONEBOT_CLIENT, OneBotWebSocketClient): + logger.info("正在关闭 WebSocket 连接...") + try: + await _ONEBOT_CLIENT.manager.stop() + logger.info("WebSocket 连接已成功关闭") + except Exception as e: # pylint: disable=broad-except + logger.error("关闭 WebSocket 连接时出错: %s", e) + + _ONEBOT_CLIENT = None diff --git a/app/utils/__init__.py b/app/utils/__init__.py index a0f759c..184c60f 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -21,7 +21,9 @@ """ from .matching import match_pattern +from .exceptions import InitializationError __all__ = [ "match_pattern", + "InitializationError", ] diff --git a/app/utils/exceptions.py b/app/utils/exceptions.py new file mode 100644 index 0000000..8b948bb --- /dev/null +++ b/app/utils/exceptions.py @@ -0,0 +1,24 @@ +# Copyright 2025 AptS-1547 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OneBot GitHub Webhook exceptions 模块 +本模块定义了应用程序中可能出现的异常类,包括初始化错误、请求错误和验证错误等。 +作者:AptS:1547 +版本:0.1.0-alpha +日期:2025-04-17 +本程序遵循 Apache License 2.0 许可证 +""" + +class InitializationError(Exception): + """初始化错误""" diff --git a/config/config.example.yaml b/config.example.yaml similarity index 100% rename from config/config.example.yaml rename to config.example.yaml diff --git a/main.py b/main.py index 3dcc6b8..24bdb77 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ """ OneBot GitHub Webhook 入口文件 -本文件用于接收 GitHub Webhook 事件,并将其转发到配置的 OneBot 目标。 +本文件用于启动应用并配置全局资源。 作者:AptS:1547 版本:0.1.0-alpha 日期:2025-04-17 @@ -21,92 +21,53 @@ """ import logging -from fastapi import FastAPI, Request, HTTPException +import asyncio +from contextlib import asynccontextmanager -from app.models.config import Config -from app.core.onebot import send_github_notification -from app.core.github import verify_signature, find_matching_webhook, extract_push_data +import aiohttp +from fastapi import FastAPI -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -app = FastAPI() +from app.api import api_router +from app.models import get_settings +from app.onebot import init_onebot_client, shutdown_onebot_client +from app.utils.exceptions import InitializationError -config = Config().from_yaml() -@app.post("/github-webhook") -async def github_webhook(request: Request): # pylint: disable=too-many-return-statements - """处理 GitHub webhook 请求""" +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) - content_type = request.headers.get("Content-Type") - if content_type != "application/json": - logger.info("收到非 JSON 格式的请求,忽略") - return {"status": "ignored", "message": "只处理 application/json 格式的请求"} +config = get_settings() - event_type = request.headers.get("X-GitHub-Event", "") - if not event_type: - logger.info("缺少 X-GitHub-Event 头,忽略") - return {"status": "ignored", "message": "缺少 X-GitHub-Event 头"} +@asynccontextmanager +async def lifespan(_: FastAPI): + """ + 管理应用程序生命周期的上下文管理器 + 在应用启动时初始化资源,在应用关闭时释放资源 + """ try: - await verify_signature( - request, - config.GITHUB_WEBHOOK, - request.headers.get("X-Hub-Signature-256") + await init_onebot_client( + client_type=config.ONEBOT_TYPE, + url=config.ONEBOT_URL, + access_token=config.ONEBOT_ACCESS_TOKEN ) - except HTTPException as e: - logger.info("签名验证失败: %s", e.detail) - return {"status": "ignored", "message": f"签名验证失败: {e.detail}"} - - payload = await request.json() - if not payload: - logger.info("请求体为空,忽略") - return {"status": "ignored", "message": "请求体为空"} - - repo_name = payload.get("repository", {}).get("full_name") - branch = payload.get("ref", "").replace("refs/heads/", "") - - matched_webhook = find_matching_webhook( - repo_name, - branch, - event_type, - config.GITHUB_WEBHOOK - ) - - if not matched_webhook: - logger.info("找不到匹配的 webhook 配置: 仓库 %s, 分支 %s, 事件类型 %s", repo_name, branch, event_type) - return {"status": "ignored", "message": "找不到匹配的 webhook 配置"} - - # 处理不同类型的事件,暂时只支持 push 事件 - if event_type == "push": - push_data = extract_push_data(payload) + except aiohttp.ClientError as e: + logger.error("初始化 OneBot 客户端失败: %s", e) + raise InitializationError("OneBot client initialization failed") from e + except asyncio.TimeoutError as e: + logger.error("初始化 OneBot 客户端超时: %s", e) + raise InitializationError("OneBot client initialization timed out") from e + except Exception as e: # pylint: disable=broad-except + logger.error("初始化 OneBot 客户端失败: %s", e) + raise InitializationError("OneBot client initialization failed") from e - logger.info("发现新的 push 事件,来自 %s 仓库", push_data["repo_name"]) - logger.info("分支: %s,推送者: %s,提交数量: %s", - push_data["branch"], - push_data["pusher"], - push_data["commit_count"]) + yield - # 向配置的所有 OneBot 目标发送通知 - for target in matched_webhook.ONEBOT: - logger.info("正在发送消息到 QQ %s %s", target.type, target.id) - await send_github_notification( - onebot_type=config.ONEBOT_TYPE, - onebot_url=config.ONEBOT_URL, - access_token=config.ONEBOT_ACCESS_TOKEN, - repo_name=push_data["repo_name"], - branch=push_data["branch"], - pusher=push_data["pusher"], - commit_count=push_data["commit_count"], - commits=push_data["commits"], - onebot_send_type=target.type, - onebot_id=target.id - ) + await shutdown_onebot_client() - return {"status": "success", "message": "处理 push 事件成功"} +app = FastAPI(lifespan=lifespan) - logger.info("收到 %s 事件,但尚未实现处理逻辑", event_type) - return {"status": "ignored", "message": f"暂不处理 {event_type} 类型的事件"} +app.include_router(api_router) if __name__ == "__main__": import uvicorn diff --git a/poetry.lock b/poetry.lock index aa98318..73888ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -510,6 +510,105 @@ type = "legacy" url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" reference = "tsinghua" +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[package.source] +type = "legacy" +url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" +reference = "tsinghua" + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[package.source] +type = "legacy" +url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" +reference = "tsinghua" + [[package]] name = "mccabe" version = "0.7.0" @@ -1405,4 +1504,4 @@ reference = "tsinghua" [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "0dcc78d394fe21d4c073a3ae935a12763cad8a873a29e07418804bdda87797cf" +content-hash = "e9ae6138753201327e7912765757cbc9d383c94d3efccbc522bd950c79627c3e" diff --git a/pyproject.toml b/pyproject.toml index a1fd058..8731f13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ dependencies = [ "pydantic (>=2.11.4,<3.0.0)", "pydantic-settings (>=2.9.1,<3.0.0)", "pyyaml (>=6.0.2,<7.0.0)", - "uvicorn (>=0.34.2,<0.35.0)" + "uvicorn (>=0.34.2,<0.35.0)", + "jinja2 (>=3.1.6,<4.0.0)" ] [[tool.poetry.source]] diff --git a/config/templates/default.txt b/templates/default.txt similarity index 100% rename from config/templates/default.txt rename to templates/default.txt diff --git a/config/templates/issues/default.txt b/templates/issues/default.txt similarity index 100% rename from config/templates/issues/default.txt rename to templates/issues/default.txt diff --git a/config/templates/pull_request/default.txt b/templates/pull_request/default.txt similarity index 100% rename from config/templates/pull_request/default.txt rename to templates/pull_request/default.txt diff --git a/config/templates/push/default.txt b/templates/push/default.txt similarity index 100% rename from config/templates/push/default.txt rename to templates/push/default.txt diff --git a/tests/test_onebot_sender.py b/tests/test_onebot_sender.py index 81f0ef3..2454d29 100644 --- a/tests/test_onebot_sender.py +++ b/tests/test_onebot_sender.py @@ -1,150 +1,284 @@ import sys import os import json +import uuid +import asyncio import pytest -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import MagicMock, AsyncMock, patch, call # 添加项目根目录到 Python 路径 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -# 导入 OneBotSender 类 -from send_message import OneBotSender +# 导入测试对象 +from app.onebot.onebot import ( + OneBotWebSocketManager, OneBotWebSocketClient, + OneBotHTTPClient, text +) +from app.onebot.onebot import WSConnectionException + +# 全局测试变量 +WS_URL = "wss://api.esaps.net/api/napcat/ws" +HTTP_URL = "http://localhost:5700" +ACCESS_TOKEN = "pJ*Z{{uzwbvi}rZrRN9CKaIvKJyjx*" +GROUP_ID = 982915192 +USER_ID = 1464170336 +TEST_UUID = "test-uuid-123456" +TEST_ECHO = "id1" + +# 为了便于使用 aiohttp.WSMsgType 常量,我们创建一个模拟类 +class WSMsgType: + TEXT = 1 + CLOSED = 8 + ERROR = 4 + @pytest.fixture def onebot_config(): """准备 OneBot 配置数据""" return { - "url": "ws://localhost:8080/ws", - "token": "test_token", + "url": WS_URL, + "token": ACCESS_TOKEN, "type": "ws" } + @pytest.fixture def message_data(): """准备消息数据""" return { "type": "group", - "id": 123456789, + "id": GROUP_ID, "message": "Test message" } -@pytest.mark.asyncio -@patch('send_message.aiohttp.ClientSession') -@patch('send_message.logger') -async def test_websocket_send(mock_logger, mock_session, onebot_config, message_data): - """测试通过 WebSocket 发送消息""" - # 设置模拟对象 - mock_ws = AsyncMock() - mock_session_instance = MagicMock() - mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance) - mock_session_instance.__aexit__ = AsyncMock(return_value=None) - mock_session_instance.ws_connect = AsyncMock(return_value=mock_ws) - mock_session.return_value = mock_session_instance - - # 创建 OneBotSender 实例 - sender = OneBotSender(onebot_config) - - # 调用发送方法 - await sender.send_to_onebot(message_data) - - # 验证调用 - mock_session_instance.ws_connect.assert_called_once_with( - "ws://localhost:8080/ws", - headers={"Authorization": "Bearer test_token"} - ) - - # 验证发送的消息格式 - sent_message = json.loads(mock_ws.send_str.call_args[0][0]) - assert sent_message["action"] == "send_group_msg" - assert sent_message["params"]["group_id"] == 123456789 - assert sent_message["params"]["message"] == "Test message" - -@pytest.mark.asyncio -@patch('send_message.aiohttp.ClientSession') -@patch('send_message.logger') -async def test_http_send(mock_logger, mock_session, onebot_config, message_data): - """测试通过 HTTP 发送消息""" - # 修改配置为 HTTP - config = onebot_config.copy() - config["type"] = "http" - config["url"] = "http://localhost:8080/api" - - # 设置模拟对象 - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock(return_value={"status": "ok"}) - - mock_session_instance = MagicMock() - mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance) - mock_session_instance.__aexit__ = AsyncMock(return_value=None) - mock_session_instance.post = AsyncMock(return_value=mock_response) - mock_session.return_value = mock_session_instance - - # 创建 OneBotSender 实例 - sender = OneBotSender(config) - - # 调用发送方法 - await sender.send_to_onebot(message_data) - - # 验证调用 - mock_session_instance.post.assert_called_once() - call_args = mock_session_instance.post.call_args - assert call_args[0][0] == "http://localhost:8080/api/send_group_msg" - - # 验证请求头和数据 - headers = call_args[1]["headers"] - assert headers["Authorization"] == "Bearer test_token" - - json_data = call_args[1]["json"] - assert json_data["group_id"] == 123456789 - assert json_data["message"] == "Test message" - -@pytest.mark.asyncio -@patch('send_message.aiohttp.ClientSession') -@patch('send_message.logger') -async def test_private_message_send(mock_logger, mock_session, onebot_config, message_data): - """测试发送私聊消息""" - # 修改消息类型为私聊 - private_message = message_data.copy() - private_message["type"] = "private" - - # 设置模拟对象 - mock_ws = AsyncMock() - mock_session_instance = MagicMock() - mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance) - mock_session_instance.__aexit__ = AsyncMock(return_value=None) - mock_session_instance.ws_connect = AsyncMock(return_value=mock_ws) - mock_session.return_value = mock_session_instance - - # 创建 OneBotSender 实例 - sender = OneBotSender(onebot_config) - - # 调用发送方法 - await sender.send_to_onebot(private_message) - - # 验证发送的消息格式 - sent_message = json.loads(mock_ws.send_str.call_args[0][0]) - assert sent_message["action"] == "send_private_msg" - assert sent_message["params"]["user_id"] == 123456789 - assert sent_message["params"]["message"] == "Test message" - -@pytest.mark.asyncio -@patch('send_message.aiohttp.ClientSession') -@patch('send_message.logger') -async def test_error_handling(mock_logger, mock_session, onebot_config, message_data): - """测试错误处理""" - # 模拟连接错误 - mock_session_instance = MagicMock() - mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance) - mock_session_instance.__aexit__ = AsyncMock(return_value=None) - mock_session_instance.ws_connect = AsyncMock(side_effect=Exception("Connection error")) - mock_session.return_value = mock_session_instance - - # 创建 OneBotSender 实例 - sender = OneBotSender(onebot_config) + +@pytest.fixture +def github_data(): + """准备 GitHub 数据""" + return { + "repo_name": "user/repo", + "branch": "main", + "pusher": "tester", + "commit_count": 2, + "commits": [ + { + "id": "1234567890abcdef", + "message": "First commit message", + "author": {"name": "Author1"} + }, + { + "id": "abcdef1234567890", + "message": "Second commit message", + "author": {"name": "Author2"} + } + ] + } + + +class TestOneBotWebSocketManager: + """测试 WebSocket 连接管理器""" + + @pytest.mark.asyncio + @patch('aiohttp.ClientSession') + async def test_singleton_pattern(self, mock_session): + """测试单例模式""" + # 创建两个实例 + manager1 = OneBotWebSocketManager(WS_URL, ACCESS_TOKEN) + manager2 = OneBotWebSocketManager(WS_URL, "another_token") + + # 验证是同一个实例 + assert manager1 is manager2 + + # 验证第一次创建的参数 + assert manager1.onebot_url == WS_URL + assert manager1.access_token == ACCESS_TOKEN + + @pytest.mark.asyncio + @patch('aiohttp.ClientSession') + async def test_start_connection(self, mock_session): + """测试启动 WebSocket 连接""" + # 设置模拟对象 + mock_ws = AsyncMock() + mock_session_instance = AsyncMock() + mock_session_instance.ws_connect = AsyncMock(return_value=mock_ws) + mock_session.return_value = mock_session_instance + + # 创建并启动管理器 + manager = OneBotWebSocketManager(WS_URL, ACCESS_TOKEN) + await manager.start() + + # 验证连接被正确建立 + mock_session_instance.ws_connect.assert_called_once_with( + WS_URL, + headers={"Authorization": f"Bearer {ACCESS_TOKEN}"} + ) + assert manager.running is True + assert manager.ws is mock_ws + + # 清理 + await manager.stop() + + @pytest.mark.asyncio + @patch('aiohttp.ClientSession') + async def test_start_connection_failure(self, mock_session): + """测试连接失败的情况""" + # 模拟连接失败 + mock_session_instance = AsyncMock() + mock_session_instance.ws_connect = AsyncMock(side_effect=Exception("Connection failed")) + mock_session.return_value = mock_session_instance + + # 创建管理器 + manager = OneBotWebSocketManager(WS_URL, ACCESS_TOKEN) + + # 验证连接失败抛出正确的异常 + with pytest.raises(WSConnectionException) as excinfo: + await manager.start() + + assert "Connection failed" in str(excinfo.value) + + +class TestOneBotClients: + """测试消息发送客户端""" - # 调用发送方法,应该捕获异常并记录 - await sender.send_to_onebot(message_data) + @pytest.mark.asyncio + @patch('app.onebot.onebot.OneBotWebSocketManager.send_request') + async def test_websocket_client_send_message(self, mock_send_request): + """测试 WebSocket 客户端发送消息""" + # 设置mock返回值 + mock_send_request.return_value = {"status": "ok", "data": {"message_id": 123}} + + # 创建客户端 + client = OneBotWebSocketClient(WS_URL, ACCESS_TOKEN) + + # 发送消息 + result = await client.send_message("group", GROUP_ID, "Hello world") + + # 验证请求格式 + expected_request = { + "action": "send_msg", + "params": { + "message_type": "group", + "group_id": GROUP_ID, + "message": "Hello world", + "auto_escape": False + } + } + mock_send_request.assert_called_once() + actual_request = mock_send_request.call_args[0][0] + assert actual_request["action"] == expected_request["action"] + assert actual_request["params"] == expected_request["params"] + + # 验证结果 + assert result["status"] == "ok" + assert result["data"]["message_id"] == 123 + + @pytest.mark.asyncio + @patch('app.onebot.onebot.OneBotWebSocketManager.send_request') + async def test_websocket_client_send_rich_message(self, mock_send_request): + """测试 WebSocket 客户端发送富文本消息""" + # 设置mock返回值 + mock_send_request.return_value = {"status": "ok", "data": {"message_id": 456}} + + # 创建客户端 + client = OneBotWebSocketClient(WS_URL, ACCESS_TOKEN) + + # 构建消息段 + message = [ + text("Hello "), + text("World!") + ] + + # 发送消息 + result = await client.send_message("private", USER_ID, message) + + # 验证请求格式 + expected_params = { + "message_type": "private", + "user_id": USER_ID, + "message": message, + "auto_escape": False + } + mock_send_request.assert_called_once() + actual_request = mock_send_request.call_args[0][0] + assert actual_request["params"] == expected_params + + # 验证结果 + assert result["status"] == "ok" + assert result["data"]["message_id"] == 456 + + @pytest.mark.asyncio + @patch('aiohttp.ClientSession.post') + async def test_http_client_send_group_message(self, mock_post): + """测试 HTTP 客户端发送群消息""" + # 设置模拟响应 + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"status": "ok", "data": {"message_id": 789}}) + mock_post.return_value.__aenter__.return_value = mock_response + + # 创建客户端 + client = OneBotHTTPClient(HTTP_URL, ACCESS_TOKEN) + + # 发送消息 + result = await client.send_message("group", GROUP_ID, "Hello via HTTP") + + # 验证请求格式 + mock_post.assert_called_once() + url = mock_post.call_args[0][0] + headers = mock_post.call_args[1]["headers"] + json_data = mock_post.call_args[1]["json"] + + assert url == f"{HTTP_URL}/send_msg" + assert headers["Authorization"] == f"Bearer {ACCESS_TOKEN}" + assert headers["Content-Type"] == "application/json" + assert json_data["message_type"] == "group" + assert json_data["group_id"] == GROUP_ID + assert json_data["message"] == "Hello via HTTP" + + # 验证结果 + assert result["status"] == "success" + + @pytest.mark.asyncio + @patch('aiohttp.ClientSession.post') + async def test_http_client_send_private_message(self, mock_post): + """测试 HTTP 客户端发送私聊消息""" + # 设置模拟响应 + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"status": "ok", "data": {"message_id": 456}}) + mock_post.return_value.__aenter__.return_value = mock_response + + # 创建客户端 + client = OneBotHTTPClient(HTTP_URL, ACCESS_TOKEN) + + # 发送消息 + result = await client.send_message("private", USER_ID, "Hello via HTTP") + + # 验证请求格式 + mock_post.assert_called_once() + json_data = mock_post.call_args[1]["json"] + + assert json_data["message_type"] == "private" + assert json_data["user_id"] == USER_ID + assert json_data["message"] == "Hello via HTTP" + + # 验证结果 + assert result["status"] == "success" + + +class TestMessageFormatting: + """测试消息格式化""" - # 验证错误被记录 - mock_logger.error.assert_called_once() \ No newline at end of file + def test_text_message(self): + """测试文本消息格式化""" + # 简单文本 + msg = text("Hello World") + assert msg == {"type": "text", "data": {"text": "Hello World"}} + + # 空文本 + empty = text("") + assert empty == {"type": "text", "data": {"text": ""}} + + +if __name__ == "__main__": + pytest.main(["-xvs"]) \ No newline at end of file