Telegram 无服务器架构
Telegram Serverless 允许开发者直接在 Telegram 基础设施上运行 Bot 和 Mini App 的后端代码,无需配置服务器或容器。开发者编写普通 JavaScript 模块,通过 `npx tgcloud push` 单命令部署,代码在靠近 Bot API 和内建数据库的轻量级 V8 隔离沙箱中执行。
Telegram 给机器人开发搭了一套完整的无服务器后端,内置数据库和 CLI,从写代码到部署都在一个文件夹里完成。做 Telegram 机器人的开发者可以彻底扔掉 VPS 和云函数了。
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 按以下顺序解析:
TGCLOUD_TOKEN环境变量——用于 CI;绝不写入磁盘。.tgcloud/credentials——由npx tgcloud login写入。- 两者都没有 → 报错并提示你查看
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