跳到正文
北京时间
原文
Hacker News 热门(buzzing.cc 中文翻译)· soheilpro·· 2026-07-15精选AI 评分77

Telegram 无服务器架构

AI 导读

Telegram Serverless 允许开发者直接在 Telegram 基础设施上运行 Bot 和 Mini App 的后端代码,无需配置服务器或容器。开发者编写普通 JavaScript 模块,通过 `npx tgcloud push` 单命令部署,代码在靠近 Bot API 和内建数据库的轻量级 V8 隔离沙箱中执行。

推荐理由

Telegram 给机器人开发搭了一套完整的无服务器后端,内置数据库和 CLI,从写代码到部署都在一个文件夹里完成。做 Telegram 机器人的开发者可以彻底扔掉 VPS 和云函数了。

正文 · AI 翻译

Telegram 无服务器

Telegram Serverless 让你能够直接在 Telegram 的基础设施上为你的机器人和 Mini App 运行后端代码——无需配置服务器,无需维持容器存活,无需考虑扩缩容。你只需编写普通的 JavaScript 模块,用一条命令部署,Telegram 便会在一个快速、隔离的 V8 沙箱中运行它们,该沙箱紧邻 Bot API 和一个内置数据库。

如果你曾经仅仅为了响应一个 /start 就把机器人接到 VPS、云函数或托管面板上,那么这部分工作你现在再也不必做了。

为什么选择无服务器

Telegram 机器人本质上是一个对更新做出反应的程序。传统上,你必须把这个程序托管在某个始终在线、可访问且安全的地方——然后一直维持这种状态。Telegram Serverless 彻底移除了这一层:

  • 无需基础设施。 没有机器需要租用、打补丁或监控。你的代码按需运行,并随你的机器人自动扩缩容。
  • 开箱即用。 Telegram Bot API、一个由 SQLite 支持的数据库以及出站 HTTP 对每个模块都开箱即用——无需安装任何东西,无需配置任何凭证。
  • 快速、隔离的执行。 每次调用都在轻量级 V8 isolate 中运行,紧邻 Telegram 自身的系统,因此对 Bot API 和你的数据库的调用既快速又可靠。
  • 真正的开发者工作流。 项目存放在你机器上的一个文件夹中,处于版本控制之下。你编辑文件,精确看到改动了什么,以原子方式部署,并通过经过审查的迁移将数据库 schema 向前推进——就像你处理其他一切事务的方式一样。

心智模型

你在三个地方工作,它们之间清晰地一一对应:

位置 存放的内容
你的项目文件夹 JavaScript 模块——schema、共享代码、更新处理器
云端 这些模块的已部署副本,以及你的 bot 的数据库
tgcloud CLI 桥梁——它向你展示差异并同步它们

你永远不需要 SSH 登录任何东西。你在本地编辑文件,运行 npx tgcloud push,平台会接管后续的一切。你的机器人的流量由已部署的模块处理;你的数据库在多次调用之间持久保留。

一个项目只有三种代码:

handlers/      # entry points — one file per Telegram update type
lib/           # shared code you import from anywhere
schema.js      # your database tables

当有更新到达时——一条消息、一次按钮按下、一次内联查询——Telegram 会将其路由到匹配的处理器(handlers/message.js、handlers/callback_query.js……)并调用其默认导出。该函数通过 SDK 与 Bot API 和数据库通信,然后返回。这就是整个循环。没有匹配处理器的更新会被直接忽略,因此你只需添加自己需要的处理器。

快速演示

下面是一个完整、可运行的演示机器人。它会回复每一条消息,并记住每个聊天中已看到多少条消息。

schema.js

import { table, integer } from 'sdk/db';

export const counters = table('counters', {
  chatId: integer('chat_id').primaryKey(),
  seen:   integer('seen').notNull().default(0),
});

handlers/message.js

import { api, db } from 'sdk';
import { counters } from 'schema';
import { sql } from 'sdk/db';

export default async function (message) {
  const chatId = message.chat.id;

  // Insert the counter, or bump it if this chat already has one — and get the
  // resulting row back in the same statement via .returning().
  const [row] = await db.insert(counters)
    .values({ chatId, seen: 1 })
    .onConflictDoUpdate({
      target: counters.chatId,
      set: { seen: sql`${counters.seen} + 1` },
    })
    .returning()
    .run();

  await api.sendMessage({
    chat_id: chatId,
    text: `Hello! I've seen ${row.seen} message(s) from you.`,
  });
}

部署它:

npx tgcloud push       # upload the modules
npx tgcloud migrate    # create the `counters` table

这就是一个带有持久状态、无需服务器的在线机器人。其中的一切——api、db、table() DSL——都在下文各节中有所描述。

Serverless 是面向 Telegram 机器人和 Mini Apps 的通用后端,而不是某一种应用的模板。它非常适合:

  • 对话式 AI 机器人,需要在数据库中存储每个用户的状态。
  • 小程序后端,用于存储用户数据并提供动态内容。
  • 游戏与工具——包括排行榜、测验等。
  • 自动化与集成,调用第三方 HTTP API 并将结果推送到聊天中。

快速上手

本演练将带你从一个空文件夹开始,构建一个能回复消息并存储数据的在线机器人。前提是你已安装 Node.js 18 或更高版本,并已在 @BotFather 注册了一个机器人。完成之后,你将用遍日常所需的每一条命令:push、migrate、run 和 status。

首先,开启 Serverless。在 @BotFather 中,打开你的机器人 → Serverless 并将其开启。这会为该机器人启用此功能,并解锁其 CLI 访问 token、handlers、library 和数据库。

1. 创建项目

最快的起步方式是使用项目创建器,它会搭建一个项目脚手架并将 CLI 安装到其中:

