├── requirements.txt ├── app ├── events.py ├── static │ ├── js │ │ ├── nav.js │ │ └── main.js │ └── css │ │ └── style.css ├── templates │ ├── pwd.html │ ├── base.html │ ├── settings.html │ ├── index.html │ ├── image_hosting.html │ └── download.html ├── core │ ├── config.py │ └── http_client.py ├── main.py ├── pages.py ├── database.py ├── bot_handler.py ├── api │ └── routes.py └── services │ └── telegram_service.py ├── .env.example ├── Dockerfile ├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── LICENSE └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn[standard] 3 | python-telegram-bot 4 | python-multipart 5 | jinja2 6 | pydantic-settings 7 | sse-starlette -------------------------------------------------------------------------------- /app/events.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | # 创建一个全局的、线程安全的异步队列作为事件总线 4 | # Bot handler 将向此队列中放入新文件通知 5 | # SSE 端点将从此队列中读取通知并发送给前端 6 | file_update_queue = asyncio.Queue() -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 你的 Telegram Bot 的 API Token。从 @BotFather 获取。 2 | BOT_TOKEN=your_telegram_bot_token 3 | 4 | # 文件存储的目标,可以是公开频道的 @username 或你自己的用户 ID。 5 | CHANNEL_NAME=@your_telegram_channel_or_your_id 6 | 7 | # [可选] 访问 Web 界面的密码。如果留空,则无需密码。 8 | PASS_WORD=your_secret_password 9 | 10 | # [可选] PicGo 上传接口的 API 密钥。如果留空,则无需密钥。 11 | PICGO_API_KEY=your_picgo_api_key 12 | 13 | # [可选] 你的服务的公开访问 URL,用于生成完整的文件下载链接。 14 | BASE_URL=http://127.0.0.1:8000 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.11-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /code 6 | 7 | # Copy the requirements file and install dependencies 8 | COPY ./requirements.txt /code/requirements.txt 9 | RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt 10 | 11 | # Copy the application code 12 | COPY ./app /code/app 13 | 14 | # Command to run the application 15 | CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /app/static/js/nav.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | const navToggle = document.getElementById('nav-toggle'); 3 | const navMenu = document.querySelector('.nav-menu'); 4 | 5 | // Toggle mobile menu 6 | if (navToggle) { 7 | navToggle.addEventListener('click', () => { 8 | navMenu.classList.toggle('active'); 9 | }); 10 | } 11 | 12 | // Set active navigation link 13 | const currentLocation = window.location.pathname; 14 | const navLinks = document.querySelectorAll('.nav-menu a'); 15 | 16 | navLinks.forEach(link => { 17 | if (link.getAttribute('href') === currentLocation) { 18 | link.classList.add('active'); 19 | } 20 | }); 21 | }); -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | jobs: 8 | build-and-push: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Log in to Docker Hub 17 | uses: docker/login-action@v3 18 | with: 19 | username: ${{ secrets.DOCKERHUB_USERNAME }} 20 | password: ${{ secrets.DOCKERHUB_TOKEN }} 21 | 22 | - name: Build and push Docker image 23 | uses: docker/build-push-action@v5 24 | with: 25 | context: . 26 | 27 | push: true 28 | 29 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/python-tgstate:latest,${{ secrets.DOCKERHUB_USERNAME }}/python-tgstate:${{ github.sha }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environments 2 | .env 3 | .env.* 4 | .env.local 5 | .env.*.local 6 | !.env.example 7 | 8 | # Python 9 | __pycache__/ 10 | *.pyc 11 | *.pyo 12 | *.pyd 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # Virtual environments 34 | .venv 35 | env/ 36 | venv/ 37 | ENV/ 38 | 39 | # Database files 40 | *.db 41 | *.sqlite3 42 | file_metadata.db 43 | 44 | # IDE / Editor specific 45 | .vscode/ 46 | .idea/ 47 | *.suo 48 | *.ntvs* 49 | *.njsproj 50 | *.sln 51 | *.sw? 52 | 53 | # Rooroo-specific files 54 | .rooroo/ 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | .hypothesis/ 67 | .pytest_cache/ -------------------------------------------------------------------------------- /app/templates/pwd.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Password Required{% endblock %} 4 | 5 | {% block content %} 6 |
此页面需要密码才能访问
13 | 14 | 25 |管理您的 tgState 应用配置
9 | 10 |在这里设置一个新的全局访问密码。如果留空,则密码保护将依赖于环境变量的设置。
13 | 25 | 26 |Drag & Drop files or Browse
12 | 13 |No files here. Try uploading something!
58 |Upload your images and get shareable links.
9 | 10 |Drag & Drop your images here or
15 | 16 | 17 |Your gallery is empty. Upload some images to get started!
82 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | ## 快速开始
33 |
34 | 推荐使用 Docker 来部署 `tgState`,这是最简单快捷的方式。
35 |
36 | ### 使用 Docker 部署
37 |
38 | ```bash
39 | docker run -d \
40 | --name tgstate \
41 | -p 8000:8000 \
42 | -e BOT_TOKEN="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" \
43 | -e CHANNEL_NAME="@my_test_channel" \
44 | -e PASS_WORD="supersecret" \
45 | -e BASE_URL="https://my-service.com" \
46 | -e PICGO_API_KEY="supersecret(可选不需要就删除这行)" \
47 | mitu233/python-tgstate:latest
48 | ```
49 |
50 |
51 | ### 本地开发与运行
52 |
53 | 如果您希望在本地环境直接运行 `tgState`:
54 |
55 | 1. **克隆项目并进入目录**:
56 |
57 | ```bash
58 | git clone https://github.com/your-repo/python-tgstate.git
59 | cd python-tgstate
60 | ```
61 |
62 | 2. **创建并激活虚拟环境**:
63 |
64 | ```bash
65 | # 创建虚拟环境
66 | python -m venv venv
67 | # 激活虚拟环境 (Windows)
68 | venv\Scripts\activate
69 | # 激活虚拟环境 (Linux/macOS)
70 | # source venv/bin/activate
71 | ```
72 |
73 | 3. **安装依赖**:
74 |
75 | ```bash
76 | pip install -r requirements.txt
77 | ```
78 |
79 | 4. **创建 `.env` 配置文件**: 参照 Docker 部分的说明,在项目根目录创建并配置 `.env` 文件。
80 |
81 | 5. **启动应用**:
82 |
83 | ```bash
84 | uvicorn app.main:app --reload
85 | ```
86 |
87 | 如果没有配置域名应用将在 `http://127.0.0.1:8000` 上运行。
88 |
89 | ## 配置 (环境变量)
90 |
91 | `tgState` 通过环境变量进行配置,应用会从根目录的 `.env` 文件中自动加载这些变量。
92 |
93 | | 变量 | 描述 | 是否必须 | 默认值 |
94 | | --------------- | ------------------------------------------------------------ | -------- | ----------------------- |
95 | | `BOT_TOKEN` | 您的 Telegram Bot API 令牌。可以从 [@BotFather](https://t.me/BotFather) 获取。 | **是** | `None` |
96 | | `CHANNEL_NAME` | 用于文件存储的目标聊天/频道。可以是公共频道的 `@username` 。 | **是** | `None` |
97 | | `PASS_WORD` | 用于保护 Web 界面访问的密码。如果留空,则应用将公开访问,无需密码。 | 否 | `None` |
98 | | `BASE_URL` | 您的服务的公共 URL。用于生成完整的下载链接。 | 否 | `http://127.0.0.1:8000` |
99 | | `PICGO_API_KEY` | 用于 PicGo 上传接口的 API 密钥。 | 否 | `None` |
100 |
101 | ## 注意密码相关
102 |
103 | 1. **两个密码都【没有】设置**:
104 | - **处理方式**:完全开放。
105 | - **结果**:无论是通过 Picogo/API 还是网页,**均可直接上传**,无需任何凭证。
106 | 2. **只设置了【Picogo密码】**:
107 | - **结果**:来自网页的上传请求会无条件允许。来自 Picogo/API 的请求会验证后允许。
108 | 3. **只设置了【登录密码】**:
109 | - **结果**:来自网页的上传只有**已登录**的网页用户能上传。来自 Picogo/API 的请求会无条件允许。
110 |
111 | ## 使用方法
112 |
113 | 1. **访问 Web 界面**: 启动应用后,在浏览器中访问 `http://127.0.0.1:8000` (或您配置的 `BASE_URL`)。
114 |
115 | 2. **密码验证**: 如果您在 `.env` 文件中设置了 `PASS_WORD`,应用会首先跳转到密码输入页面。输入正确密码后即可访问主界面。
116 |
117 | 3. **上传文件**: 在主界面,点击上传区域选择文件,或直接将文件拖拽到上传框中。上传成功后,文件会出现在下方的文件列表中。
118 |
119 | 4. **群组上传**支持转发到群组上传,支持小于20m的文件和照片(tg官方的限制)。
120 |
121 | 5. **获取链接**: 文件列表中的每个文件都会显示其文件名、大小和上传日期。点击文件名即可复制该文件的直接下载链接。
122 |
123 | 6. **群组获取链接: **群组中回复文件get获取下载链接
124 |
125 |
126 |
127 | ## PicGo 配置指南
128 |
129 | ### 前置要求
130 |
131 | 1. 安装插件web-uploader
132 |
133 | ### 核心配置
134 |
135 | 1. **图床配置名**: 随便
136 |
137 | 2. **api地址**: 填写你的服务提供的上传地址。本地是 `http://<你的服务器IP>:8000/api/upload`。
138 |
139 | 3. **POST参数名**: `file`。
140 |
141 | 4. **JSON 参数名**: `url`。
142 |
143 | 5. **自定义请求头**:{"x-api-key": "PICGO_API_KEY"}(可选推荐)(验证方式二选一)
144 |
145 | 6. **自定义body**:{"key":"PICGO_API_KEY"}(可选推荐)(验证方式二选一)
146 |
147 |
148 |
149 |
150 |
151 | ### 目前缺点
152 |
153 | 1. 删除文件慢,群组内删除前端不会更新需要手动删除。
154 |
155 | 用 roocode 和 白嫖的心 制作。****
--------------------------------------------------------------------------------
/app/bot_handler.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | from telegram import Update
4 | from telegram.ext import Application, MessageHandler, filters, ContextTypes
5 | from .core.config import get_settings
6 | from .services.telegram_service import get_telegram_service
7 | from . import database
8 | from .events import file_update_queue
9 |
10 | async def handle_new_file(update: Update, context: ContextTypes.DEFAULT_TYPE):
11 | """
12 | 处理新增的文件或照片,将其元数据存入数据库,并通过队列发送通知。
13 | 在函数内部检查消息来源是否为授权的聊天(私聊、群组或频道)。
14 | """
15 | settings = get_settings()
16 | message = update.message or update.channel_post
17 |
18 | # 1. 确保有消息
19 | if not message:
20 | return
21 |
22 | # 2. 检查消息来源是否为指定的频道/群组
23 | channel_identifier = settings.CHANNEL_NAME
24 | if not channel_identifier:
25 | print("错误: CHANNEL_NAME 未在 .env 中设置,无法处理文件。")
26 | return
27 |
28 | chat = message.chat
29 | is_allowed = False
30 | # 检查是公开频道 (e.g., "@username") 还是私密频道 (e.g., "-100123456789")
31 | if channel_identifier.startswith('@'):
32 | if chat.username and chat.username == channel_identifier.lstrip('@'):
33 | is_allowed = True
34 | else:
35 | if str(chat.id) == channel_identifier:
36 | is_allowed = True
37 |
38 | if not is_allowed:
39 | return
40 |
41 | # 3. 确定文件/照片信息
42 | file_obj = None
43 | file_name = None
44 |
45 | if message.document:
46 | file_obj = message.document
47 | file_name = file_obj.file_name
48 | elif message.photo:
49 | # 选择分辨率最高的照片
50 | file_obj = message.photo[-1]
51 | # 为照片创建一个默认文件名
52 | file_name = f"photo_{message.message_id}.jpg"
53 |
54 | # 4. 如果成功获取到文件或照片对象,则处理它
55 | if file_obj and file_name:
56 | # 我们只关心小于20MB的、非清单文件
57 | if file_obj.file_size < (20 * 1024 * 1024) and not file_name.endswith('.manifest'):
58 | # 使用复合ID "message_id:file_id"
59 | composite_id = f"{message.message_id}:{file_obj.file_id}"
60 |
61 | database.add_file_metadata(
62 | filename=file_name,
63 | file_id=composite_id,
64 | filesize=file_obj.file_size
65 | )
66 |
67 | # 将新文件信息放入队列,以便 SSE 端点可以推送给前端
68 | file_info = {
69 | "action": "add",
70 | "filename": file_name,
71 | "file_id": composite_id,
72 | "filesize": file_obj.file_size,
73 | "upload_date": message.date.isoformat()
74 | }
75 | await file_update_queue.put(json.dumps(file_info))
76 |
77 | async def handle_get_reply(update: Update, context: ContextTypes.DEFAULT_TYPE):
78 | """
79 | 处理对文件消息回复 "get" 的情况。
80 | """
81 | if not (update.message and update.message.reply_to_message and (update.message.reply_to_message.document or update.message.reply_to_message.photo)):
82 | return
83 |
84 | # 检查回复的文本是否完全是 "get"
85 | if update.message.text.lower().strip() != 'get':
86 | return
87 |
88 | document = update.message.reply_to_message.document or update.message.reply_to_message.photo[-1]
89 | file_id = document.file_id
90 | file_name = getattr(document, 'file_name', f"photo_{update.message.reply_to_message.message_id}.jpg")
91 | settings = get_settings()
92 |
93 | final_file_id = file_id
94 | final_file_name = file_name
95 |
96 | # 如果是清单文件,我们需要解析它以获取原始文件名
97 | if file_name.endswith('.manifest'):
98 | telegram_service = get_telegram_service()
99 | download_url = await telegram_service.get_download_url(file_id)
100 | if download_url:
101 | # 在实际应用中,我们应该流式处理而不是一次性下载
102 | # 但为了简化,我们先下载清单内容
103 | import httpx
104 | async with httpx.AsyncClient() as client:
105 | try:
106 | resp = await client.get(download_url)
107 | resp.raise_for_status()
108 | content = resp.content
109 | if content.startswith(b'tgstate-blob\n'):
110 | lines = content.decode('utf-8').strip().split('\n')
111 | final_file_name = lines[1] # 获取原始文件名
112 | # file_id 保持为清单文件的 ID,下载路由会处理它
113 | except httpx.RequestError as e:
114 | print(f"下载清单文件时出错: {e}")
115 | await update.message.reply_text("错误:无法获取清单文件内容。")
116 | return
117 |
118 | file_path = f"/d/{final_file_id}"
119 |
120 | if settings.BASE_URL:
121 | download_link = f"{settings.BASE_URL.strip('/')}{file_path}"
122 | reply_text = f"这是 '{final_file_name}' 的下载链接:\n{download_link}"
123 | else:
124 | reply_text = f"这是 '{final_file_name}' 的下载路径 (请自行拼接域名):\n`{file_path}`"
125 |
126 | await update.message.reply_text(reply_text)
127 |
128 | async def handle_deleted_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
129 | """
130 | 处理消息删除事件,同步删除数据库中的文件记录。
131 | """
132 | # 在 `python-telegram-bot` v20+ 中,删除事件是通过 `update.edited_message` 捕获的,
133 | # 当消息被删除时,它会变成一个内容为空的 `edited_message`。
134 | # 我们通过检查 `update.edited_message` 是否存在来判断消息是否被删除。
135 | if update.edited_message and not update.edited_message.text:
136 | message_id = update.edited_message.message_id
137 | deleted_file_id = database.delete_file_by_message_id(message_id)
138 | if deleted_file_id:
139 | # 如果成功删除了数据库记录,就通知前端
140 | delete_info = {
141 | "action": "delete",
142 | "file_id": deleted_file_id
143 | }
144 | await file_update_queue.put(json.dumps(delete_info))
145 |
146 | def create_bot_app() -> Application:
147 | """
148 | 创建并配置 Telegram Bot 应用实例。
149 | """
150 | settings = get_settings()
151 | if not settings.BOT_TOKEN:
152 | print("错误: .env 文件中未设置 BOT_TOKEN。机器人无法创建。")
153 | raise ValueError("BOT_TOKEN not configured.")
154 |
155 | application = Application.builder().token(settings.BOT_TOKEN).build()
156 |
157 | # --- 添加处理器 ---
158 |
159 | # 1. 处理对文件消息回复 "get" 的情况 (在任何地方)
160 | get_handler = MessageHandler(
161 | filters.TEXT & (~filters.COMMAND) & filters.REPLY,
162 | handle_get_reply
163 | )
164 | application.add_handler(get_handler)
165 |
166 | # 2. 处理所有文件和照片消息
167 | new_file_handler = MessageHandler(filters.ALL, handle_new_file)
168 | application.add_handler(new_file_handler, group=0)
169 |
170 | # 3. 处理消息删除事件
171 | # 注意:机器人需要有管理员权限才能接收到此事件
172 | delete_handler = MessageHandler(filters.UpdateType.EDITED_MESSAGE, handle_deleted_message)
173 | application.add_handler(delete_handler, group=1)
174 |
175 | return application
176 |
--------------------------------------------------------------------------------
/app/templates/download.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Download {{ file.filename }}{% endblock %}
4 |
5 | {% block content %}
6 |
133 |
134 |
191 |
192 |
193 |
217 | {% endblock %}
--------------------------------------------------------------------------------
/app/api/routes.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | import tempfile
3 | import os
4 | import shutil
5 | import mimetypes
6 | from urllib.parse import quote
7 | from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Response, Request, Header, Form
8 | from pydantic import BaseModel
9 | from typing import List, Optional
10 | from fastapi.responses import StreamingResponse, JSONResponse
11 | from ..core.config import Settings, get_settings, get_active_password
12 | from ..services.telegram_service import TelegramService, get_telegram_service
13 | from ..core.http_client import get_http_client # 导入共享客户端
14 |
15 | router = APIRouter()
16 |
17 | @router.post("/api/upload")
18 | async def upload_file(
19 | request: Request, # 引入 Request 对象以访问 cookie
20 | file: UploadFile = File(...),
21 | key: Optional[str] = Form(None), # 从表单中获取 key
22 | settings: Settings = Depends(get_settings),
23 | telegram_service: TelegramService = Depends(get_telegram_service),
24 | x_api_key: Optional[str] = Header(None) # 从请求头中获取 x-api-key
25 | ):
26 | """
27 | 处理文件上传。
28 | 此端点现在支持两种验证方式:
29 | 1. API 密钥:通过 x-api-key (Header) 或 key (Form) 提供。
30 | 2. Cookie 验证:通过检查已登录用户的会话 cookie。
31 | 如果未设置任何密码,则允许匿名上传。
32 | """
33 | # 获取配置的密码
34 | picgo_api_key = settings.PICGO_API_KEY
35 | active_password = get_active_password()
36 | submitted_key = x_api_key or key
37 |
38 | # 修正后的验证逻辑
39 | # 通过 'Referer' 头来区分网页端和 API 端的请求
40 | is_web_request = "referer" in request.headers
41 |
42 | auth_ok = False
43 | error_detail = "验证失败"
44 |
45 | # 1. 两个密码都【没有】设置:完全开放
46 | if not active_password and not picgo_api_key:
47 | auth_ok = True
48 |
49 | # 2. 只设置了【Picogo密码】:网页开放,API需验证
50 | elif picgo_api_key and not active_password:
51 | if is_web_request:
52 | auth_ok = True # 网页请求无条件允许
53 | else:
54 | if picgo_api_key == submitted_key:
55 | auth_ok = True # API 请求验证密钥
56 | else:
57 | error_detail = "无效的 API 密钥"
58 |
59 | # 3. 只设置了【登录密码】:网页需登录,API开放
60 | elif not picgo_api_key and active_password:
61 | if is_web_request:
62 | session_password = request.cookies.get("password")
63 | if active_password == session_password:
64 | auth_ok = True # 网页请求验证会话
65 | else:
66 | error_detail = "需要网页登录"
67 | else:
68 | auth_ok = True # API 请求无条件允许
69 |
70 | # 4. 两个密码都【设置了】:网页和 API 都需要验证
71 | elif active_password and picgo_api_key:
72 | if is_web_request:
73 | session_password = request.cookies.get("password")
74 | if active_password == session_password:
75 | auth_ok = True
76 | else:
77 | error_detail = "需要网页登录"
78 | else:
79 | if picgo_api_key == submitted_key:
80 | auth_ok = True
81 | else:
82 | error_detail = "无效的 API 密钥"
83 |
84 | if not auth_ok:
85 | raise HTTPException(status_code=401, detail=error_detail)
86 | temp_file_path = None
87 | try:
88 | # 使用 tempfile 创建一个安全的临时文件
89 | with tempfile.NamedTemporaryFile(delete=False, suffix=f"_{file.filename}") as temp_file:
90 | temp_file_path = temp_file.name
91 | # 将上传的文件内容写入临时文件
92 | shutil.copyfileobj(file.file, temp_file)
93 |
94 | file_id = await telegram_service.upload_file(temp_file_path, file.filename)
95 | finally:
96 | # 确保临时文件在操作后被删除
97 | if temp_file_path and os.path.exists(temp_file_path):
98 | os.unlink(temp_file_path)
99 |
100 | if file_id:
101 | encoded_filename = quote(file.filename)
102 | file_path = f"/d/{file_id}/{encoded_filename}" # 关键变更:将文件名添加到URL中
103 | full_url = f"{settings.BASE_URL.strip('/')}{file_path}"
104 | return {"path": file_path, "url": str(full_url)}
105 | else:
106 | raise HTTPException(status_code=500, detail="文件上传失败。")
107 |
108 | @router.get("/d/{file_id}/{filename}")
109 | async def download_file(
110 | file_id: str, # Note: This can be a composite ID "message_id:file_id"
111 | filename: str, # 关键变更:从URL中获取原始文件名
112 | telegram_service: TelegramService = Depends(get_telegram_service),
113 | client: httpx.AsyncClient = Depends(get_http_client) # 注入共享客户端
114 | ):
115 | """
116 | 处理文件下载。
117 | 该函数实现了对清单文件和单个文件的真正流式处理,并确保文件名正确。
118 | """
119 | # 从复合ID中提取真实的 file_id,同时保持对旧格式的兼容性
120 | try:
121 | _, real_file_id = file_id.split(':', 1)
122 | except ValueError:
123 | real_file_id = file_id # 假定是旧格式
124 |
125 | download_url = await telegram_service.get_download_url(real_file_id)
126 | if not download_url:
127 | raise HTTPException(status_code=404, detail="文件未找到或下载链接已过期。")
128 |
129 | # 使用共享客户端进行范围请求,检查文件类型
130 | range_headers = {"Range": "bytes=0-127"}
131 | try:
132 | head_resp = await client.get(download_url, headers=range_headers)
133 | head_resp.raise_for_status()
134 | first_bytes = head_resp.content
135 | except httpx.RequestError as e:
136 | raise HTTPException(status_code=503, detail=f"无法连接到 Telegram 服务器: {e}")
137 |
138 | # 检查是否是清单文件
139 | if first_bytes.startswith(b'tgstate-blob\n'):
140 | # 是清单文件,使用共享客户端下载整个清单来解析
141 | manifest_resp = await client.get(download_url)
142 | manifest_resp.raise_for_status()
143 | manifest_content = manifest_resp.content
144 |
145 | lines = manifest_content.decode('utf-8').strip().split('\n')
146 | original_filename = lines[1]
147 | chunk_file_ids = lines[2:]
148 |
149 | filename_encoded = quote(str(original_filename))
150 | response_headers = {
151 | 'Content-Disposition': f"attachment; filename*=UTF-8''{filename_encoded}"
152 | }
153 | # 将共享客户端传递给分块流式传输器
154 | return StreamingResponse(stream_chunks(chunk_file_ids, telegram_service, client), headers=response_headers)
155 | else:
156 | # 是单个文件,直接流式传输
157 | # 关键变更:文件名现在直接从URL参数中获取,不再需要数据库查询。
158 |
159 | # 检查文件是否为图片
160 | image_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp')
161 | is_image = filename.lower().endswith(image_extensions)
162 |
163 | filename_encoded = quote(str(filename))
164 |
165 | # 动态获取 Content-Type
166 | content_type, _ = mimetypes.guess_type(filename)
167 | if content_type is None:
168 | content_type = "application/octet-stream" # 如果无法猜测,则使用默认值
169 |
170 | # 根据是否为图片设置不同的 Content-Disposition
171 | disposition_type = "inline" if is_image else "attachment"
172 | response_headers = {
173 | 'Content-Disposition': f"{disposition_type}; filename*=UTF-8''{filename_encoded}",
174 | 'Content-Type': content_type, # 关键修复:添加 Content-Type 头
175 | }
176 |
177 | async def single_file_streamer():
178 | # 使用共享客户端进行流式传输
179 | async with client.stream("GET", download_url) as resp:
180 | resp.raise_for_status()
181 | async for chunk in resp.aiter_bytes():
182 | yield chunk
183 |
184 | return StreamingResponse(single_file_streamer(), headers=response_headers)
185 |
186 | from .. import database
187 | from ..events import file_update_queue
188 | import asyncio
189 | from sse_starlette.sse import EventSourceResponse
190 |
191 | @router.get("/api/file-updates")
192 | async def file_updates(request: Request):
193 | """
194 | 一个 SSE 端点,用于向客户端实时推送新文件通知。
195 | """
196 | async def event_generator():
197 | while True:
198 | # 检查客户端是否已断开连接
199 | if await request.is_disconnected():
200 | print("客户端已断开连接,停止推送。")
201 | break
202 |
203 | try:
204 | # 等待队列中的新消息,设置一个超时以定期检查连接状态
205 | update_json = await asyncio.wait_for(file_update_queue.get(), timeout=30)
206 | yield {"data": update_json}
207 | file_update_queue.task_done()
208 | except asyncio.TimeoutError:
209 | # 超时是正常的,只是为了让我们有机会检查 is_disconnected
210 | continue
211 | except Exception as e:
212 | print(f"推送事件时出错: {e}")
213 |
214 | return EventSourceResponse(event_generator())
215 |
216 | @router.get("/api/files")
217 | async def get_files_list():
218 | """
219 | 从数据库获取文件列表。
220 | """
221 | files = database.get_all_files()
222 | return files
223 |
224 | @router.delete("/api/files/{file_id}")
225 | async def delete_file(
226 | file_id: str, # Note: This is the composite ID "message_id:file_id"
227 | telegram_service: TelegramService = Depends(get_telegram_service)
228 | ):
229 | """
230 | 完全删除一个文件,包括其所有分块(如果存在)。
231 | """
232 | # 步骤 1: 调用新的、更全面的删除服务
233 | delete_result = await telegram_service.delete_file_with_chunks(file_id)
234 |
235 | # 步骤 2: 检查删除结果,特别是处理“未找到”的情况
236 | # 关键逻辑:如果 TG 返回“未找到”,我们应将其视为成功,并确保数据库同步
237 | is_not_found_error = "not found" in delete_result.get("error", "").lower()
238 |
239 | if delete_result.get("main_message_deleted") or is_not_found_error:
240 | # 如果 TG 确认删除,或者返回“未找到”,我们都清理数据库
241 | was_deleted_from_db = database.delete_file_metadata(file_id)
242 |
243 | if is_not_found_error:
244 | print(f"文件 {file_id} 在 Telegram 中未找到,视为已删除。正在清理数据库...")
245 | delete_result["db_status"] = "deleted_after_not_found"
246 | # 强制将状态置为成功,以便前端正确处理
247 | delete_result["status"] = "success"
248 | elif not was_deleted_from_db:
249 | delete_result["db_status"] = "not_found_in_db"
250 | print(f"警告: 文件 {file_id} 在 Telegram 中已删除,但在数据库中未找到。")
251 | else:
252 | delete_result["db_status"] = "deleted"
253 | else:
254 | # 仅在删除失败且不是“未找到”错误时,才跳过数据库操作
255 | delete_result["db_status"] = "skipped_due_to_tg_error"
256 |
257 | # 步骤 3: 根据最终状态返回响应
258 | if delete_result["status"] == "success":
259 | # 包含了正常删除和“未找到”的情况
260 | return {"status": "ok", "message": f"文件 {file_id} 已成功处理。", "details": delete_result}
261 |
262 | elif delete_result["status"] == "partial_failure":
263 | raise HTTPException(
264 | status_code=500,
265 | detail={
266 | "message": f"文件 {file_id} 删除部分失败。",
267 | "details": delete_result
268 | }
269 | )
270 | else: # 'error'
271 | # 现在只有真正的、非“未找到”的错误才会走到这里
272 | raise HTTPException(
273 | status_code=400,
274 | detail={
275 | "message": f"删除文件 {file_id} 时出错。",
276 | "details": delete_result
277 | }
278 | )
279 |
280 |
281 | class PasswordRequest(BaseModel):
282 | password: str
283 |
284 | class BatchDeleteRequest(BaseModel):
285 | file_ids: List[str]
286 |
287 | @router.post("/api/set-password")
288 | async def set_password(payload: PasswordRequest):
289 | """
290 | 设置或更新应用程序密码。
291 | 密码将存储在 .password 文件中,覆盖通过 .env 设置的密码。
292 | """
293 | try:
294 | with open(".password", "w", encoding="utf-8") as f:
295 | f.write(payload.password)
296 |
297 | # 清除 get_settings 的缓存,以防万一(尽管新逻辑绕过了它)
298 | get_settings.cache_clear()
299 |
300 | return JSONResponse(
301 | status_code=200,
302 | content={"status": "ok", "message": "密码已成功设置。"}
303 | )
304 | except Exception as e:
305 | raise HTTPException(status_code=500, detail=f"无法写入密码文件: {e}")
306 |
307 | @router.post("/api/batch_delete")
308 | async def batch_delete_files(
309 | request_data: BatchDeleteRequest,
310 | telegram_service: TelegramService = Depends(get_telegram_service)
311 | ):
312 | """
313 | 批量、完全地删除文件,包括它们的所有分块。
314 | """
315 | successful_deletions = []
316 | failed_deletions = []
317 |
318 | for file_id in request_data.file_ids:
319 | try:
320 | # 调用重构后的单个文件删除逻辑
321 | response = await delete_file(file_id, telegram_service)
322 | successful_deletions.append(response)
323 | except HTTPException as e:
324 | failed_deletions.append(e.detail)
325 |
326 | return {
327 | "status": "completed",
328 | "deleted": successful_deletions,
329 | "failed": failed_deletions
330 | }
331 |
332 |
333 | async def stream_chunks(chunk_composite_ids, telegram_service: TelegramService, client: httpx.AsyncClient):
334 | """一个使用共享客户端的异步生成器,用于流式传输分块。"""
335 | for chunk_id in chunk_composite_ids:
336 | try:
337 | # 关键变更:从复合ID "message_id:actual_file_id" 中解析出 actual_file_id
338 | _, actual_chunk_id = chunk_id.split(':', 1)
339 | except (ValueError, IndexError):
340 | print(f"警告:无效的分块ID格式 '{chunk_id}',跳过。")
341 | continue
342 |
343 | # 关键修复:在每次循环时都实时获取最新的下载链接
344 | chunk_url = await telegram_service.get_download_url(actual_chunk_id)
345 | if not chunk_url:
346 | print(f"警告:无法为分块 {actual_chunk_id} 获取下载链接,跳过。")
347 | continue
348 |
349 | print(f"正在流式传输分块 {chunk_id} 从 URL: {chunk_url[:50]}...")
350 | try:
351 | async with client.stream('GET', chunk_url) as chunk_resp:
352 | # 检查状态码,以防链接过期
353 | if chunk_resp.status_code != 200:
354 | print(f"错误:获取分块 {chunk_id} 失败,状态码: {chunk_resp.status_code}")
355 | # 尝试为这个分块重新获取一次URL
356 | print("正在尝试重新获取URL...")
357 | await asyncio.sleep(1) # 短暂等待
358 | chunk_url = await telegram_service.get_download_url(actual_chunk_id) # 使用 actual_chunk_id
359 | if not chunk_url:
360 | print(f"重试失败:无法为分块 {chunk_id} 获取新的URL。")
361 | break
362 |
363 | # 使用新的URL重试
364 | async with client.stream('GET', chunk_url) as retry_resp:
365 | retry_resp.raise_for_status()
366 | async for chunk_data in retry_resp.aiter_bytes():
367 | yield chunk_data
368 | else:
369 | async for chunk_data in chunk_resp.aiter_bytes():
370 | yield chunk_data
371 |
372 | except httpx.RequestError as e:
373 | print(f"流式传输分块 {chunk_id} 时出现网络错误: {e}")
374 | break
375 |
--------------------------------------------------------------------------------
/app/services/telegram_service.py:
--------------------------------------------------------------------------------
1 | import telegram
2 | import os
3 | import io
4 | from functools import lru_cache
5 | from typing import BinaryIO
6 | import telegram
7 | from telegram import Update
8 | from telegram.ext import CallbackContext
9 | from telegram.request import HTTPXRequest
10 | from ..core.config import Settings, get_settings
11 | from .. import database
12 |
13 | # Telegram Bot API 对通过 getFile 方法下载的文件有 20MB 的限制。
14 | # 我们将分块大小设置为 19.5MB 以确保上传和下载都能成功。
15 | CHUNK_SIZE_BYTES = int(19.5 * 1024 * 1024)
16 |
17 | class TelegramService:
18 | """
19 | 用于与 Telegram Bot API 交互的服务。
20 | """
21 | def __init__(self, settings: Settings):
22 | # 为大文件上传设置更长的超时时间 (例如 5 分钟)
23 | request = HTTPXRequest(
24 | connect_timeout=300.0,
25 | read_timeout=300.0,
26 | write_timeout=300.0
27 | )
28 | self.bot = telegram.Bot(token=settings.BOT_TOKEN, request=request)
29 | self.channel_name = settings.CHANNEL_NAME
30 |
31 | async def _upload_chunk(self, chunk_data: bytes, chunk_name: str) -> str | None:
32 | """一个上传单个数据块的辅助函数。"""
33 | try:
34 | with io.BytesIO(chunk_data) as document_chunk:
35 | message = await self.bot.send_document(
36 | chat_id=self.channel_name,
37 | document=document_chunk,
38 | filename=chunk_name
39 | )
40 | if message.document:
41 | return message.document.file_id
42 | except Exception as e:
43 | print(f"上传分块 {chunk_name} 到 Telegram 时出错: {e}")
44 | return None
45 |
46 | async def _upload_as_chunks(self, file_path: str, original_filename: str) -> str | None:
47 | """
48 | 将大文件分割成块,并通过回复链将所有部分聚合起来。
49 | """
50 | chunk_file_ids = []
51 | first_message_id = None
52 |
53 | try:
54 | with open(file_path, 'rb') as f:
55 | chunk_number = 1
56 | while True:
57 | chunk = f.read(CHUNK_SIZE_BYTES)
58 | if not chunk:
59 | break
60 |
61 | chunk_name = f"{original_filename}.part{chunk_number}"
62 | print(f"正在上传分块: {chunk_name}")
63 |
64 | with io.BytesIO(chunk) as chunk_io:
65 | # 如果是第一个块,正常发送。否则,作为对第一个块的回复发送。
66 | reply_to_id = first_message_id if first_message_id else None
67 | message = await self.bot.send_document(
68 | chat_id=self.channel_name,
69 | document=chunk_io,
70 | filename=chunk_name,
71 | reply_to_message_id=reply_to_id
72 | )
73 |
74 | # 如果是第一个块,保存其 message_id
75 | if not first_message_id:
76 | first_message_id = message.message_id
77 |
78 | # 关键变更:存储复合ID (message_id:file_id) 而不是只有 file_id
79 | chunk_file_ids.append(f"{message.message_id}:{message.document.file_id}")
80 | chunk_number += 1
81 | except IOError as e:
82 | print(f"读取或上传文件块时出错: {e}")
83 | return None
84 | except Exception as e:
85 | print(f"发送文件块时出错: {e}")
86 | return None
87 |
88 | # 生成并上传清单文件,同样作为对第一个块的回复
89 | manifest_content = f"tgstate-blob\n{original_filename}\n" + "\n".join(chunk_file_ids)
90 | manifest_name = f"{original_filename}.manifest"
91 |
92 | print("所有分块上传完毕。正在上传清单文件...")
93 | try:
94 | with io.BytesIO(manifest_content.encode('utf-8')) as manifest_file:
95 | message = await self.bot.send_document(
96 | chat_id=self.channel_name,
97 | document=manifest_file,
98 | filename=manifest_name,
99 | reply_to_message_id=first_message_id
100 | )
101 | if message.document:
102 | print("清单文件上传成功。")
103 | # 将大文件的元数据存入数据库
104 | total_size = os.path.getsize(file_path)
105 | # 创建复合ID,格式为 "message_id:file_id"
106 | composite_id = f"{message.message_id}:{message.document.file_id}"
107 | database.add_file_metadata(
108 | filename=original_filename,
109 | file_id=composite_id, # 我们存储复合ID
110 | filesize=total_size
111 | )
112 | return composite_id # 返回复合ID
113 | except Exception as e:
114 | print(f"上传清单文件时出错: {e}")
115 |
116 | return None
117 |
118 | async def upload_file(self, file_path: str, file_name: str) -> str | None:
119 | """
120 | 将文件上传到指定的 Telegram 频道。
121 | 如果文件大于 200MB,则分块上传。
122 |
123 | 参数:
124 | file_path: 文件的本地路径。
125 | file_name: 文件名。
126 |
127 | 返回:
128 | 如果成功,则返回文件的 file_id,否则返回 None。
129 | """
130 | if not self.channel_name:
131 | print("错误:环境变量中未设置 CHANNEL_NAME。")
132 | return None
133 |
134 | try:
135 | file_size = os.path.getsize(file_path)
136 | except OSError as e:
137 | print(f"无法获取文件大小: {e}")
138 | return None
139 |
140 | if file_size >= CHUNK_SIZE_BYTES:
141 | print(f"文件大小 ({file_size / 1024 / 1024:.2f} MB) 超过或等于 {CHUNK_SIZE_BYTES / 1024 / 1024:.2f}MB。正在启动分块上传...")
142 | return await self._upload_as_chunks(file_path, file_name)
143 |
144 | print(f"文件大小 ({file_size / 1024 / 1024:.2f} MB) 小于 {CHUNK_SIZE_BYTES / 1024 / 1024:.2f}MB。正在直接上传...")
145 | try:
146 | with open(file_path, 'rb') as document_file:
147 | message = await self.bot.send_document(
148 | chat_id=self.channel_name,
149 | document=document_file,
150 | filename=file_name
151 | )
152 | if message.document:
153 | # 将小文件的元数据存入数据库
154 | # 创建复合ID,格式为 "message_id:file_id"
155 | composite_id = f"{message.message_id}:{message.document.file_id}"
156 | database.add_file_metadata(
157 | filename=file_name,
158 | file_id=composite_id, # 存储复合ID
159 | filesize=file_size
160 | )
161 | return composite_id # 返回复合ID
162 | except Exception as e:
163 | print(f"上传文件到 Telegram 时出错: {e}")
164 |
165 | return None
166 |
167 | async def get_download_url(self, file_id: str) -> str | None:
168 | """
169 | 为给定的 file_id 获取临时下载链接。
170 |
171 | 参数:
172 | file_id: 来自 Telegram 的文件 ID。
173 |
174 | 返回:
175 | 如果成功,则返回临时下载链接,否则返回 None。
176 | """
177 | try:
178 | file = await self.bot.get_file(file_id)
179 | return file.file_path
180 | except Exception as e:
181 | print(f"从 Telegram 获取下载链接时出错: {e}")
182 | return None
183 |
184 | async def delete_message(self, message_id: int) -> tuple[bool, str]:
185 | """
186 | 从频道中删除指定 ID 的消息。
187 |
188 | 参数:
189 | message_id: 要删除的消息的 ID。
190 |
191 | 返回:
192 | 一个元组 (success, reason),其中 success 表示逻辑上是否成功,
193 | reason 可以是 'deleted', 'not_found', 或 'error'。
194 | """
195 | try:
196 | await self.bot.delete_message(
197 | chat_id=self.channel_name,
198 | message_id=message_id
199 | )
200 | return (True, "deleted")
201 | except telegram.error.BadRequest as e:
202 | if "not found" in str(e).lower():
203 | print(f"消息 {message_id} 未找到,视为已删除。")
204 | return (True, "not_found")
205 | else:
206 | print(f"删除消息 {message_id} 失败 (BadRequest): {e}")
207 | return (False, "error")
208 | except Exception as e:
209 | print(f"删除消息 {message_id} 时发生未知错误: {e}")
210 | return (False, "error")
211 |
212 | async def delete_file_with_chunks(self, file_id: str) -> dict:
213 | """
214 | 完全删除一个文件,包括其所有可能的分块。
215 | 该函数会处理清单文件,并删除所有引用的分块。
216 |
217 | 参数:
218 | file_id: 要删除的文件的复合 ID ("message_id:actual_file_id")。
219 |
220 | 返回:
221 | 一个包含删除操作结果的字典。
222 | """
223 | results = {
224 | "status": "pending",
225 | "main_file_id": file_id,
226 | "deleted_chunks": [],
227 | "failed_chunks": [],
228 | "main_message_deleted": False,
229 | "is_manifest": False,
230 | "reason": ""
231 | }
232 |
233 | try:
234 | main_message_id_str, main_actual_file_id = file_id.split(':', 1)
235 | main_message_id = int(main_message_id_str)
236 | except (ValueError, IndexError):
237 | results["status"] = "error"
238 | results["reason"] = "Invalid composite file_id format."
239 | return results
240 |
241 | # 步骤 1: 检查文件是否为清单
242 | download_url = await self.get_download_url(main_actual_file_id)
243 | if not download_url:
244 | print(f"警告: 无法为文件 {main_actual_file_id} 获取下载链接。将只尝试删除主消息。")
245 | results["reason"] = f"Could not get download URL for {main_actual_file_id}."
246 | else:
247 | try:
248 | import httpx
249 | async with httpx.AsyncClient(timeout=60.0) as client:
250 | response = await client.get(download_url)
251 | if response.status_code == 200 and response.content.startswith(b'tgstate-blob\n'):
252 | results["is_manifest"] = True
253 | print(f"文件 {file_id} 是一个清单文件。正在处理分块删除...")
254 |
255 | manifest_content = response.content.decode('utf-8')
256 | lines = manifest_content.strip().split('\n')
257 | chunk_composite_ids = lines[2:]
258 |
259 | for chunk_id in chunk_composite_ids:
260 | try:
261 | chunk_message_id_str, _ = chunk_id.split(':', 1)
262 | chunk_message_id = int(chunk_message_id_str)
263 | success, _ = await self.delete_message(chunk_message_id)
264 | if success:
265 | results["deleted_chunks"].append(chunk_id)
266 | else:
267 | results["failed_chunks"].append(chunk_id)
268 | except Exception as e:
269 | print(f"处理或删除分块 {chunk_id} 时出错: {e}")
270 | results["failed_chunks"].append(chunk_id)
271 | except Exception as e:
272 | error_message = f"下载或解析清单文件 {file_id} 时出错: {e}"
273 | print(error_message)
274 | results["reason"] += " " + error_message
275 | # 即使清单处理失败,我们也要继续尝试删除主消息
276 |
277 | # 步骤 2: 删除主消息 (清单文件本身或单个文件)
278 | main_message_deleted, delete_reason = await self.delete_message(main_message_id)
279 | results["main_message_deleted"] = main_message_deleted
280 |
281 | if main_message_deleted:
282 | if delete_reason == "deleted":
283 | print(f"主消息 {main_message_id} 已成功删除。")
284 | elif delete_reason == "not_found":
285 | print(f"主消息 {main_message_id} 在 Telegram 中未找到,视为成功。")
286 | else:
287 | print(f"删除主消息 {main_message_id} 失败。")
288 |
289 | # 步骤 3: 决定最终状态
290 | if results["main_message_deleted"] and (not results["is_manifest"] or not results["failed_chunks"]):
291 | results["status"] = "success"
292 | else:
293 | results["status"] = "partial_failure"
294 | if not results["main_message_deleted"]:
295 | results["reason"] += " Failed to delete main message."
296 | if results["failed_chunks"]:
297 | results["reason"] += f" Failed to delete {len(results['failed_chunks'])} chunks."
298 |
299 |
300 | return results
301 |
302 |
303 | async def list_files_in_channel(self) -> list[dict]:
304 | """
305 | 遍历频道历史记录,智能地列出所有文件。
306 | - 小于20MB的文件直接显示。
307 | - 大于20MB但通过清单管理的文件,显示原始文件名。
308 | """
309 | files = []
310 | # Telegram API 限制 get_chat_history 一次最多返回 100 条
311 | # 我们需要循环获取,直到没有更多消息
312 | last_message_id = None
313 |
314 | # 为了避免无限循环,我们设置一个最大迭代次数
315 | MAX_ITERATIONS = 100
316 |
317 | print("开始从频道获取历史消息...")
318 |
319 | for i in range(MAX_ITERATIONS):
320 | try:
321 | # 获取一批消息
322 | messages = await self.bot.get_chat_history(
323 | chat_id=self.channel_name,
324 | limit=100,
325 | offset_id=last_message_id if last_message_id else 0
326 | )
327 | except Exception as e:
328 | print(f"获取聊天历史时出错: {e}")
329 | break
330 |
331 | if not messages:
332 | print("没有更多历史消息了。")
333 | break
334 |
335 | for message in messages:
336 | if message.document:
337 | doc = message.document
338 | # 小于20MB的普通文件
339 | if doc.file_size < 20 * 1024 * 1024 and not doc.file_name.endswith('.manifest'):
340 | files.append({
341 | "name": doc.file_name,
342 | "file_id": doc.file_id,
343 | "size": doc.file_size
344 | })
345 | # 清单文件
346 | elif doc.file_name.endswith('.manifest'):
347 | # 下载并解析清单文件以获取原始文件名和大小
348 | manifest_url = await self.get_download_url(doc.file_id)
349 | if not manifest_url: continue
350 |
351 | import httpx
352 | async with httpx.AsyncClient() as client:
353 | try:
354 | resp = await client.get(manifest_url)
355 | if resp.status_code == 200 and resp.content.startswith(b'tgstate-blob\n'):
356 | lines = resp.content.decode('utf-8').strip().split('\n')
357 | original_filename = lines[1]
358 | # 注意:这里我们无法轻易获得原始总大小,暂时留空
359 | files.append({
360 | "name": original_filename,
361 | "file_id": doc.file_id, # 关键:使用清单文件的ID
362 | "size": None # 标记为未知大小
363 | })
364 | except httpx.RequestError:
365 | continue
366 |
367 | # 设置下一次迭代的偏移量
368 | last_message_id = messages[-1].message_id
369 | print(f"已处理批次 {i+1},最后的消息 ID: {last_message_id}")
370 |
371 | print(f"文件列表获取完毕,共找到 {len(files)} 个有效文件。")
372 | return files
373 |
374 | @lru_cache()
375 | def get_telegram_service() -> TelegramService:
376 | """
377 | TelegramService 的缓存工厂函数。
378 | """
379 | return TelegramService(settings=get_settings())
--------------------------------------------------------------------------------
/app/static/js/main.js:
--------------------------------------------------------------------------------
1 | const uploadArea = document.getElementById('upload-area');
2 | const fileInput = document.getElementById('file-input');
3 | const progressArea = document.getElementById('progress-area');
4 | const uploadedArea = document.getElementById('uploaded-area');
5 |
6 | // --- Event Listeners ---
7 | // Make the entire upload area clickable
8 | uploadArea.addEventListener('click', () => fileInput.click());
9 |
10 | // --- Upload Queue Logic ---
11 | const uploadQueue = [];
12 | let isUploading = false;
13 |
14 | function addToQueue(files) {
15 | for (const file of files) {
16 | uploadQueue.push(file);
17 | }
18 | processQueue();
19 | }
20 |
21 | function processQueue() {
22 | if (isUploading || uploadQueue.length === 0) {
23 | return;
24 | }
25 | isUploading = true;
26 | const fileToUpload = uploadQueue.shift();
27 | uploadFile(fileToUpload).then(() => {
28 | isUploading = false;
29 | processQueue();
30 | });
31 | }
32 |
33 | fileInput.addEventListener('change', ({ target }) => {
34 | const files = target.files;
35 | if (files.length > 0) {
36 | progressArea.innerHTML = '';
37 | uploadedArea.innerHTML = '';
38 | addToQueue(files);
39 | }
40 | });
41 |
42 | uploadArea.addEventListener('dragover', (event) => {
43 | event.preventDefault();
44 | uploadArea.classList.add('active');
45 | });
46 |
47 | uploadArea.addEventListener('dragleave', () => {
48 | uploadArea.classList.remove('active');
49 | });
50 |
51 | uploadArea.addEventListener('drop', (event) => {
52 | event.preventDefault();
53 | uploadArea.classList.remove('active');
54 | const files = event.dataTransfer.files;
55 | if (files.length > 0) {
56 | progressArea.innerHTML = '';
57 | uploadedArea.innerHTML = '';
58 | addToQueue(files);
59 | }
60 | });
61 |
62 | // --- Main Upload Function ---
63 | function uploadFile(file) {
64 | return new Promise((resolve) => {
65 | const formData = new FormData();
66 | formData.append('file', file, file.name);
67 |
68 | const xhr = new XMLHttpRequest();
69 | xhr.open('POST', '/api/upload', true);
70 |
71 | const fileId = `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
72 |
73 | xhr.upload.onprogress = ({ loaded, total }) => {
74 | updateProgressBar(file.name, loaded, total, fileId);
75 | };
76 |
77 | xhr.onload = () => {
78 | handleUploadCompletion(xhr, file.name, fileId);
79 | resolve(); // Resolve the promise when upload is complete (success or fail)
80 | };
81 |
82 | xhr.onerror = () => {
83 | // Also handle network errors
84 | handleUploadCompletion(xhr, file.name, fileId);
85 | resolve();
86 | };
87 |
88 | const initialProgressHTML = ``;
89 | progressArea.insertAdjacentHTML('beforeend', initialProgressHTML);
90 |
91 | xhr.send(formData);
92 | });
93 | }
94 |
95 | // --- File List Logic ---
96 | // The file list is now rendered by the backend.
97 | // We only need the delete functionality and to refresh the page on upload.
98 |
99 | function deleteFile(fileId) {
100 | if (!confirm('Are you sure you want to delete this file? This action cannot be undone.')) {
101 | return;
102 | }
103 |
104 | fetch(`/api/files/${fileId}`, {
105 | method: 'DELETE',
106 | })
107 | .then(response => {
108 | if (!response.ok) {
109 | // 如果响应状态码不是 2xx,则先解析错误信息
110 | return response.json().then(err => Promise.reject(err));
111 | }
112 | return response.json(); // 否则正常解析成功响应
113 | })
114 | .then(data => {
115 | if (data.status === 'ok') {
116 | showToast(data.message || `File ${fileId} deleted successfully.`);
117 | const fileItem = document.getElementById(`file-item-${fileId.replace(':', '-')}`);
118 | if (fileItem) {
119 | fileItem.remove();
120 | }
121 | } else {
122 | // 这种情况理论上不应该发生,因为错误已在 .catch 中处理
123 | showToast(`An unexpected issue occurred: ${data.message}`, 'error');
124 | }
125 | })
126 | .catch(error => {
127 | console.error('Error:', error);
128 | // 从 error 对象中提取更详细的错误信息
129 | const errorMessage = error.detail?.message || error.message || 'An error occurred while deleting the file.';
130 | showToast(errorMessage, 'error');
131 | });
132 | }
133 |
134 | // 在上传成功后也刷新文件列表
135 | function handleUploadCompletion(xhr, originalFileName, fileId) {
136 | const progressRow = document.getElementById(`progress-${fileId}`);
137 | if (progressRow) {
138 | progressRow.remove(); // Remove the progress bar for this file
139 | }
140 |
141 | let uploadedHTML;
142 | if (xhr.status === 200) {
143 | const response = JSON.parse(xhr.responseText);
144 | const fileUrl = response.url;
145 | uploadedHTML = `