| import os |
| import asyncio |
| import json |
| import logging |
| import re |
| import base64 |
| import time |
| from datetime import datetime, timedelta |
| from collections import deque |
|
|
| |
| from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Bot, CallbackQuery |
| |
| from telegram.constants import ParseMode |
| from telegram.error import BadRequest, TelegramError |
| from telegram.ext import ( |
| Application, |
| CommandHandler, |
| ConversationHandler, |
| MessageHandler, |
| CallbackQueryHandler, |
| CallbackContext, |
| ContextTypes, |
| filters, |
| ) |
| import httpx |
|
|
| from pikpakapi import PikPakApi |
| |
| from apscheduler.schedulers.asyncio import AsyncIOScheduler |
| from apscheduler.triggers.interval import IntervalTrigger |
|
|
|
|
| from typing import Union, Any, Dict, List, Optional, Tuple |
| from fastapi import ( |
| FastAPI, |
| APIRouter, |
| Depends, |
| Request, |
| Query, |
| Body, |
| Path, |
| Response, |
| HTTPException, |
| status, |
| Request, |
| ) |
| from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse |
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials |
| from fastapi.templating import Jinja2Templates |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel, Extra, Field |
|
|
|
|
| |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') |
| logger = logging.getLogger(__name__) |
|
|
| |
| ITEMS_PER_PAGE = 10 |
|
|
| |
| COMMAND_HELP_TEXT_TEMPLATE = """ |
| 🚀 *欢迎使用 Thunder X 管理机器人* 🚀 |
| |
| 我是由 *{bot_username}* 控制的。 |
| |
| 📋 *可用命令:* |
| • 直接发送 `magnet:` 开头的磁力链接 |
| • 直接发送 `http(s)://` 开头的受支持下载链接 |
| • 直接发送 `40位BT Hash` \(例如: `{example_hash}`\) |
| • 直接发送 `share:<分享ID>` \(例如: `share:ABCd123...`\) |
| • 在链接或Hash后添加 `folder_id:<你的文件夹ID>` 来指定保存目录 \(例如: `magnet:... folder_id:xxx`\) |
| |
| • /tasks \- 🚀 查看和管理离线任务 |
| • /files \- 📂 浏览和管理云盘文件 |
| • /shares \- 🔗 查看和管理分享链接 |
| • /quota \- 💾 查看存储空间使用情况 |
| • /emptytrash \- 🗑️ 清空回收站 |
| • /help \- ℹ️ 显示此帮助信息 |
| """ |
|
|
| class PostRequest(BaseModel): |
| class Config: |
| extra = Extra.allow |
|
|
|
|
| class FileRequest(BaseModel): |
| size: int = 100 |
| parent_id: str | None = "" |
| next_page_token: str | None = "" |
| additional_filters: Dict | None = {} |
|
|
| class Config: |
| extra = Extra.allow |
|
|
|
|
| class OfflineRequest(BaseModel): |
| file_url: str = "" |
| |
| parent_id: str | None = Field(default=None, description="目标文件夹ID, 不填则使用环境变量或根目录") |
| name: str | None = "" |
|
|
| class Config: |
| extra = Extra.allow |
|
|
|
|
| security = HTTPBearer() |
| |
| SECRET_TOKEN = os.getenv("SECRET_TOKEN") |
| if SECRET_TOKEN is None: |
| raise ValueError("请在环境变量中设置SECRET_TOKEN,确保安全!") |
|
|
| THUNDERX_USERNAME = os.getenv("THUNDERX_USERNAME") |
| if THUNDERX_USERNAME is None: |
| raise ValueError("请在环境变量中设置THUNDERX_USERNAME,用户名【邮箱】用来登陆!") |
|
|
|
|
| THUNDERX_PASSWORD = os.getenv("THUNDERX_PASSWORD") |
| if THUNDERX_PASSWORD is None: |
| raise ValueError("请在环境变量中设置THUNDERX_PASSWORD,密码用来登陆!") |
|
|
| PROXY_URL = os.getenv("PROXY_URL") |
| TG_BOT_TOKEN = os.getenv("TG_BOT_TOKEN") |
| TG_WEBHOOK_URL = os.getenv("TG_WEBHOOK_URL") |
|
|
| |
| CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "false").lower() == "true" |
| CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "48")) |
| CLEANUP_SIZE_THRESHOLD_MB = int(os.getenv("CLEANUP_SIZE_THRESHOLD_MB", "50")) |
| CLEANUP_TARGET_FOLDER_ID = os.getenv("CLEANUP_TARGET_FOLDER_ID", None) |
| DEFAULT_DOWNLOAD_FOLDER_ID = os.getenv("DEFAULT_DOWNLOAD_FOLDER_ID", "") |
|
|
| |
| |
| CLEANUP_RECURSIVE = os.getenv("CLEANUP_RECURSIVE", "false").lower() == "true" |
| logger.info(f"Cleanup recursive mode enabled: {CLEANUP_RECURSIVE}") |
| |
|
|
| |
| TG_ADMIN_CHAT_ID = os.getenv("TG_ADMIN_CHAT_ID") |
| if CLEANUP_ENABLED and not TG_ADMIN_CHAT_ID: |
| logger.warning("Cleanup task is enabled, but TG_ADMIN_CHAT_ID is not set. Completion notifications will NOT be sent.") |
| |
|
|
| |
| ALLOWED_USER_ID: Optional[int] = None |
| if TG_ADMIN_CHAT_ID: |
| try: |
| ALLOWED_USER_ID = int(TG_ADMIN_CHAT_ID) |
| logger.info(f"Telegram Bot usage will be restricted to User ID: {ALLOWED_USER_ID}") |
| except ValueError: |
| logger.error("Invalid TG_ADMIN_CHAT_ID provided. It must be a number. Bot authorization may not work.") |
| ALLOWED_USER_ID = None |
| else: |
| |
| logger.error("TG_ADMIN_CHAT_ID not set. Telegram Bot will respond to *any* user. User restriction is disabled!") |
|
|
| |
| CLEANUP_SIZE_THRESHOLD_BYTES = CLEANUP_SIZE_THRESHOLD_MB * 1024 * 1024 |
|
|
| |
| scheduler = AsyncIOScheduler() |
|
|
| async def verify_token( |
| request: Request, credentials: HTTPAuthorizationCredentials = Depends(security) |
| ): |
| |
| if credentials.scheme != "Bearer": |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Invalid authentication scheme", |
| ) |
|
|
| |
| if credentials.credentials != SECRET_TOKEN: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" |
| ) |
|
|
|
|
| def format_bytes(size: Union[int, str, None]) -> str: |
| units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] |
| if size is None: return "N/A" |
| try: |
| size_int = int(size) |
| except (ValueError, TypeError): |
| return "N/A" |
|
|
| if size_int < 0: |
| return "0 B" |
|
|
| unit_index = 0 |
| size_float = float(size_int) |
| while size_float >= 1024 and unit_index < len(units) - 1: |
| size_float /= 1024.0 |
| unit_index += 1 |
| return f"{size_float:.2f} {units[unit_index]}" |
|
|
|
|
| app = FastAPI() |
|
|
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| api_router = APIRouter(dependencies=[Depends(verify_token)]) |
| front_router = APIRouter() |
|
|
| templates = Jinja2Templates( |
| directory="templates", variable_start_string="{[", variable_end_string="]}" |
| ) |
|
|
|
|
| async def log_token(THUNDERX_CLIENT, extra_data): |
| logger.info(f"Token refreshed, Extra Data: {extra_data}") |
|
|
|
|
| THUNDERX_CLIENT = None |
| TG_BOT_APPLICATION: Optional[Application] = None |
| TG_BASE_URL = "https://api.telegram.org/bot" |
|
|
|
|
| |
| def escape_markdown(text: Union[str, int, float, None]) -> str: |
| """Escapes characters reserved in Telegram's MarkdownV2 syntax.""" |
| if text is None: |
| return "" |
| text_str = str(text) |
| |
| escape_chars = r'[_*\[\]()~`>#+\-=|{}.!]' |
| return re.sub(escape_chars, r'\\\g<0>', text_str) |
|
|
| |
| async def send_telegram_error_message( |
| target: Union[Update, CallbackQuery], |
| error_text: str, |
| log_message: str, |
| e: Optional[Exception] = None |
| ): |
| """Logs an error and sends a formatted error message to Telegram.""" |
| if e: |
| logger.error(f"{log_message}: {e}", exc_info=True) |
| escaped_error = escape_markdown(str(e)) |
| final_text = f"❌ {error_text}\n错误详情: `{escaped_error}`" |
| else: |
| logger.error(log_message) |
| final_text = f"❌ {error_text}" |
|
|
| try: |
| if isinstance(target, CallbackQuery): |
| |
| await target.message.edit_text(final_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) |
| await target.answer(f"❌ {error_text[:50]}...", show_alert=True) |
| elif isinstance(target, Update) and target.message: |
| |
| await target.message.reply_text(final_text, parse_mode=ParseMode.MARKDOWN_V2) |
| else: |
| logger.error(f"Cannot send error message: Invalid target type {type(target)}") |
|
|
| except TelegramError as tg_err: |
| logger.error(f"Failed to send Telegram error notification! Original error: {log_message}. TG Error: {tg_err}") |
| except Exception as final_err: |
| logger.error(f"Unexpected error while sending Telegram error notification! Original error: {log_message}. Final Error: {final_err}") |
|
|
|
|
| |
| async def _perform_cleanup_deletion(files_to_delete: List[str], mode: str) -> Tuple[int, str, bool]: |
| """ |
| Performs the deletion of files and returns deleted_count, status_message, error_occurred. |
| Mode should be 'Recursive' or 'Non-Recursive' for logging. |
| """ |
| deleted_count = 0 |
| status_message = "" |
| error_occurred = False |
| error_message_detail = "" |
|
|
| if not files_to_delete: |
| status_message = f"扫描完成,未发现需要删除的文件 ({mode}模式)。" |
| return deleted_count, status_message, error_occurred |
|
|
| logger.info(f"[{mode}] Attempting to move {len(files_to_delete)} files to trash.") |
| try: |
| result = await THUNDERX_CLIENT.delete_to_trash(files_to_delete) |
| logger.info(f"[{mode}] Deletion task result: {result}") |
| if result and result.get('task_id'): |
| deleted_count = len(files_to_delete) |
| status_message = f"已成功提交 {deleted_count} 个文件的删除任务 ({mode}模式)。" |
| elif result: |
| deleted_count = len(files_to_delete) |
| status_message = f"{deleted_count} 个文件的删除请求已发送,API 返回: {result} ({mode}模式)。" |
| else: |
| status_message = f"为 {len(files_to_delete)} 个文件发送了删除命令,但收到意外的空/假响应 ({mode}模式)。" |
| logger.warning(status_message) |
| error_occurred = True |
| error_message_detail = "API 对删除操作返回了空/假响应。" |
|
|
| except Exception as e: |
| logger.error(f"[{mode}] Error during file deletion: {e}", exc_info=True) |
| error_occurred = True |
| error_message_detail = f"文件删除过程中出错: {e}" |
| status_message = f"清理任务在删除阶段失败 ({mode}模式): {error_message_detail}" |
|
|
| |
| if error_occurred and not status_message.startswith("清理任务在删除阶段失败"): |
| status_message = f"删除阶段出错 ({mode}模式): {error_message_detail}" |
|
|
| return deleted_count, status_message, error_occurred |
|
|
| |
| async def _send_cleanup_notification( |
| mode: str, |
| error_occurred: bool, |
| deleted_count: int, |
| duration_str: str, |
| folders_scanned: Optional[int], |
| total_files_scanned: int, |
| target_folder_display: Optional[str], |
| status_message: str |
| ): |
| """Sends the cleanup task completion notification to Telegram.""" |
| global TG_BOT_APPLICATION, TG_ADMIN_CHAT_ID, CLEANUP_ENABLED |
|
|
| if not (TG_BOT_APPLICATION and TG_ADMIN_CHAT_ID): |
| if not TG_ADMIN_CHAT_ID and CLEANUP_ENABLED: |
| logger.warning("Cleanup finished, but notification skipped as TG_ADMIN_CHAT_ID is not set.") |
| elif not TG_BOT_APPLICATION: |
| logger.error("Cannot send cleanup notification: Telegram bot object not found in application.") |
| return |
|
|
| |
| if error_occurred: |
| status_icon = "❌" |
| status_summary = "任务执行过程中遇到错误。" |
| elif deleted_count > 0: |
| status_icon = "✅" |
| status_summary = "任务成功完成,部分文件已清理。" |
| else: |
| status_icon = "ℹ️" |
| status_summary = "任务完成,未发现需要清理的文件。" |
|
|
| |
| safe_mode = escape_markdown(mode) |
| notification_text = f"🧹 *PikPak 清理报告 \({mode}\)* {status_icon}\n\n" |
| notification_text += f"{status_summary}\n\n" |
| notification_text += f"⏱️ *任务耗时:* `{escape_markdown(duration_str)}`\n" |
| if folders_scanned is not None: |
| notification_text += f"📁 *扫描文件夹数:* `{escape_markdown(folders_scanned)}`\n" |
| notification_text += f"📄 *扫描文件总数:* `{escape_markdown(total_files_scanned)}`\n" |
|
|
| if deleted_count > 0: |
| notification_text += f"🗑️ *移至回收站:* `{escape_markdown(deleted_count)}`\n" |
| elif not error_occurred: |
| notification_text += f"🗑️ *移至回收站:* `0`\n" |
|
|
| if target_folder_display is not None: |
| notification_text += f"🎯 *目标文件夹:* `{escape_markdown(target_folder_display)}`\n" |
|
|
| safe_status_message = escape_markdown(status_message) |
| if error_occurred: |
| notification_text += f"ℹ️ *错误详情:* `{safe_status_message}`" |
| else: |
| notification_text += f"ℹ️ *任务详情:* `{safe_status_message}`" |
|
|
| |
| try: |
| if TG_BOT_APPLICATION.bot: |
| await TG_BOT_APPLICATION.bot.send_message( |
| chat_id=TG_ADMIN_CHAT_ID, |
| text=notification_text, |
| parse_mode=ParseMode.MARKDOWN_V2 |
| ) |
| logger.info(f"Sent {mode} cleanup completion notification to chat ID {TG_ADMIN_CHAT_ID}.") |
| else: |
| logger.error("Cannot send cleanup notification: Telegram bot object not found in application.") |
| except BadRequest as e: |
| logger.error(f"Failed to send Telegram notification (BadRequest) for {mode} cleanup: {e}\nFinal Text:\n{notification_text}", exc_info=True) |
| except Exception as e: |
| logger.error(f"Failed to send Telegram notification for {mode} cleanup task: {e}", exc_info=True) |
|
|
|
|
| |
| async def cleanup_small_files(): |
| global THUNDERX_CLIENT, CLEANUP_RECURSIVE |
| if not THUNDERX_CLIENT: |
| logger.error("Cleanup task dispatch skipped: PikPak client not initialized.") |
| return |
|
|
| if CLEANUP_RECURSIVE: |
| logger.info("Dispatching cleanup task to: Recursive Mode") |
| await cleanup_small_files_recursive() |
| else: |
| logger.info("Dispatching cleanup task to: Non-Recursive Mode") |
| await cleanup_small_files_non_recursive() |
| |
|
|
|
|
| |
| async def cleanup_small_files_recursive(): |
| global THUNDERX_CLIENT, CLEANUP_SIZE_THRESHOLD_BYTES, CLEANUP_TARGET_FOLDER_ID |
|
|
| if not THUNDERX_CLIENT: |
| logger.error("Recursive Cleanup task skipped: PikPak client not initialized.") |
| return |
|
|
| start_time = time.monotonic() |
| mode = "递归" |
| logger.info(f"Starting small file cleanup task (Recursive). Threshold: {CLEANUP_SIZE_THRESHOLD_MB}MB. Target Folder ID: {CLEANUP_TARGET_FOLDER_ID or 'Entire Drive'}") |
|
|
| files_to_delete = [] |
| deleted_count = 0 |
| total_files_scanned = 0 |
| folders_scanned = 0 |
| error_occurred = False |
| error_message_detail = "" |
| final_status_message = "递归清理任务已启动。" |
|
|
| folders_to_scan = deque() |
| start_folder_id = CLEANUP_TARGET_FOLDER_ID if CLEANUP_TARGET_FOLDER_ID else "" |
| folders_to_scan.append(start_folder_id) |
| visited_folders = set() |
|
|
| try: |
| |
| while folders_to_scan: |
| current_folder_id = folders_to_scan.popleft() |
| if current_folder_id in visited_folders: continue |
| visited_folders.add(current_folder_id) |
| folders_scanned += 1 |
| logger.info(f"[Recursive] Scanning folder ID: '{current_folder_id}' (Root if empty). Folder #{folders_scanned}") |
|
|
| next_page_token = None |
| page_limit = 100 |
| error_in_folder = False |
|
|
| while True: |
| try: |
| file_list = await THUNDERX_CLIENT.file_list( |
| size=page_limit, parent_id=current_folder_id, next_page_token=next_page_token, |
| additional_filters={"trashed": {"eq": False}, "phase": {"eq": "PHASE_TYPE_COMPLETE"}} |
| ) |
| if not file_list or 'files' not in file_list or file_list['files'] is None: break |
|
|
| for item in file_list['files']: |
| |
| item_kind = item.get('kind') |
| item_id = item.get('id') |
| item_name = item.get('name', 'N/A') |
|
|
| if item_kind == 'drive#file': |
| total_files_scanned += 1 |
| file_size_str = item.get('size') |
| if file_size_str is not None: |
| try: |
| file_size = int(file_size_str) |
| if 0 < file_size < CLEANUP_SIZE_THRESHOLD_BYTES: |
| files_to_delete.append(item_id) |
| except (ValueError, TypeError): |
| logger.warning(f"[Recursive] Could not parse size for file: {item_name} (ID: {item_id}, Size: {file_size_str})") |
| elif item_kind == 'drive#folder': |
| if item_id and item_id not in visited_folders: |
| folders_to_scan.append(item_id) |
|
|
| next_page_token = file_list.get('next_page_token') |
| if not next_page_token: break |
| await asyncio.sleep(0.5) |
| except Exception as e: |
| logger.error(f"[Recursive] Error fetching file list page for folder '{current_folder_id}': {e}", exc_info=True) |
| error_occurred = True |
| error_in_folder = True |
| error_message_detail = f"获取文件夹 {current_folder_id} 列表时出错: {e}" |
| final_status_message = f"清理任务因扫描错误结束 ({mode}模式): {error_message_detail}" |
| break |
|
|
| if error_in_folder: |
| logger.warning(f"[Recursive] Skipping further scan in folder '{current_folder_id}' due to previous error.") |
| |
|
|
| await asyncio.sleep(1) |
|
|
| |
| |
| if not error_occurred: |
| deleted_count, status_msg_delete, error_delete = await _perform_cleanup_deletion(files_to_delete, mode) |
| final_status_message = status_msg_delete |
| if error_delete: |
| error_occurred = True |
|
|
| except Exception as e: |
| logger.error(f"An unexpected error occurred during the recursive cleanup process: {e}", exc_info=True) |
| error_occurred = True |
| error_message_detail = f"发生意外错误: {e}" |
| final_status_message = f"清理任务因意外错误失败 ({mode}模式): {error_message_detail}" |
|
|
| finally: |
| |
| end_time = time.monotonic() |
| duration_seconds = end_time - start_time |
| duration_str = f"{duration_seconds:.2f} 秒" |
|
|
| logger.info(f"Recursive small file cleanup task finished in {duration_seconds:.4f} seconds. Result: {final_status_message}") |
|
|
| await _send_cleanup_notification( |
| mode=mode, |
| error_occurred=error_occurred, |
| deleted_count=deleted_count, |
| duration_str=duration_str, |
| folders_scanned=folders_scanned, |
| total_files_scanned=total_files_scanned, |
| target_folder_display=None, |
| status_message=final_status_message |
| ) |
| |
|
|
|
|
| |
| async def cleanup_small_files_non_recursive(): |
| global THUNDERX_CLIENT, CLEANUP_SIZE_THRESHOLD_BYTES, CLEANUP_TARGET_FOLDER_ID |
|
|
| if not THUNDERX_CLIENT: |
| logger.error("Non-Recursive Cleanup task skipped: PikPak client not initialized.") |
| return |
|
|
| start_time = time.monotonic() |
| mode = "非递归" |
| target_folder_id_actual = CLEANUP_TARGET_FOLDER_ID if CLEANUP_TARGET_FOLDER_ID else "" |
| target_folder_display = target_folder_id_actual or 'Root' |
|
|
| logger.info(f"Starting small file cleanup task (Non-Recursive). Threshold: {CLEANUP_SIZE_THRESHOLD_MB}MB. Target Folder ID: {target_folder_display}") |
|
|
| files_to_delete = [] |
| deleted_count = 0 |
| total_files_scanned = 0 |
| error_occurred = False |
| error_message_detail = "" |
| final_status_message = "非递归清理任务已启动。" |
|
|
| next_page_token = None |
| page_limit = 100 |
|
|
| try: |
| |
| logger.info(f"[Non-Recursive] Scanning only folder ID: '{target_folder_display}'") |
| while True: |
| try: |
| file_list = await THUNDERX_CLIENT.file_list( |
| size=page_limit, parent_id=target_folder_id_actual, next_page_token=next_page_token, |
| additional_filters={"trashed": {"eq": False}, "phase": {"eq": "PHASE_TYPE_COMPLETE"}} |
| ) |
| if not file_list or 'files' not in file_list or file_list['files'] is None: |
| logger.info("[Non-Recursive] No more files found or reached end.") |
| break |
|
|
| for file in file_list['files']: |
| |
| if file.get('kind') == 'drive#file': |
| total_files_scanned += 1 |
| file_id = file.get('id') |
| file_name = file.get('name', 'N/A') |
| file_size_str = file.get('size') |
| if file_size_str is not None: |
| try: |
| file_size = int(file_size_str) |
| if 0 < file_size < CLEANUP_SIZE_THRESHOLD_BYTES: |
| files_to_delete.append(file_id) |
| except (ValueError, TypeError): |
| logger.warning(f"[Non-Recursive] Could not parse size for file: {file_name} (ID: {file_id}, Size: {file_size_str})") |
|
|
|
|
| next_page_token = file_list.get('next_page_token') |
| if not next_page_token: |
| logger.info("[Non-Recursive] Reached end of file list for target folder.") |
| break |
| await asyncio.sleep(1) |
| except Exception as e: |
| logger.error(f"Error fetching file list page during non-recursive cleanup: {e}", exc_info=True) |
| error_occurred = True |
| error_message_detail = f"获取文件列表时出错: {e}" |
| final_status_message = f"清理任务因扫描错误结束 ({mode}模式): {error_message_detail}" |
| break |
|
|
| |
| if not error_occurred: |
| deleted_count, status_msg_delete, error_delete = await _perform_cleanup_deletion(files_to_delete, mode) |
| final_status_message = status_msg_delete |
| if error_delete: |
| error_occurred = True |
|
|
| except Exception as e: |
| logger.error(f"An unexpected error occurred during the non-recursive cleanup process: {e}", exc_info=True) |
| error_occurred = True |
| error_message_detail = f"发生意外错误: {e}" |
| final_status_message = f"清理任务因意外错误失败 ({mode}模式): {error_message_detail}" |
|
|
| finally: |
| |
| end_time = time.monotonic() |
| duration_seconds = end_time - start_time |
| duration_str = f"{duration_seconds:.2f} 秒" |
|
|
| logger.info(f"Non-Recursive small file cleanup task finished in {duration_seconds:.4f} seconds. Result: {final_status_message}") |
|
|
| await _send_cleanup_notification( |
| mode=mode, |
| error_occurred=error_occurred, |
| deleted_count=deleted_count, |
| duration_str=duration_str, |
| folders_scanned=None, |
| total_files_scanned=total_files_scanned, |
| target_folder_display=target_folder_display, |
| status_message=final_status_message |
| ) |
| |
|
|
|
|
| |
|
|
| |
| async def check_callback_authorization(query: CallbackQuery, context: CallbackContext) -> bool: |
| """Checks if the callback query user is the allowed admin.""" |
| global ALLOWED_USER_ID |
|
|
| if ALLOWED_USER_ID is None: |
| logger.error("Callback authorization check failed: ALLOWED_USER_ID is not configured.") |
| try: |
| await query.answer(escape_markdown("❌ 机器人未配置授权用户,操作拒绝。"), show_alert=True) |
| except Exception as e: |
| logger.error(f"Error sending unauthorized answer (no admin ID): {e}") |
| return False |
|
|
| if query.from_user.id != ALLOWED_USER_ID: |
| logger.warning(f"Unauthorized callback query attempt by user ID: {query.from_user.id}") |
| try: |
| await query.answer(escape_markdown("❌ 您无权使用此按钮。"), show_alert=True) |
| except Exception as e: |
| logger.error(f"Error sending unauthorized answer: {e}") |
| return False |
| return True |
|
|
|
|
| |
| async def start(update: Update, context: CallbackContext): |
| |
| bot_username = escape_markdown(context.bot.username) |
| example_hash = escape_markdown("96451E6F1ADBC8827B43621B74EDB30DF45012D6") |
| help_text = COMMAND_HELP_TEXT_TEMPLATE.format(bot_username=bot_username, example_hash=example_hash) |
| await update.message.reply_text(help_text.strip(), parse_mode=ParseMode.MARKDOWN_V2) |
|
|
| async def help_command(update: Update, context: CallbackContext): |
| |
| bot_username = escape_markdown(context.bot.username) |
| example_hash = escape_markdown("96451E6F1ADBC8827B43621B74EDB30DF45012D6") |
| help_text = COMMAND_HELP_TEXT_TEMPLATE.format(bot_username=bot_username, example_hash=example_hash) |
| await update.message.reply_text(help_text.strip(), parse_mode=ParseMode.MARKDOWN_V2) |
|
|
|
|
| async def quota(update: Update, context: CallbackContext): |
| |
| message_to_edit = await update.message.reply_text("⏳ 正在查询空间信息\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) |
| try: |
| quota_info = await THUNDERX_CLIENT.get_quota_info() |
| usage_str = quota_info.get('quota', {}).get('usage', '0') |
| limit_str = quota_info.get('quota', {}).get('limit', '0') |
| expires_at_ts = quota_info.get('expires_at') |
| expires_at_str = "N/A" |
| if expires_at_ts: |
| try: |
| dt_obj = datetime.fromisoformat(expires_at_ts.replace('Z', '+00:00')) |
| expires_at_str = dt_obj.strftime('%Y-%m-%d %H:%M:%S %Z') |
| except: |
| expires_at_str = str(expires_at_ts) |
|
|
| usage_formatted = format_bytes(usage_str) |
| limit_bytes = int(limit_str) if limit_str and limit_str.isdigit() else 0 |
| limit_formatted = format_bytes(limit_bytes) if limit_bytes > 0 else "无限" |
|
|
| safe_usage = escape_markdown(usage_formatted) |
| safe_limit = escape_markdown(limit_formatted) |
| safe_expires = escape_markdown(expires_at_str) |
|
|
| message_text = ( |
| f"💾 *存储空间信息*\n\n" |
| f"📊 *已用空间:* `{safe_usage}`\n" |
| f"📈 *总空间:* `{safe_limit}`\n" |
| f"⏰ *会员到期:* `{safe_expires}`" |
| ) |
| await message_to_edit.edit_text(message_text, parse_mode=ParseMode.MARKDOWN_V2) |
|
|
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=update, |
| error_text="获取空间信息失败", |
| log_message="Error fetching quota info", |
| e=e |
| ) |
| |
| try: await message_to_edit.delete() |
| except: pass |
|
|
|
|
| async def tg_emptytrash(update: Update, context: CallbackContext): |
| message_to_edit = await update.message.reply_text("⏳ 正在尝试清空回收站\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) |
| try: |
| result = await THUNDERX_CLIENT.emptytrash() |
| if result and result.get("task_id"): |
| safe_task_id = escape_markdown(result['task_id']) |
| success_text = f"✅ 清空回收站任务已创建 \(Task ID: `{safe_task_id}`\)" |
| await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) |
| else: |
| logger.warning(f"Empty trash command possibly failed. API Response: {result}") |
| warn_text = f"⚠️ 操作可能未成功或无需清空。\nAPI 返回: `{escape_markdown(str(result))}`" |
| await message_to_edit.edit_text(warn_text, parse_mode=ParseMode.MARKDOWN_V2) |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=update, |
| error_text="执行清空回收站时出错", |
| log_message="Error emptying trash", |
| e=e |
| ) |
| try: await message_to_edit.delete() |
| except: pass |
|
|
|
|
| |
| async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): |
| if not update.message or not update.message.text: return |
| text = update.message.text.strip() |
| file_url = None |
| parent_id = DEFAULT_DOWNLOAD_FOLDER_ID if DEFAULT_DOWNLOAD_FOLDER_ID else "" |
| name = None |
| processed = False |
| message_to_edit = None |
|
|
| folder_id_match = re.search(r'folder_id:(\S+)', text, re.IGNORECASE) |
| if folder_id_match: |
| parent_id = folder_id_match.group(1) |
| text = re.sub(r'\s*folder_id:\S+', '', text, flags=re.IGNORECASE).strip() |
| logger.info(f"Target folder ID specified: {parent_id}") |
|
|
| |
| if text.lower().startswith("share:"): |
| processed = True |
| share_id_match = re.match(r'share:(\S+)', text, re.IGNORECASE) |
| if share_id_match: |
| share_id = share_id_match.group(1) |
| safe_share_id = escape_markdown(share_id) |
| logger.info(f"Processing Share ID: {share_id}") |
| message_to_edit = await update.message.reply_text(f"⏳ 正在尝试转存分享 `{safe_share_id}`\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) |
| try: |
| target_restore_folder = parent_id if folder_id_match else None |
| logger.info(f"Attempting to restore share {share_id} to folder: {target_restore_folder or 'Root'}") |
| result = await THUNDERX_CLIENT.restore(share_id, None, target_restore_folder) |
|
|
| if result is not None and result.get('task', {}).get('id'): |
| safe_task_id = escape_markdown(result['task']['id']) |
| success_text = f"✅ 转存分享 `{safe_share_id}` 任务已提交 \(Task ID: `{safe_task_id}`\)。" |
| await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) |
| elif result is not None: |
| warn_text = f"✅ 转存分享 `{safe_share_id}` 任务已提交,但未获取到任务ID。\nAPI响应: `{escape_markdown(str(result)[:100])}`" |
| await message_to_edit.edit_text(warn_text, parse_mode=ParseMode.MARKDOWN_V2) |
| else: |
| fail_text = f"❌ 转存分享 `{safe_share_id}` 失败。\nAPI响应: `{escape_markdown(str(result)[:100])}`" |
| await message_to_edit.edit_text(fail_text, parse_mode=ParseMode.MARKDOWN_V2) |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=update, |
| error_text=f"转存分享 `{safe_share_id}` 时出错", |
| log_message=f"Error restoring share {share_id}", |
| e=e |
| ) |
| try: await message_to_edit.delete() |
| except: pass |
| else: |
| await update.message.reply_text(f"❌ 无效的 `share:` 格式。请使用 `share:<分享ID>`。") |
| return |
|
|
| |
| if text.lower().startswith(("magnet:", "http:", "https:", "ftp:", "ftps:", "ed2k:")): |
| file_url = text |
| logger.info(f"Processing Link: {file_url[:60]}... Target Folder: {parent_id or '默认(Root)'}") |
| processed = True |
| elif re.fullmatch(r'[a-fA-F0-9]{40}', text, re.IGNORECASE): |
| hash_val = text |
| file_url = f"magnet:?xt=urn:btih:{hash_val}" |
| logger.info(f"Processing Hash: {hash_val}. Converted to magnet. Target Folder: {parent_id or '默认(Root)'}") |
| processed = True |
|
|
| if file_url: |
| safe_file_url_short = escape_markdown(file_url[:60] + "...") |
| message_to_edit = await update.message.reply_text(f"⏳ 正在为 `{safe_file_url_short}` 创建离线任务\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) |
| try: |
| result = await THUNDERX_CLIENT.offline_download(file_url, parent_id, name) |
| if result and result.get("task") and result["task"].get("id"): |
| task_id = result["task"]["id"] |
| safe_task_id = escape_markdown(task_id) |
| success_text = f"✅ 离线任务已创建 \(ID: `{safe_task_id}`\)" |
| await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) |
| else: |
| fail_text = f"❌ 创建离线任务失败。\nAPI 返回: `{escape_markdown(str(result)[:100])}`" |
| await message_to_edit.edit_text(fail_text, parse_mode=ParseMode.MARKDOWN_V2) |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=update, |
| error_text="创建离线任务时出错", |
| log_message=f"Error creating offline task for URL: {file_url[:60]}...", |
| e=e |
| ) |
| try: await message_to_edit.delete() |
| except: pass |
| return |
|
|
| |
| if not processed and not text.startswith('/'): |
| logger.info(f"Ignoring unsupported message format: {text[:100]}...") |
| |
|
|
|
|
| |
| async def handle_copy_text(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
|
|
| try: |
| parts = query.data.split(":", 1) |
| if len(parts) < 2: raise ValueError("Invalid copy_text data") |
| text_to_copy = parts[1] |
| safe_text = escape_markdown(text_to_copy) |
| try: |
| await query.edit_message_text(f"👇 请长按复制下面的文本:\n\n`{safe_text}`", parse_mode=ParseMode.MARKDOWN_V2) |
| await query.answer("文本已显示,请手动复制。", show_alert=False) |
| except BadRequest as e: |
| if "message is not modified" in str(e).lower(): await query.answer() |
| else: raise e |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query, |
| error_text="处理复制时出错", |
| log_message=f"Error in handle_copy_text: {query.data}", |
| e=e |
| ) |
|
|
|
|
| |
| async def tg_show_shares(update: Update, context: CallbackContext): |
| message_to_edit = await update.message.reply_text("⏳ 正在获取分享列表\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) |
| try: |
| shares = await THUNDERX_CLIENT.get_share_list("") |
| keyboard = [] |
| if not shares or 'data' not in shares or not shares['data']: |
| await message_to_edit.edit_text("ℹ️ 未找到任何分享链接。", reply_markup=None) |
| return |
|
|
| header_text = "🔗 *分享列表*:\n\(点击分享标题复制分享码\)" |
| for share in shares["data"]: |
| |
| share_id = share.get('share_id') |
| if not share_id: continue |
| title = share.get('title', 'N/A') |
| safe_display_title = escape_markdown((title[:20] + '...') if len(title) > 23 else title) |
| share_link_text = f"share:{share_id}" |
| callback_copy = f"copy_text:{share_link_text}" |
| callback_del = f"del_s:{share_id}" |
| row_buttons = [] |
| copy_button = InlineKeyboardButton(f"📋 {safe_display_title}", callback_data=callback_copy) |
| del_button = InlineKeyboardButton(f"❌ 取消", callback_data=callback_del) |
| copy_ok = len(callback_copy.encode('utf-8')) <= 64 |
| del_ok = len(callback_del.encode('utf-8')) <= 64 |
| |
| if copy_ok and del_ok: row_buttons = [copy_button, del_button] |
| elif copy_ok: row_buttons = [copy_button, InlineKeyboardButton("❌ (ID过长)", callback_data="noop")] |
| elif del_ok: row_buttons = [InlineKeyboardButton(f"📋 {safe_display_title} (ID过长)", callback_data="noop"), del_button] |
| else: row_buttons = [InlineKeyboardButton(f"{safe_display_title} (ID过长无法操作)", callback_data="noop")] |
|
|
| keyboard.append(row_buttons) |
|
|
| reply_markup = InlineKeyboardMarkup(keyboard) |
| await message_to_edit.edit_text(header_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=update, |
| error_text="获取分享列表时出错", |
| log_message="Error fetching shares", |
| e=e |
| ) |
| try: await message_to_edit.delete() |
| except: pass |
|
|
|
|
| |
| async def handle_share_operation(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| action, share_id = query.data.split(":", 1) |
| except ValueError: |
| await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid callback data format: {query.data}") |
| return |
| try: await query.answer() |
| except Exception: pass |
|
|
| if action == "del_s": |
| confirm_callback = f"yes_s_del_s:{share_id}" |
| cancel_callback = f"cancel_generic" |
| if len(confirm_callback.encode('utf-8')) > 64: |
| await query.edit_message_text("❌ 内部错误:无法创建确认按钮 (ID过长)。") |
| return |
| keyboard = [[InlineKeyboardButton("⚠️ 确认取消分享", callback_data=confirm_callback)], [InlineKeyboardButton("🔙 返回", callback_data=cancel_callback)]] |
| reply_markup = InlineKeyboardMarkup(keyboard) |
| safe_share_id_short = escape_markdown(share_id[:15] + "...") |
| confirm_text = f"❓ 你确定要取消分享 `{safe_share_id_short}` 吗?" |
| try: |
| await query.edit_message_text(confirm_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) |
| except BadRequest as e: |
| if "message is not modified" not in str(e).lower(): raise e |
| except Exception as edit_e: |
| logger.error(f"Error showing share delete confirmation: {edit_e}", exc_info=True) |
|
|
|
|
| |
| async def handle_share_confirmation(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| parts = query.data.split(":", 1) |
| action_details = parts[0] |
| share_id = parts[1] |
| except (IndexError, ValueError): |
| await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid share confirmation callback data format: {query.data}") |
| return |
| try: await query.answer() |
| except Exception: pass |
|
|
| safe_share_id_short = escape_markdown(share_id[:15] + "...") |
| if action_details == "yes_s_del_s": |
| logger.info(f"User confirmed deletion for share ID: {share_id}") |
| processing_text = f"⏳ 正在取消分享 `{safe_share_id_short}`\.\.\." |
| message_to_edit = query.message |
| try: |
| await message_to_edit.edit_text(processing_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) |
| except Exception as edit_e: |
| logger.warning(f"Could not edit message to processing state: {edit_e}") |
| try: |
| result = await THUNDERX_CLIENT.share_batch_delete([share_id]) |
| logger.info(f"Share deletion API call result for {share_id}: {result}") |
| success_text = f"✅ 分享 `{safe_share_id_short}` 已取消。" |
| await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query, |
| error_text=f"取消分享 `{safe_share_id_short}` 时出错", |
| log_message=f"Error deleting share {share_id}", |
| e=e |
| ) |
| else: |
| logger.warning(f"Received unexpected share confirmation action: {query.data}") |
| await query.edit_message_text(f"❓ 未知的确认操作: `{escape_markdown(query.data)}`", parse_mode=ParseMode.MARKDOWN_V2) |
|
|
|
|
| |
| async def handle_cancel_generic(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| await query.answer() |
| await query.edit_message_text(f"ℹ️ 操作已取消。") |
| except BadRequest as e: |
| if "message is not modified" in str(e).lower(): logger.warning(f"Msg not modified: {e}") |
| else: logger.error(f"Error editing cancel msg: {e}", exc_info=True) |
| except Exception as e: |
| logger.error(f"Error handling cancel: {e}", exc_info=True) |
|
|
|
|
| |
|
|
| |
| async def tg_show_files(update: Update, context: CallbackContext, folder_id: str = "", page: int = 1): |
| is_callback = update.callback_query is not None |
| query = update.callback_query |
| target_message = query.message if is_callback else update.message |
| chat_id = target_message.chat_id if target_message else None |
| if not target_message or not chat_id: return |
|
|
| logger.info(f"Showing files for folder_id: '{folder_id or 'Root'}' | Page: {page}") |
| loading_text = f"⏳ 正在加载文件夹内容 \(页 {escape_markdown(str(page))}\)\.\.\." |
| message_to_edit = target_message |
| try: |
| if is_callback: await message_to_edit.edit_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) |
| else: message_to_edit = await target_message.reply_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2) |
| except Exception as e: logger.warning(f"Could not set loading state: {e}") |
|
|
| try: |
| files_per_api_call = 200 |
| files_data = await THUNDERX_CLIENT.file_list( |
| size=files_per_api_call, parent_id=folder_id, next_page_token="", |
| additional_filters={"trashed": {"eq": False}, "phase": {"eq": "PHASE_TYPE_COMPLETE"}} |
| ) |
| all_items = files_data.get('files', []) if files_data else [] |
| folders = sorted([f for f in all_items if f.get('kind') == 'drive#folder'], key=lambda x: x.get('name', '').lower()) |
| files = sorted([f for f in all_items if f.get('kind') == 'drive#file'], key=lambda x: x.get('name', '').lower()) |
| sorted_items = folders + files |
| total_items = len(sorted_items) |
| total_pages = (total_items + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE if total_items > 0 else 1 |
| page = max(1, min(page, total_pages)) |
| start_index = (page - 1) * ITEMS_PER_PAGE |
| end_index = start_index + ITEMS_PER_PAGE |
| paginated_items = sorted_items[start_index:end_index] |
|
|
| keyboard = [] |
| parent_folder_id = "" |
| current_folder_name_safe = escape_markdown("根目录") |
|
|
| if folder_id: |
| try: |
| folder_info = await THUNDERX_CLIENT.get_file_info(folder_id) |
| if folder_info and folder_info.get('kind') == 'drive#folder': |
| parent_folder_id = folder_info.get('parent_id', "") |
| current_folder_name_safe = escape_markdown(folder_info.get('name', f'ID_{folder_id[-6:]}')) |
| else: parent_folder_id, current_folder_name_safe = "", escape_markdown(f"未知位置_{folder_id[-6:]}") |
| except Exception as e: logger.warning(f"Could not fetch info for folder {folder_id}: {e}"); parent_folder_id, current_folder_name_safe = "", escape_markdown(f"错误_{folder_id[-6:]}") |
| else: parent_folder_id = "" |
|
|
| message_text_prefix = f"📂 `{current_folder_name_safe}`" |
| if folder_id: |
| back_callback = f"ls_b:{parent_folder_id}:1" |
| if len(back_callback.encode('utf-8')) <= 64: keyboard.append([InlineKeyboardButton(f"↩️ 返回上级", callback_data=back_callback)]) |
| else: keyboard.append([InlineKeyboardButton(f"↩️ 返回上级 (ID过长)", callback_data="noop")]) |
|
|
| if not paginated_items and total_items == 0: message_text = f"{message_text_prefix} \(目录为空\)" |
| else: |
| message_text = f"{message_text_prefix} \(第 {escape_markdown(str(page))}/{escape_markdown(str(total_pages))} 页\)" |
| for item in paginated_items: |
| |
| item_id, item_name, item_kind = item.get('id'), item.get('name', 'N/A'), item.get('kind', 'drive#file') |
| if not item_id: continue |
| safe_display_name_short = escape_markdown((item_name[:20] + '...') if len(item_name) > 23 else item_name) |
| row, action_buttons, item_button = [], [], None |
| delete_cb, share_cb, download_cb, list_cb = f"del_f:{item_id}:{folder_id}:{page}", f"sh_f:{item_id}:{folder_id}:{page}", f"dw_f:{item_id}:{folder_id}:{page}", f"ls_f:{item_id}:1" |
| del_ok, share_ok, dw_ok, list_ok = (len(cb.encode('utf-8')) <= 64 for cb in [delete_cb, share_cb, download_cb, list_cb]) |
|
|
| if item_kind == 'drive#folder': |
| item_button = InlineKeyboardButton(f"📁 {safe_display_name_short}", callback_data=list_cb if list_ok else "noop") |
| action_buttons.append(InlineKeyboardButton("🔗", callback_data=share_cb if share_ok else "noop")) |
| action_buttons.append(InlineKeyboardButton("🗑️", callback_data=delete_cb if del_ok else "noop")) |
| if not list_ok or not share_ok or not del_ok: |
| if not list_ok: item_button = InlineKeyboardButton(f"📁 {safe_display_name_short} (ID过长)", callback_data="noop") |
| if not share_ok: action_buttons[0] = InlineKeyboardButton("🔗(ID过长)", callback_data="noop") |
| if not del_ok: action_buttons[1] = InlineKeyboardButton("🗑️(ID过长)", callback_data="noop") |
| else: |
| safe_size_str = escape_markdown(f"({format_bytes(item.get('size'))})") |
| file_display_text = f"📄 {safe_display_name_short} {safe_size_str}" |
| item_button = InlineKeyboardButton(file_display_text, callback_data=download_cb if dw_ok else "noop") |
| action_buttons.append(InlineKeyboardButton("🔗", callback_data=share_cb if share_ok else "noop")) |
| action_buttons.append(InlineKeyboardButton("🗑️", callback_data=delete_cb if del_ok else "noop")) |
| if not dw_ok or not share_ok or not del_ok: |
| if not dw_ok: item_button = InlineKeyboardButton(f"📄 {safe_display_name_short} (ID过长)", callback_data="noop") |
| if not share_ok: action_buttons[0] = InlineKeyboardButton("🔗(ID过长)", callback_data="noop") |
| if not del_ok: action_buttons[1] = InlineKeyboardButton("🗑️(ID过长)", callback_data="noop") |
| row.append(item_button); row.extend(action_buttons); keyboard.append(row) |
|
|
| page_row = [] |
| if page > 1: |
| prev_callback = f"ls_p:{folder_id}:{page-1}" |
| page_row.append(InlineKeyboardButton("⬅️ 上一页", callback_data=prev_callback) if len(prev_callback.encode('utf-8')) <= 64 else InlineKeyboardButton("⬅️ (错误)", callback_data="noop")) |
| if page < total_pages: |
| next_callback = f"ls_n:{folder_id}:{page+1}" |
| page_row.append(InlineKeyboardButton("下一页 ➡️", callback_data=next_callback) if len(next_callback.encode('utf-8')) <= 64 else InlineKeyboardButton("➡️ (错误)", callback_data="noop")) |
| if page_row: keyboard.append(page_row) |
|
|
| reply_markup = InlineKeyboardMarkup(keyboard) if keyboard else None |
| try: |
| current_text_md = message_to_edit.text_markdown_v2_urled or message_to_edit.text_markdown_v2 |
| if current_text_md == message_text and message_to_edit.reply_markup == reply_markup: |
| if query: await query.answer() |
| else: |
| await message_to_edit.edit_text(message_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup) |
| if query: await query.answer() |
| except BadRequest as e: |
| if "message is not modified" in str(e).lower(): |
| if query: await query.answer() |
| else: raise e |
| except Exception as edit_error: |
| logger.error(f"Failed to edit message for file list: {edit_error}", exc_info=True) |
| if query: await query.answer("❌ 更新列表时出错", show_alert=True) |
|
|
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query if is_callback else update, |
| error_text="获取文件列表时出错", |
| log_message=f"Error showing files for folder {folder_id}, page {page}", |
| e=e |
| ) |
| |
|
|
|
|
| |
| async def tg_show_files_command(update: Update, context: CallbackContext): |
| if not update.message: return |
| await tg_show_files(update, context, folder_id="", page=1) |
|
|
| |
| async def handle_prev_page(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| _, folder_id, page_str = query.data.split(":", 2) |
| page = max(1, int(page_str)) |
| await tg_show_files(update, context, folder_id=folder_id, page=page) |
| except Exception as e: |
| await send_telegram_error_message(query, "处理上一页时出错", f"Error handling prev page: {query.data}", e) |
|
|
| async def handle_next_page(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| _, folder_id, page_str = query.data.split(":", 2) |
| page = int(page_str) |
| await tg_show_files(update, context, folder_id=folder_id, page=page) |
| except Exception as e: |
| await send_telegram_error_message(query, "处理下一页时出错", f"Error handling next page: {query.data}", e) |
|
|
| async def handle_back_folder(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| _, folder_id, page_str = query.data.split(":", 2) |
| await tg_show_files(update, context, folder_id=folder_id, page=1) |
| except Exception as e: |
| await send_telegram_error_message(query, "处理返回上级时出错", f"Error handling back folder: {query.data}", e) |
|
|
| |
| async def handle_file_confirmation(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| action_details, file_id, parent_id, page_str = query.data.split(":", 3) |
| page = int(page_str) |
| except (IndexError, ValueError): |
| await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid file confirmation callback data format: {query.data}") |
| return |
| try: await query.answer() |
| except Exception: pass |
|
|
| safe_file_id_short = escape_markdown(file_id[:15] + "...") |
| if action_details == "yes_f_del_f": |
| logger.info(f"User confirmed deletion for file ID: {file_id}") |
| processing_text = f"⏳ 正在将 `{safe_file_id_short}` 移至回收站\.\.\." |
| message_to_edit = query.message |
| try: |
| await message_to_edit.edit_text(processing_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) |
| except Exception as edit_e: logger.warning(f"Could not edit msg: {edit_e}") |
| try: |
| result = await THUNDERX_CLIENT.delete_to_trash([file_id]) |
| logger.info(f"File deletion API call result for {file_id}: {result}") |
| success_text = f"✅ 文件/文件夹 `{safe_file_id_short}` 已移至回收站。" |
| await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query, |
| error_text=f"删除 `{safe_file_id_short}` 时出错", |
| log_message=f"Error deleting file {file_id}", |
| e=e |
| ) |
| else: |
| logger.warning(f"Received unexpected file confirmation action: {query.data}") |
| await query.edit_message_text(f"❓ 未知的确认操作: `{escape_markdown(query.data)}`", parse_mode=ParseMode.MARKDOWN_V2) |
|
|
|
|
| |
| async def handle_file_operation(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| parts = query.data.split(":") |
| action = parts[0] |
| if action == "ls_f": |
| if len(parts) != 3: raise ValueError("Invalid ls_f format") |
| target_folder_id, page = parts[1], int(parts[2]) |
| await tg_show_files(update, context, folder_id=target_folder_id, page=page) |
| elif action in ["del_f", "sh_f", "dw_f"]: |
| if len(parts) != 4: raise ValueError("Invalid file action format") |
| file_id, folder_id, page = parts[1], parts[2], int(parts[3]) |
| try: await query.answer() |
| except Exception: pass |
| await perform_file_action(update, context, action, file_id, folder_id, page) |
| elif action == "noop": |
| await query.answer("ℹ️ 此按钮无效 (可能因ID过长)。") |
| else: |
| raise ValueError(f"Unknown file action: {action}") |
| except (ValueError, IndexError) as e: |
| await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid file operation callback data: {query.data}, Error: {e}") |
| except Exception as e: |
| await send_telegram_error_message(query, "处理文件操作时出错", f"Error in handle_file_operation: {query.data}", e) |
|
|
|
|
| async def perform_file_action( |
| update: Update, context: CallbackContext, action: str, file_id: str, folder_id: str, page: int |
| ): |
| query = update.callback_query |
| if not query or not query.message: return |
|
|
| safe_file_id_short = escape_markdown(file_id[:15] + "...") |
| original_message_text = query.message.text_markdown_v2_urled or query.message.text_markdown_v2 or query.message.text |
| original_reply_markup = query.message.reply_markup |
| message_to_edit = query.message |
|
|
| if action == "del_f": |
| confirm_callback, cancel_callback = f"yes_f_del_f:{file_id}:{folder_id}:{page}", "cancel_generic" |
| if len(confirm_callback.encode('utf-8')) > 64: |
| await message_to_edit.edit_text("❌ 内部错误:无法创建确认按钮 (ID过长)。") |
| return |
| keyboard = [[InlineKeyboardButton("⚠️ 确认移到回收站", callback_data=confirm_callback)], [InlineKeyboardButton("🔙 取消", callback_data=cancel_callback)]] |
| reply_markup = InlineKeyboardMarkup(keyboard) |
| confirm_text = f"❓ 你确定要把 `{safe_file_id_short}` 移到回收站吗?" |
| try: |
| await message_to_edit.edit_text(confirm_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) |
| except BadRequest as e: |
| if "message is not modified" not in str(e).lower(): raise e |
| except Exception as e: logger.error(f"Error showing delete confirmation: {e}", exc_info=True) |
|
|
| elif action == "dw_f": |
| logger.info(f"Requesting download URL for file ID: {file_id}") |
| loading_text = f"⏳ 正在获取文件 `{safe_file_id_short}` 的下载链接\.\.\." |
| try: await message_to_edit.edit_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) |
| except Exception as e: logger.warning(f"Could not edit msg: {e}") |
| try: |
| result = await THUNDERX_CLIENT.get_download_url(file_id) |
| download_url = result.get("web_content_link") |
| if not download_url and result.get("medias"): |
| for media in result.get("medias", []): |
| if media.get("link") and media["link"].get("url"): download_url = media["link"]["url"]; break |
| if download_url: |
| logger.info(f"Successfully obtained download URL for {file_id}") |
| link_message_text = f"📄 文件 `{safe_file_id_short}` 下载链接 \(有效期较短\):\n\n[点此下载]({download_url})" |
| await message_to_edit.reply_text(link_message_text, parse_mode=ParseMode.MARKDOWN_V2, disable_web_page_preview=True) |
| confirm_text = f"✅ 已发送 `{safe_file_id_short}` 的下载链接 \(见上方新消息\)。\n\n{original_message_text or ''}" |
| try: await message_to_edit.edit_text(confirm_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) |
| except Exception as edit_e: logger.warning(f"Could not edit confirm msg: {edit_e}") |
| else: |
| logger.error(f"Could not find download URL for {file_id}. Response: {result}") |
| error_text = f"❌ 未找到文件 `{safe_file_id_short}` 的下载链接!\nAPI响应: `{escape_markdown(str(result)[:100])}`" |
| try: await message_to_edit.edit_text(error_text + f"\n\n{original_message_text or ''}", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) |
| except Exception as edit_e: logger.warning(f"Could not edit error msg: {edit_e}") |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query, |
| error_text=f"获取 `{safe_file_id_short}` 下载链接时出错", |
| log_message=f"Error getting download URL for {file_id}", |
| e=e |
| ) |
| |
| try: await message_to_edit.edit_text(original_message_text or "错误", reply_markup=original_reply_markup, parse_mode=ParseMode.MARKDOWN_V2) |
| except: pass |
|
|
|
|
| elif action == "sh_f": |
| logger.info(f"Requesting share creation for file ID: {file_id}") |
| loading_text = f"⏳ 正在为 `{safe_file_id_short}` 创建分享链接\.\.\." |
| try: await message_to_edit.edit_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) |
| except Exception as e: logger.warning(f"Could not edit msg: {e}") |
| try: |
| result = await THUNDERX_CLIENT.file_batch_share([file_id], need_password=False, expiration_days=-1) |
| share_id = result.get("share_id") |
| if share_id: |
| logger.info(f"Successfully created share for {file_id}: {share_id}") |
| share_link_text = f"share:{share_id}"; copy_callback = f"copy_text:{share_link_text}"; copy_button = None |
| if len(copy_callback.encode('utf-8')) <= 64: copy_button = InlineKeyboardButton(f"📋 点击复制分享码", callback_data=copy_callback) |
| safe_share_link_display = escape_markdown(share_link_text) |
| share_message_text = f"✅ 分享码已生成:\n`{safe_share_link_display}`" |
| await message_to_edit.reply_text(share_message_text, reply_markup=InlineKeyboardMarkup([[copy_button]]) if copy_button else None, parse_mode=ParseMode.MARKDOWN_V2) |
| confirm_text = f"✅ 已发送 `{safe_file_id_short}` 的分享链接 \(见上方新消息\)。\n\n{original_message_text or ''}" |
| try: await message_to_edit.edit_text(confirm_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) |
| except Exception as edit_e: logger.warning(f"Could not edit confirm msg: {edit_e}") |
| else: |
| logger.error(f"Failed to create share for {file_id}. API Response: {result}") |
| error_text = f"❌ 为 `{safe_file_id_short}` 创建分享失败!\nAPI响应: `{escape_markdown(str(result)[:100])}`" |
| try: await message_to_edit.edit_text(error_text + f"\n\n{original_message_text or ''}", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) |
| except Exception as edit_e: logger.warning(f"Could not edit error msg: {edit_e}") |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query, |
| error_text=f"为 `{safe_file_id_short}` 创建分享时出错", |
| log_message=f"Error creating share for {file_id}", |
| e=e |
| ) |
| try: await message_to_edit.edit_text(original_message_text or "错误", reply_markup=original_reply_markup, parse_mode=ParseMode.MARKDOWN_V2) |
| except: pass |
|
|
|
|
| |
| async def tg_show_task(update: Update, context: CallbackContext): |
| logger.info("Fetching offline task list.") |
| is_callback = update.callback_query is not None |
| query = update.callback_query |
| target_message = query.message if is_callback else update.message |
| chat_id = target_message.chat_id if target_message else None |
| if not target_message or not chat_id: return |
|
|
| loading_text = "⏳ 正在获取任务列表..." |
| message_to_edit = target_message |
| try: |
| if is_callback: await message_to_edit.edit_text(loading_text, reply_markup=None) |
| else: message_to_edit = await target_message.reply_text(loading_text) |
| except Exception as e: logger.warning(f"Could not set loading state: {e}") |
|
|
| try: |
| tasks_data = await THUNDERX_CLIENT.offline_list(size=50, next_page_token=None, phase=None) |
| keyboard = [] |
| if not tasks_data or 'tasks' not in tasks_data or not tasks_data['tasks']: |
| await message_to_edit.edit_text("ℹ️ 当前没有离线下载任务。", reply_markup=None) |
| return |
|
|
| tasks_list_text = f"🚀 *离线任务列表*:" |
| for task in tasks_data["tasks"]: |
| |
| task_id, task_name = task.get('id'), task.get('name', 'N/A') |
| task_phase_raw, task_progress, task_message = task.get('phase', 'UNKNOWN'), task.get('progress', 0), task.get('message', '') |
| if not task_id: continue |
| task_phase = task_phase_raw.replace('PHASE_TYPE_', '') |
| status_emoji = "✅" if task_phase == "COMPLETE" else "⏳" if task_phase in ["PENDING", "RUNNING"] else "🌱" if task_phase == "SEEDING" else "❌" if task_phase == "ERROR" else "❓" |
| safe_display_name = escape_markdown((task_name[:25] + '...') if len(task_name) > 28 else task_name) |
| safe_phase = escape_markdown(task_phase); safe_progress = escape_markdown(f"[{task_progress}%]") |
| status_line = f"{status_emoji} {safe_display_name} \({safe_phase}\) {safe_progress}" |
| if task_phase == "ERROR" and task_message: status_line += f"\n └─ 错误: `{escape_markdown(task_message[:30] + '...')}`" |
| delete_callback = f"delete_task:{task_id}" |
| row = [InlineKeyboardButton(status_line, callback_data="noop")] |
| if len(delete_callback.encode('utf-8')) <= 64: row.append(InlineKeyboardButton("🗑️ 删除", callback_data=delete_callback)) |
| else: row.append(InlineKeyboardButton("🗑️ (ID过长)", callback_data="noop")) |
| keyboard.append(row) |
|
|
| if tasks_data.get('next_page_token'): keyboard.append([InlineKeyboardButton("...(更多任务未显示 - 暂不支持翻页)", callback_data="noop")]) |
| refresh_callback = "refresh_tasks" |
| keyboard.append([InlineKeyboardButton("🔄 刷新列表", callback_data=refresh_callback)]) |
|
|
| reply_markup = InlineKeyboardMarkup(keyboard) |
| await message_to_edit.edit_text(tasks_list_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) |
|
|
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query if is_callback else update, |
| error_text="获取离线任务列表时出错", |
| log_message="Error fetching/editing offline tasks", |
| e=e |
| ) |
| |
|
|
| |
| async def handle_refresh_tasks(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| await query.answer("正在刷新...") |
| await tg_show_task(update, context) |
| except Exception as e: |
| |
| await send_telegram_error_message(query, "刷新失败", f"Error refreshing tasks: {query.data}", e) |
|
|
|
|
| |
| async def handle_tasks_operation(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| action, task_id = query.data.split(":", 1) |
| except ValueError: |
| await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid task operation callback data: {query.data}") |
| return |
| try: await query.answer() |
| except Exception: pass |
|
|
| safe_task_id_short = escape_markdown(task_id[:15] + "...") |
| if action == "delete_task": |
| confirm_callback, cancel_callback = f"confirm_task_delete_task:{task_id}", "refresh_tasks" |
| if len(confirm_callback.encode('utf-8')) > 64: |
| await query.edit_message_text("❌ 内部错误:无法创建确认按钮 (ID过长)。") |
| return |
| keyboard = [[InlineKeyboardButton("⚠️ 确认删除任务", callback_data=confirm_callback)], [InlineKeyboardButton("🔙 返回列表", callback_data=cancel_callback)]] |
| reply_markup = InlineKeyboardMarkup(keyboard) |
| confirm_text = f"❓ 你确定要删除任务 `{safe_task_id_short}` 吗?\n(注意:这 *不会* 删除已下载的文件)" |
| try: |
| await query.edit_message_text(confirm_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) |
| except BadRequest as e: |
| if "message is not modified" not in str(e).lower(): raise e |
| except Exception as e: logger.error(f"Error showing task delete confirmation: {e}", exc_info=True) |
| elif action == "noop": |
| await query.answer("ℹ️ 此按钮无效。") |
| else: |
| logger.warning(f"Received unexpected task action: {action}") |
| await query.edit_message_text(f"❓ 未知任务操作: `{escape_markdown(action)}`", parse_mode=ParseMode.MARKDOWN_V2) |
|
|
|
|
| |
| async def handle_task_confirmation(update: Update, context: CallbackContext): |
| query = update.callback_query |
| if not await check_callback_authorization(query, context): return |
| try: |
| action_details, task_id = query.data.split(":", 1) |
| except (IndexError, ValueError): |
| await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid task confirmation callback data format: {query.data}") |
| return |
| try: await query.answer() |
| except Exception: pass |
|
|
| safe_task_id_short = escape_markdown(task_id[:15] + "...") |
| if action_details == "confirm_task_delete_task": |
| logger.info(f"User confirmed deletion for task ID: {task_id}") |
| processing_text = f"⏳ 正在删除任务 `{safe_task_id_short}`\.\.\." |
| message_to_edit = query.message |
| try: await message_to_edit.edit_text(processing_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) |
| except Exception as edit_e: logger.warning(f"Could not edit msg: {edit_e}") |
| try: |
| result = await THUNDERX_CLIENT.delete_tasks([task_id], delete_files=False) |
| logger.info(f"Task deletion API call result for {task_id}: {result}") |
| success_text = f"✅ 任务 `{safe_task_id_short}` 已删除。" |
| keyboard = [[InlineKeyboardButton("🔄 刷新任务列表", callback_data="refresh_tasks")]] |
| await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=InlineKeyboardMarkup(keyboard)) |
| except Exception as e: |
| |
| await send_telegram_error_message( |
| target=query, |
| error_text=f"删除任务 `{safe_task_id_short}` 时出错", |
| log_message=f"Error deleting task {task_id}", |
| e=e |
| ) |
| |
| try: |
| keyboard = [[InlineKeyboardButton("🔄 刷新任务列表", callback_data="refresh_tasks")]] |
| await query.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(keyboard)) |
| except Exception as final_edit_e: logger.warning(f"Could not add refresh button after task delete error: {final_edit_e}") |
| else: |
| logger.warning(f"Received unexpected task confirmation action: {query.data}") |
| await query.edit_message_text(f"❓ 未知的确认操作: `{escape_markdown(query.data)}`", parse_mode=ParseMode.MARKDOWN_V2) |
|
|
|
|
| @app.on_event("startup") |
| async def init_client(): |
| global THUNDERX_CLIENT, TG_BOT_APPLICATION, scheduler, ALLOWED_USER_ID |
| logger.info("===== Application Startup =====") |
| |
| token_file = "thunderx.json" |
| |
| if os.path.exists(token_file): |
| logger.info(f"Loading PikPak client state from {token_file}") |
| try: |
| with open(token_file, "r") as f: data = json.load(f) |
| httpx_args = {"proxies": PROXY_URL, "timeout": 300, "follow_redirects": True} if PROXY_URL else {"timeout": 300, "follow_redirects": True} |
| THUNDERX_CLIENT = PikPakApi.from_dict(data, httpx_client_args=httpx_args) |
| THUNDERX_CLIENT.token_refresh_callback = log_token |
| THUNDERX_CLIENT.token_refresh_callback_kwargs = {"extra_data": "loaded_refresh"} |
| logger.info("PikPak client loaded. Verifying token...") |
| user_info = await THUNDERX_CLIENT.get_user_info() |
| logger.info(f"Token verified. User: {user_info.get('name', 'N/A')}") |
| except Exception as e: |
| logger.error(f"Failed to load or validate PikPak client from {token_file}: {e}. Will attempt new login.", exc_info=True) |
| THUNDERX_CLIENT = None |
| else: logger.info(f"{token_file} not found. Attempting new login.") |
| if THUNDERX_CLIENT is None: |
| logger.info("Initializing new PikPak client and logging in...") |
| try: |
| THUNDERX_CLIENT = PikPakApi( |
| username=THUNDERX_USERNAME, password=THUNDERX_PASSWORD, |
| httpx_client_args={"proxies": PROXY_URL, "timeout": 300, "follow_redirects": True} if PROXY_URL else {"timeout": 300, "follow_redirects": True}, |
| token_refresh_callback=log_token, token_refresh_callback_kwargs={"extra_data": "startup_refresh"}, |
| ) |
| await THUNDERX_CLIENT.login() |
| with open(token_file, "w") as f: f.write(json.dumps(THUNDERX_CLIENT.to_dict(), indent=4)) |
| logger.info(f"Login successful. Client state saved to {token_file}.") |
| user_info = THUNDERX_CLIENT.get_user_info() |
| logger.info(f"User Info (New Login): {user_info.get('name', 'N/A')}") |
| except Exception as e: raise RuntimeError(f"Could not login to PikPak: {e}") |
|
|
| |
| if not TG_BOT_TOKEN: logger.warning("TG_BOT_TOKEN not set. Telegram bot functionality disabled.") |
| else: |
| logger.info("Initializing Telegram Bot...") |
| if not ALLOWED_USER_ID: |
| logger.critical("TG_ADMIN_CHAT_ID is not set or invalid. Bot WILL NOT be started.") |
| TG_BOT_APPLICATION = None |
| else: |
| try: |
| TG_BOT_APPLICATION = Application.builder().base_url(TG_BASE_URL).token(TG_BOT_TOKEN).build() |
| if TG_WEBHOOK_URL: |
| logger.info(f"Setting webhook to: {TG_WEBHOOK_URL}") |
| webhook_set = await TG_BOT_APPLICATION.bot.set_webhook(url=TG_WEBHOOK_URL, allowed_updates=Update.ALL_TYPES) |
| if webhook_set: logger.info("Webhook set successfully.") |
| else: logger.error("Failed to set webhook!") |
| else: logger.warning("TG_WEBHOOK_URL not set.") |
|
|
| user_filter = filters.User(user_id=ALLOWED_USER_ID) |
| logger.info(f"Applying Telegram user filter for User ID: {ALLOWED_USER_ID}") |
|
|
| |
| TG_BOT_APPLICATION.add_handler(CommandHandler("start", start, filters=user_filter)) |
| TG_BOT_APPLICATION.add_handler(CommandHandler("help", help_command, filters=user_filter)) |
| TG_BOT_APPLICATION.add_handler(CommandHandler("quota", quota, filters=user_filter)) |
| TG_BOT_APPLICATION.add_handler(CommandHandler("emptytrash", tg_emptytrash, filters=user_filter)) |
| TG_BOT_APPLICATION.add_handler(CommandHandler("tasks", tg_show_task, filters=user_filter)) |
| TG_BOT_APPLICATION.add_handler(CommandHandler("files", tg_show_files_command, filters=user_filter)) |
| TG_BOT_APPLICATION.add_handler(CommandHandler("shares", tg_show_shares, filters=user_filter)) |
| TG_BOT_APPLICATION.add_handler(MessageHandler(user_filter & filters.TEXT & ~filters.COMMAND, handle_message)) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_copy_text, pattern="^copy_text:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_cancel_generic, pattern="^cancel_generic$")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_refresh_tasks, pattern="^refresh_tasks$")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_task_confirmation, pattern="^confirm_task_delete_task:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_tasks_operation, pattern="^(delete_task|noop):")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_share_confirmation, pattern="^yes_s_del_s:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_share_operation, pattern="^del_s:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_file_confirmation, pattern="^yes_f_del_f:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_prev_page, pattern="^ls_p:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_next_page, pattern="^ls_n:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_back_folder, pattern="^ls_b:")) |
| TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_file_operation, pattern="^(ls_f|del_f|dw_f|sh_f|noop):")) |
|
|
| logger.info(f"Registered Telegram handlers restricted to User ID: {ALLOWED_USER_ID}") |
| await TG_BOT_APPLICATION.initialize() |
| logger.info("Telegram Bot application initialized.") |
| except Exception as e: |
| logger.error(f"Failed to initialize Telegram Bot: {e}", exc_info=True) |
| TG_BOT_APPLICATION = None |
|
|
| |
| if CLEANUP_ENABLED and THUNDERX_CLIENT: |
| cleanup_mode = "递归" if CLEANUP_RECURSIVE else "非递归" |
| target_desc = f"目标文件夹: {CLEANUP_TARGET_FOLDER_ID}" if CLEANUP_TARGET_FOLDER_ID and not CLEANUP_RECURSIVE else "根目录" if not CLEANUP_RECURSIVE else "根目录及子目录" if CLEANUP_RECURSIVE else "整个云盘" |
| logger.info(f"调度小文件清理任务 ({cleanup_mode}, {target_desc}) 每 {CLEANUP_INTERVAL_HOURS} 小时执行一次。阈值: {CLEANUP_SIZE_THRESHOLD_MB}MB。") |
| try: |
| scheduler.add_job( |
| cleanup_small_files, trigger=IntervalTrigger(hours=CLEANUP_INTERVAL_HOURS), |
| id="cleanup_small_files_job", name=f"PikPak Small File Cleanup ({cleanup_mode})", |
| replace_existing=True, next_run_time=datetime.now() + timedelta(minutes=5) |
| ) |
| scheduler.start() |
| logger.info("调度器已成功启动。") |
| except Exception as e: logger.error(f"无法调度清理任务: {e}", exc_info=True) |
| elif not CLEANUP_ENABLED: logger.info("自动小文件清理已禁用 (CLEANUP_ENABLED=false)。") |
| elif not THUNDERX_CLIENT: logger.warning("无法启动清理任务,因为 PikPak 客户端不可用。") |
|
|
|
|
| @app.on_event("shutdown") |
| async def shutdown_event(): |
| logger.info("===== Application Shutdown =====") |
| |
| if scheduler and scheduler.running: scheduler.shutdown(); logger.info("Scheduler stopped.") |
| if TG_BOT_APPLICATION: await TG_BOT_APPLICATION.shutdown(); logger.info("Telegram Bot application shut down.") |
| if THUNDERX_CLIENT: await THUNDERX_CLIENT.close(); logger.info("PikPak client connection closed.") |
|
|
|
|
| |
| @app.post("/webhook", include_in_schema=False) |
| async def webhook(request: Request): |
| if not TG_BOT_APPLICATION: raise HTTPException(status_code=503, detail="Telegram Bot not available") |
| try: |
| data = await request.json() |
| update = Update.de_json(data, TG_BOT_APPLICATION.bot) |
| logger.debug(f"Processing update ID: {update.update_id}") |
| await TG_BOT_APPLICATION.process_update(update) |
| return JSONResponse({"status": "ok"}) |
| except json.JSONDecodeError: logger.error("Failed to decode JSON from webhook."); raise HTTPException(status_code=400, detail="Invalid JSON received") |
| except Exception as e: logger.error(f"Error processing webhook update: {e}", exc_info=True); return JSONResponse({"status": "error processing update"}, status_code=200) |
|
|
|
|
| |
| @front_router.get("/", response_class=HTMLResponse, summary="前台页面", description="前台管理页面", tags=["前端"]) |
| async def home(request: Request): |
| context = {"request": request}; return templates.TemplateResponse("index.html", context) |
|
|
|
|
| |
| |
| |
| @api_router.post("/files", summary="文件列表", description="获取文件列表", tags=["文件"]) |
| async def get_files(item: FileRequest): |
| logger.info(f"API Request: Get files. ParentID: {item.parent_id or 'root'}, Size: {item.size}, Token: {item.next_page_token}, Filters: {item.additional_filters}") |
| try: |
| api_filters = item.additional_filters or {} |
| if 'trashed' not in api_filters: api_filters['trashed'] = {"eq": False} |
| if 'phase' not in api_filters: api_filters['phase'] = {"eq": "PHASE_TYPE_COMPLETE"} |
| return await THUNDERX_CLIENT.file_list(item.size, item.parent_id, item.next_page_token, api_filters) |
| except Exception as e: |
| logger.error(f"API Error - file_list: {e}", exc_info=True) |
| raise HTTPException(status_code=500, detail=f"Failed to fetch file list from PikPak: {str(e)}") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| @api_router.post( |
| "/offline", summary="添加离线任务", description="添加离线任务 (支持 magnet, http(s), ed2k, hash)", tags=["离线任务"] |
| ) |
| async def offline(item: OfflineRequest): |
| file_url = item.file_url.strip() |
| parent_id = item.parent_id if item.parent_id is not None else DEFAULT_DOWNLOAD_FOLDER_ID |
| name = item.name |
| logger.info(f"API Request: Add offline task. URL/Hash: {file_url[:60]}..., ParentID: {parent_id or 'Default(Root)'}, Name: {name or 'Auto'}") |
| if re.fullmatch(r'[a-fA-F0-9]{40}', file_url, re.IGNORECASE): |
| file_url = f"magnet:?xt=urn:btih:{file_url}" |
| elif not file_url.lower().startswith(("magnet:", "http:", "https:", "ftp:", "ftps:", "ed2k:")): |
| raise HTTPException(status_code=400, detail="Unsupported URL scheme or invalid input.") |
| if not file_url: raise HTTPException(status_code=400, detail="file_url (or hash) is required.") |
| try: |
| result = await THUNDERX_CLIENT.offline_download(file_url, parent_id, name) |
| if result and result.get("task") and result["task"].get("id"): return result |
| else: |
| error_detail = result.get("error_description", result.get("error", "Unknown error")) if isinstance(result, dict) else str(result) |
| raise HTTPException(status_code=400, detail=f"Failed to create task on PikPak: {error_detail}") |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") |
|
|
| @api_router.get("/files/{file_id}", summary="文件信息", tags=["文件"]) |
| async def get_file_info(file_id: str = Path(...)): |
| logger.info(f"API Request: Get file info ID: {file_id}") |
| try: |
| result = await THUNDERX_CLIENT.get_file_info(file_id) |
| if not result: raise HTTPException(status_code=404, detail="File not found") |
| if isinstance(result, dict) and result.get("error"): raise HTTPException(status_code=400, detail=result.get("error_description", "API error")) |
| return result |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/file_star_list", summary="加星文件列表", tags=["文件"]) |
| async def file_star_list(size: int = Query(100), next_page_token: Optional[str] = Query(None)): |
| logger.info(f"API Request: Get starred files. Size: {size}, Token: {next_page_token}") |
| try: return await THUNDERX_CLIENT.file_star_list(size, next_page_token) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.delete("/files/{file_id}", summary="删除文件(到回收站)", tags=["文件"]) |
| async def delete_file_info(file_id: str = Path(...)): |
| logger.info(f"API Request: Delete file/folder ID: {file_id}") |
| try: return await THUNDERX_CLIENT.delete_to_trash([file_id]) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/file_rename/{file_id}", summary="重命名文件", tags=["文件"]) |
| async def file_rename(file_id: str = Path(...), name: str = Body(..., embed=True)): |
| new_name = name.strip(); |
| if not new_name: raise HTTPException(status_code=400, detail="Name cannot be empty") |
| logger.info(f"API Request: Rename ID: {file_id} to '{new_name}'") |
| try: return await THUNDERX_CLIENT.file_rename(file_id, new_name) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/file_batch_copy", summary="批量复制文件", tags=["文件"]) |
| async def file_batch_copy(ids: List[str] = Body(..., embed=True), to_parent_id: str = Body(..., embed=True)): |
| if not ids: raise HTTPException(status_code=400, detail="IDs list required") |
| logger.info(f"API Request: Batch copy IDs: {ids} to Parent: {to_parent_id}") |
| try: return await THUNDERX_CLIENT.file_batch_copy(ids, to_parent_id) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/file_batch_move", summary="批量移动文件", tags=["文件"]) |
| async def file_batch_move(ids: List[str] = Body(..., embed=True), to_parent_id: str = Body(..., embed=True)): |
| if not ids: raise HTTPException(status_code=400, detail="IDs list required") |
| logger.info(f"API Request: Batch move IDs: {ids} to Parent: {to_parent_id}") |
| try: return await THUNDERX_CLIENT.file_batch_move(ids, to_parent_id) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/create_folder", summary="创建文件夹", tags=["文件"]) |
| async def create_folder(name: str = Body(..., embed=True), parent_id: str = Body("", embed=True)): |
| folder_name = name.strip(); |
| if not folder_name: raise HTTPException(status_code=400, detail="Name required") |
| logger.info(f"API Request: Create folder '{folder_name}' in Parent: {parent_id or 'root'}") |
| try: return await THUNDERX_CLIENT.create_folder(folder_name, parent_id) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/delete_to_trash", summary="批量移到回收站", tags=["文件"]) |
| async def delete_to_trash_api(ids: List[str] = Body(..., embed=True)): |
| if not ids: raise HTTPException(status_code=400, detail="IDs list required") |
| logger.info(f"API Request: Move to trash IDs: {ids}") |
| try: return await THUNDERX_CLIENT.delete_to_trash(ids) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/delete_forever", summary="批量彻底删除", tags=["文件"]) |
| async def delete_forever_api(ids: List[str] = Body(..., embed=True)): |
| if not ids: raise HTTPException(status_code=400, detail="IDs list required") |
| logger.warning(f"API Request: PERMANENTLY DELETE IDs: {ids}") |
| try: return await THUNDERX_CLIENT.delete_forever(ids) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/untrash", summary="批量从回收站恢复", tags=["文件"]) |
| async def untrash_api(ids: List[str] = Body(..., embed=True)): |
| if not ids: raise HTTPException(status_code=400, detail="IDs list required") |
| logger.info(f"API Request: Restore from trash IDs: {ids}") |
| try: return await THUNDERX_CLIENT.untrash(ids) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/file_batch_star", summary="批量加星", tags=["文件"]) |
| async def file_batch_star(ids: List[str] = Body(..., embed=True)): |
| if not ids: raise HTTPException(status_code=400, detail="IDs list required") |
| logger.info(f"API Request: Star items IDs: {ids}") |
| try: return await THUNDERX_CLIENT.file_batch_star(ids) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/file_batch_unstar", summary="批量取消加星", tags=["文件"]) |
| async def file_batch_unstar(ids: List[str] = Body(..., embed=True)): |
| if not ids: raise HTTPException(status_code=400, detail="IDs list required") |
| logger.info(f"API Request: Unstar items IDs: {ids}") |
| try: return await THUNDERX_CLIENT.file_batch_unstar(ids) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/emptytrash", summary="清空回收站", tags=["文件"]) |
| async def emptytrash_api(): |
| logger.warning(f"API Request: Empty trash.") |
| try: return await THUNDERX_CLIENT.emptytrash() |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.get("/get_share_list", summary="获取分享列表", tags=["分享"]) |
| async def get_share_list(next_page_token: str = Query("")): |
| logger.info(f"API Request: Get share list. Token: {next_page_token or 'start'}") |
| try: return await THUNDERX_CLIENT.get_share_list(next_page_token) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/file_batch_share", summary="批量创建分享", tags=["分享"]) |
| async def file_batch_share(file_ids: List[str]=Body(..., embed=True), need_password: bool=Body(False, embed=True), expiration_days: int=Body(-1, embed=True)): |
| if not file_ids: raise HTTPException(status_code=400, detail="File IDs required") |
| logger.info(f"API Request: Create share IDs: {file_ids}, Pwd: {need_password}, Exp: {expiration_days}") |
| try: return await THUNDERX_CLIENT.file_batch_share(file_ids, need_password, expiration_days) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/share_batch_delete", summary="批量取消分享", tags=["分享"]) |
| async def share_batch_delete(share_ids: List[str] = Body(..., embed=True)): |
| if not share_ids: raise HTTPException(status_code=400, detail="Share IDs required") |
| logger.info(f"API Request: Delete shares IDs: {share_ids}") |
| try: return await THUNDERX_CLIENT.share_batch_delete(share_ids) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/get_share_folder/{share_id}", summary="获取分享文件夹信息", tags=["分享"]) |
| async def get_share_folder(share_id: str = Path(...), parent_id: str = Body("", embed=True)): |
| logger.info(f"API Request: Get share folder. Share: {share_id}, Folder: {parent_id or 'root'}") |
| try: return await THUNDERX_CLIENT.get_share_folder(share_id, parent_id) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/restore/{share_id}", summary="转存分享", tags=["分享"]) |
| async def restore_api(share_id: str=Path(...), file_ids: Optional[List[str]]=Body(None, embed=True), parent_folder_id: Optional[str]=Body(None, embed=True)): |
| target_folder = parent_folder_id if parent_folder_id is not None else DEFAULT_DOWNLOAD_FOLDER_ID |
| files_to_restore = file_ids if file_ids else None |
| logger.info(f"API Request: Restore share. Share: {share_id}, Files: {files_to_restore or 'All'}, Target: {target_folder or 'Root'}") |
| try: |
| result = await THUNDERX_CLIENT.restore(share_id, files_to_restore, target_folder) |
| if isinstance(result, dict) and result.get("error"): raise HTTPException(status_code=400, detail=result.get("error_description", "API error")) |
| return result |
| except HTTPException: raise |
| except Exception as e: |
| if "System folder type is unknown" in str(e): raise HTTPException(status_code=403, detail="Restore failed: System folder type is unknown") |
| raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.get("/offline_list", summary="离线任务列表", tags=["离线任务"]) |
| async def offline_list(size: int=Query(100), next_page_token: Optional[str]=Query(None), phase: Optional[str]=Query(None)): |
| logger.info(f"API Request: Get offline list. Size: {size}, Token: {next_page_token}, Phase: {phase}") |
| try: return await THUNDERX_CLIENT.offline_list(size, next_page_token, phase) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/delete_tasks", summary="批量删除离线任务", tags=["离线任务"]) |
| async def delete_tasks_api(task_ids: List[str]=Body(..., embed=True), delete_files: bool=Body(False, embed=True)): |
| if not task_ids: raise HTTPException(status_code=400, detail="Task IDs required") |
| log_level = logging.WARNING if delete_files else logging.INFO |
| logger.log(log_level, f"API Request: Delete tasks IDs: {task_ids}, Delete files: {delete_files}") |
| try: return await THUNDERX_CLIENT.delete_tasks(task_ids, delete_files) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.get("/userinfo", summary="用户信息", tags=["账号"]) |
| async def userinfo(): |
| logger.info("API Request: Get user info.") |
| try: return await THUNDERX_CLIENT.get_user_info() |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.get("/quota", summary="空间使用信息", tags=["账号"]) |
| async def quota_info_api(): |
| logger.info("API Request: Get quota info.") |
| try: return await THUNDERX_CLIENT.get_quota_info() |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
| @api_router.post("/get_invite_code", summary="获取邀请码信息", tags=["账号"]) |
| async def get_invite_code(channel: str = Body("TELEGRAM", embed=True)): |
| logger.info(f"API Request: Get invite code channel: {channel}") |
| try: return await THUNDERX_CLIENT.get_invite_code(channel) |
| except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") |
|
|
|
|
| |
| app.include_router(front_router) |
| app.include_router(api_router) |
|
|
| |