npm create @tgcloud/bot example_bot
cd example_bot

参数是目标文件夹:传入 . 即可在当前文件夹中搭建脚手架,也可以传入任意路径。它在已有文件夹中同样可用,并且绝不会覆盖你已有的文件。

这样你就得到了一个可直接编辑的项目:

example_bot/
├─ docs/
│  └─ tgcloud-sdk.md    # SDK reference (for you and your AI tools)
├─ handlers/
│  └─ message.js        # a starter message handler (echoes text back)
├─ lib/                 # your shared modules go here (empty to start)
├─ AGENTS.md            # orientation for AI coding assistants
├─ package.json
└─ schema.js            # your database tables

脚手架生成的文件自带文档说明——每个文件都包含带注释的示例,展示你接下来可以做什么。

该 CLI 会作为本地开发依赖安装到项目中,因此你可以通过以下方式运行它npx tgcloud <command>(npx 会在你项目的node_modules中查找该副本),或通过npm run脚手架添加到package.json (npm run deploy, npm run status的快捷方式运行)。默认情况下没有全局tgcloud在你的PATH.

你也可以全局安装它——npm install -g @tgcloud/cli——如果你更想在任何地方直接输入一个裸的 tgcloud。这在任何空文件夹里运行 tgcloud init 时很方便,而且这也是 shell 制表符补全所需要的。无论哪种方式,你得到的都是同一个项目。

2. 关联你的机器人

每个项目都绑定到一个机器人。用 login 将它们连接起来,它会要求你提供 CLI 访问 token(@BotFather → 你的机器人 → Serverless → CLI Access → Access token——这是一个与你的 机器人 API token 不同的独立 token),并将其存储在本地:

npx tgcloud login

该 token 的形式为 app<id>:<secret>。CLI 会把它保存在 .tgcloud/ 中,该文件已被 git 忽略,并且绝不会打印其中的机密部分。登录是唯一会要求你提供它的时刻——关于 CI 中如何解析 token,请参见 Authentication。

3. 环顾四周

有两个命令可以随时告诉你当前的状态,且都完全离线:

npx tgcloud status     # what has changed locally vs. the deployed copy
npx tgcloud diff       # the line‑by‑line changes

在 init 之后,一切都是全新的,还没有部署任何东西。status 会显示等待上传的起始文件。

4. 部署

将你的模块发送到云端:

npx tgcloud push

push 会以单个原子批次上传每一个发生变更的模块,并更新你本地记录的云端当前所持有的内容。你的机器人已经上线:在 Telegram 中打开它并发送一条消息——起始处理器会将其回显。

部署永远不会触碰你的数据库。 推送代码和更改数据库 schema 是刻意分开的步骤,因此代码部署永远不会用数据迁移让你措手不及。这正是下一步的用途。

5. 添加一张数据库表

让我们让机器人记住一些东西。打开 schema.js 并声明一张表:

import { table, integer, text, sql } from 'sdk/db';

export const messages = table('messages', {
  id:      integer('id').primaryKey({ autoIncrement: true }),
  chatId:  integer('chat_id').notNull(),
  text:    text('text'),
  created: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});

部署 schema,然后将其应用到数据库:

npx tgcloud push       # uploads the new schema.js
npx tgcloud migrate    # creates the `messages` table

push 会报告 schema 不同步,并向你展示待处理的变更,但不会应用任何内容。migrate 会引导你完成变更,并在你确认后创建该表。这种两步模型——以及像删除这类风险更高的变更会发生什么——在 Migrations 中有详细介绍。

6. 存储和读取数据

现在在你的 handler 中使用该表。编辑 handlers/message.js:

import { api, db } from 'sdk';
import { messages } from 'schema';
import { eq } from 'sdk/db';

export default async function (message) {
  // Save this message.
  await db.insert(messages)
    .values({ chatId: message.chat.id, text: message.text })
    .run();

  // Count how many we've stored for this chat.
  const count = await db.$count(messages, eq(messages.chatId, message.chat.id));

  await api.sendMessage({
    chat_id: message.chat.id,
    text: `Saved. That's ${count} message(s) from this chat so far.`,
  });
}

用 npx tgcloud push 部署更新后的 handler,然后给你的 bot 发送几条消息,观察计数攀升。数据库在多次调用之间持久保留——这就是你 bot 的记忆。

7. 无需部署即可测试

你不必为了尝试一项变更而进行部署。npx tgcloud run 会使用你的 本地 文件在平台上执行 handler,而无需发布它们:

npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hello" }'

该参数是你的 handler 接收的 payload——对于 handlers/message,是一个 Message——以 JSON5 编写(因此你可以省略键的引号)。该命令会打印 handler 用 console.* 记录的任何内容、返回值以及耗时。这是迭代逻辑最紧凑的循环——无需部署,无需等待真实消息。

8. 保持同步

在开发过程中,几个命令可以让你的本地项目与云端保持同步:npx tgcloud status 显示变更内容,npx tgcloud push 执行部署,npx tgcloud pull 让本地项目与云端对齐,npx tgcloud fetch 在不改动你文件的情况下刷新参考副本,npx tgcloud reset 则丢弃本地更改。

如果两个人(或两台机器)部署到同一个 bot,平台会检测到冲突,push 会停下来让你先 pull——你永远无法在他人不知情的情况下覆盖其工作成果。参见 保持同步。

用 AI 构建

更倾向于用 AI 助手来构建——或者你团队里唯一的程序员就是 AI?你依然可以发布一个 bot。我们已经迈出了第一步,让 AI 智能体在项目中感到宾至如归:每个新项目都会自动生成一份 AGENTS.md 和一份 docs/tgcloud-sdk.md 参考文档,智能体编程工具会自动读取它们。

