From 2eed4640f34aec36f53f9a7cdfa7fa7725f71db0 Mon Sep 17 00:00:00 2001 From: wuxu Date: Tue, 4 Nov 2025 09:39:45 +0800 Subject: [PATCH] init: first commit --- .env | 9 ++ .gitignore | 1 + Dockerfile | 24 ++++ bot.py | 319 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 37 ++++++ requirements.txt | 1 + 6 files changed, 391 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bot.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..cc7d5f6 --- /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/.gitignore b/.gitignore new file mode 100644 index 0000000..c6f9a44 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/settings.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..62172ae --- /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"] \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..e21bb94 --- /dev/null +++ b/bot.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +import os +import logging +import mimetypes +from datetime import datetime +from aiogram import Bot, Dispatcher, types, F +from aiogram.filters import Command +from aiogram.types import FSInputFile +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.client.telegram import TelegramAPIServer + +# === CONFIGURATION === +# 从环境变量中安全地读取配置 +BOT_TOKEN = os.environ.get("BOT_TOKEN") +# 将 USER_ID 转换为整数,如果未设置则默认为 0 +AUTHORIZED_USER_ID = int(os.environ.get("AUTHORIZED_USER_ID", 0)) +# 允许从环境变量覆盖存储目录,默认为 "local_storage" +# 注意:在 docker-compose 中,这个值被设为 /var/lib/telegram-bot-api +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 === +local_api_server = TelegramAPIServer.from_base("http://bot-api:8081") +session = AiohttpSession(api=local_api_server) +bot = Bot(token=BOT_TOKEN, session=session) +dp = Dispatcher() + +def ensure_storage(): + """Ensure the local storage directory exists.""" + if not os.path.exists(STORAGE_DIR): + os.makedirs(STORAGE_DIR) + logger.info(f"Storage folder created at: {STORAGE_DIR}") + else: + logger.info(f"Storage folder exists at: {STORAGE_DIR}") + +def is_authorized(user_id: int) -> bool: + """Check if the user is authorized.""" + return user_id == AUTHORIZED_USER_ID + +def search_files(keyword: str): + """Search for files containing the keyword.""" + matches = [] + for root, _, files in os.walk(STORAGE_DIR): + for f in files: + if keyword.lower() in f.lower(): + matches.append(os.path.join(root, f)) + return matches + +def get_storage_summary(): + """Return summary of all files.""" + total_size = 0 + files_info = [] + for root, dirs, files in os.walk(STORAGE_DIR): + for f in files: + path = os.path.join(root, f) + 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: + """Guess the correct extension from MIME type.""" + ext = mimetypes.guess_extension(mime_type or "") + return ext if ext else "" + +# === COMMAND HANDLERS === +@dp.message(Command("start")) +async def cmd_start(message: types.Message): + if not is_authorized(message.from_user.id): + await message.answer("🚫 Access denied.") + return + ensure_storage() + await message.answer("✅ Bot initialized! Local storage ready.") + +@dp.message(Command("overview")) +async def cmd_overview(message: types.Message): + if not is_authorized(message.from_user.id): + await message.answer("🚫 Access denied.") + return + + total_size, files_info = get_storage_summary() + if not files_info: + 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')}" for f, s, d in files_info] + ) + await message.answer(f"📦 Files:\n{summary}\n\nTotal: {total_size/1024:.1f} KB") + +@dp.message(Command("search")) +async def cmd_search(message: types.Message): + if not is_authorized(message.from_user.id): + await message.answer("🚫 Access denied.") + return + + args = message.text.split(maxsplit=1) + if len(args) < 2: + await message.answer("Usage: /search ") + return + + keyword = args[1] + results = search_files(keyword) + if not results: + await message.answer("🔍 No matching files found.") + return + + builder = InlineKeyboardBuilder() + # 限制搜索结果数量,避免消息过长 + 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(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): + if not is_authorized(callback.from_user.id): + await callback.message.answer("🚫 Access denied.") + return + + path = callback.data.split("|", 1)[1] + if not os.path.exists(path): + await callback.message.answer("File not found (it may have been deleted).") + await callback.answer() + return + + 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): + if not is_authorized(message.from_user.id): + await message.answer("🚫 Access denied.") + return + + args = message.text.split(maxsplit=1) + if len(args) < 2: + await message.answer("Usage: /delete ") + return + + keyword = args[1] + matches = search_files(keyword) + if not matches: + await message.answer("File not found.") + return + + deleted_files = [] + for f in matches: + 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.") + + +# === MESSAGE HANDLER FOR FILES/TEXT === +@dp.message(F.photo | F.video | F.document | F.text) +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"{timestamp}.txt" + # 文本文档也放入 'documents' 文件夹 + 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}` (in /{subdir})", parse_mode="Markdown") + return + + # --- Handle media/documents --- + file_obj = None + file_name = None + mime_type = None + is_photo = False + + if message.document: + file_obj = message.document + 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"{timestamp}.jpg" + mime_type = "image/jpeg" + is_photo = True # 标记为照片 + elif message.video: + file_obj = message.video + file_name = message.video.file_name + mime_type = message.video.mime_type + + if not file_name: # 备用文件名 + file_name = timestamp + + if file_obj: + logger.info(f"Received file. Original file_name variable: '{file_name}' (MIME: {mime_type}) [IsPhoto: {is_photo}]") + + # ... (文件名清理逻辑不变) ... + base_name, ext = os.path.splitext(file_name) + if not ext: + ext = get_extension_from_mime(mime_type) + if not ext: + ext = ".bin" # Fallback + + 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("video/"): + subdir = "video" + elif mime_type.startswith("image/"): + subdir = "pictures" + elif mime_type in ("application/zip", "application/x-zip-compressed", "application/x-rar-compressed", "application/gzip", "application/x-7z-compressed"): + subdir = "zip" + elif mime_type.startswith("audio/"): + subdir = "audio" + elif mime_type in ("application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/plain"): + subdir = "documents" + + target_dir = os.path.join(STORAGE_DIR, subdir) + + if not os.path.exists(target_dir): + try: + os.makedirs(target_dir) + logger.info(f"Created subdirectory: {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 name set to: '{final_name}' in subdir: '{subdir}'") + + # --- 根据 is_photo 区分处理 --- + try: + if is_photo: + # --- 照片处理逻辑 --- + # 照片无法使用 get_file + rename,必须直接下载 + logger.info(f"Processing as Photo. Downloading file_id: {file_obj.file_id}") + await bot.download_file(file_obj.file_id, destination_path) + logger.info(f"Downloaded photo to: {destination_path}") + + else: + # --- 视频/文档处理逻辑 --- + # 1. 从 API 获取文件信息 + file_info = await bot.get_file(file_obj.file_id) + + # 2. 这是文件在 API 服务器上的绝对路径 + source_path = file_info.file_path + + logger.info(f"Processing as Document/Video. File is already on disk at: {source_path}") + + # 3. 检查路径 + 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 {STORAGE_DIR}. Check docker-compose command.") + # 后备方案:尝试下载 (这会很慢,并且可能因404失败) + await bot.download_file(file_info.file_path, destination_path) + else: + # 4. 【核心】移动文件 + os.rename(source_path, destination_path) + logger.info(f"Moved file from {source_path} to {destination_path}") + + await message.answer(f"💾 Saved `{final_name}` (in /{subdir})", parse_mode="Markdown") + + except Exception as e: + # 确保在这里打印完整的异常,包括 file_id + logger.error(f"Failed to save file {file_obj.file_id} (Dest: {destination_path}): {e}", exc_info=True) + await message.answer(f"Error saving file: {e}") + +# === MAIN ENTRY === +async def main(): + ensure_storage() + 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..6cd0026 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + bot: + build: + context: . + # no_cache: true # 解决问题后请删除此行 + container_name: secure-file-bot + restart: unless-stopped + # 告诉 bot 服务在 bot-api 服务启动后再启动 + depends_on: + - bot-api + volumes: + # 路径必须与 bot-api 服务中的路径一致 + - ./local_storage:/app/local_storage + # 将敏感信息移动到 .env 文件中 + env_file: + - ./.env + + bot-api: + image: telegram/bot-api:latest + container_name: local-bot-api + restart: unless-stopped + # 关键:在这里使用 --local 标志 + command: telegram-bot-api --local --http-port 8081 + ports: + # (可选) 如果你希望从外部访问API服务器,取消注释下一行 + # - "8081:8081" + volumes: + # 路径必须与 bot 服务中的路径一致 + - ./local_storage:/app/local_storage + # (可选) API 服务器也需要一个工作目录来处理文件 + - ./bot_api_data:/var/lib/telegram-bot-api + environment: + # 这些变量将从 .env 文件中读取 + - TELEGRAM_API_ID=${TELEGRAM_API_ID} + - TELEGRAM_API_HASH=${TELEGRAM_API_HASH} \ No newline at end of file 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