Cloudflare Workers 与 Telegram 打造多源图床:伪分布式部署的尝试

24 年 11 月 29 日 星期五
4071 字
21 分钟

AI 摘要

奋力赶来...

最近折腾了借助 Cloudflare Workers + Telegram 作为图床的新花活,其实就是弱化版的图片老妈。不过既然开了头,就去做点不一样的东西,主体业务逻辑不作调整,就考虑在服务健壮性方面做些调整。最终就实现了一个“伪”分布式部署的文件应用(图床)逻辑,“伪”字很真实。

架构与技术栈

  1. 存储服务: Telegram、Cloudflare R2、GitHub
  2. 运行环境
    • Cloudflare Workers:核心逻辑,包括 API 接口和存储操作,Telegram Bot 接入、资源调用等功能。
    • Telegram Bot:作为上传入口,并返回文件调用地址。
    • GitHub Action:同步 Cloudflare R2 文件变动,发布静态文件。
  3. 数据库: Cloudflare D1

继续延用“图片老妈”所选择的 Telegram 作为操作入口,也因为输入密码登录只为上传几张图片的操作流程有些繁琐,远不及 Telegram 这样长期稳定在线的手机、电脑双端 APP 来的便利。另外 Telegram Bot 接入容易,虽有限制,却也足够。如果不考虑实际应用场景(我个人),放弃 Serverless,配合自部署 Telegram Bot API 服务,完全可以将 Cloudflare Workers、Telegram 的限制降到最低。

主体业务流程

业务流程

文件上传

  1. 通过 Telegram Bot 发送文件
  2. Worker 通过 Telegram API 下载文件并保存到 R2 之中
  3. Worker 将文件信息保存到 D1 数据库
  4. Worker 通过 API 触发 GitHub Action 同步事件
  5. GitHub Action 从 R2 同步文件并推送到仓库
  6. 触发 Vercel、Netlify、Render、Cloudflare Pages 等静态网站托管服务自动部署

同一份文件此时就处于了不同厂商的 CDN 之上,对于小文件无论是效率还是访问限制,调用静态网站中的文件都不输 R2。

直接请求

通过一次上传,就会得到多个镜像站点、三个文件存储,选择任一来源都能构建请求。

  1. Telegram 作为第一份文件源,可以借助 Worker 实现访问,但需要先请求路径,再请求文件,整体响应时间会比较长。
  2. Cloudflare R2 作为第二份文件源,直接通过自定义域名访问即可,响应时间受 Cloudflare CDN 影响。
  3. GitHub 作为第三份文件源,通过 https://raw.githubusercontent.com/ 或者发布 GitHub Pages 后访问,响应速度相对来说比较慢。
  4. 其他静态镜像站点直接通过自定义域名或默认域名访问,响应速度受各自 CDN 影响。

竞速请求

相对于直接访问,竞速请求会并发请求多个地址,并将最快成功响应的文件返回用户,可以提高容错性,优化用户体验。

混合请求

并发请求全部静态镜像站、Cloudflare R2 以及 Telegram,并将最快成功返回文件返回用户。容错性最高,但同时伴随着大量的资源浪费。

  1. Telegram 需要两次 API 请求才能获取到文件,拖慢了最终响应速度。
  2. 无论是否由 Cloudflare R2 提供最终结果,都会消耗访问数。

静态镜像站请求

相对于混合请求,去除 R2、Telegram 的请求,仅并发请求全部静态镜像站点,响应速度会快一些,同时还能保持较高的容错性。静态镜像站点可以自行增加托管数量,进一步提高容错性。

部署过程

部署分为 Telegram Bot 创建、Cloudflare 服务与主应用逻辑搭建、GitHub 文件源仓库创建、静态镜像站发布。

Telegram Bot 创建

  1. 在 Telegram 通过 BotFather 创建 Bot,并获取 HTTP API Token,作为文件上传入口使用。
Bot Token
  1. 使用 userinfobot 获取自己 Telegram 账户的 ID,用于限制 Telegram Bot 的用户使用范围。

Cloudflare D1 && R2 创建

Cloudflare D1 创建

  1. 创建 D1 数据库,保存数据库 ID 与数据库名,类似“c8132132d-0222-4dfa-olkb-12323123213a82”。
  2. 控制台中运行以下语句,完成数据表创建。
sql
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 创建

  1. 创建 R2 存储桶,保存桶名。
  2. 绑定自定义域名,用于外部访问。
  3. 创建 R2 存储桶操作权限,并保存调用参数。
R2 Token Settings
R2 Token

GitHub 文件源仓库

主要用于同步 Cloudflare R2 中的文件,并发布到多个静态网站托管平台。主体逻辑通过 Python 脚本实现,配合 GitHub Action 实现定时同步、接口触发同步、手动同步三种方式。

  1. 创建空白仓库,并在设置中开启 Action 的推送权限。
Action Settings
  1. 跟目录创建 sync_r2_to_git.py,用于 GitHub Action 调用使用。
python
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)
  1. 创建 .github/workflows/r2_incremental_sync.yml
yaml
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
  1. 将之前创建 Cloudflare R2 时的变量添加到 Repository secrets 之中。
Repository secrets
  1. 创建 GitHub 访问 Token。
GitHub Token

静态镜像网站发布

在 Vercel、Netlify 等平台使用 GitHub 仓库发布静态网页,并绑定不同的自定义域名(比如:f1.example.com、f2.example.com 等),也可以使用默认域名。

Cloudflare Workers 创建

  1. 创建全新的worker,并在设置中绑定创建好的 D1、R2,变量名分别设置为 DB、BUCKET。同时绑定好需要使用的自定义域名。
绑定D1、R2
  1. 创建变量用于存储必要的参数。

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

  1. 编辑 Worker 代码并使用以下代码重新部署。
javascript
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
  1. 浏览器中访问 /setWebhook ,完成初始化,比如:https://f.example.com/setWebhook

反思

“伪”分布式部署的主要原因是采用 Worker 作为前置代理,无论最终来自哪个文件源,通过一段时间的运行,都会将访问压力转嫁到 Cloudflare CDN 之上。如果本质依赖的 Cloudflare,那么直接访问 Cloudflare R2 或者 Cloudflare Pages 效率更高,相较于 R2 使用量的限制,Pages 会是一个不错的替代方案。整个项目逻辑的价值会在 R2 和 Pages 故障,但是 Workers 正常的情况下才会有意义。

文章标题:Cloudflare Workers 与 Telegram 打造多源图床:伪分布式部署的尝试

文章作者:Cedar

文章链接:https://some.fylsen.com/posts/cloudflare-workers-telegram-multi-source-image-hosting  [复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。