Telegram 无服务器
Telegram 无服务器让你直接在 Telegram 的基础设施上运行机器人和迷你应用的后端代码——无需配置服务器、无需保持容器运行、无需考虑扩展。你只需编写纯 JavaScript 模块,通过一条命令部署,Telegram 就会在紧邻 Bot API 和内置数据库的快速、隔离的 V8 沙箱中运行它们。
如果你曾经为了响应一个 /start 命令而把机器人连接到 VPS、云函数或托管面板,那么这部分工作你不再需要做了。
本页内容
- 为什么选择无服务器
- 快速上手
- 结合 AI 构建
- 通过 BotFather 随时操作
- 项目与模块
- 数据库
- SDK
- CLI 参考
为什么选择无服务器
Telegram 机器人本质上是一个响应更新的程序。传统上,你必须把该程序托管在一个始终在线、可访问且安全的地方——并一直保持这种状态。Telegram 无服务器完全移除了这一层:
- 无需基础设施。没有需要租用、修补或监控的机器。你的代码按需运行,并随你的机器人自动扩展。
- 开箱即用。Telegram Bot API、基于 SQLite 的数据库以及出站 HTTP 请求,每个模块都直接可用——无需安装任何东西,无需配置任何凭证。
- 快速、隔离的执行。每次调用都在轻量级的 V8 隔离环境中运行,靠近 Telegram 自身的系统,因此对 Bot API 和数据库的调用快速且可靠。
- 真正的开发者工作流。项目位于你机器上受版本控制的文件夹中。你编辑文件,精确查看更改内容,原子化部署,并通过经过审查的迁移来推进数据库模式——就像你处理其他所有事情一样。
心智模型
你在三个地方工作,它们之间清晰对应:
| 位置 | 那里存放什么 |
|---|---|
| 你的项目文件夹 | JavaScript 模块——模式、共享代码、更新处理器 |
| 云端 | 这些模块的已部署副本,加上你机器人的数据库 |
| 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 机器人。
- 存储用户数据并提供动态内容的 Mini App 后端。
- 游戏和工具——包括排行榜、测验等。
- 调用第三方 HTTP API 并将结果推送到聊天中的自动化和集成。
开始使用
本教程将带你从一个空文件夹开始,构建一个能够回复消息并存储数据的在线机器人。前提是你已安装 Node.js 18 或更高版本,并通过 @BotFather 注册了一个机器人。完成本教程后,你将掌握日常所需的全部命令:push、migrate、run 和 status。
首先,打开 Serverless 功能。在 @BotFather 中,打开你的机器人 → Serverless 并将其开启。这会为该机器人开启此功能,并解锁其 CLI 访问令牌、处理程序、库和数据库。
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` 中的副本),或者通过脚手架添加到 `package.json` 中的 npm run 快捷方式(`npm run deploy`、`npm run status`)来运行。默认情况下,你的 PATH 中不会有全局的 `tgcloud`。
你也可以全局安装它——`npm install -g @tgcloud/cli`——如果你更愿意在任何地方直接输入 `tgcloud` 的话。这对于在任何空文件夹中运行 `tgcloud init` 很方便,并且这也是 shell 选项卡补全所需要的。无论哪种方式,你都会得到相同的项目。
2. 关联你的机器人
每个项目都与一个机器人绑定。通过 `login` 命令将它们连接起来,该命令会要求你提供 CLI 访问令牌(@BotFather → 你的机器人 → Serverless → CLI Access → Access token——这是一个与你的机器人 API 令牌不同的独立令牌)并将其存储在本地:
npx tgcloud login
令牌的格式为 `app<id>:<secret>`。CLI 会将其保存在 `.tgcloud/` 目录中,该目录已被 git 忽略,并且永远不会打印出 secret 部分。你只需要在登录时提供它——关于如何在 CI 中解析令牌,请参阅身份验证部分。
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 中打开它并发送一条消息——起始处理器会将其原样回复。
部署永远不会触及你的数据库。推送代码和更改数据库模式是刻意分开的步骤,因此代码部署永远不会因数据迁移而让你措手不及。这正是下一步要做的。
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())`),
});
部署模式,然后将其应用到数据库:
npx tgcloud push # uploads the new schema.js
npx tgcloud migrate # creates the `messages` table
`push` 会报告模式不同步,并显示待处理的更改,但不会应用任何内容。`migrate` 会引导你完成更改,并在你确认后创建该表。这种两步模型——以及对于删除等风险较高的更改会发生什么——在迁移部分有详细说明。
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,然后给你的机器人发几条消息,观察计数增长。数据库在多次调用之间持久存在——这就是你机器人的记忆。
7. 不部署直接测试
你不需要部署就能尝试修改。`npx tgcloud run` 会使用你的本地文件在平台上执行一个 handler,而不会发布这些文件:
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hello" }'
参数是你的 handler 接收到的负载——对于 handlers/message 来说,就是一个 Message——以 JSON5 格式编写(因此你可以省略键的引号)。该命令会打印 handler 通过 `console.*` 记录的任何内容、返回值以及执行耗时。这是迭代逻辑的最紧凑循环——无需部署,无需等待真实消息。
8. 保持同步
在你工作时,少数几个命令就能让你的本地项目与云端保持一致:`npx tgcloud status` 显示变更内容,`npx tgcloud push` 进行部署,`npx tgcloud pull` 让你的本地项目与云端同步,`npx tgcloud fetch` 刷新参考副本而不触碰你的文件,`npx tgcloud reset` 则丢弃本地更改。
如果两个人(或两台机器)向同一个机器人部署,平台会检测到冲突并阻止 push,让你先执行 pull——你永远不会静默覆盖他人的工作。详见“保持同步”部分。
用 AI 构建
更倾向于用 AI 助手来构建——或者你团队里唯一的程序员就是 AI?你仍然可以发布一个机器人。我们迈出了第一步,让 AI 智能体在项目中感到得心应手:每个新项目都会附带一个 `AGENTS.md` 和 `docs/tgcloud-sdk.md` 参考文件,智能体编码工具会自动读取它们。
再加上一个小巧、自包含的运行时——一个 SDK,无需处理 npm 包——这能让助手快速上手那些通用代码生成往往遗漏的约定:通过裸名称导入、无外键、每个数据库调用都是异步的、每个更新类型对应一个 handler、以及两步式的 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 管理的全部功能:
- 处理程序(Handlers)——创建、编辑和测试运行更新处理程序;BotFather 会保持 webhook 与你当前拥有的处理程序同步(即 CLI 所报告的"同步中/不同步"状态)。
- 库(Library)——你的共享 lib/ 模块。
- 数据库(Database)——使用与 Drizzle 类似的语法编辑 schema.js,审查待处理的变更,然后应用这些变更;支持部署。
- CLI 访问(CLI Access)——当你回到键盘前时,可在此处获取 CLI 访问令牌。
这是同一个云项目,因此你可以在手机上创建一个处理程序,稍后在笔记本电脑上用 `npx tgcloud pull` 拉取它——所有操作都不绑定到单一客户端。运行处理程序时,甚至能直接在聊天中显示其控制台输出,就像 `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 以及 lib/ 和 handlers/ 目录下的 .js 文件会被部署。其他所有内容——Markdown 文件、配置文件、.tgcloud/ 文件夹——都保留在你的本地机器上。
schema.js——你的数据库。它使用 schema DSL 以命名导出的方式声明数据表,作为单个文件位于项目根目录。它像其他模块一样被部署,但部署它永远不会改变数据库——schema 变更需要通过 `npx tgcloud migrate` 单独应用。详见《数据库》章节。
lib/ — 共享代码,任何你想在多个处理器间复用的内容:纯辅助函数、数据库访问层、格式化、与外部服务的集成。lib/ 是唯一可以包含子目录的目录(例如 lib/internal/util.js、lib/payments/stripe.js),因此你可以按自己的喜好组织更大的代码库。lib/ 中的模块永远不会被平台直接调用;它们的存在是为了被处理器以及彼此之间导入使用。
handlers/ — 你的机器人的入口点。每个文件对应一种 Telegram 更新类型,平台会将每个传入的更新路由到匹配的处理器:
| 文件 | 处理 |
|---|---|
| handlers/message.js | 新传入的消息 |
| handlers/inline_query.js | 新传入的内联查询 |
| handlers/callback_query.js | 新传入的回调查询 |
| … | 任何其他 Bot API 更新类型 |
handlers/ 是扁平的——没有子目录。处理器的 export default 是平台调用的函数(参见处理器)。
只有当某个更新类型的处理器文件存在且非空时,该更新类型才会被处理。如果没有 handlers/<type>.js 文件——或者该文件为空——则该类型的更新会被忽略,平台不会为其运行任何操作。因此,只保留你实际需要的处理器:每添加一个处理器,你的机器人就会多处理一种更新类型,而省略掉其他类型则意味着 Telegram 不会为那些你最终只会丢弃的更新而启动你的代码。
要搭建一个新的处理器,请运行 npx tgcloud add handlers/<type>。
.tgcloud/ — 完全由 CLI 管理的机器本地状态:你保存的凭据、用于离线差异比较的已部署代码镜像,以及一个小型缓存。它被 git 忽略,你绝不应手动读取或写入它——请改用 CLI 命令。
模块系统
在运行时,一个模块只能看到两样东西:平台 SDK 和你项目中的其他模块。没有 npm 包,没有文件系统,除了通过 SDK 的 fetch 之外也没有网络。
模块通过其名称来寻址——即从项目根目录开始的路径,不带 .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)——也就是整个平台的能力面,详见《SDK》——以及你自己在 schema、lib/、handlers/ 下的模块。这就是完整清单。如果你的代码导入了其他任何东西,它都不会被解析。这个约束保证了模块加载快速且运行安全。
处理器
处理器是 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)时可以访问它。
处理器可以是异步的(通常如此),并且可以返回一个值。它通过 SDK 访问 Bot API、数据库和出站 HTTP。
你不需要真实的更新来测试处理器。npx tgcloud run 会在平台上用你提供的载荷和你当前的本地代码来执行它:
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hi" }'
参数是载荷——也就是你的处理器接收到的同一个对象——采用 JSON5 格式。要提供处理器的 ctx(它的第二个参数),添加 --ctx,例如 --ctx '{ update: { update_id: 1 } }'。这会针对你的本地文件运行,因此你可以在部署之前尝试修改。参见 run。
部署的内容
当你执行 npx tgcloud push 时,CLI 会收集 schema.js、lib/ 和 handlers/ 下的所有 .js 文件,并将这组文件作为你项目的模块空间发送。云端存在但项目中缺失的任何内容都会被移除,因此部署状态始终与你的文件夹保持一致——包括删除操作。这些位置之外的文件会被忽略。项目根目录下多余的 .js 文件(非配置文件)会被标记出来,以免被无声忽略,因为项目根目录只应存放无服务器内容。Markdown 文件、点文件和 .tgcloud/ 目录永远不会被部署。
数据库
每个机器人都有自己的数据库——一个基于 SQLite 的存储,在多次调用之间持久存在,并且每个模块都可以通过 `db` 访问。你在 `schema.js` 中使用一个小型、带类型的 DSL 描述数据表;通过流畅的查询构建器进行读写;并通过经过审查的迁移来演进数据表。
了解 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 之间的转换方式。
| 模式 | 存储为 | 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 的子类,因此这是可移植的基础类型)。上述模式仅控制值的编码方式,与列的存储类型无关:因此 `blob('col', { mode: 'json' })` 是 `json()` 的 BLOB 对应版本——采用相同的 JSON 编码,但存储在 BLOB 列中而非 TEXT 列。
总结:`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
在 `json()` 列上使用 `.default()` 会为你自动编码该值;而 `sql…`` 形式的默认值则会原样传递。`.deprecated()` 是终结性修饰符——参见迁移(Migrations)部分。
索引与约束
索引和约束在 `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=off` 运行。声明的外键将静默失效——无级联操作,无孤儿保护——这比完全没有外键更糟糕,因此 DSL 使其无法实现。具体来说,`.references()` 和表级 `foreignKey()` 在声明时会抛出错误,使用它们的 schema 将无法部署。
你应该使用普通列来建模关系(例如 `userId: integer('user_id')`),并在应用程序代码中强制完整性:先插入父记录再插入子记录,先删除子记录再删除父记录,妥善处理错误,并在需要时使用 `LEFT JOIN … WHERE parent.id IS NULL` 来清理孤儿记录。
缺少外键是一个刻意的约束,而非疏忽。在规划你的机器人时,请尽早考虑到这一点。
查询
`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 变量数量限制(行数 × 列数);如果超出限制,插入操作会报错——你需要自行分块处理。
运算符从 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 列显示为字符串,时间戳显示为数字。只有绑定表的构建器才会转换值。
数据库迁移
随着你的机器人不断成长,数据库也会发生变化。平台通过将部署代码与更改数据分离,并根据风险等级对每个模式变更进行分类,来确保这一过程的安全。
部署操作从不触及数据库。当你运行 npx tgcloud push 推送一个已修改的 schema.js 时,平台会记录新的模式,并告知你数据库将发生哪些变化——但不会应用任何更改:
npx tgcloud push # deploy schema.js; reports pending DB changes
npx tgcloud migrate # review and apply them
npx tgcloud migrate 会计算你的模式与实时数据库之间的差异,并引导你逐步完成迁移。在应用任何更改之前,系统都会向你确认。这意味着常规的代码部署绝不会意外触发数据迁移。
每个待处理的变更都带有一个状态,该状态决定了 migrate 如何处理它:
| 状态 | 含义 | 在迁移中的处理方式 |
|---|---|---|
| 安全 | 新增且非阻塞——例如新表、新列或新索引 | 确认后一次性全部应用 |
| 警告 | 可能具有破坏性或速度较慢——例如删除某些内容,或在大型表上创建索引 | 逐个呈现,每个变更单独确认 |
| 手动 | 无法自动完成——例如更改列的数据类型 | 附带指导说明显示;你需要手动执行 |
| 未记录 | 存在于数据库中,但不在你的模式中 | 为提醒而显示;不会被应用 |
安全变更在本质上快速且可逆,因此会一并执行。每条警告都需要经过深思熟虑的单独确认——破坏性变更不存在“全部应用”的选项。
手动变更附带原因说明和建议操作。migrate 命令执行完毕后会显示摘要:有多少变更已应用、已跳过、等待手动修复,或不在你的数据库模式中。
关于 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 | 数据库——查询构建器和模式 DSL → 参见《数据库》章节 |
| api | Telegram Bot API——api.sendMessage(...) → 参见下文 |
| fetch | 出站 HTTP → 参见下文 |
通过裸名称(from 'schema'、from 'lib/cart')导入你自己的项目模块——切勿使用相对路径或 .js 扩展名。参见《模块系统》章节。
Bot API
api 为你提供完整的 Telegram Bot API。以 api.<方法>(参数) 的形式调用任意方法。所有当前及未来的 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 直接返回结果——getMe() 解析为用户对象,而非包装对象。参数使用 Bot API 自身的 snake_case 命名(chat_id、message_id、reply_markup……)。
失败时会抛出 BotApiError。当 Bot API 返回 `{ ok: false }` 时,调用会抛出 BotApiError 而非返回假值,因此你不会意外忽略它。该错误携带 `.code`(Bot API 的 error_code)、`.description`(人类可读的消息)、`.method`(哪个方法失败)以及 `.parameters`(额外数据,例如 429 状态码中的 retry_after,或 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) { … }`——这就是你消费服务器发送事件或 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
每一行都带有来源的 [文件:行号] 标签。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 | 将项目关联到一个机器人(保存 token) |
| status | 显示本地与云端之间的变更内容 |
| diff | 显示逐行变更 |
| push | 将已变更的模块部署到云端 |
| migrate | 将 schema 变更应用到数据库 |
| run | 在平台上执行一个模块,无需部署 |
| fetch | 从云端刷新本地参考副本 |
| pull | 使本地文件与云端保持一致 |
| reset | 放弃本地变更;从云端状态恢复 |
| webhook | 检查并重新同步平台管理的 webhook |
| completion | 打印 shell 补全脚本(bash/zsh/fish) |
身份验证
一个项目通过其 token 与一个机器人绑定,token 的格式为 app<id>:<secret>。app<id> 部分是公开的,可以打印;secret 永远不会出现在日志或错误信息中。
token 按以下顺序解析:
- TGCLOUD_TOKEN 环境变量——用于 CI;永远不会写入磁盘。
- .tgcloud/credentials——由 npx tgcloud login 写入。
- 两者都不存在 → 报错,提示你执行 npx tgcloud login。
CLI 永远不会在命令执行中途提示输入 token——意外的提示会导致脚本和 CI 挂起。登录始终是一个明确的步骤,如果保存的 token 失效(401/403),CLI 会清除它并要求你重新登录,而不是在原地再次提示。
init
npx tgcloud init
在当前目录中搭建一个新项目:schema.js、lib/、handlers/、一个 starter handler、AGENTS.md、docs/ 以及 .tgcloud/ 状态文件夹。它创建的文件集合由平台提供,因此无需升级 CLI 即可出现新的 starter 文件和目录。离线时,它会回退到内置副本,因此 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,因此 handler 在部署后立即生效——无需取消任何注释。使用 push 部署新模块。
npx tgcloud login
提示输入你的 CLI 访问令牌——来自 @BotFather → 你的 bot → Serverless → CLI Access → Access token,这是一个与你的 bot API 令牌不同的独立令牌——它会向平台验证该令牌,并将其保存到 .tgcloud/credentials。
login 是唯一需要输入令牌的命令。它需要真实的终端环境,没有终端就无法运行,因此绝不会在 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 中。
参见该模型的迁移文档。
选项:`--dry-run`(仅打印所有内容,不执行任何操作)、`--safe`(自动应用安全更改,跳过警告)、`--yes`(自动应用安全更改及所有警告,跳过手动操作——请谨慎使用)、`--local`(与你本地的 `schema.js` 进行差异对比,而非已部署的版本)。不带标志时,`migrate` 需要终端环境,在非交互式环境中会报错,而非自行猜测。
run
npx tgcloud run <module> [args] [--ctx <json5>]
使用你当前的本地文件,在平台上执行一个处理器(handler)而无需部署。这是用于测试逻辑的快速内循环。
- `<module>` —— 一个裸名称(在 `handlers/` 目录下查找)或一个路径,如 `handlers/message`。
- `[args]` —— 传递给处理器的负载(payload),以 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 向你的机器人传递更新,该平台会为你管理这个 webhook——你永远不需要手动指向任何地方。`npx tgcloud webhook` 会显示其当前状态:URL、allowed_updates 列表、待处理的更新数量、最后一次投递错误(如果有的话),以及它是否与你已部署的 handlers 保持同步。
“同步”意味着 webhook 指向该平台,并且其 allowed_updates 与你已部署的 handlers 相匹配——因此 Telegram 只会投递你处理的那几种更新类型,而不会投递其他类型。部署新的 handler(或移除一个)可能会导致 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 补全脚本输出到标准输出。启用一次后,按 <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(或重新加载该文件)。在不带任何参数的情况下运行 `tgcloud completion` 会再次打印这些说明。
保持同步
云端的每个项目都有一个单调递增的修订版本号,每次部署时都会递增。CLI 会记住它上次同步的修订版本号,并在每次推送时将其发送出去。如果云端版本已经更新——因为另一台机器或团队成员进行了部署——推送会被拒绝,而不是静默覆盖他们的工作,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 的原因。命令在失败时返回非零退出码——无论是部署被拒、迁移失败、身份验证错误,还是运行时抛出异常的模块——因此它们可以干净地集成到脚本和 CI 流水线中。