From d6f77d52a046c14aae0d67646bf496ccc7fdad72 Mon Sep 17 00:00:00 2001 From: dnslin Date: Mon, 15 Dec 2025 16:55:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E7=AD=96=E7=95=A5=E7=9A=84=E6=97=B6=E5=80=99?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E5=88=A0=E9=99=A4=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/core/config.py | 79 +++++++ src/telegram/app.py | 4 + src/telegram/handlers.py | 461 ++++++++++++++++++++++++++++++++------ src/telegram/keyboards.py | 54 ++++- 5 files changed, 525 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index c3717ac..b3c20da 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ uv run main.py | 变量 | 说明 | | -------------------------------------- | -------------------------- | | `TELEGRAM_CHANNEL_ENABLED` | 启用频道存储(true/false) | -| `TELEGRAM_CHANNEL_ID` | 频道 ID 或 @username | +| `TELEGRAM_CHANNEL_ID` | 频道 ID | | `TELEGRAM_CHANNEL_AUTO_UPLOAD` | 下载完成后自动发送 | | `TELEGRAM_CHANNEL_DELETE_AFTER_UPLOAD` | 发送后删除本地文件 | diff --git a/src/core/config.py b/src/core/config.py index e147d64..dcf0c60 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -1,12 +1,16 @@ """Configuration dataclass for aria2bot.""" from __future__ import annotations +import json import os from dataclasses import dataclass, field from pathlib import Path from src.core.constants import DOWNLOAD_DIR +# 云存储配置持久化文件路径 +CLOUD_CONFIG_FILE = Path.home() / ".config" / "aria2bot" / "cloud_config.json" + @dataclass class Aria2Config: @@ -111,3 +115,78 @@ class BotConfig: onedrive=onedrive, telegram_channel=telegram_channel, ) + + +def save_cloud_config(onedrive: OneDriveConfig, telegram: TelegramChannelConfig) -> bool: + """保存云存储配置到文件 + + Args: + onedrive: OneDrive 配置 + telegram: Telegram 频道配置 + + Returns: + 是否保存成功 + """ + try: + CLOUD_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + data = { + "onedrive": { + "auto_upload": onedrive.auto_upload, + "delete_after_upload": onedrive.delete_after_upload, + "remote_path": onedrive.remote_path, + }, + "telegram_channel": { + "channel_id": telegram.channel_id, + "auto_upload": telegram.auto_upload, + "delete_after_upload": telegram.delete_after_upload, + } + } + CLOUD_CONFIG_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + return True + except Exception: + return False + + +def load_cloud_config() -> dict | None: + """从文件加载云存储配置 + + Returns: + 配置字典,如果文件不存在或解析失败则返回 None + """ + try: + if CLOUD_CONFIG_FILE.exists(): + return json.loads(CLOUD_CONFIG_FILE.read_text()) + except Exception: + pass + return None + + +def apply_saved_config(onedrive: OneDriveConfig, telegram: TelegramChannelConfig) -> None: + """将保存的配置应用到配置对象(文件配置优先级低于环境变量) + + 只有当环境变量未设置时,才使用文件中的配置 + """ + saved = load_cloud_config() + if not saved: + return + + # 应用 OneDrive 配置(仅当环境变量未明确设置时) + if "onedrive" in saved: + od = saved["onedrive"] + # auto_upload: 如果环境变量未设置,使用文件配置 + if not os.environ.get("ONEDRIVE_AUTO_UPLOAD"): + onedrive.auto_upload = od.get("auto_upload", False) + if not os.environ.get("ONEDRIVE_DELETE_AFTER_UPLOAD"): + onedrive.delete_after_upload = od.get("delete_after_upload", False) + if not os.environ.get("ONEDRIVE_REMOTE_PATH"): + onedrive.remote_path = od.get("remote_path", "/aria2bot") + + # 应用 Telegram 频道配置 + if "telegram_channel" in saved: + tg = saved["telegram_channel"] + if not os.environ.get("TELEGRAM_CHANNEL_ID"): + telegram.channel_id = tg.get("channel_id", "") + if not os.environ.get("TELEGRAM_CHANNEL_AUTO_UPLOAD"): + telegram.auto_upload = tg.get("auto_upload", False) + if not os.environ.get("TELEGRAM_CHANNEL_DELETE_AFTER_UPLOAD"): + telegram.delete_after_upload = tg.get("delete_after_upload", False) diff --git a/src/telegram/app.py b/src/telegram/app.py index 4aec88b..fbc8777 100644 --- a/src/telegram/app.py +++ b/src/telegram/app.py @@ -7,6 +7,7 @@ from telegram import Bot, BotCommand from telegram.ext import Application from src.core import BotConfig, is_aria2_installed +from src.core.config import apply_saved_config from src.aria2.service import Aria2ServiceManager, get_service_mode from src.telegram.handlers import Aria2BotAPI, build_handlers from src.utils import setup_logger @@ -46,6 +47,9 @@ async def post_init(application: Application) -> None: def create_app(config: BotConfig) -> Application: """创建 Telegram Application""" + # 应用保存的云存储配置 + apply_saved_config(config.onedrive, config.telegram_channel) + builder = Application.builder().token(config.token).post_init(post_init) if config.api_base_url: builder = builder.base_url(config.api_base_url).base_file_url(config.api_base_url + "/file") diff --git a/src/telegram/handlers.py b/src/telegram/handlers.py index 803304b..7ca017d 100644 --- a/src/telegram/handlers.py +++ b/src/telegram/handlers.py @@ -22,7 +22,7 @@ from src.core import ( ARIA2_CONF, DOWNLOAD_DIR, ) -from src.core.config import OneDriveConfig, TelegramChannelConfig +from src.core.config import OneDriveConfig, TelegramChannelConfig, save_cloud_config from src.cloud.base import UploadProgress, UploadStatus from src.aria2 import Aria2Installer, Aria2ServiceManager from src.aria2.rpc import Aria2RpcClient, DownloadTask, _format_size @@ -36,6 +36,9 @@ from src.telegram.keyboards import ( build_cloud_menu_keyboard, build_cloud_settings_keyboard, build_detail_keyboard_with_upload, + build_onedrive_menu_keyboard, + build_telegram_channel_menu_keyboard, + build_telegram_channel_settings_keyboard, ) # Reply Keyboard 按钮文本到命令的映射 @@ -109,6 +112,7 @@ class Aria2BotAPI: self._telegram_channel = None self._api_base_url = api_base_url self._channel_uploaded_gids: set[str] = set() # 已上传到频道的 GID + self._pending_channel_input: dict[int, bool] = {} # 等待用户输入频道ID async def _check_permission(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: """检查用户权限,返回 True 表示有权限""" @@ -147,6 +151,34 @@ class Aria2BotAPI: self._telegram_channel = TelegramChannelClient(self._telegram_channel_config, bot, is_local_api) return self._telegram_channel + def _recreate_telegram_channel_client(self, bot): + """重新创建 Telegram 频道客户端(配置更新后调用)""" + self._telegram_channel = None + return self._get_telegram_channel_client(bot) + + async def _delete_local_file(self, local_path, gid: str) -> tuple[bool, str]: + """删除本地文件,返回 (成功, 消息)""" + import shutil + from pathlib import Path + if isinstance(local_path, str): + local_path = Path(local_path) + try: + if local_path.is_dir(): + shutil.rmtree(local_path) + else: + local_path.unlink() + logger.info(f"已删除本地文件 GID={gid}: {local_path}") + return True, "🗑️ 本地文件已删除" + except Exception as e: + logger.error(f"删除本地文件失败 GID={gid}: {e}") + return False, f"⚠️ 删除本地文件失败: {e}" + + def _save_cloud_config(self) -> bool: + """保存云存储配置""" + if self._onedrive_config and self._telegram_channel_config: + return save_cloud_config(self._onedrive_config, self._telegram_channel_config) + return False + async def _reply(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text: str, **kwargs): if update.effective_message: return await update.effective_message.reply_text(text, **kwargs) @@ -688,15 +720,22 @@ class Aria2BotAPI: )) async def _do_auto_upload( - self, client, local_path, remote_path: str, task_name: str, chat_id: int, gid: str - ) -> None: - """后台执行自动上传任务""" - import shutil + self, client, local_path, remote_path: str, task_name: str, chat_id: int, gid: str, + skip_delete: bool = False + ) -> bool: + """后台执行自动上传任务 + + Args: + skip_delete: 是否跳过删除(用于并行上传协调) + + Returns: + 上传是否成功 + """ from .app import _bot_instance # 获取全局 bot 实例 if _bot_instance is None: logger.error(f"自动上传失败:无法获取 bot 实例 GID={gid}") - return + return False # 发送上传开始通知 try: @@ -706,7 +745,7 @@ class Aria2BotAPI: ) except Exception as e: logger.error(f"自动上传失败:发送消息失败 GID={gid}: {e}") - return + return False loop = asyncio.get_running_loop() @@ -734,26 +773,24 @@ class Aria2BotAPI: if success: result_text = f"✅ 自动上传成功: {task_name}" - if self._onedrive_config and self._onedrive_config.delete_after_upload: - try: - if local_path.is_dir(): - shutil.rmtree(local_path) - else: - local_path.unlink() - result_text += "\n🗑️ 本地文件已删除" - except Exception as e: - result_text += f"\n⚠️ 删除本地文件失败: {e}" + # 只有不跳过删除且配置了删除时才删除 + if not skip_delete and self._onedrive_config and self._onedrive_config.delete_after_upload: + _, delete_msg = await self._delete_local_file(local_path, gid) + result_text += f"\n{delete_msg}" await msg.edit_text(result_text) logger.info(f"自动上传成功 GID={gid}") + return True else: await msg.edit_text(f"❌ 自动上传失败: {task_name}") logger.error(f"自动上传失败 GID={gid}") + return False except Exception as e: logger.error(f"自动上传异常 GID={gid}: {e}") try: await msg.edit_text(f"❌ 自动上传失败: {task_name}\n错误: {e}") except Exception: pass + return False async def _trigger_channel_auto_upload(self, chat_id: int, gid: str, bot) -> None: """触发频道自动上传""" @@ -793,40 +830,227 @@ class Aria2BotAPI: asyncio.create_task(self._do_channel_upload(client, local_path, task.name, chat_id, gid, bot)) - async def _do_channel_upload(self, client, local_path, task_name: str, chat_id: int, gid: str, bot) -> None: - """执行频道上传""" - import shutil + async def _do_channel_upload( + self, client, local_path, task_name: str, chat_id: int, gid: str, bot, + skip_delete: bool = False + ) -> bool: + """执行频道上传 + Args: + skip_delete: 是否跳过删除(用于并行上传协调) + + Returns: + 上传是否成功 + """ try: msg = await bot.send_message(chat_id=chat_id, text=f"📢 正在发送到频道: {task_name}") except Exception as e: logger.error(f"频道上传失败:发送消息失败 GID={gid}: {e}") - return + return False try: success, result = await client.upload_file(local_path) if success: result_text = f"✅ 已发送到频道: {task_name}" - if self._telegram_channel_config and self._telegram_channel_config.delete_after_upload: - try: - if local_path.is_dir(): - shutil.rmtree(local_path) - else: - local_path.unlink() - result_text += "\n🗑️ 本地文件已删除" - except Exception as e: - result_text += f"\n⚠️ 删除本地文件失败: {e}" + # 只有不跳过删除且配置了删除时才删除 + if not skip_delete and self._telegram_channel_config and self._telegram_channel_config.delete_after_upload: + _, delete_msg = await self._delete_local_file(local_path, gid) + result_text += f"\n{delete_msg}" await msg.edit_text(result_text) logger.info(f"频道上传成功 GID={gid}") + return True else: await msg.edit_text(f"❌ 发送到频道失败: {task_name}\n原因: {result}") logger.error(f"频道上传失败 GID={gid}: {result}") + return False except Exception as e: logger.error(f"频道上传异常 GID={gid}: {e}") try: await msg.edit_text(f"❌ 发送到频道失败: {task_name}\n错误: {e}") except Exception: pass + return False + + async def _coordinated_auto_upload(self, chat_id: int, gid: str, task, bot) -> None: + """协调多云存储并行上传 + + 当 OneDrive 和 Telegram 频道都启用自动上传且都启用删除时, + 并行执行上传,全部成功后才删除本地文件。 + """ + from pathlib import Path + + local_path = Path(task.dir) / task.name + if not local_path.exists(): + logger.error(f"协调上传失败:本地文件不存在 GID={gid}") + return + + # 检测哪些云存储需要上传 + need_onedrive = ( + self._onedrive_config and + self._onedrive_config.enabled and + self._onedrive_config.auto_upload + ) + need_telegram = ( + self._telegram_channel_config and + self._telegram_channel_config.enabled and + self._telegram_channel_config.auto_upload + ) + + # 检测是否需要协调删除(两个都启用删除) + onedrive_delete = need_onedrive and self._onedrive_config.delete_after_upload + telegram_delete = need_telegram and self._telegram_channel_config.delete_after_upload + need_coordinated_delete = onedrive_delete and telegram_delete + + if need_coordinated_delete: + # 并行执行,跳过各自的删除,最后统一删除 + logger.info(f"启动协调并行上传 GID={gid}") + await self._parallel_upload_with_coordinated_delete( + chat_id, gid, local_path, task.name, bot + ) + else: + # 独立执行(保持现有逻辑) + if need_onedrive and gid not in self._auto_uploaded_gids: + self._auto_uploaded_gids.add(gid) + asyncio.create_task(self._trigger_auto_upload(chat_id, gid)) + + if need_telegram and gid not in self._channel_uploaded_gids: + self._channel_uploaded_gids.add(gid) + asyncio.create_task(self._trigger_channel_auto_upload(chat_id, gid, bot)) + + async def _parallel_upload_with_coordinated_delete( + self, chat_id: int, gid: str, local_path, task_name: str, bot + ) -> None: + """并行上传到多个云存储,全部成功后才删除文件""" + from .app import _bot_instance + + # 准备 OneDrive 上传参数 + onedrive_client = self._get_onedrive_client() + onedrive_authenticated = onedrive_client and await onedrive_client.is_authenticated() + + # 计算 OneDrive 远程路径 + try: + download_dir = DOWNLOAD_DIR.resolve() + relative_path = local_path.resolve().relative_to(download_dir) + remote_path = f"{self._onedrive_config.remote_path}/{relative_path.parent}" + except ValueError: + remote_path = self._onedrive_config.remote_path + + # 准备 Telegram 频道客户端 + telegram_client = self._get_telegram_channel_client(bot) + + # 检查文件大小是否超过 Telegram 限制 + telegram_size_ok = True + if telegram_client: + file_size = local_path.stat().st_size + if file_size > telegram_client.get_max_size(): + telegram_size_ok = False + limit_mb = telegram_client.get_max_size_mb() + await bot.send_message( + chat_id=chat_id, + text=f"⚠️ 文件 {task_name} 超过 {limit_mb}MB 限制,跳过频道上传" + ) + + # 构建上传任务列表 + tasks = [] + task_names = [] + + if onedrive_authenticated: + tasks.append(self._do_auto_upload( + onedrive_client, local_path, remote_path, task_name, chat_id, gid, + skip_delete=True + )) + task_names.append("onedrive") + + if telegram_client and telegram_size_ok: + tasks.append(self._do_channel_upload( + telegram_client, local_path, task_name, chat_id, gid, bot, + skip_delete=True + )) + task_names.append("telegram") + + if not tasks: + logger.warning(f"协调上传跳过:没有可用的上传目标 GID={gid}") + return + + # 并行执行上传 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 分析结果 + all_success = True + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error(f"协调上传异常 ({task_names[i]}) GID={gid}: {result}") + all_success = False + elif result is not True: + all_success = False + + # 只有全部成功才删除 + if all_success and len(tasks) > 0: + _, delete_msg = await self._delete_local_file(local_path, gid) + if _bot_instance: + await _bot_instance.send_message( + chat_id=chat_id, + text=f"📦 所有上传完成: {task_name}\n{delete_msg}" + ) + elif not all_success: + if _bot_instance: + await _bot_instance.send_message( + chat_id=chat_id, + text=f"⚠️ 部分上传失败,保留本地文件: {task_name}" + ) + + async def handle_channel_id_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + """处理频道ID输入,返回 True 表示已处理""" + user_id = update.effective_user.id if update.effective_user else None + if not user_id or user_id not in self._pending_channel_input: + return False + + # 清除等待状态 + del self._pending_channel_input[user_id] + + text = update.message.text.strip() + if not text: + await self._reply(update, context, "❌ 频道ID不能为空") + return True + + # 验证格式 + if not (text.startswith("@") or text.startswith("-100") or text.lstrip("-").isdigit()): + await self._reply( + update, context, + "❌ 无效的频道ID格式\n\n" + "请使用以下格式之一:\n" + "• `@channel_name`\n" + "• `-100xxxxxxxxxx`", + parse_mode="Markdown" + ) + return True + + # 更新配置 + if self._telegram_channel_config: + self._telegram_channel_config.channel_id = text + # 重新创建客户端 + self._recreate_telegram_channel_client(context.bot) + # 保存配置 + self._save_cloud_config() + await self._reply( + update, context, + f"✅ 频道ID已设置为: `{text}`\n\n" + "请确保 Bot 已被添加为频道管理员", + parse_mode="Markdown" + ) + else: + await self._reply(update, context, "❌ 频道配置未初始化") + + return True + + async def handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """处理文本消息(包括频道ID输入和按钮点击)""" + # 先检查是否是频道ID输入 + if await self.handle_channel_id_input(update, context): + return + + # 然后检查是否是按钮点击 + await self.handle_button_text(update, context) async def handle_button_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """处理 Reply Keyboard 按钮点击""" @@ -1209,14 +1433,25 @@ class Aria2BotAPI: # 任务完成或出错时停止刷新 if task.status in ("complete", "error", "removed"): - # 任务完成时检查是否需要自动上传 - if (task.status == "complete" and - gid not in self._auto_uploaded_gids and - self._onedrive_config and - self._onedrive_config.enabled and - self._onedrive_config.auto_upload): - self._auto_uploaded_gids.add(gid) - asyncio.create_task(self._trigger_auto_upload(message.chat_id, gid)) + # 任务完成时检查是否需要自动上传(使用协调上传) + if task.status == "complete" and gid not in self._auto_uploaded_gids: + from .app import _bot_instance + need_onedrive = ( + self._onedrive_config and + self._onedrive_config.enabled and + self._onedrive_config.auto_upload + ) + need_telegram = ( + self._telegram_channel_config and + self._telegram_channel_config.enabled and + self._telegram_channel_config.auto_upload + ) + if need_onedrive or need_telegram: + self._auto_uploaded_gids.add(gid) + self._channel_uploaded_gids.add(gid) + asyncio.create_task( + self._coordinated_auto_upload(message.chat_id, gid, task, _bot_instance) + ) break await asyncio.sleep(2) @@ -1269,13 +1504,8 @@ class Aria2BotAPI: text = f"✅ *下载完成*\n📄 {safe_name}\n📦 大小: {task.size_str}\n🆔 GID: `{task.gid}`" try: await _bot_instance.send_message(chat_id=chat_id, text=text, parse_mode="Markdown") - # 触发频道自动上传 - if (self._telegram_channel_config and - self._telegram_channel_config.enabled and - self._telegram_channel_config.auto_upload and - task.gid not in self._channel_uploaded_gids): - self._channel_uploaded_gids.add(task.gid) - asyncio.create_task(self._trigger_channel_auto_upload(chat_id, task.gid, _bot_instance)) + # 注意:自动上传已在 _auto_refresh_task 中通过 _coordinated_auto_upload 处理 + # 这里不再单独触发,避免重复上传 except Exception as e: logger.warning(f"发送完成通知失败 (GID={task.gid}): {e}") @@ -1316,11 +1546,43 @@ class Aria2BotAPI: sub_action = parts[1] - if sub_action == "auth": - # 认证请求 + # 主菜单 + if sub_action == "menu": + keyboard = build_cloud_menu_keyboard() + await query.edit_message_text("☁️ *云存储管理*\n\n选择要配置的云存储:", parse_mode="Markdown", reply_markup=keyboard) + + # OneDrive 相关 + elif sub_action == "onedrive": + await self._handle_onedrive_callback(query, update, context, parts[2:] if len(parts) > 2 else []) + + # Telegram 频道相关 + elif sub_action == "telegram": + await self._handle_telegram_channel_callback(query, update, context, parts[2:] if len(parts) > 2 else []) + + # 兼容旧的回调格式 + elif sub_action == "auth": await self.cloud_auth(update, context) elif sub_action == "status": - # 状态查询 + await self._handle_onedrive_callback(query, update, context, ["status"]) + elif sub_action == "settings": + await self._handle_onedrive_callback(query, update, context, ["settings"]) + elif sub_action == "logout": + await self._handle_onedrive_callback(query, update, context, ["logout"]) + elif sub_action == "toggle": + await self._handle_onedrive_callback(query, update, context, ["toggle"] + parts[2:]) + + async def _handle_onedrive_callback(self, query, update: Update, context: ContextTypes.DEFAULT_TYPE, parts: list) -> None: + """处理 OneDrive 相关回调""" + action = parts[0] if parts else "menu" + + if action == "menu": + keyboard = build_onedrive_menu_keyboard() + await query.edit_message_text("☁️ *OneDrive 设置*", parse_mode="Markdown", reply_markup=keyboard) + + elif action == "auth": + await self.cloud_auth(update, context) + + elif action == "status": client = self._get_onedrive_client() if not client: await query.edit_message_text("❌ OneDrive 未配置") @@ -1336,39 +1598,108 @@ class Aria2BotAPI: f"🗑️ 上传后删除: {'✅ 开启' if delete_after else '❌ 关闭'}\n" f"📁 远程路径: `{remote_path}`" ) - keyboard = build_cloud_menu_keyboard() + keyboard = build_onedrive_menu_keyboard() await query.edit_message_text(text, parse_mode="Markdown", reply_markup=keyboard) - elif sub_action == "settings": - # 设置页面 + + elif action == "settings": auto_upload = self._onedrive_config.auto_upload if self._onedrive_config else False delete_after = self._onedrive_config.delete_after_upload if self._onedrive_config else False keyboard = build_cloud_settings_keyboard(auto_upload, delete_after) - await query.edit_message_text("⚙️ *云存储设置*\n\n点击切换设置:", parse_mode="Markdown", reply_markup=keyboard) - elif sub_action == "logout": - # 登出 + await query.edit_message_text("⚙️ *OneDrive 设置*\n\n点击切换设置:", parse_mode="Markdown", reply_markup=keyboard) + + elif action == "logout": client = self._get_onedrive_client() if client and await client.logout(): await query.edit_message_text("✅ 已登出 OneDrive") else: await query.edit_message_text("❌ 登出失败") - elif sub_action == "menu": - # 返回菜单 - keyboard = build_cloud_menu_keyboard() - await query.edit_message_text("☁️ *云存储管理*", parse_mode="Markdown", reply_markup=keyboard) - elif sub_action == "toggle": - # 切换设置(注意:运行时修改配置,重启后会重置) - if len(parts) < 3: + + elif action == "toggle": + if len(parts) < 2: return - setting = parts[2] + setting = parts[1] if self._onedrive_config: if setting == "auto_upload": self._onedrive_config.auto_upload = not self._onedrive_config.auto_upload elif setting == "delete_after": self._onedrive_config.delete_after_upload = not self._onedrive_config.delete_after_upload + # 保存配置 + self._save_cloud_config() auto_upload = self._onedrive_config.auto_upload if self._onedrive_config else False delete_after = self._onedrive_config.delete_after_upload if self._onedrive_config else False keyboard = build_cloud_settings_keyboard(auto_upload, delete_after) - await query.edit_message_text("⚙️ *云存储设置*\n\n点击切换设置:", parse_mode="Markdown", reply_markup=keyboard) + await query.edit_message_text("⚙️ *OneDrive 设置*\n\n点击切换设置:", parse_mode="Markdown", reply_markup=keyboard) + + async def _handle_telegram_channel_callback(self, query, update: Update, context: ContextTypes.DEFAULT_TYPE, parts: list) -> None: + """处理 Telegram 频道相关回调""" + action = parts[0] if parts else "menu" + + if action == "menu": + enabled = self._telegram_channel_config.enabled if self._telegram_channel_config else False + channel_id = self._telegram_channel_config.channel_id if self._telegram_channel_config else "" + keyboard = build_telegram_channel_menu_keyboard(enabled, channel_id) + await query.edit_message_text("📢 *Telegram 频道设置*", parse_mode="Markdown", reply_markup=keyboard) + + elif action == "info": + # 显示频道信息 + if not self._telegram_channel_config: + await query.answer("频道未配置") + return + channel_id = self._telegram_channel_config.channel_id + if channel_id: + await query.answer(f"当前频道: {channel_id}") + else: + await query.answer("频道ID未设置,请在设置中配置") + + elif action == "settings": + auto_upload = self._telegram_channel_config.auto_upload if self._telegram_channel_config else False + delete_after = self._telegram_channel_config.delete_after_upload if self._telegram_channel_config else False + channel_id = self._telegram_channel_config.channel_id if self._telegram_channel_config else "" + keyboard = build_telegram_channel_settings_keyboard(auto_upload, delete_after, channel_id) + await query.edit_message_text("⚙️ *Telegram 频道设置*\n\n点击切换设置:", parse_mode="Markdown", reply_markup=keyboard) + + elif action == "toggle": + if len(parts) < 2: + return + setting = parts[1] + if self._telegram_channel_config: + if setting == "enabled": + self._telegram_channel_config.enabled = not self._telegram_channel_config.enabled + # 重新创建客户端 + self._recreate_telegram_channel_client(context.bot) + elif setting == "auto_upload": + self._telegram_channel_config.auto_upload = not self._telegram_channel_config.auto_upload + elif setting == "delete_after": + self._telegram_channel_config.delete_after_upload = not self._telegram_channel_config.delete_after_upload + # 保存配置 + self._save_cloud_config() + + # 根据来源返回不同页面 + if setting == "enabled": + enabled = self._telegram_channel_config.enabled if self._telegram_channel_config else False + channel_id = self._telegram_channel_config.channel_id if self._telegram_channel_config else "" + keyboard = build_telegram_channel_menu_keyboard(enabled, channel_id) + await query.edit_message_text("📢 *Telegram 频道设置*", parse_mode="Markdown", reply_markup=keyboard) + else: + auto_upload = self._telegram_channel_config.auto_upload if self._telegram_channel_config else False + delete_after = self._telegram_channel_config.delete_after_upload if self._telegram_channel_config else False + channel_id = self._telegram_channel_config.channel_id if self._telegram_channel_config else "" + keyboard = build_telegram_channel_settings_keyboard(auto_upload, delete_after, channel_id) + await query.edit_message_text("⚙️ *Telegram 频道设置*\n\n点击切换设置:", parse_mode="Markdown", reply_markup=keyboard) + + elif action == "set_channel": + # 提示用户输入频道ID + user_id = update.effective_user.id if update.effective_user else None + if user_id: + self._pending_channel_input = {user_id: True} + await query.edit_message_text( + "📝 *设置频道ID*\n\n" + "请发送频道ID或频道用户名:\n" + "• 频道ID格式: `-100xxxxxxxxxx`\n" + "• 用户名格式: `@channel_name`\n\n" + "注意:Bot 必须是频道管理员才能发送消息", + parse_mode="Markdown" + ) async def _handle_upload_callback(self, query, update: Update, context: ContextTypes.DEFAULT_TYPE, parts: list) -> None: """处理上传回调""" @@ -1470,8 +1801,10 @@ def build_handlers(api: Aria2BotAPI) -> list: CommandHandler("stats", wrap_with_permission(api.global_stats)), # 云存储命令 CommandHandler("cloud", wrap_with_permission(api.cloud_command)), - # Reply Keyboard 按钮文本处理 - MessageHandler(filters.TEXT & filters.Regex(button_pattern), wrap_with_permission(api.handle_button_text)), + # Reply Keyboard 按钮文本处理(也处理频道ID输入) + MessageHandler(filters.TEXT & filters.Regex(button_pattern), wrap_with_permission(api.handle_text_message)), + # 频道ID输入处理(捕获 @channel 或 -100xxx 格式) + MessageHandler(filters.TEXT & filters.Regex(r"^(@[\w]+|-?\d+)$"), wrap_with_permission(api.handle_channel_id_input)), # OneDrive 认证回调 URL 处理 MessageHandler(filters.TEXT & filters.Regex(r"^https://login\.microsoftonline\.com"), wrap_with_permission(api.handle_auth_callback)), # 种子文件处理 diff --git a/src/telegram/keyboards.py b/src/telegram/keyboards.py index 3071bb8..85904f8 100644 --- a/src/telegram/keyboards.py +++ b/src/telegram/keyboards.py @@ -121,14 +121,10 @@ def build_main_reply_keyboard() -> ReplyKeyboardMarkup: def build_cloud_menu_keyboard() -> InlineKeyboardMarkup: - """构建云存储管理菜单""" + """构建云存储主菜单 - 选择配置哪个云存储""" return InlineKeyboardMarkup([ - [InlineKeyboardButton("🔐 OneDrive 认证", callback_data="cloud:auth:onedrive")], - [ - InlineKeyboardButton("📊 状态", callback_data="cloud:status"), - InlineKeyboardButton("⚙️ 设置", callback_data="cloud:settings"), - ], - [InlineKeyboardButton("🚪 登出", callback_data="cloud:logout")], + [InlineKeyboardButton("☁️ OneDrive 设置", callback_data="cloud:onedrive:menu")], + [InlineKeyboardButton("📢 Telegram 频道设置", callback_data="cloud:telegram:menu")], ]) @@ -141,16 +137,54 @@ def build_upload_choice_keyboard(gid: str) -> InlineKeyboardMarkup: def build_cloud_settings_keyboard(auto_upload: bool, delete_after: bool) -> InlineKeyboardMarkup: - """构建云存储设置键盘""" + """构建 OneDrive 设置键盘""" auto_text = "✅ 自动上传" if auto_upload else "❌ 自动上传" delete_text = "✅ 上传后删除" if delete_after else "❌ 上传后删除" return InlineKeyboardMarkup([ - [InlineKeyboardButton(auto_text, callback_data="cloud:toggle:auto_upload")], - [InlineKeyboardButton(delete_text, callback_data="cloud:toggle:delete_after")], + [InlineKeyboardButton(auto_text, callback_data="cloud:onedrive:toggle:auto_upload")], + [InlineKeyboardButton(delete_text, callback_data="cloud:onedrive:toggle:delete_after")], [InlineKeyboardButton("🔙 返回", callback_data="cloud:menu")], ]) +def build_onedrive_menu_keyboard() -> InlineKeyboardMarkup: + """构建 OneDrive 菜单键盘""" + return InlineKeyboardMarkup([ + [InlineKeyboardButton("🔐 认证", callback_data="cloud:onedrive:auth")], + [ + InlineKeyboardButton("📊 状态", callback_data="cloud:onedrive:status"), + InlineKeyboardButton("⚙️ 设置", callback_data="cloud:onedrive:settings"), + ], + [InlineKeyboardButton("🚪 登出", callback_data="cloud:onedrive:logout")], + [InlineKeyboardButton("🔙 返回", callback_data="cloud:menu")], + ]) + + +def build_telegram_channel_menu_keyboard(config_enabled: bool, channel_id: str) -> InlineKeyboardMarkup: + """构建 Telegram 频道菜单键盘""" + status_text = f"📢 频道: {channel_id}" if channel_id else "📢 频道: 未设置" + enabled_text = "✅ 已启用" if config_enabled else "❌ 未启用" + return InlineKeyboardMarkup([ + [InlineKeyboardButton(status_text, callback_data="cloud:telegram:info")], + [InlineKeyboardButton(enabled_text, callback_data="cloud:telegram:toggle:enabled")], + [InlineKeyboardButton("⚙️ 设置", callback_data="cloud:telegram:settings")], + [InlineKeyboardButton("🔙 返回", callback_data="cloud:menu")], + ]) + + +def build_telegram_channel_settings_keyboard(auto_upload: bool, delete_after: bool, channel_id: str) -> InlineKeyboardMarkup: + """构建 Telegram 频道设置键盘""" + auto_text = "✅ 自动上传" if auto_upload else "❌ 自动上传" + delete_text = "✅ 上传后删除" if delete_after else "❌ 上传后删除" + channel_text = f"📝 频道ID: {channel_id}" if channel_id else "📝 设置频道ID" + return InlineKeyboardMarkup([ + [InlineKeyboardButton(channel_text, callback_data="cloud:telegram:set_channel")], + [InlineKeyboardButton(auto_text, callback_data="cloud:telegram:toggle:auto_upload")], + [InlineKeyboardButton(delete_text, callback_data="cloud:telegram:toggle:delete_after")], + [InlineKeyboardButton("🔙 返回", callback_data="cloud:telegram:menu")], + ]) + + def build_detail_keyboard_with_upload(gid: str, status: str, show_onedrive: bool = False, show_channel: bool = False) -> InlineKeyboardMarkup: """构建详情页面的操作按钮(含上传选项)""" buttons = []