神代綺凛の随波逐流

在 Cloudflare Workers 上部署基于 Telegraf 框架的 Telegram Bot

当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »

用 serverless 解放你的服务器

Head Pic: #アークナイツ timeout - Alcxome - pixiv

Cloudflare Workers

官网:https://workers.cloudflare.com/

我也不做过多介绍了,可以从官网上了解,简单来说它是个运行 javascript 的 serverless 平台,虽然还说支持 Rust 或者 C 等等,但实际上得将它们编译为 Wasm

有官方客户端 wrangler,但这客户端 bug 是真的多,请做好各种搜 issue 的准备(

以下描述针对免费版计划

为什么值得用:

  1. 每天免费调用十万次,超出只会终止而不扣费
  2. 最多创建 30 个 worker,管够
  3. 赠送一个专门用于调用 worker 的 workers.dev 子域,子域名自定义
  4. 可以自定义路由来使用你自己的域名
  5. 没有流量限制
  6. 可以使用 KV(cf 提供的键值数据库,2020年11月起对免费套餐开放使用了)

局限性:

  1. Serverless 的特性所决定的局限性
  2. 每个请求限制 10ms CPU 时间(网络 IO 时间不计),无法完成某些太耗算力的任务,例如较复杂的图片处理或者使用 cheerio 这类效率不高的 HTML 解析库
  3. (目前)不允许使用 websocket

关于 KV

由于免费版套餐也可以白嫖 KV 了,这意味着你可以编写有状态的 bot 逻辑了,但 KV 的写入生效可能存在延迟,并且存在每秒一次(目前)的频率限制,不能与内存相提并论,因此请根据实际情况合理使用

Telegram Bot

实现 Telegram Bot 的处理消息有两种方式,长轮询和 webhook,想依靠 serverless 处理则选用 webhook 方式

注意,webhook 每一次被 tg 服务器调用都只能返回一次,因此没办法做到“等待某条消息发送后再xxxx”,只能一次性发送完所有操作

我一般用 Telegraf 框架开发,所以这里主要说下怎么将使用该框架开发的 bot 迁移至 cfworker

!> 下面的内容是对于 Telegraf 3 的,目前已经支持 Telegraf 4,具体使用方法请以 github 项目为准

迁移思路

如果只想知道最终该怎么做可以直接跳过这一节

首先 Telegraf 的文档有说明如何处理 webhook

我一般使用 cf 官方的框架来开发 cfworker:

@cfworker/web

这是一个类 Koa 框架,并包含了 Router,但 cfworker 的RequestResponse的 API 和 Koa 略有不同,具体可以见官方文档

综合考虑决定参考 Telegraf 文档中 Koa 的示例,使用handleUpdate方法来处理 webhook

这个函数接受两个参数,第一个是 webhook 请求的 payload,也就是JSON.parse body 所得到的内容,这个只要await req.json()就可以得到

而第二个参数是一个 http.ServerResponse,但 web-router 的ResponseBuilder由于 API 不同所以不能直接拿来用,需要改造一下

Telegraf 框架的这段代码作用就是产生 webhook 的应答,因此我从这里入手进行分析

function isKoaResponse (response) {
  return typeof response.set === 'function' && typeof response.header === 'object'
}

function answerToWebhook (response, payload = {}, options) {
  if (!includesMedia(payload)) {
    if (isKoaResponse(response)) {
      response.body = payload
      return Promise.resolve(WEBHOOK_REPLY_STUB)
    }
    if (!response.headersSent) {
      response.setHeader('content-type', 'application/json')
    }
    return new Promise((resolve) => {
      if (response.end.length === 2) {
        response.end(JSON.stringify(payload), 'utf-8')
        return resolve(WEBHOOK_REPLY_STUB)
      }
      response.end(JSON.stringify(payload), 'utf-8', () => resolve(WEBHOOK_REPLY_STUB))
    })
  }

  return buildFormDataConfig(payload, options.agent)
    .then(({ headers, body }) => {
      if (isKoaResponse(response)) {
        Object.keys(headers).forEach(key => response.set(key, headers[key]))
        response.body = body
        return Promise.resolve(WEBHOOK_REPLY_STUB)
      }
      if (!response.headersSent) {
        Object.keys(headers).forEach(key => response.setHeader(key, headers[key]))
      }
      return new Promise((resolve) => {
        response.on('finish', () => resolve(WEBHOOK_REPLY_STUB))
        body.pipe(response)
      })
    })
}

根据 cfworker 中Response的 API,其body是支持被赋值Stream的,因此向 KoaResponse 靠拢会比较好处理,不需要考虑 pipe 的问题

那么就需要使isKoaResponse(response)true,根据这段代码我们需要对 web-router 的ResponseBuilder做的适配有以下几点:

  1. 实现set()函数,用于设置 header
  2. header属性且为Object,由于没有实际使用所以随便糊弄也可以
  3. body支持被赋值一个Object(但Array最好也考虑进去,与 Koa API 一致),相当于构造一个 JSON Response

考虑到第三点,做一个 Proxy 就可以实现

function getKoaLikeResponse(res) {
  return new Proxy(
    Object.assign(res, {
      set: (...args) => res.headers.set(...args),
      header: res.headers,
    }),
    {
      set(obj, prop, value) {
        if (prop === 'body' && ['Object', 'Array'].includes(Object.getPrototypeOf(value).constructor.name)) {
          obj.body = JSON.stringify(value);
          obj.headers.set('content-type', 'application/json');
          return true;
        }
        return Reflect.set(...arguments);
      },
    }
  );
}

迁移流程

你可以选择直接使用我写好的模板 Tsuk1ko/cfworker-telegraf-template 来建仓库,或者是根据下面的步骤自己动手

0. 安装依赖

我将以上解决方案写了个 npm 包,可以直接作为 cfworker 中间件使用

[button color="dark" icon="fa fa-github" url="https:\/\/github.com\/Tsuk1ko\/cfworker-middware-telegraf"]Tsuk1ko/cfworker-middware-telegraf[/button]

在你的新项目中安装这些依赖:

npm i @cfworker/web cfworker-middware-telegraf telegraf
npm i -D webpack webpack-cli

1. 写代码

主入口应该为这样的结构

const { Telegraf } = require('telegraf');
const { Application, Router } = require('@cfworker/web');
const createTelegrafMiddware = require('cfworker-middware-telegraf');

const bot = new Telegraf('BOT_TOKEN');

// 写 bot 逻辑,但不要 bot.launch()

const router = new Router();

// `/SECRET_PATH` 指的是一个不容易猜到的路径,以防止他人访问你的 webhook
// 可以滚键盘或者用 UUID 之类的生成,例如 '/d4507ff0-08d1-4160-bad8-1addf587374a'
router.post('/SECRET_PATH', createTelegrafMiddware(bot));

new Application().use(router.middleware).listen();

所以很简单,把这段代码抄走然后补上你的 bot 逻辑即可

顺带一提,cfworker 支持设置 secret,它们会成为代码中的全局变量,所以像TG_BOT_TOKENSECRET_PATH这类不宜写入代码的变量可以直接设置成 secret

2. webpack

要在 cfworker 上运行需要 webpack 一下,把依赖全打进一个文件中,然后……粗暴的复制粘贴到 cfworker 编辑器中保存就行

当然你也可以用 wrangler,可通过配置实现自动 webpack 并部署

下面是一份示例配置

// webpack.config.js
module.exports = {
    target: 'webworker',
    entry: './index.js',
    mode: 'production',
    node: {
        fs: 'empty',
    },
    module: {
        rules: [
            {
                test: /\.mjs$/,
                include: /node_modules/,
                type: 'javascript/auto',
            },
        ],
    },
};

至于其中的 node 配置是干嘛的,你可以看 webpack 文档

如果在打包时出现找不到模块错误,就说明你的依赖中用到了 webworker 不支持的 node 模块或全局变量,需要配置上面所说的 node

如果代码实际运行时不会用到这些模块或全局变量,则 empty 就可以解决;如果实际用到则需要尝试 webpack 提供的 polyfill 或 mock,如果仍无法正常执行那就只能放弃使用该依赖了

3. 设置 webhook

接着我们设置 bot 的 webhook,另起一个 js 在本地执行一下即可

只用执行一次即可,这段不需要放进 bot 代码中

const Telegraf = require('telegraf');
const bot = new Telegraf(TG_BOT_TOKEN);

// 设置 webhook,请改成你自己的回调地址
bot.telegram.setWebhook('https://name.subdomain.workers.dev/SECRET_PATH');

// 删除 webhook
// bot.telegram.deleteWebhook();

// 查看当前 webhook
// bot.telegram.getWebhookInfo().then(console.log);