再加上一个小巧、自包含的运行时——一个 SDK,无需折腾任何 npm 包——让助手能快速上手那些通用代码生成往往容易忽略的约定:按裸名称导入、无外键、每个 db 调用都是异步的、每种更新类型对应一个处理器,以及两步式的 push/migrate 流程。

试试看:

npm create @tgcloud/bot my-bot
cd my-bot
opencode            # or Claude Code, Cursor, … — any agent that reads AGENTS.md

然后直接用大白话提问:

编写一个机器人,记住每个人的待办事项列表——当他们发送文本时添加一项,当他们发送 /list 时显示整个列表。

助手会为你编辑 schema.js 和你的处理程序;你进行审查,用 npx tgcloud run 即时测试更改,然后通过 npx tgcloud push 和 npx tgcloud migrate 上线。AGENTS.md 是你项目的一部分——随着机器人的成长编辑它,以保持指导准确。

随时随地使用 BotFather

手边只有手机?整个项目也存在于 @BotFather 中——打开你的机器人 → Serverless,你就能在触摸屏上获得 CLI 管理的所有内容:

  • 处理程序——创建、编辑和测试运行更新处理程序;BotFather 会保持 webhook 与你拥有的处理程序同步(与 CLI 报告的 In sync / Out of sync 相同)。
  • 库——你共享的 lib/ 模块。
  • 数据库——用类似的 Drizzle 语法编辑 schema.js,审查待处理的更改并应用它们;部署。
  • CLI 访问——当你回到键盘前时,在这里获取 CLI 访问 token。

这是同一个云项目,因此你可以在手机上启动一个 handler,之后再npx tgcloud pull到你的笔记本电脑上——没有任何东西绑定到单个客户端。运行 handler 甚至会在聊天中直接显示其Console输出,就像npx tgcloud run一样。

项目与模块

无服务器项目就是版本控制下的一个普通文件夹。它只包含 JavaScript 模块和少量本地状态——没有构建步骤,运行时没有node_modules,也没有服务器入口点。

一个项目的剖析

example_bot/
├─ handlers/            # update handlers — flat, one level only
│  ├─ message.js
│  └─ callback_query.js
├─ lib/                 # shared modules; subdirectories allowed
│  ├─ reply.js
│  └─ internal/util.js
├─ schema.js            # database schema — one file, at the root
└─ .tgcloud/            # CLI state — credentials, snapshot, cache (git‑ignored)

仅schema.js以及.js下的文件lib/以及handlers/会被部署。其余所有内容——Markdown、配置文件、.tgcloud/文件夹——都留在你的机器上。

schema.js —— 你的数据库。它使用 schema DSL 以具名导出的形式声明数据表,并作为单个文件位于项目根目录。它像任何其他模块一样被部署,但部署它永远不会改变数据库——schema 变更通过 npx tgcloud migrate 单独应用。参见 数据库。

lib/ —— 共享代码,任何你想在多个 handler 之间复用的内容:纯辅助函数、数据库访问层、格式化、与外部服务的集成。lib/ 是唯一可以包含子目录(lib/internal/util.js、lib/payments/stripe.js)的目录,因此你可以按自己的喜好组织更大的代码库。lib/ 中的模块永远不会被平台直接调用;它们的存在是为了被 handler 以及彼此之间导入。

handlers/ —— 你的 bot 的入口点。每个文件对应一种 Telegram update 类型,平台会将每个传入的 update 路由到匹配的 handler:

文件 处理
handlers/message.js 新传入的消息
handlers/inline_query.js 新传入的内联查询
handlers/callback_query.js 新传入的回调查询
… 任何其他 Bot API update 类型

handlers/ 是 扁平的——没有子目录。处理器的 export default 就是平台所调用的函数(参见 Handlers)。

只有当某个更新类型的处理器文件存在且非空时,该更新类型才会被处理。如果没有 handlers/<type>.js——或者文件为空——该类型的更新会被忽略,平台不会为它们运行任何东西。因此只保留你真正需要的处理器:你每添加一个,就是多一种你的机器人会被唤醒去处理的更新类型,而省略其余的则意味着 Telegram 不会为那些你反正会丢弃的更新启动你的代码。

要搭建一个新处理器,请运行 npx tgcloud add handlers/<type>。

.tgcloud/——完全由 CLI 管理的机器本地状态:你保存的凭据、用于离线差异对比的已部署代码镜像,以及一个小型缓存。它被 git 忽略,你绝不应手动读取或写入它——请改用 CLI 命令。

模块系统

在运行时,一个模块只能看到两样东西:平台 SDK和你项目中的其他模块。除了通过 SDK 的 fetch 之外,没有 npm 包,没有文件系统,也没有网络。

模块通过其名称来寻址——即从项目根目录开始的路径,不带.js扩展名——而不是通过它们在磁盘上的位置。始终使用该裸名称进行导入:

import { users } from 'schema';            //  the schema module
import { addItem } from 'lib/cart';        //  a lib module
import { format } from 'lib/internal/fmt'; //  nested lib module
import { db, api, fetch } from 'sdk';      //  the platform SDK

相对路径和文件扩展名无法使用——平台在其模块空间中解析名称,而不是在目录中解析文件:

import { users } from './schema';    //  won't compile
import { users } from '../schema';   //  won't compile
import x from 'lib/cart.js';         //  drop the .js

运行时可供使用的恰好只有两样东西:sdk及其子模块(sdk/db、sdk/api、sdk/fetch)——即整个平台接口,参见The SDK——以及你自己的模块,位于schema、lib/、handlers/之下。这就是完整的列表。如果你的代码import了任何其他内容,它将无法解析。正是这一约束使得模块加载快速且运行安全。

