t / main.py
naxbr's picture
Rename main (1).py to main.py
547b952 verified
Raw
History Blame Contribute Delete
98.1 kB
import os
import asyncio
import json
import logging
import re # For hash checking and Markdown escaping
import base64 # For encoding path parts in callback data if needed (currently simplified)
import time # NEW: For cleanup task duration calculation
from datetime import datetime, timedelta # NEW: Import datetime and timedelta for scheduling
from collections import deque # NEW: For cleanup task folder traversal
# MODIFIED: Import Bot, CallbackQuery for typing and context
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Bot, CallbackQuery
# MODIFIED: Import constants explicitly for parse_mode
from telegram.constants import ParseMode
from telegram.error import BadRequest, TelegramError # Import BadRequest and base TelegramError
from telegram.ext import (
Application,
CommandHandler,
ConversationHandler,
MessageHandler,
CallbackQueryHandler,
CallbackContext,
ContextTypes,
filters,
)
import httpx
from pikpakapi import PikPakApi
# NEW: Import scheduler library
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from typing import Union, Any, Dict, List, Optional, Tuple # Added 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 # MODIFIED: Import Field
# NEW: Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Constants ---
ITEMS_PER_PAGE = 10 # NEW: Items per page for TG file browser (Adjust as needed)
# SIMPLIFICATION: Define shared command help text
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 = ""
# MODIFIED: Make parent_id optional in request body, default handled later
parent_id: str | None = Field(default=None, description="目标文件夹ID, 不填则使用环境变量或根目录")
name: str | None = ""
class Config:
extra = Extra.allow
security = HTTPBearer()
# SECRET_TOKEN = "SECRET_TOKEN"
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")
# NEW: Environment variables for cleanup and default download folder
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "false").lower() == "true"
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "48")) # Default: Run every 2 days
CLEANUP_SIZE_THRESHOLD_MB = int(os.getenv("CLEANUP_SIZE_THRESHOLD_MB", "50")) # Default: 50MB
CLEANUP_TARGET_FOLDER_ID = os.getenv("CLEANUP_TARGET_FOLDER_ID", None) # Default: None (clean root or specific folder based on RECURSIVE)
DEFAULT_DOWNLOAD_FOLDER_ID = os.getenv("DEFAULT_DOWNLOAD_FOLDER_ID", "") # Default: Root directory ""
# --- NEW: Cleanup Recursion Option ---
# Default to False (non-recursive) if not specified. Set to "true" to enable recursion.
CLEANUP_RECURSIVE = os.getenv("CLEANUP_RECURSIVE", "false").lower() == "true"
logger.info(f"Cleanup recursive mode enabled: {CLEANUP_RECURSIVE}")
# ------------------------------------
# --- NEW: Admin Chat ID for Notifications ---
TG_ADMIN_CHAT_ID = os.getenv("TG_ADMIN_CHAT_ID") # Used for notifications AND authorization
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.")
# -------------------------------------------
# --- Convert Admin ID for Filtering ---
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 # Disable filtering if ID is invalid
else:
# If no admin ID is set, log an error because restriction is expected
logger.error("TG_ADMIN_CHAT_ID not set. Telegram Bot will respond to *any* user. User restriction is disabled!")
# NEW: Calculate size threshold in bytes
CLEANUP_SIZE_THRESHOLD_BYTES = CLEANUP_SIZE_THRESHOLD_MB * 1024 * 1024
# NEW: Scheduler instance
scheduler = AsyncIOScheduler()
async def verify_token(
request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)
):
# 验证Bearer格式
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) # Try converting string size from API
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}") # MODIFIED: Use logger
THUNDERX_CLIENT = None
TG_BOT_APPLICATION: Optional[Application] = None # MODIFIED: Type hint for clarity
TG_BASE_URL = "https://api.telegram.org/bot"
# --- Helper to escape MarkdownV2 ---
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)
# Characters to escape: _ * [ ] ( ) ~ ` > # + - = | { } . !
escape_chars = r'[_*\[\]()~`>#+\-=|{}.!]'
return re.sub(escape_chars, r'\\\g<0>', text_str)
# --- SIMPLIFICATION: Telegram Error Sending Helper ---
async def send_telegram_error_message(
target: Union[Update, CallbackQuery], # Can be Update or Query
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) # Log without exception details if e is None
final_text = f"❌ {error_text}"
try:
if isinstance(target, CallbackQuery):
# Try to edit the message the query came from
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) # Show alert as well
elif isinstance(target, Update) and target.message:
# Send as a reply to the original 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: # Catch specific Telegram errors during error reporting
logger.error(f"Failed to send Telegram error notification! Original error: {log_message}. TG Error: {tg_err}")
except Exception as final_err: # Catch any other errors during error reporting
logger.error(f"Unexpected error while sending Telegram error notification! Original error: {log_message}. Final Error: {final_err}")
# --- SIMPLIFICATION: Cleanup Helper - Deletion ---
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}"
# Combine status message and error detail if error occurred during deletion
if error_occurred and not status_message.startswith("清理任务在删除阶段失败"):
status_message = f"删除阶段出错 ({mode}模式): {error_message_detail}"
return deleted_count, status_message, error_occurred
# --- SIMPLIFICATION: Cleanup Helper - Notification ---
async def _send_cleanup_notification(
mode: str,
error_occurred: bool,
deleted_count: int,
duration_str: str,
folders_scanned: Optional[int], # None for Non-Recursive
total_files_scanned: int,
target_folder_display: Optional[str], # None for Recursive
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
# Determine status icon and summary
if error_occurred:
status_icon = "❌"
status_summary = "任务执行过程中遇到错误。"
elif deleted_count > 0:
status_icon = "✅"
status_summary = "任务成功完成,部分文件已清理。"
else:
status_icon = "ℹ️"
status_summary = "任务完成,未发现需要清理的文件。"
# Build notification text
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: # Only show for recursive
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: # Only show for non-recursive
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}`"
# Send the 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)
# --- Main Cleanup Dispatcher Function ---
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()
# --- End of Main Cleanup Dispatcher Function ---
# --- Recursive Cleanup Function ---
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 = "递归" # For helpers
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 = "" # Store specific error message
final_status_message = "递归清理任务已启动。" # Overall status
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:
# --- Scanning Phase ---
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 # Track error within this folder's pagination
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']:
# ... (logic to identify small files and folders - unchanged) ...
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 # Mark overall error
error_in_folder = True
error_message_detail = f"获取文件夹 {current_folder_id} 列表时出错: {e}"
final_status_message = f"清理任务因扫描错误结束 ({mode}模式): {error_message_detail}"
break # Stop pagination for this folder
if error_in_folder:
logger.warning(f"[Recursive] Skipping further scan in folder '{current_folder_id}' due to previous error.")
# Optionally: break # Stop entire scan on first folder error? Current logic continues.
await asyncio.sleep(1) # Delay between folders
# --- Deletion Phase (using helper) ---
# Only perform deletion if no scanning errors occurred
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 # Update overall status
if error_delete:
error_occurred = True # Mark overall error if deletion failed
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:
# --- Send Notification (using helper) ---
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, # Pass folder count
total_files_scanned=total_files_scanned,
target_folder_display=None, # Not applicable for recursive report summary
status_message=final_status_message
)
# --- End of Recursive Cleanup Function ---
# --- Non-Recursive Cleanup Function ---
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 = "非递归" # For helpers
target_folder_id_actual = CLEANUP_TARGET_FOLDER_ID if CLEANUP_TARGET_FOLDER_ID else ""
target_folder_display = target_folder_id_actual or 'Root' # For logging/display
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:
# --- Scanning Phase ---
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']:
# ... (logic to identify small files - unchanged) ...
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 # Stop cleanup on error
# --- Deletion Phase (using helper) ---
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:
# --- Send Notification (using helper) ---
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, # Not applicable for non-recursive
total_files_scanned=total_files_scanned,
target_folder_display=target_folder_display, # Pass target folder
status_message=final_status_message
)
# --- End of Non-Recursive Cleanup Function ---
###################TG机器人功能区###################
# --- Authorization Check Function (Helper) ---
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
# --- Command Handlers ---
async def start(update: Update, context: CallbackContext):
# SIMPLIFICATION: Use template text
bot_username = escape_markdown(context.bot.username)
example_hash = escape_markdown("96451E6F1ADBC8827B43621B74EDB30DF45012D6") # Escape example hash
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):
# SIMPLIFICATION: Use template text
bot_username = escape_markdown(context.bot.username)
example_hash = escape_markdown("96451E6F1ADBC8827B43621B74EDB30DF45012D6") # Escape example hash
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):
# Use message reference for potential edit
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:
# SIMPLIFICATION: Use error helper
await send_telegram_error_message(
target=update, # Pass Update object here
error_text="获取空间信息失败",
log_message="Error fetching quota info",
e=e
)
# Attempt to clean up the "Loading..." message if possible
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:
# SIMPLIFICATION: Use error helper
await send_telegram_error_message(
target=update,
error_text="执行清空回收站时出错",
log_message="Error emptying trash",
e=e
)
try: await message_to_edit.delete()
except: pass
# --- Message Handler ---
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 # Store reference for editing status messages
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}")
# --- Share Link Handling ---
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:
# SIMPLIFICATION: Use error helper
await send_telegram_error_message(
target=update, # Original update obj
error_text=f"转存分享 `{safe_share_id}` 时出错",
log_message=f"Error restoring share {share_id}",
e=e
)
try: await message_to_edit.delete() # Clean up loading message
except: pass
else:
await update.message.reply_text(f"❌ 无效的 `share:` 格式。请使用 `share:<分享ID>`。")
return # Handled share
# --- Link/Hash Handling ---
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:
# SIMPLIFICATION: Use error helper
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 # Handled offline task
# --- Unsupported Message ---
if not processed and not text.startswith('/'):
logger.info(f"Ignoring unsupported message format: {text[:100]}...")
# await update.message.reply_text(f"ℹ️ 不支持的消息格式。请发送 magnet/http/hash/share 链接。\n使用 /help 查看帮助。")
# --- Callback Handler for Copy Text ---
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) # Changed show_alert to False
except BadRequest as e:
if "message is not modified" in str(e).lower(): await query.answer()
else: raise e
except Exception as e:
# SIMPLIFICATION: Use error helper
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"]:
# ... (Button building logic largely unchanged, ensures lengths checked) ...
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
# ... (logic to handle long callbacks - unchanged) ...
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:
# SIMPLIFICATION: Use error helper
await send_telegram_error_message(
target=update,
error_text="获取分享列表时出错",
log_message="Error fetching shares",
e=e
)
try: await message_to_edit.delete()
except: pass
# --- Callback Handler Modified with Auth Check ---
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)
# --- Callback Handler Modified with Auth Check ---
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:
# SIMPLIFICATION: Use error helper (editing the processing message)
await send_telegram_error_message(
target=query, # Pass query to allow editing
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)
# --- Generic Cancel Handler ---
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)
#################### 文件操作 (with Pagination) #############################
# --- Main function to show files with pagination ---
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:
# ... (Button building logic largely unchanged) ...
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 # Re-raise other BadRequests
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:
# SIMPLIFICATION: Use error helper
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
)
# We attempt to edit the loading message within send_telegram_error_message
# --- Initial command call ---
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)
# --- Callback Handlers for Pagination ---
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)) # Go back to previous page
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) # Go to next page
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) # Page is always 1 when going back
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)
# --- Callback Handler Modified with Auth Check ---
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:
# SIMPLIFICATION: Use error helper
await send_telegram_error_message(
target=query, # Pass query to allow editing
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)
# --- Callback Handler Modified with Auth Check ---
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 # Reference for potential edits
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"): # Fallback
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:
# SIMPLIFICATION: Use error helper
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
)
# Attempt to restore original message context if edit failed in helper
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:
# SIMPLIFICATION: Use error helper
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 button building logic unchanged) ...
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 "❓" # Added seeding emoji
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:
# SIMPLIFICATION: Use error helper
await send_telegram_error_message(
target=query if is_callback else update,
error_text="获取离线任务列表时出错",
log_message="Error fetching/editing offline tasks",
e=e
)
# Don't delete message_to_edit here, as the error msg should replace it
# --- Callback Handler for Refresh Tasks ---
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) # Call main function
except Exception as e:
# Error during refresh itself (not the initial fetch)
await send_telegram_error_message(query, "刷新失败", f"Error refreshing tasks: {query.data}", e)
# --- Callback Handler Modified with Auth Check ---
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)
# --- Callback Handler Modified with Auth Check ---
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:
# SIMPLIFICATION: Use error helper
await send_telegram_error_message(
target=query, # Pass query
error_text=f"删除任务 `{safe_task_id_short}` 时出错",
log_message=f"Error deleting task {task_id}",
e=e
)
# Add refresh button even on error (the helper will edit the message)
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 =====")
# --- PikPak Client Initialization (Unchanged) ---
token_file = "thunderx.json"
# ... (Existing PikPak loading/login logic) ...
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}")
# --- Initialize Telegram Bot (Unchanged logic, just handler registration) ---
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}")
# Register Handlers (Same handlers as before)
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
# --- Initialize Scheduler (Unchanged) ---
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 =====")
# --- Shutdown logic (Unchanged) ---
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.")
# --- Webhook Endpoint (Unchanged) ---
@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)
# --- Frontend Route (Unchanged) ---
@front_router.get("/", response_class=HTMLResponse, summary="前台页面", description="前台管理页面", tags=["前端"])
async def home(request: Request):
context = {"request": request}; return templates.TemplateResponse("index.html", context)
# --- API Endpoints (Largely unchanged, rely on existing logging/error handling) ---
# ... (All @api_router endpoints remain the same as in your provided code) ...
# Example of one endpoint structure (rest are similar)
@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)}")
# --- PASTE THE REST OF YOUR @api_router endpoints here ---
# (get_file_info, offline, file_star_list, delete_file_info, file_rename,
# file_batch_copy, file_batch_move, create_folder, delete_to_trash_api,
# delete_forever_api, untrash_api, file_batch_star, file_batch_unstar,
# emptytrash_api, get_share_list, file_batch_share, share_batch_delete,
# get_share_folder, restore_api, offline_list, delete_tasks_api, userinfo,
# quota_info_api, get_invite_code)
# Make sure they follow the same pattern of logging and try/except raising HTTPException
@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)}")
# --- Include routers ---
app.include_router(front_router)
app.include_router(api_router)
# --- END OF FILE main.py ---