From 61b5205503d46d36f6d11e45d05bc2418ed084c7 Mon Sep 17 00:00:00 2001 From: wuxu Date: Sat, 27 Dec 2025 10:45:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E5=B9=85=E5=A2=9E=E5=8A=A0=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E6=97=B6=E9=97=B4,=20=E5=A2=9E=E5=8A=A0=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 9 ++ Dockerfile | 24 ++++ bot.py | 287 +++++++++++++++++++++++++++++++++++++++------ docker-compose.yml | 41 +++++++ requirements.txt | 1 + 5 files changed, 325 insertions(+), 37 deletions(-) create mode 100644 .env create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..428de45 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +# 你的机器人 Token (使用你新生成的!) +BOT_TOKEN=8449769801:AAGdXIfkGfVIHg72FPXkBvS_7p5Lii8VEF8 + +# 你的用户 ID +AUTHORIZED_USER_ID=5701893747 + +# 你从 my.telegram.org 获取的凭据 +TELEGRAM_API_ID=17920091 +TELEGRAM_API_HASH= fcd526da4fa4f802b1a1b61c6370f8df \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..18ccdf0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim-bookworm + +# 设置环境变量,确保 Python 输出为 UTF-8 +ENV PYTHONIOENCODING=utf-8 +ENV LANG C.UTF-8 + +# 设置工作目录 +WORKDIR /app + +# 复制依赖文件并安装依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制所有的应用程序代码到工作目录 +COPY bot.py . + +# 声明存储目录为一个卷 +# 这表明 /app/local_storage 路径下的数据应该被持久化 +# 注意:实际的路径将由 bot.py 中的 STORAGE_DIR 环境变量决定 +# 我们在这里声明默认值 +VOLUME /app/local_storage + + +CMD ["python", "bot.py"] diff --git a/bot.py b/bot.py index 0b4f6fd..4c713ad 100644 --- a/bot.py +++ b/bot.py @@ -1,32 +1,57 @@ +# -*- coding: utf-8 -*- import os import logging import mimetypes +import re +import asyncio from datetime import datetime + from aiogram import Bot, Dispatcher, types, F -from aiogram.filters import Command -from aiogram.types import FSInputFile +from aiogram.filters import Command, StateFilter # <--- [修改1] 引入 StateFilter +from aiogram.types import FSInputFile, ForceReply from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.client.telegram import TelegramAPIServer +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.context import FSMContext # === CONFIGURATION === -BOT_TOKEN = "YOUR_BOT_TOKEN_HERE" -AUTHORIZED_USER_ID = 123456789 # Replace with your Telegram user_id -STORAGE_DIR = "local_storage" +# 从环境变量中安全地读取配置 +BOT_TOKEN = os.environ.get("BOT_TOKEN") +# 将 USER_ID 转换为整数,如果未设置则默认为 0 +AUTHORIZED_USER_ID = int(os.environ.get("AUTHORIZED_USER_ID", 0)) +# 允许从环境变量覆盖存储目录 +STORAGE_DIR = os.environ.get("STORAGE_DIR", "local_storage") + +# === 启动时检查 === +if not BOT_TOKEN: + raise ValueError("错误:未设置 BOT_TOKEN 环境变量。") +if AUTHORIZED_USER_ID == 0: + raise ValueError("错误:未设置 AUTHORIZED_USER_ID 环境变量。") # === LOGGING === logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # === INITIAL SETUP === -bot = Bot(token=BOT_TOKEN) +# 增加 timeout 设置,防止 session 层面的过早断开 +local_api_server = TelegramAPIServer.from_base("http://bot-api:8081") +session = AiohttpSession(api=local_api_server, timeout=7200) +bot = Bot(token=BOT_TOKEN, session=session) dp = Dispatcher() +# === STATE DEFINITION === +class RenameState(StatesGroup): + waiting_for_new_name = State() + +# === HELPER FUNCTIONS === def ensure_storage(): """Ensure the local storage directory exists.""" if not os.path.exists(STORAGE_DIR): os.makedirs(STORAGE_DIR) - logger.info("Storage folder created.") + logger.info(f"Storage folder created at: {STORAGE_DIR}") else: - logger.info("Storage folder exists.") + pass def is_authorized(user_id: int) -> bool: """Check if the user is authorized.""" @@ -48,10 +73,13 @@ def get_storage_summary(): for root, dirs, files in os.walk(STORAGE_DIR): for f in files: path = os.path.join(root, f) - size = os.path.getsize(path) - mtime = datetime.fromtimestamp(os.path.getmtime(path)) - total_size += size - files_info.append((f, size, mtime)) + try: + size = os.path.getsize(path) + mtime = datetime.fromtimestamp(os.path.getmtime(path)) + total_size += size + files_info.append((f, size, mtime)) + except FileNotFoundError: + logger.warning(f"File not found during summary: {path}") return total_size, files_info def get_extension_from_mime(mime_type: str) -> str: @@ -79,9 +107,14 @@ async def cmd_overview(message: types.Message): await message.answer("📁 Storage is empty.") return + files_info.sort(key=lambda x: x[2], reverse=True) + summary = "\n".join( - [f"{f} — {s/1024:.1f} KB — {d.strftime('%Y-%m-%d %H:%M:%S')}" for f, s, d in files_info] + [f"{f} — {s/1024:.1f} KB — {d.strftime('%Y-%m-%d %H:%M')}" for f, s, d in files_info] ) + if len(summary) > 3500: + summary = summary[:3500] + "\n... (list truncated)" + await message.answer(f"📦 Files:\n{summary}\n\nTotal: {total_size/1024:.1f} KB") @dp.message(Command("search")) @@ -102,10 +135,11 @@ async def cmd_search(message: types.Message): return builder = InlineKeyboardBuilder() - for i, f in enumerate(results): + for i, f in enumerate(results[:50]): builder.button(text=f"{i+1}. {os.path.basename(f)}", callback_data=f"get|{f}") + builder.adjust(1) - await message.answer("🔍 Found files:", reply_markup=builder.as_markup()) + await message.answer(f"🔍 Found {len(results)} file(s):", reply_markup=builder.as_markup()) @dp.callback_query(F.data.startswith("get|")) async def cb_get_file(callback: types.CallbackQuery): @@ -115,10 +149,16 @@ async def cb_get_file(callback: types.CallbackQuery): path = callback.data.split("|", 1)[1] if not os.path.exists(path): - await callback.message.answer("File not found.") + await callback.message.answer("File not found (it may have been deleted).") + await callback.answer() return - - await callback.message.answer_document(FSInputFile(path)) + + await callback.answer(f"Sending {os.path.basename(path)}...") + try: + await callback.message.answer_document(FSInputFile(path)) + except Exception as e: + logger.error(f"Failed to send file {path}: {e}") + await callback.message.answer(f"Error sending file: {e}") @dp.message(Command("delete")) async def cmd_delete(message: types.Message): @@ -128,7 +168,7 @@ async def cmd_delete(message: types.Message): args = message.text.split(maxsplit=1) if len(args) < 2: - await message.answer("Usage: /delete ") + await message.answer("Usage: /delete ") return keyword = args[1] @@ -137,27 +177,44 @@ async def cmd_delete(message: types.Message): await message.answer("File not found.") return + deleted_files = [] for f in matches: - os.remove(f) + try: + os.remove(f) + deleted_files.append(os.path.basename(f)) + except OSError as e: + logger.error(f"Failed to delete {f}: {e}") + + if deleted_files: + await message.answer(f"🗑️ Deleted {len(deleted_files)} file(s):\n" + "\n".join(deleted_files)) + else: + await message.answer("Could not delete matching files.") - await message.answer(f"🗑️ Deleted {len(matches)} file(s).") # === MESSAGE HANDLER FOR FILES/TEXT === -@dp.message(F.photo | F.video | F.document | F.text) +# [修改2] 增加 StateFilter(None),只有在当前没有处于任何 FSM 状态时,才执行这个保存逻辑 +# 这样当我们在 waiting_for_new_name 状态时,就不会误触发这里 +@dp.message(F.photo | F.video | F.document | F.text, StateFilter(None)) async def handle_incoming(message: types.Message): if not is_authorized(message.from_user.id): await message.answer("🚫 Access denied.") return ensure_storage() + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') # --- Handle text --- if message.text and not (message.photo or message.video or message.document): - filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - path = os.path.join(STORAGE_DIR, filename) + filename = f"{timestamp}.txt" + subdir = "documents" + target_dir = os.path.join(STORAGE_DIR, subdir) + if not os.path.exists(target_dir): + os.makedirs(target_dir) + + path = os.path.join(target_dir, filename) with open(path, "w", encoding="utf-8") as f: f.write(message.text) - await message.answer(f"💾 Text saved as `{filename}`", parse_mode="Markdown") + await message.answer(f"💾 Text saved as `{filename}` (in /{subdir})", parse_mode="Markdown") return # --- Handle media/documents --- @@ -167,36 +224,192 @@ async def handle_incoming(message: types.Message): if message.document: file_obj = message.document - file_name = message.document.file_name or f"{datetime.now().strftime('%Y%m%d_%H%M%S')}" + file_name = message.document.file_name mime_type = message.document.mime_type elif message.photo: file_obj = message.photo[-1] # Highest resolution - file_name = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg" + file_name = f"{timestamp}.jpg" mime_type = "image/jpeg" elif message.video: file_obj = message.video - file_name = message.video.file_name or f"{datetime.now().strftime('%Y%m%d_%H%M%S')}" + file_name = message.video.file_name mime_type = message.video.mime_type + if not file_name: # Fallback name + file_name = timestamp + if file_obj: - ext = os.path.splitext(file_name)[1] + logger.info(f"Received file. Original file_name variable: '{file_name}' (MIME: {mime_type})") + + base_name, ext = os.path.splitext(file_name) if not ext: ext = get_extension_from_mime(mime_type) if not ext: - ext = ".bin" # Fallback + ext = ".bin" + + safe_base_name = "".join(c for c in base_name if c.isalnum() or c in (' ', '.', '_', '-')).rstrip() + final_name = f"{timestamp}_{safe_base_name}{ext}" + + # --- 子目录分类逻辑 --- + subdir = "others" + if mime_type: + if mime_type.startswith("image/"): + subdir = "pictures" + elif mime_type.startswith("video/"): + subdir = "video" + elif mime_type.startswith("audio/"): + subdir = "audio" + elif mime_type in ("application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/rtf", "application/epub+zip", "text/plain", "text/csv", "text/markdown", "text/html"): + subdir = "documents" + elif mime_type in ("application/zip", "application/vnd.rar", "application/x-zip-compressed", "application/x-rar-compressed", "application/gzip", "application/x-7z-compressed", "application/x-tar", "application/x-bzip2"): + subdir = "archives" + elif mime_type in ("application/vnd.android.package-archive", "application/octet-stream", "application/x-msdownload", "application/x-apple-diskimage", "application/x-debian-package", "application/x-rpm", "application/x-sh", "application/x-csh", "application/bat"): + subdir = "executables" + elif mime_type in ("application/x-iso9660-image",): + subdir = "disk_images" + + target_dir = os.path.join(STORAGE_DIR, subdir) + if not os.path.exists(target_dir): + try: + os.makedirs(target_dir) + except OSError as e: + logger.error(f"Failed to create subdirectory {target_dir}: {e}") + target_dir = STORAGE_DIR + + destination_path = os.path.join(target_dir, final_name) + logger.info(f"Target file: '{final_name}' in '{subdir}'") + + # === 重试循环 === + max_retries = 3 + for attempt in range(max_retries): + try: + logger.info(f"Requesting file info for file_id: {file_obj.file_id} (Attempt {attempt+1}/{max_retries})") + + # 7200秒超时 + file_info = await bot.get_file(file_obj.file_id, request_timeout=7200) + + source_path = file_info.file_path + logger.info(f"File info received. Path on disk: {source_path}") - final_name = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" - path = os.path.join(STORAGE_DIR, final_name) - file_info = await bot.get_file(file_obj.file_id) - await bot.download_file(file_info.file_path, path) - await message.answer(f"💾 Saved `{final_name}` ({mime_type or 'unknown'})", parse_mode="Markdown") + if not os.path.abspath(source_path).startswith(os.path.abspath(STORAGE_DIR)): + logger.error(f"FATAL: File path {source_path} is outside STORAGE_DIR. Fallback download.") + await bot.download_file(file_info.file_path, destination_path) + else: + if os.path.exists(source_path): + os.rename(source_path, destination_path) + logger.info(f"Moved file to {destination_path}") + else: + raise FileNotFoundError(f"API says file is at {source_path} but it is missing.") + + break + + except Exception as e: + logger.warning(f"Attempt {attempt+1} failed: {e}") + + if attempt == max_retries - 1: + logger.error("All retry attempts failed.") + await message.answer(f"❌ Failed after {max_retries} attempts. Server Error: {e}") + return + + await asyncio.sleep(5) + + # === 文件保存成功后 === + try: + builder = InlineKeyboardBuilder() + builder.button(text="✏️ 重命名", callback_data="rename_file") + + await message.answer( + f"💾 Saved `{final_name}` (in /{subdir})", + parse_mode="Markdown", + reply_markup=builder.as_markup() + ) + except Exception as e: + logger.error(f"Failed to send success message: {e}") + + +# === RENAME FEATURE HANDLERS === + +@dp.callback_query(F.data == "rename_file") +async def click_rename(callback: types.CallbackQuery, state: FSMContext): + if not is_authorized(callback.from_user.id): + return + + message_text = callback.message.text + match = re.search(r"Saved (.+) \(in /(.+)\)", message_text) + + if not match: + await callback.answer("❌ 无法解析文件路径,可能消息格式已变。", show_alert=True) + return + + old_filename = match.group(1).strip() + subdir = match.group(2).strip() + + full_path = os.path.join(STORAGE_DIR, subdir, old_filename) + + if not os.path.exists(full_path): + await callback.answer("❌ 文件已不存在(可能已被删除或移动)。", show_alert=True) + return + + await state.update_data( + file_path=full_path, + file_dir=os.path.dirname(full_path), + old_ext=os.path.splitext(old_filename)[1] + ) + + await state.set_state(RenameState.waiting_for_new_name) + + await callback.message.answer( + f"当前文件名: `{old_filename}`\n请直接回复**新的文件名** (无需后缀):", + parse_mode="Markdown", + reply_markup=ForceReply(selective=True) + ) + await callback.answer() + +@dp.message(RenameState.waiting_for_new_name) +async def process_rename(message: types.Message, state: FSMContext): + if not is_authorized(message.from_user.id): + return + + new_name_input = message.text.strip() + safe_new_name = "".join(c for c in new_name_input if c.isalnum() or c in (' ', '.', '_', '-')).rstrip() + + if not safe_new_name: + await message.answer("❌ 文件名无效,请重新输入。") + return + + data = await state.get_data() + old_path = data.get('file_path') + file_dir = data.get('file_dir') + old_ext = data.get('old_ext') + + if not old_path or not os.path.exists(old_path): + await message.answer("❌ 原文件已丢失,操作取消。") + await state.clear() + return + + if not os.path.splitext(safe_new_name)[1]: + safe_new_name += old_ext + + new_path = os.path.join(file_dir, safe_new_name) + + if os.path.exists(new_path): + await message.answer("❌ 目标文件名已存在,请换一个名字。") + return + + try: + os.rename(old_path, new_path) + await message.answer(f"✅ 重命名成功!\n`{safe_new_name}`", parse_mode="Markdown") + except OSError as e: + logger.error(f"Rename failed: {e}") + await message.answer(f"❌ 重命名失败: {e}") + + await state.clear() # === MAIN ENTRY === async def main(): ensure_storage() - logger.info("🚀 Bot is starting...") + logger.info(f"🚀 Bot is starting... Storage: {STORAGE_DIR}") await dp.start_polling(bot) if __name__ == "__main__": - import asyncio asyncio.run(main()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e508cde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + bot: + build: + context: . + container_name: secure-file-bot + restart: unless-stopped + depends_on: + - bot-api + volumes: + # 1. 路径已更改,以匹配 bot-api 的默认值 + - ./data:/var/lib/telegram-bot-api + environment: + # 2. 告诉 bot.py 脚本新的存储目录 + - STORAGE_DIR=/var/lib/telegram-bot-api + env_file: + - ./.env + + bot-api: + image: aiogram/telegram-bot-api:latest # 使用你指定的镜像 + container_name: local-bot-api + restart: unless-stopped + + # 3. 删除了 'command:' 行。 + # TELEGRAM_LOCAL=1 环境变量会自动处理 --local 标志。 + + ports: + - "8081:8081" + + volumes: + # 4. 两个服务现在共享完全相同的路径 + - ./data:/var/lib/telegram-bot-api + + environment: + # 这些将从 .env 文件中读取 + - TELEGRAM_API_ID=${TELEGRAM_API_ID} + - TELEGRAM_API_HASH=${TELEGRAM_API_HASH} + # 这一行是 aiogram 镜像所需要的,它会自动启用 --local + - TELEGRAM_LOCAL=1 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..823afd9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiogram==3.13 \ No newline at end of file