处理器

处理器是handlers/中的一个模块,当匹配的更新到达时,平台会调用其默认导出。

// handlers/message.js
import { api } from 'sdk';

export default async function (message) {
  await api.sendMessage({
    chat_id: message.chat.id,
    text: `You said: ${message.text ?? '(no text)'}`,
  });
}

处理函数会接收该更新的载荷作为其参数——平台会为你解包 Telegram 的Update。handlers/message.js会拿到Message(即update.message);handlers/callback_query.js会拿到CallbackQuery;以此类推。处理函数的第二个参数是一个每次调用专属的上下文对象,ctx。它把原始的Update作为ctx.update携带——当你需要载荷之外的东西时,比如update_id,就可以取用它。

处理函数可以是async(通常也是),并且可以返回一个值。它通过 SDK 访问 Bot API、数据库以及对外 HTTP。

你不需要真实的更新就能测试处理程序。npx tgcloud run 会在平台上使用你提供的 payload 和你当前的本地代码来执行它:

npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hi" }'

参数就是载荷——也就是你的处理函数接收到的同一个对象——采用 JSON5 格式。要提供处理函数的 ctx(即它的第二个参数),请添加 --ctx,例如 --ctx '{ update: { update_id: 1 } }'。这会针对你的 本地文件运行,因此你可以在部署之前先试用改动。参见 run。

部署的内容

当你npx tgcloud push时,CLI 会收集每一个.js文件,位于schema.js, lib/之下,以及handlers/,并将这一精确集合作为你项目的模块空间发送出去。任何存在于云端但不在你项目中的内容都会被移除,因此部署后的状态始终与你的文件夹保持一致——包括删除操作。这些位置之外的文件会被忽略。项目根目录下出现一个游离的.js(不是配置文件)会被标记出来,以免它悄无声息地被忽略,因为项目根目录本应只存放无服务器内容。Markdown、dotfiles 以及.tgcloud/永远不会被部署。

数据库

每个 bot 都拥有自己的数据库——一个由 SQLite 支持的存储,在多次调用之间持久保存,并可通过 db 供每个模块使用。你用一个小巧的、带类型的 DSL 在 schema.js 中描述你的表;用流式查询构建器读写它们;并通过经过审查的迁移来演进它们。

了解 Drizzle ORM 吗? 那你已经知道如何在这里与数据库对话了。schema DSL 和查询构建器都遵循 Drizzle —— 列构建器、select().from().where()、各种操作符、onConflictDoUpdate、.returning(),以及 sql 标签的行为都如你所料,因此读写数据用的就是你早已熟悉的 API。你只需从 sdk/db 导入它,另外有几处平台特有的细节(最显著的是 不支持外键)会在遇到时指出。

声明表

表是具名导出位于schema.js。调用table()会在加载时构建描述(它不会访问数据库);平台会在你部署时发现导出的表schema.js并将数据库迁移至匹配状态。

import { table, integer, text, boolean, json, index, sql } from 'sdk/db';

export const users = table('users', {
  id:      integer('id').primaryKey({ autoIncrement: true }),
  tgId:    integer('tg_id').unique(),
  name:    text('name').notNull(),
  lang:    text('lang').default('en'),
  isAdmin: boolean('is_admin').default(false),
  prefs:   json('prefs'),
  created: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
}, (t) => ({
  createdIdx: index('idx_users_created').on(t.created),
}));

table(name, columns, extras?):

  • name 是 SQL 表名;
  • columns 是 JS 属性 → 列定义的映射;
  • extras 是一个可选的回调 (t) => ({ … }),其中 t 暴露了各列(t.created 是对该列的引用)——在此声明索引和表级约束。
列类型
工厂 SQLite 类型 说明
text() TEXT
integer() INTEGER
real() REAL 别名 float()
numeric() NUMERIC
blob() BLOB 读取/写入 Uint8Array
boolean() INTEGER 存储为 0/1,读取为 true/false
json() TEXT 自动 JSON.stringify / JSON.parse

列名参数是可选的——省略它就会使用 JS 键名。mode 选项控制值在 SQLite 和 JavaScript 之间如何转换。

mode 存储为 JS 值
boolean INTEGER 0/1 boolean
json TEXT(JSON) 任意对象/数组
timestamp INTEGER(unix 秒) Date
timestamp_ms INTEGER(unix 毫秒) Date
bytes BLOB Uint8Array

boolean() 和 json() 是 integer(name, { mode: 'boolean' }) 和 text(name, { mode: 'json' }) 的简写。

一个 blob() 会读写一个 Uint8Array —— 运行时没有 Node Buffer(而 Buffer 是 Uint8Array 的子类,所以这是可移植的基础类型)。上文的 mode 只决定一个值如何被 编码,与该列的存储类型无关:因此 blob('col', { mode: 'json' }) 是 json() 的 BLOB 对应物 —— 相同的 JSON 编码,保存在 BLOB 列而非 TEXT 列中。

TLDR:blob() 使用 Uint8Array。mode 控制的是编码,而非存储,因此 blob(..., { mode: 'json' }) 将 JSON 存储为 BLOB,而 json() 将其存储为 TEXT。

列修饰符

列修饰符会链式附加到列上。

integer('id').primaryKey({ autoIncrement: true })
text('name').notNull()
text('tg').unique()
text('lang').default('en')
integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`)
text('slug').generatedAlwaysAs(sql`lower(name)`, { mode: 'stored' })  // or 'virtual'
text('email').deprecated('replaced by login')   // marks the column for removal

.default() 作用于 json() 列时会为你编码该值;sql…`` 默认值则原样透传。.deprecated() 是终止性的 —— 参见 迁移。

索引与约束

索引与约束在 extras 回调中声明,此时各列处于作用域内。

table('t', { /* … */ }, (t) => ({
  uq:     unique('uq_email').on(t.email),
  chk:    check('chk_done', sql`${t.done} in (0, 1)`),
  idx:    index('idx_name').on(t.col),
  uidx:   uniqueIndex('uidx_email').on(t.email),
  lower:  index('idx_lower').on(sql`lower(${t.email})`),          // expression index
  active: index('idx_active').on(t.userId).where(sql`done = 0`),  // partial index
}));

表级修饰符链式附加在 table(...) 之后:.strict()、.withoutRowid()、.deprecated('reason')。

无外键

运行时在PRAGMA foreign_keys关闭状态下运行。声明了外键也会静默失效——没有级联,没有孤儿保护——这比完全没有外键更糟,因此该 DSL 让这种情况不可能发生。也就是说,.references() 和表级 foreignKey()在声明时会抛出异常,使用了它们的 schema 将无法部署。

你应该用普通列(userId: integer('user_id'))来建模关系,并在应用代码中强制完整性:先插入父记录再插入子记录,先删除子记录再删除父记录,妥善处理错误,并在需要时用 LEFT JOIN … WHERE parent.id IS NULL 清扫孤儿记录。

外键的缺失是一项刻意为之的约束,而非疏漏。在规划你的 bot 时要尽早考虑到这一点。

查询

db 是一个流式查询构建器。每个查询都是异步的——终结方法(.all()、.get()、.values()、.run())返回 Promise,所以始终await。

import { db } from 'sdk';
import { users, todos } from 'schema';
import { eq, and, desc, asc, count, sql } from 'sdk/db';

await db.select().from(todos).all();                          // all rows
await db.select().from(todos).where(eq(todos.id, 1)).get();   // first row or null
await db.select().from(todos).values();                       // rows as value arrays

await db.select().from(todos)
  .where(and(eq(todos.userId, uid), eq(todos.done, false)))
  .orderBy(desc(todos.priority), asc(todos.id))
  .limit(10).offset(20)
  .all();

// custom projection: { alias: columnRef | sqlExpr | aggregate }
await db.select({ id: todos.id, title: todos.text, n: count() })
  .from(todos).groupBy(todos.userId).having(sql`count(*) > ${1}`).all();

// row count — a helper, not a builder terminal:
await db.$count(todos);                        // all rows
await db.$count(todos, eq(todos.done, false)); // with a filter
  • 可链式调用:.where()、.orderBy()、.limit()、.offset()、.groupBy()、.having()、.distinct()。
  • 终结方法:.all()、.get()、.values()。
  • 用 db.$count(table, where?) 统计行数(或在投影中使用 count())。

插入、更新、删除:

await db.insert(todos).values({ userId: 1, text: 'Buy milk' }).run();
await db.insert(todos).values([{ text: 'A' }, { text: 'B' }]).run();   // batch
await db.insert(todos).values({ text: 'X' }).returning().run();        // RETURNING *

await db.insert(users).values({ tgId: 42, name: 'Ann' })
  .onConflictDoUpdate({ target: users.tgId, set: { name: 'Ann' } }).run();

await db.update(todos).set({ done: true }).where(eq(todos.id, 1)).run();
await db.delete(todos).where(eq(todos.id, 1)).run();

注意,批量插入是一条语句,因此受 SQLite 变量上限的限制(rows × columns);如果超出这个限制,插入就会报错——你需要自己分块处理。

运算符从 sdk/db 导入:

import {
  eq, ne, gt, gte, lt, lte,
  like, notLike,
  isNull, isNotNull, and, or, not,
  between, notBetween, inArray, notInArray,
  count, sum, avg, min, max,
  asc, desc,
} from 'sdk/db';

.where(a, b) 带多个参数时等同于 and(a, b)。比较的第二个参数默认是一个值,但也可以是另一列或 sql…``——例如 eq(a.x, b.y)。聚合函数(count/sum/avg/min/max)是用于 .select({ … }) 投影的 SQL 片段。

原始 SQL——当构建器不够用时,就退回到原始 SQL。模式由方法决定:写入用 db.run,多行用 db.all,单行用 db.get。

await db.run('UPDATE todos SET done = 1 WHERE id = :id', { ':id': 5 });
await db.all(sql`SELECT * FROM todos WHERE done = ${false}`);
await db.get(sql`SELECT count(*) AS c FROM todos`);

sql…`` 标签会把 ${value} 变成一个绑定参数,把 ${table.column} 变成一个标识符,并拼接嵌套的 sql 片段。对于不带参数的字面量,使用 sql.raw('…')。

原始查询不绑定到任何表,因此其返回的行不会进行模式转换——布尔值是 0/1,JSON 列是字符串,时间戳是数字。只有绑定到表的构建器才会转换值。

迁移

你的数据库会随着机器人成长而变化。平台通过将部署代码与变更数据分离,并按风险高低对每一项 schema 变更进行分类,来确保这一过程的安全。

部署永远不会触碰数据库。当你npx tgcloud push一个变更后的schema.js时,平台会记录新的 schema,并告诉你数据库将会发生什么变化——但不会应用任何变更:

npx tgcloud push       # deploy schema.js; reports pending DB changes
npx tgcloud migrate    # review and apply them

npx tgcloud migrate会计算你的 schema 与线上数据库之间的差异,并逐步引导你完成。在任何变更被应用之前,都会先征求你的确认。这意味着一次常规的代码部署绝不会意外触发数据迁移。

每一项待处理的变更都带有一个状态,决定migrate如何处理它:

状态 含义 在migrate中
安全 增量式且非阻塞——新增表、列或索引 确认后一步合并应用
警告 可能具有破坏性或耗时较长——例如删除某个对象,或在大表上创建索引 每次呈现一项,每项单独确认
手动 无法自动完成——例如更改某一列的类型 附带指引展示;由你手动执行
未记录文档的 存在于数据库中,但不在你的 schema 中 仅作提示展示;未应用

安全变更在本质上快速且可逆,因此会一并执行。每条警告都需要单独、审慎地确认——对于破坏性变更,不存在“全部应用”的选项。

手动更改会附带原因和建议操作。migrate 以摘要结尾:应用了多少项更改、跳过了多少项、有多少项等待手动修复,或有多少项不在你的 schema 中。

参见 migrate 了解这些标志(--dry-run、--safe、--yes、--local)。

移除内容

从schema.js中删除一张表或一列不会真正将其删除——那样会让一次误删变成灾难性后果。要移除某个对象,请将其标记为已弃用:

// drop a column
text('email').deprecated('replaced by login')

// drop a whole table
export const oldSessions = table('old_sessions', { /* … */ }).deprecated('unused');

在下一次migrate时,已弃用的对象会以警告状态的删除项出现,你需要逐项确认。一旦它被删除,再移除该声明。

更改列的类型

类型更改是手动的——SQLite 并不总能就地完成,而且强制转换现有值需要人为判断。migrate会展示该更改及其理由;你需要用原始 SQL(db.run(...))自行执行,通常的做法是新建一列或一张表,复制数据,然后进行替换。

SDK

在运行时,一个模块只有一个库:sdk。它打包了机器人后端所需的三个东西——一个数据库、Telegram Bot API 以及出站 HTTP——无需安装任何东西,也无需配置任何凭据。数据库(db)在数据库一节中介绍;本节介绍api、fetch以及console全局对象。

import { db, api, fetch, BotApiError } from 'sdk';   // the whole surface
// or from submodules:
import { table, integer, text, eq, sql } from 'sdk/db';
import { api } from 'sdk/api';
import { fetch } from 'sdk/fetch';
导入 它是什么
db 数据库——查询构建器与 schema DSL → 数据库
api Telegram Bot API——api.sendMessage(...) → 见下文
fetch 出站 HTTP → 见下文

通过裸名称(from 'schema'、from 'lib/cart')导入你自己的项目模块——绝不使用相对路径或 .js 扩展名。参见模块系统。

Bot API

api 为你提供完整的Telegram Bot API。以 api.<method>(params) 调用任意方法。所有当前——以及未来——的 Bot API 方法均可使用,无需更新 SDK。

import { api } from 'sdk';

const me = await api.getMe();                            // → the unwrapped result
await api.sendMessage({ chat_id: id, text: 'Hello!' });
await api.editMessageText({ chat_id, message_id, text: 'Updated' });
await api.answerCallbackQuery({ callback_query_id, text: 'Done' });

响应外层封装已被解开。 Bot API 通常将结果包裹在 { ok: true, result: … } 中。api 直接返回 result——getMe() 解析为用户对象,而非包装层。参数使用 Bot API 自身的 snake_case 命名(chat_id、message_id、reply_markup……)。

失败会抛出异常BotApiError.当 Bot API 返回{ ok: false }时,该调用会抛出一个BotApiError,而不是返回一个假值,因此你无法意外地忽略它。该错误携带.code(Bot APIerror_code), .description(人类可读的消息),.method(哪个方法失败了),以及.parameters(额外数据,例如retry_after在 429 上,或migrate_to_chat_id)。捕获它以处理预期内的失败,并重新抛出其余的:

import { api, BotApiError } from 'sdk';

try {
  await api.deleteMessage({ chat_id, message_id });
} catch (e) {
  if (e instanceof BotApiError && e.code === 400) {
    // 400 = the message is already gone; that's fine here.
  } else {
    throw e;
  }
}
文件限制

你可以通过 file_id 直接操作已在 Telegram 服务器上的文件——发送、转发或复用它们——但下载文件的字节内容(getFile 加上获取内容)或从处理程序上传新文件目前尚不支持。

你可以通过传递 file_id 而非原始字节,轻松绕开这一临时限制进行设计。

HTTP

fetch 是一个类似 fetch 的客户端,用于调用外部世界——第三方 API、webhook,以及任何基于 HTTP 的服务。

import { fetch } from 'sdk';

const res = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Pavel' }),
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();

响应与 Web 平台保持一致:res.status、res.statusText、res.ok(200–299 时为 true)、res.url、res.headers(.get()、.has()、.keys()、.entries()),以及响应体读取器 await res.json() / await res.text()。

你也可以将响应体作为流来增量读取——for await (const chunk of res.body) { … }——这正是你消费服务器发送事件(server-sent events)或AI API逐 token 输出的方式。

响应体辅助函数会为你设置匹配的Content-Type:

await fetch(url, { method: 'POST', body: fetch.body.json({ a: 1 }) }); // application/json
await fetch(url, { method: 'POST', body: fetch.body.form({ a: 1 }) }); // x-www-form-urlencoded
await fetch(url, { method: 'POST', body: fetch.body.text('hi') });     // text/plain

除此之外,它的行为与你已经熟悉的标准fetch一致,但有两项限制:

  • 响应内容是文本形式(不支持二进制载荷)。
  • 整个响应上限为 32 MB。该上限覆盖整个响应——使用res.body进行流式传输可以让你增量处理较大的响应体,但并不会提高这一限制。

日志记录

标准的全局console可用——无需导入,它就像在任何 JavaScript 中一样直接存在。其输出会被npx tgcloud run捕获并显示,这使它成为你开发过程中的主要调试工具。

console.log('processing', { chatId: id });   // log / debug — plain
console.info('started');                     // info  — blue
console.warn('rate limited');                // warn  — yellow
console.error(err);                          // error — red, with a stack trace

每一行都会标注其来源的[file:line]。console.error和console.trace会附加完整堆栈,而console.warn则不会。当你npx tgcloud run一个模块时,这些行会按级别以彩色前缀、自运行开始以来的时间以及来源打印出来——参见run。

命令行界面

tgcloud 是你的项目文件夹与云端之间的桥梁。它可以搭建项目脚手架、展示变更内容、部署、运行模块,以及应用数据库迁移。它需要 Node.js 18 或更高版本。有两种获取方式:

# Recommended — create a project with the CLI installed into it:
npm create @tgcloud/bot example_bot

# Or install the CLI globally and init an empty folder:
npm install -g @tgcloud/cli
tgcloud init

npm 包是 @tgcloud/cli;它安装的命令是 tgcloud。CLI 会从当前目录向上查找最近的 .tgcloud/ 来定位你的项目,因此每条命令都可以从任意子文件夹中运行。

命令 用途
init 在当前文件夹中搭建新项目脚手架
add 搭建新模块脚手架(handler 或 lib 模块)
login 将项目关联到某个 bot(保存 token)
status 显示本地与云端之间的变更
diff 逐行显示变更
push 将变更的模块部署到云端
migrate 将 schema 变更应用到数据库
run 在平台上执行模块而无需部署
fetch 从云端刷新本地参考副本
pull 使本地文件与云端保持一致
reset 丢弃本地变更;从云端状态恢复
webhook 检查并重新同步平台管理的 webhook
completion 打印 shell 补全脚本(bash/zsh/fish)

身份验证

一个项目通过其 token 与一个 bot 绑定,token 的形式为 app<id>:<secret>。其中 app<id> 部分是公开的,可以打印;密钥部分绝不会出现在日志或错误信息中。

token 按以下顺序解析:

  1. TGCLOUD_TOKEN 环境变量——用于 CI;绝不写入磁盘。
  2. .tgcloud/credentials——由 npx tgcloud login 写入。
  3. 两者都没有 → 报错并提示你查看 npx tgcloud login。

CLI 绝不会在命令执行中途提示输入 token——突如其来的提示会让脚本和 CI 挂起。登录始终是显式的 login 步骤,如果已保存的 token 失效(401/403),CLI 会清除它并要求你再次 login,而不是就地重新提示。

init

npx tgcloud init

在当前目录中搭建一个新项目:schema.js、lib/、handlers/、一个起始 handler、AGENTS.md、docs/,以及 .tgcloud/ 状态文件夹。它创建的文件集合由平台提供,因此无需升级 CLI 就能出现新的起始文件和目录。离线时,它会回退到内置副本,因此 init 始终可用。

init 拒绝在另一个项目内部嵌套——即一个已经含有 .tgcloud/ 的祖先目录——因此你不会意外地遮蔽某个项目;在项目自身的根目录中重新运行 init 是没问题的,只会补全任何缺失的内容。

add

npx tgcloud add <target>

搭建单个新模块,已接好线并可随时编辑——一个 handler 或一个 lib/ 模块。

npx tgcloud add handlers/callback_query   # a new update handler
npx tgcloud add lib/cart                  # a new shared module

请注意,add 从不覆盖已存在的文件。

<target> 是模块的路径(末尾的 .js 是可选的)。对于 handlers/,名称必须是 Telegram 更新类型;平台会公布有效集合,因此无效名称会在一开始就被拒绝。handlers/ 是扁平的;lib/ 可以嵌套(lib/payments/stripe)。

模块名称是必需的。只给出目录是一个错误——但这是一个有用的错误:对于 handlers/,它会列出你尚未拥有的更新类型,以便你复制一个。

$ npx tgcloud add handlers
Error: Specify a name, e.g. "npx tgcloud add handlers/callback_query".
Available handlers/ types: callback_query, inline_query, chat_member, …

<Tab> 补全提供了相同的集合——参见 completion。

生成的文件带有一个实时 export default,因此你一部署处理程序就会生效——无需取消任何注释。使用 push 部署新模块。

login

npx tgcloud login

提示你输入 CLI 访问 token——从 @BotFather → 你的 bot → Serverless → CLI Access → Access token,这是一个与你的 bot 的 API token 不同的独立 token——它会针对平台进行验证,并将其保存到 .tgcloud/credentials。

login 是唯一会要求输入 token 的命令。它需要真实的终端,没有终端就无法运行,因此它绝不会在 CI 中挂起。

status

npx tgcloud status

按文件显示你的工作目录与已部署副本之间的变化:已修改、新增、已删除、未更改。完全离线——它会与 .tgcloud/ 中的本地参考副本进行比较。完整运行还会对项目根目录下散落的 .js 文件发出警告。

diff

npx tgcloud diff

类似 status,但会显示已更改模块的逐行实际差异。同样离线。

push

npx tgcloud push [files...]

以单个原子批次将你的项目部署到云端。

使用无参数时,它会部署整个项目,并使部署后的状态与你的文件夹完全一致——你在本地删除的模块也会在云端被移除。

使用文件或目录参数(npx tgcloud push handlers/message.js、npx tgcloud push handlers/)时,它会缩小发送哪些变更的范围,但仍会发送完整的清单,因此定向推送绝不会删除未触及的模块。

它唯一的选项是--force——跳过并发检查并覆盖云端现有的任何内容。仅在你确信无误时使用(参见保持同步)。

部署之后,如果schema.js发生了变化且数据库不同步,push会打印待处理变更的摘要,并建议npx tgcloud migrate。它绝不会自行应用这些变更。

migrate

npx tgcloud migrate

将你的 schema 变更应用到数据库。它会计算schema.js与实时数据库之间的差异,然后通过一个持续更新的[N/M]计数器,逐步引导你完成操作:

  • 所有待处理变更的简要摘要。
  • 安全变更,在你确认后于单一步骤中一并应用。
  • 警告(丢弃、慢操作),一次一个,每个单独确认。
  • 手动变更,附带原因和建议操作一并展示,不会自动应用。
  • 未记录对象(存在于数据库中但不在你的 schema 中),仅作提示展示。

最后以一份摘要收尾:已应用、已跳过、等待手动修复、不在 schema 中。

模型说明参见 Migrations。

选项:--dry-run(打印全部内容,不应用任何变更)、--safe(自动应用安全变更,跳过警告)、--yes(自动应用安全变更和所有警告,跳过手动变更——请谨慎使用)、--local(与本地 schema.js 而非已部署版本进行 diff 对比)。不带任何标志时,migrate 需要终端,在非交互式环境中会报错,而不是靠猜测继续。

run

npx tgcloud run <module> [args] [--ctx <json5>]

在平台上不部署直接执行处理程序,使用你当前的本地文件。这是用于测试逻辑的快速内循环。

  • <module> —— 一个裸名称(在 handlers/ 下搜索)或类似 handlers/message 的路径。
  • [args] —— 传递给处理器的载荷,使用 JSON5 编写,因此你可以省略键的引号。它就是你的处理器接收到的更新类型对象(例如 handlers/message 的 Message)。
  • --ctx <json5> —— 处理器的上下文对象(它的第二个参数),同样是 JSON5。用它来提供处理器从 ctx 读取的内容——例如原始更新:--ctx '{ update: { update_id: 1 } }'。
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hi" }'

平台会针对由你的 本地项目组装而成的模块空间运行该模块(因此本地已修改的 lib/ 代码也会被使用),并返回返回值、通过 console.* 记录的任何日志以及耗时。使用 npx tgcloud run handlers/message "$(cat message.json5)" 从文件中读取大型参数。

fetch

npx tgcloud fetch

刷新已部署状态的本地参考副本,而不触碰你的工作文件。在决定如何解决冲突之前,可用于重新检查冲突。

pull

npx tgcloud pull

使你的本地项目与云端保持一致——将参考副本和工作文件都更新为已部署状态。

reset

npx tgcloud reset

丢弃你的本地更改,并从最后一次已知的云端状态恢复工作目录。用它来抛弃一次实验。

webhook

npx tgcloud webhook
npx tgcloud webhook sync [--drop-pending]

Telegram 通过 webhook 向你的 bot 投递更新,该 webhook 由平台为你管理——你无需手动将其指向任何地方。npx tgcloud webhook 显示其当前状态:URL、allowed_updates 列表、有多少更新处于待处理状态、最后一次投递错误(如果有的话),以及它是否与你已部署的处理器 保持同步。

“保持同步”意味着 webhook 指向平台,且其 allowed_updates 与你已部署的处理器相匹配——因此 Telegram 只会投递你所处理的那些更新类型,不多不少。部署一个新的处理器(或移除一个)可能会让 webhook 处于不同步状态,直到它被刷新;npx tgcloud status 也会标记出这种情况。

npx tgcloud webhook sync 可以修复它——它会把 webhook 重新指向平台,并根据你已部署的 handlers 重建 allowed_updates。添加 --drop-pending 以丢弃 Telegram 在同步之前已经排队的更新(否则一旦 webhook 恢复正常,这些更新就会被投递)。

completion

注意: tab 补全仅在裸 tgcloud 位于你的 PATH 上时才有效——因此请将其全局安装(npm install -g @tgcloud/cli),或以其他方式将该二进制文件放入你的 PATH。它无法接入 npx。

tgcloud completion <bash|zsh|fish>

将一个 shell 补全脚本打印到 stdout。启用一次之后,<Tab> 就会补全命令、标志、模块目录、你尚未拥有的 handler 更新类型,以及你本地可运行的模块——这些建议是实时计算出来的,因此能反映当前项目以及平台所公布的更新类型。

# bash — needs the bash-completion package:
echo 'eval "$(tgcloud completion bash)"' >> ~/.bashrc

# zsh — ensure `autoload -U compinit && compinit` runs in your ~/.zshrc:
echo 'eval "$(tgcloud completion zsh)"' >> ~/.zshrc

# fish:
tgcloud completion fish > ~/.config/fish/completions/tgcloud.fish

之后请重启你的 shell(或重新 source 该文件)。不带任何 shell 运行 tgcloud completion 会再次打印这些说明。

保持同步

每个项目在云端都有一个单调递增的 revision,每次部署时都会递增。CLI 会记住它上次同步时的 revision,并在每次 push 时发送它。如果云端已经前进——因为另一台机器或队友进行了部署——那么这次推送会被 拒绝,而不是静默覆盖他们的工作,CLI 会提供三种继续推进的方式:

npx tgcloud fetch           # pull the latest into the reference copy, then re-check
npx tgcloud pull            # pull the latest into both reference and working files
npx tgcloud push --force    # overwrite the cloud state (dangerous)

正是这种乐观并发检查,让你可以在团队中共享一个 bot,而无需步调一致的部署流程。命令在失败时会以非零状态退出——被拒绝的部署、失败的迁移、认证错误、在 run 期间抛错的模块——因此它们能干净地组合进脚本和 CI 流水线中。

来源:Hacker News 热门(buzzing.cc 中文翻译) · core.telegram.org