最近折腾了借助 Cloudflare Workers + Telegram 作为图床的新花活,其实就是弱化版的图片老妈。不过既然开了头,就去做点不一样的东西,主体业务逻辑不作调整,就考虑在服务健壮性方面做些调整。最终就实现了一个“伪”分布式部署的文件应用(图床)逻辑,“伪”字很真实。
架构与技术栈
- 存储服务: Telegram、Cloudflare R2、GitHub
- 运行环境:
- Cloudflare Workers:核心逻辑,包括 API 接口和存储操作,Telegram Bot 接入、资源调用等功能。
- Telegram Bot:作为上传入口,并返回文件调用地址。
- GitHub Action:同步 Cloudflare R2 文件变动,发布静态文件。
- 数据库: Cloudflare D1
继续延用“图片老妈”所选择的 Telegram 作为操作入口,也因为输入密码登录只为上传几张图片的操作流程有些繁琐,远不及 Telegram 这样长期稳定在线的手机、电脑双端 APP 来的便利。另外 Telegram Bot 接入容易,虽有限制,却也足够。如果不考虑实际应用场景(我个人),放弃 Serverless,配合自部署 Telegram Bot API 服务,完全可以将 Cloudflare Workers、Telegram 的限制降到最低。
主体业务流程
文件上传
- 通过 Telegram Bot 发送文件
- Worker 通过 Telegram API 下载文件并保存到 R2 之中
- Worker 将文件信息保存到 D1 数据库
- Worker 通过 API 触发 GitHub Action 同步事件
- GitHub Action 从 R2 同步文件并推送到仓库
- 触发 Vercel、Netlify、Render、Cloudflare Pages 等静态网站托管服务自动部署
同一份文件此时就处于了不同厂商的 CDN 之上,对于小文件无论是效率还是访问限制,调用静态网站中的文件都不输 R2。
直接请求
通过一次上传,就会得到多个镜像站点、三个文件存储,选择任一来源都能构建请求。
- Telegram 作为第一份文件源,可以借助 Worker 实现访问,但需要先请求路径,再请求文件,整体响应时间会比较长。
- Cloudflare R2 作为第二份文件源,直接通过自定义域名访问即可,响应时间受 Cloudflare CDN 影响。
- GitHub 作为第三份文件源,通过
https://raw.githubusercontent.com/
或者发布 GitHub Pages 后访问,响应速度相对来说比较慢。 - 其他静态镜像站点直接通过自定义域名或默认域名访问,响应速度受各自 CDN 影响。
竞速请求
相对于直接访问,竞速请求会并发请求多个地址,并将最快成功响应的文件返回用户,可以提高容错性,优化用户体验。
混合请求
并发请求全部静态镜像站、Cloudflare R2 以及 Telegram,并将最快成功返回文件返回用户。容错性最高,但同时伴随着大量的资源浪费。
- Telegram 需要两次 API 请求才能获取到文件,拖慢了最终响应速度。
- 无论是否由 Cloudflare R2 提供最终结果,都会消耗访问数。
静态镜像站请求
相对于混合请求,去除 R2、Telegram 的请求,仅并发请求全部静态镜像站点,响应速度会快一些,同时还能保持较高的容错性。静态镜像站点可以自行增加托管数量,进一步提高容错性。
部署过程
部署分为 Telegram Bot 创建、Cloudflare 服务与主应用逻辑搭建、GitHub 文件源仓库创建、静态镜像站发布。
Telegram Bot 创建
- 在 Telegram 通过 BotFather 创建 Bot,并获取 HTTP API Token,作为文件上传入口使用。
- 使用 userinfobot 获取自己 Telegram 账户的 ID,用于限制 Telegram Bot 的用户使用范围。
Cloudflare D1 && R2 创建
Cloudflare D1 创建
- 创建 D1 数据库,保存数据库 ID 与数据库名,类似“c8132132d-0222-4dfa-olkb-12323123213a82”。
- 控制台中运行以下语句,完成数据表创建。
CREATE TABLE file (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT UNIQUE,
original_name TEXT,
upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
mime_type TEXT,
telegram_file_id TEXT,
path TEXT,
media_type TEXT,
size INTEGER
);
Cloudflare R2 创建
- 创建 R2 存储桶,保存桶名。
- 绑定自定义域名,用于外部访问。
- 创建 R2 存储桶操作权限,并保存调用参数。
GitHub 文件源仓库
主要用于同步 Cloudflare R2 中的文件,并发布到多个静态网站托管平台。主体逻辑通过 Python 脚本实现,配合 GitHub Action 实现定时同步、接口触发同步、手动同步三种方式。
- 创建空白仓库,并在设置中开启 Action 的推送权限。
- 跟目录创建
sync_r2_to_git.py
,用于 GitHub Action 调用使用。
import os
import json
import boto3
import sys
from botocore.config import Config
CACHE_FILE = "sync_cache.json"
LOCAL_SYNC_DIR = "./"
def load_cache():
"""加载本地缓存"""
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r") as f:
return json.load(f)
return {}
def save_cache(cache):
"""保存缓存到本地文件"""
with open(CACHE_FILE, "w") as f:
json.dump(cache, f, indent=2)
def sync_from_r2(specific_path=None):
"""从 R2 增量同步文件
Args:
specific_path: 如果指定,则只同步该路径下的文件
"""
session = boto3.session.Session()
s3 = session.client(
"s3",
endpoint_url=os.getenv("R2_ENDPOINT"),
aws_access_key_id=os.getenv("R2_ACCESS_KEY"),
aws_secret_access_key=os.getenv("R2_SECRET_KEY"),
config=Config(signature_version="s3v4"),
)
# 加载本地缓存
cache = load_cache()
current_files = set()
# 配置分页器参数
paginator = s3.get_paginator("list_objects_v2")
pagination_config = {'Bucket': os.getenv("R2_BUCKET_NAME")}
if specific_path:
pagination_config['Prefix'] = specific_path
pages = paginator.paginate(**pagination_config)
for page in pages:
for obj in page.get("Contents", []):
file_key = obj["Key"]
current_files.add(file_key)
etag = obj["ETag"]
# 跳过未改变的文件
if file_key in cache and cache[file_key] == etag:
print(f"Skipping unchanged file: {file_key}")
continue
# 下载文件
local_path = os.path.join(LOCAL_SYNC_DIR, file_key)
os.makedirs(os.path.dirname(local_path), exist_ok=True)
print(f"Downloading: {file_key}")
s3.download_file(os.getenv("R2_BUCKET_NAME"), file_key, local_path)
# 更新缓存
cache[file_key] = etag
# 处理已删除的文件
if not specific_path: # 只在完整同步时处理删除
cached_files = set(cache.keys())
deleted_files = cached_files - current_files
for file_key in deleted_files:
local_path = os.path.join(LOCAL_SYNC_DIR, file_key)
if os.path.exists(local_path):
print(f"Removing deleted file: {file_key}")
os.remove(local_path)
# 尝试删除空目录
try:
os.removedirs(os.path.dirname(local_path))
except OSError:
pass # 目录非空或已删除,忽略错误
del cache[file_key]
# 保存最新缓存
save_cache(cache)
if __name__ == "__main__":
# 检查是否有指定路径参数
specific_path = None
if len(sys.argv) > 1:
specific_path = sys.argv[1]
sync_from_r2(specific_path)
- 创建
.github/workflows/r2_incremental_sync.yml
。
name: Incremental Sync from Cloudflare R2 to GitHub
on:
schedule:
- cron: '0 */12 * * *' # 每 12 小时运行
workflow_dispatch: # 手动触发
repository_dispatch:
types:
- SyncFromR2
concurrency:
group: r2-sync-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
steps:
# Step 1: Checkout Repository
- name: Checkout Repository
uses: actions/checkout@v3
with:
fetch-depth: 0
# Step 2: Set up Python
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.9'
# Step 3: Install Dependencies
- name: Install Dependencies
run: |
pip install boto3
# Step 4: Sync from R2
- name: Sync Files from R2
env:
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
run: |
if [ "${{ github.event_name }}" = "repository_dispatch" ] && [ -n "${{ github.event.client_payload.path }}" ]; then
python sync_r2_to_git.py "${{ github.event.client_payload.path }}"
else
python sync_r2_to_git.py
fi
# Step 5: Push Changes to GitHub
- name: Push Changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "GitHub Action Bot"
git config --global user.email "actions@github.com"
git add .
git commit -m "Sync R2 files at $(date --utc +'%Y-%m-%dT%H:%M:%SZ')" || echo "No changes to commit"
git push origin main
- 将之前创建 Cloudflare R2 时的变量添加到 Repository secrets 之中。
- 创建 GitHub 访问 Token。
静态镜像网站发布
在 Vercel、Netlify 等平台使用 GitHub 仓库发布静态网页,并绑定不同的自定义域名(比如:f1.example.com、f2.example.com 等),也可以使用默认域名。
Cloudflare Workers 创建
- 创建全新的worker,并在设置中绑定创建好的 D1、R2,变量名分别设置为 DB、BUCKET。同时绑定好需要使用的自定义域名。
- 创建变量用于存储必要的参数。
FILE_SERVERS: 静态网站域名,使用“,”分隔多个,比如:f1.example.com,f2.example.com GITHUB_OWNER: 所用 GitHub 的用户名 GITHUB_REPO: 所用 GitHub 的仓库名 GITHUB_TOKEN: 创建好的 GitHub 访问 Token R2_CUSTOM_DOMAIN: R2 中绑定的自定义域名 TELEGRAM_BOT_TOKEN: 创建 Telegram Bot 获得的 Token TELEGRAM_ALLOWED_USERS:之前获取到的 Telegram 用户 ID
- 编辑 Worker 代码并使用以下代码重新部署。
var __defProp = Object.defineProperty
var __name = (target, value) => __defProp(target, 'name', { value, configurable: true })
// src/config/env.ts
function getEnv(env) {
if (!env.TELEGRAM_BOT_TOKEN) {
throw new Error('TELEGRAM_BOT_TOKEN is required')
}
if (!env.TELEGRAM_ALLOWED_USERS) {
throw new Error('TELEGRAM_ALLOWED_USERS is required')
}
return env
}
__name(getEnv, 'getEnv')
function getAllowedUsers(env) {
return env.TELEGRAM_ALLOWED_USERS.split(',').map((id) => parseInt(id.trim(), 10))
}
__name(getAllowedUsers, 'getAllowedUsers')
// src/utils/hash.ts
async function calculateHash(data) {
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
__name(calculateHash, 'calculateHash')
// src/utils/path.ts
function generateMediaPath(mediaType, hash, extension) {
const date = /* @__PURE__ */ new Date()
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${mediaType}s/${year}/${month}/${hash}${extension}`
}
__name(generateMediaPath, 'generateMediaPath')
function getExtensionFromPath(filePath) {
const extension = filePath.split('.').pop()
return extension ? `.${extension}` : ''
}
__name(getExtensionFromPath, 'getExtensionFromPath')
// src/config/constants.ts
var MEDIA_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
VOICE: 'voice',
DOCUMENT: 'document',
}
var MIME_TYPE_MAPPING = {
'image/jpeg': MEDIA_TYPES.IMAGE,
'image/png': MEDIA_TYPES.IMAGE,
'image/gif': MEDIA_TYPES.IMAGE,
'image/webp': MEDIA_TYPES.IMAGE,
'image/svg+xml': MEDIA_TYPES.IMAGE,
'video/mp4': MEDIA_TYPES.VIDEO,
'video/webm': MEDIA_TYPES.VIDEO,
'video/quicktime': MEDIA_TYPES.VIDEO,
'audio/mpeg': MEDIA_TYPES.VOICE,
'audio/ogg': MEDIA_TYPES.VOICE,
'audio/wav': MEDIA_TYPES.VOICE,
'audio/webm': MEDIA_TYPES.VOICE,
'text/csv': MEDIA_TYPES.DOCUMENT,
'text/plain': MEDIA_TYPES.DOCUMENT,
'application/pdf': MEDIA_TYPES.DOCUMENT,
'application/msword': MEDIA_TYPES.DOCUMENT,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': MEDIA_TYPES.DOCUMENT,
'application/vnd.ms-excel': MEDIA_TYPES.DOCUMENT,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': MEDIA_TYPES.DOCUMENT,
}
// src/services/telegram/webhook.service.ts
var WebhookService = class {
constructor(telegram, storage, db, github, allowedUsers, origin, r2CustomDomain) {
this.telegram = telegram
this.storage = storage
this.db = db
this.github = github
this.allowedUsers = allowedUsers
this.origin = origin
this.r2CustomDomain = r2CustomDomain
}
getMediaTypeFromMimeType(mimeType) {
if (!mimeType) return MEDIA_TYPES.DOCUMENT
return MIME_TYPE_MAPPING[mimeType] || MEDIA_TYPES.DOCUMENT
}
async getFileInfo(message) {
if (!message) return null
if (message.document) {
const mediaType = this.getMediaTypeFromMimeType(message.document.mime_type)
return {
fileId: message.document.file_id,
fileName: message.document.file_name,
mimeType: message.document.mime_type,
mediaType,
fileSize: message.document.file_size,
}
}
if (message.photo?.length) {
const photo = message.photo[message.photo.length - 1]
return {
fileId: photo.file_id,
mimeType: 'image/jpeg',
mediaType: MEDIA_TYPES.IMAGE,
fileSize: photo.file_size,
}
}
if (message.video) {
return {
fileId: message.video.file_id,
mimeType: message.video.mime_type,
mediaType: MEDIA_TYPES.VIDEO,
fileSize: message.video.file_size,
}
}
if (message.voice) {
return {
fileId: message.voice.file_id,
mimeType: message.voice.mime_type,
mediaType: MEDIA_TYPES.VOICE,
fileSize: message.voice.file_size,
}
}
return null
}
async enrichFileInfo(fileInfo) {
const telegramFile = await this.telegram.getFile(fileInfo.fileId)
const filePath = telegramFile.file_path
return {
...fileInfo,
fileName: fileInfo.fileName || filePath.split('/').pop(),
mimeType: fileInfo.mimeType || telegramFile.mime_type,
mediaType: fileInfo.mediaType || this.getMediaTypeFromMimeType(fileInfo.mimeType),
}
}
async handleUpdate(update) {
if (!update.message) return
const chatId = update.message.chat.id
const userId = update.message.from?.id
try {
if (!userId || !this.allowedUsers.includes(userId)) {
await this.telegram.sendMessage(
chatId,
`Sorry, you don't have permission to use this bot (add ${userId} to use)`,
)
return
}
const basicFileInfo = await this.getFileInfo(update.message)
console.log(basicFileInfo)
if (!basicFileInfo) {
await this.telegram.sendMessage(
chatId,
'Please send a media file (image, video, voice, or document)',
)
return
}
const fileInfo = await this.enrichFileInfo(basicFileInfo)
if (!fileInfo.mimeType) {
await this.telegram.sendMessage(chatId, 'Unable to identify file type')
return
}
const { file_path } = await this.telegram.getFile(fileInfo.fileId)
const fileData = await this.telegram.downloadFile(file_path)
const hash = await calculateHash(fileData)
const existing = await this.db.findByHash(hash)
if (existing) {
const message2 = `File already exists!
Path:
/${existing.path}
Hybrid:
${this.origin}/file?h=${existing.hash}
Static:
${this.origin}/${existing.path}
Cloudflare R2:
${this.r2CustomDomain}/${existing.path}
Telegram:
${this.origin}/telegram?h=${existing.hash}
`
await this.telegram.sendMessage(chatId, message2)
return
}
const extension = getExtensionFromPath(file_path)
const path = generateMediaPath(fileInfo.mediaType, hash, extension)
const r2Url = await this.storage.upload(fileData, path, fileInfo.mimeType)
await this.db.createFile({
hash,
original_name: fileInfo.fileName || path.split('/').pop() || '',
mime_type: fileInfo.mimeType,
telegram_file_id: fileInfo.fileId,
path,
media_type: fileInfo.mediaType,
size: fileInfo.fileSize || 0,
})
const dispatchBody = {
path,
hash,
}
const githubSync = await this.github.dispatch('SyncFromR2', dispatchBody)
let message = 'Upload successful.\n\n'
if (githubSync.success) {
message += `Path:
/${path}
Hybrid:
${this.origin}/file?h=${hash}
Static:
${this.origin}/${path}
`
} else {
console.error('GitHub sync failed:', githubSync.message)
}
message += `Cloudflare R2:
${r2Url}
Telegram:
${this.origin}/telegram?h=${hash}
`
await this.telegram.sendMessage(chatId, message)
} catch (error) {
console.error('Failed to process update:', error)
await this.telegram.sendMessage(
chatId,
'An error occurred while processing the file. Please try again later',
)
}
}
}
__name(WebhookService, 'WebhookService')
// src/utils/response.ts
function successResponse(data) {
return {
status: 'success',
data,
}
}
__name(successResponse, 'successResponse')
function errorResponse(message) {
return {
status: 'error',
message,
}
}
__name(errorResponse, 'errorResponse')
// src/services/telegram/telegram.service.ts
var TelegramService = class {
constructor(token) {
this.token = token
this.baseUrl = `https://api.telegram.org/bot${token}`
}
baseUrl
async getFile(fileId) {
const response = await fetch(`${this.baseUrl}/getFile?file_id=${fileId}`)
const data = await response.json()
if (!data.ok) {
throw new Error(`Failed to get file: ${data.description || 'Unknown error'}`)
}
return data.result
}
async downloadFile(filePath) {
const response = await fetch(`https://api.telegram.org/file/bot${this.token}/${filePath}`)
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`)
}
return await response.arrayBuffer()
}
async sendMessage(chatId, text) {
const response = await fetch(`${this.baseUrl}/sendMessage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML',
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(`Failed to send message: ${error.description || response.statusText}`)
}
}
}
__name(TelegramService, 'TelegramService')
// src/services/storage/r2.service.ts
var R2StorageService = class {
constructor(bucket, R2_CUSTOM_DOMAIN) {
this.bucket = bucket
this.R2_CUSTOM_DOMAIN = R2_CUSTOM_DOMAIN
}
async upload(file, path, mimeType) {
await this.bucket.put(path, file, {
httpMetadata: { contentType: mimeType },
})
return `https://${this.R2_CUSTOM_DOMAIN}/${path}`
}
async delete(path) {
await this.bucket.delete(path)
}
async exists(path) {
const object = await this.bucket.head(path)
return object !== null
}
async download(path) {
const object = await this.bucket.get(path)
if (!object || !object.body) {
throw new Error(`File not found: ${path}`)
}
return await object.blob().then((blob) => blob.arrayBuffer())
}
}
__name(R2StorageService, 'R2StorageService')
// src/services/database/d1.service.ts
var D1Service = class {
constructor(db) {
this.db = db
}
async createFile(file) {
const { hash, original_name, mime_type, telegram_file_id, path, media_type, size } = file
const result = await this.db
.prepare(
'INSERT INTO file (hash, original_name, mime_type, telegram_file_id, path, media_type, size) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *',
)
.bind(hash, original_name, mime_type, telegram_file_id, path, media_type, size)
.first()
if (!result) {
throw new Error('Failed to create file record')
}
return result
}
async findByHash(hash) {
return await this.db.prepare('SELECT * FROM file WHERE hash = ?').bind(hash).first()
}
async delete(hash) {
const result = await this.db.prepare(`delete from file where hash = ?`).bind(hash).run()
if (!result.success) {
throw new Error('Failed to delete file record')
}
return result.success
}
}
__name(D1Service, 'D1Service')
// src/services/fetcher/fetcher.service.ts
var FileFetcherService = class {
servers
storage
telegram
constructor(servers, storage, telegram) {
if (servers.length === 0) {
throw new Error('Server list cannot be empty')
}
this.servers = servers.map((server) => server.trim())
this.storage = storage
this.telegram = telegram
}
/**
* 并发获取最快响应的文件 ArrayBuffer,R2 请求与分布式服务器并行
* @param path 文件路径
* @returns 最快响应的 ArrayBuffer
*/
async fetchFirst(path, file_id) {
const urls = this.servers.map((server) => `https://${server}/${path}`)
const fetchPromises = [
...urls.map((url) => this.fetchFromServer(url)),
this.storage.download(path).catch((error) => {
console.error(`R2 download failed for ${path}: ${error.message}`)
throw error
}),
]
if (file_id) {
try {
const fileInfo = await this.telegram.getFile(file_id)
if (fileInfo && fileInfo.file_path) {
fetchPromises.push(
this.telegram.downloadFile(fileInfo.file_path).catch((error) => {
console.error(`Telegram download failed for ${path}: ${error.message}`)
throw error
}),
)
} else {
console.warn(`Telegram file not found or file_path is missing for file_id: ${file_id}`)
}
} catch (error) {
console.error(
`Failed to get Telegram file info: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
try {
return await Promise.any(fetchPromises)
} catch (error) {
throw new Error('All fetch attempts failed')
}
}
/**
* 从指定的服务器获取文件
* @param url 文件 URL
* @returns 文件的 ArrayBuffer
*/
async fetchFromServer(url) {
try {
const response = await fetch(url, { method: 'GET' })
if (!response.ok) {
throw new Error(`Failed to download file from ${url}: ${response.statusText}`)
}
return await response.arrayBuffer()
} catch (error) {
throw new Error(`Fetch error for ${url}: ${error}`)
}
}
}
__name(FileFetcherService, 'FileFetcherService')
// src/services/dispatch/github.service.ts
var GitHubDispatchService = class {
dispatchApi
token
constructor(env) {
this.token = env.GITHUB_TOKEN
this.dispatchApi = `https://api.github.com/repos/${env.GITHUB_OWNER}/${env.GITHUB_REPO}/dispatches`
}
async dispatch(eventType, clientPayload) {
try {
const payload = {
event_type: eventType,
client_payload: clientPayload,
}
const response = await fetch(this.dispatchApi, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
'X-GitHub-Api-Version': '2022-11-28',
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
'User-Agent': 'TelegramBot/1.0',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorData = await response.text()
console.error('GitHub API Error:', errorData)
return {
success: false,
message: `GitHub API error: ${response.status} - ${errorData}`,
}
}
return { success: true, message: 'GitHub action triggered successfully' }
} catch (error) {
console.error('Dispatch error:', error)
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
}
}
}
}
__name(GitHubDispatchService, 'GitHubDispatchService')
// src/utils/isStaticFilePath.ts
function isStaticFilePath(path, prefixes) {
return prefixes.some((prefix) => path.startsWith(prefix))
}
__name(isStaticFilePath, 'isStaticFilePath')
// src/routes/router.ts
async function handleRequest(request, env) {
const url = new URL(request.url)
if (url.pathname === '/setWebhook' && request.method === 'GET') {
try {
const webhookUrl = `${url.origin}/webhook`
const response = await fetch(
`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/setWebhook`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: webhookUrl,
allowed_updates: ['message'],
}),
},
)
const result = await response.json()
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
})
} catch (error) {
return new Response(JSON.stringify(errorResponse(error.message)), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
if (url.pathname === '/webhook' && request.method === 'POST') {
try {
const update = await request.json()
const webhookService = new WebhookService(
new TelegramService(env.TELEGRAM_BOT_TOKEN),
new R2StorageService(env.BUCKET, env.R2_CUSTOM_DOMAIN),
new D1Service(env.DB),
new GitHubDispatchService(env),
getAllowedUsers(env),
url.origin,
env.R2_CUSTOM_DOMAIN,
)
await webhookService.handleUpdate(update)
return new Response(JSON.stringify(successResponse({})), {
headers: { 'Content-Type': 'application/json' },
})
} catch (error) {
return new Response(JSON.stringify(errorResponse(error.message)), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
if (url.pathname === '/telegram' && request.method === 'GET') {
try {
const hash = url.searchParams.get('h') || url.searchParams.get('hash')
if (!hash) return new Response('Missing required parameter: hash,fid', { status: 400 })
const db = new D1Service(env.DB)
const existing = await db.findByHash(hash)
if (!existing) return new Response('File not found', { status: 404 })
if (!existing.telegram_file_id)
return new Response('File not available in Telegram', { status: 404 })
const telegram = new TelegramService(env.TELEGRAM_BOT_TOKEN)
try {
const fileInfo = await telegram.getFile(existing.telegram_file_id)
if (!fileInfo || !fileInfo.file_path) {
return new Response('File no longer exists in Telegram', { status: 404 })
}
const fileData = await telegram.downloadFile(fileInfo.file_path)
return new Response(fileData, {
status: 200,
headers: {
'Content-Type': existing.mime_type,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch (telegramError) {
console.error('Telegram file fetch error:', telegramError)
return new Response('Failed to fetch file from Telegram', { status: 502 })
}
} catch (error) {
console.error('Telegram route error:', error)
return new Response(
JSON.stringify(errorResponse(error instanceof Error ? error.message : 'Unknown error')),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}
}
if (url.pathname === '/file' && request.method === 'GET') {
try {
const hash = url.searchParams.get('h') || url.searchParams.get('hash')
if (!hash) return new Response('Missing required parameter: hash', { status: 400 })
const db = new D1Service(env.DB)
const existing = await db.findByHash(hash)
if (!existing) return new Response('File not found', { status: 404 })
const servers = env.FILE_SERVERS.split(',')
const fileFetcher = new FileFetcherService(
servers,
new R2StorageService(env.BUCKET, env.R2_CUSTOM_DOMAIN),
new TelegramService(env.TELEGRAM_BOT_TOKEN),
)
try {
const fileData = await fileFetcher.fetchFirst(existing.path, existing.telegram_file_id)
return new Response(fileData, {
status: 200,
headers: {
'Content-Type': existing.mime_type,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch (fetchError) {
console.error('File fetch error:', fetchError)
return new Response('Failed to fetch file from all available sources', {
status: 502,
headers: { 'Content-Type': 'text/plain' },
})
}
} catch (error) {
console.error('File route error:', error)
return new Response(
JSON.stringify(errorResponse(error instanceof Error ? error.message : 'Unknown error')),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}
}
const STATIC_FILE_PREFIXES = ['/images/', '/videos/', '/voices/', '/documents/']
if (isStaticFilePath(url.pathname, STATIC_FILE_PREFIXES) && request.method === 'GET') {
try {
const servers = env.FILE_SERVERS.split(',')
const urls = servers.map((server) => `https://${server}${url.pathname}`)
const fetchPromises = urls.map(async (url2) => {
const response = await fetch(url2, { method: 'GET' })
if (!response.ok) {
throw new Error(`Failed to fetch from ${url2}`)
}
return {
buffer: await response.arrayBuffer(),
contentType: response.headers.get('Content-Type') || 'application/octet-stream',
}
})
try {
const result = await Promise.any(fetchPromises)
return new Response(result.buffer, {
status: 200,
headers: {
'Content-Type': result.contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch (fetchError) {
console.error('Direct file fetch error:', fetchError)
return new Response('Failed to fetch file from all available sources', {
status: 502,
headers: { 'Content-Type': 'text/plain' },
})
}
} catch (error) {
console.error('Direct file route error:', error)
return new Response(
JSON.stringify(errorResponse(error instanceof Error ? error.message : 'Unknown error')),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}
}
return new Response('Not Found', { status: 404 })
}
__name(handleRequest, 'handleRequest')
// src/index.ts
var src_default = {
async fetch(request, env, ctx) {
try {
const validEnv = getEnv(env)
return await handleRequest(request, validEnv)
} catch (error) {
return new Response(
JSON.stringify({
status: 'error',
message: 'Internal Server Error',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}
},
}
export { src_default as default }
//# sourceMappingURL=index.js.map
- 浏览器中访问
/setWebhook
,完成初始化,比如:https://f.example.com/setWebhook
。
反思
“伪”分布式部署的主要原因是采用 Worker 作为前置代理,无论最终来自哪个文件源,通过一段时间的运行,都会将访问压力转嫁到 Cloudflare CDN 之上。如果本质依赖的 Cloudflare,那么直接访问 Cloudflare R2 或者 Cloudflare Pages 效率更高,相较于 R2 使用量的限制,Pages 会是一个不错的替代方案。整个项目逻辑的价值会在 R2 和 Pages 故障,但是 Workers 正常的情况下才会有意义。