Compare commits

...

10 Commits

Author SHA1 Message Date
61b5205503 大幅增加超时时间, 增加重命名机制 2025-12-27 10:45:21 +08:00
f0d1343955 更新 gitignore 2025-12-27 09:52:49 +08:00
nickdu088
07c64209c4 Add screenshot to README_cn.md 2025-11-03 10:49:28 +11:00
nickdu088
208ce13444 Revise example folder structure in README
Updated the example folder structure section to reflect server organization and added a screenshot.
2025-11-03 10:48:44 +11:00
nickdu088
40505239f2 Add files via upload 2025-11-03 10:45:28 +11:00
nickdu088
a937053d71 Add Chinese README link and update formatting 2025-11-03 10:44:59 +11:00
nickdu088
355f492c7e Update project title in README_cn.md 2025-11-03 09:48:27 +11:00
nickdu088
8305ae4d52 Update README_cn.md for bot file name changes
Updated README_cn.md to reflect changes in file names and descriptions.
2025-11-03 09:47:59 +11:00
nickdu088
8fa4c1315d Update README for bot filename and description 2025-11-03 09:46:25 +11:00
nickdu088
271b4432ae Add Chinese README for Secure File Bot
添加中文 README 文件,详细介绍了私有 Telegram 文件存储机器人的功能、安装步骤和指令说明。
2025-11-03 08:25:31 +11:00
9 changed files with 422 additions and 42 deletions

9
.env Normal file
View File

@@ -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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
data/*
local_storage/*

24
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -1,4 +1,5 @@
# 🛡️ Secure File Bot (aiogram)
# 🛡️ Personal Secure File Bot (aiogram)
[中文说明](README_cn.md)
A private **Telegram file storage bot** built with **[aiogram 3.x](https://docs.aiogram.dev/)**.
It securely accepts messages, pictures, videos, and documents **only from a specific Telegram user**,
@@ -6,6 +7,8 @@ then saves them to a local storage folder.
You can search, retrieve, delete, and summarize your stored files — all via Telegram commands.
[How to create a telegram bot](https://core.telegram.org/bots/tutorial)
---
## ✨ Features
@@ -43,7 +46,7 @@ STORAGE_DIR = "local_storage" # Folder where files are saved
## 🚀 Run the Bot
```
python secure_file_bot_aiogram.py
python bot.py
```
Once running, open your bot in Telegram and type:
```
@@ -69,10 +72,10 @@ Command Description
* When you send a document → saved using its original name and MIME extension
* Everything is organized in your local_storage/ folder
## 🧩 Example Folder Structure
## 🧩 Example Folder Structure in Server
```
secure_file_bot_aiogram/
├── secure_file_bot_aiogram.py
├── bot.py
├── requirements.txt
├── README.md
└── local_storage/
@@ -81,4 +84,4 @@ secure_file_bot_aiogram/
├── 20251103_141223.mp4
└── ...
```
![screen shot](screenshot.jpeg)

87
README_cn.md Normal file
View File

@@ -0,0 +1,87 @@
# 🛡️ 个人专属TG机器人 (aiogram)
一个基于 **[aiogram 3.x](https://docs.aiogram.dev/)** 构建的 **私有 Telegram 文件存储机器人**
它只允许指定的 Telegram 用户发送消息、图片、视频和文件,并自动保存到本地文件夹中。
你可以通过 Telegram 命令来 **搜索、获取、删除、概览** 文件,未来还可集成 OpenAI 或第三方 API。
[如何申请Telegram机器人](https://core.telegram.org/bots/tutorial)
---
## ✨ 功能特点
**访问控制** — 只有配置的 `user_id` 才能使用机器人。
**自动存储** — 自动保存文本、图片、视频和文件到本地。
**支持所有格式** — JPG、PNG、WEBP、MP4、AVI、MOV 等都能识别。
**搜索与获取** — 支持关键字搜索与快速取回文件。
**删除功能** — 可通过命令删除指定文件。
**概览功能** — 查看所有文件、大小、保存时间。
**可扩展性强** — 方便未来对接 OpenAI 或其他 API。
---
## 🧰 安装步骤
### 1. 克隆项目
```bash
git clone https://github.com/yourusername/secure-file-bot.git
cd secure-file-bot
```
### 2. 安装依赖
```
pip install aiogram==3.13
```
### 3. 配置参数
打开 Python 脚本 secure_file_bot_aiogram.py修改以下变量
```
BOT_TOKEN = "YOUR_BOT_TOKEN_HERE" # 从 @BotFather 获取的机器人 Token
AUTHORIZED_USER_ID = 123456789 # 你的 Telegram 用户 ID
STORAGE_DIR = "local_storage" # 本地文件存储目录
```
## 🚀 启动机器人
```
python bot.py
```
启动后,在 Telegram 中向机器人发送:
```
/start
```
## 💬 指令说明
| 命令 | 功能 |
| ----------------- | ------------------ |
| `/start` | 初始化机器人并创建存储目录 |
| `/overview` | 查看所有已保存文件的列表、大小与时间 |
| `/search <关键词>` | 按名称搜索文件 |
| `/get <编号>` | 从搜索结果中取回文件 |
| `/delete <文件名>` | 删除匹配的文件 |
| *(发送文本、图片、视频或文档)* | 自动保存到本地 |
## 🧠 工作原理
* 发送文本 → 自动保存为 .txt
* 发送图片 → 自动识别并保存为正确格式JPG、PNG、WEBP...
* 发送视频 → 自动识别并保存为正确格式MP4、AVI、MOV...
* 发送文件 → 使用原文件名与 MIME 类型保存
* 所有内容都会存放在 local_storage/ 文件夹中
## 🧩 项目结构示例
```
secure_file_bot_aiogram/
├── bot.py
├── requirements.txt
├── README.md
└── local_storage/
├── 20251103_140501.txt
├── 20251103_141012.jpg
├── 20251103_141223.mp4
└── ...
```
![screen shot](screenshot.jpeg)

287
bot.py
View File

@@ -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 <filename>")
await message.answer("Usage: /delete <filename_keyword>")
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())

41
docker-compose.yml Normal file
View File

@@ -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

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
aiogram==3.13

BIN
screenshot.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB