Telegram 无服务器架构
TL;DR · AI 摘要
Telegram 无服务器架构 · AI HOT Hacker News 热门(buzzing.cc 中文翻译) 精选 77 导出 Markdown Telegram 无服务器架构 2026-07-15 23:37 · 3小时前 sohei...
核心要点
- 主题聚焦:Telegram 无服务器架构
- 来源:AI HOT 精选,建议结合原文判断细节。
- AI 分析暂不可用,本条为保底评分与摘要。
Telegram 无服务器架构 · AI HOT
Hacker News 热门(buzzing.cc 中文翻译)
精选
77
导出 Markdown
Telegram 无服务器架构
2026-07-15 23:37
·
3小时前
soheilpro
阅读原文
core.telegram.org
精选理由
Telegram 给机器人开发搭了一套完整的无服务器后端,内置数据库和 CLI,从写代码到部署都在一个文件夹里完成。做 Telegram 机器人的开发者可以彻底扔掉 VPS 和云函数了。
AI 摘要
Telegram Serverless 允许开发者直接在 Telegram 基础设施上运行 Bot 和 Mini App 的后端代码,无需配置服务器或容器。开发者编写普通 JavaScript 模块,通过 npx tgcloud push 单命令部署,代码在靠近 Bot API 和内建数据库的轻量级 V8 隔离沙箱中执行。
正文 · AI 翻译
中文
原文
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 pushpush 会以原子批处理的方式上传每个已更改的模块,并更新你本地关于云端当前内容的记录。你的机器人现已上线:在 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` tablepush 会报告模式不同步,并显示待处理的更改,但不会应用任何内容。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/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()
存储为 0/1,读取为 true/false
json()
自动 JSON.stringify / JSON.parse
列名参数是可选的——省略时使用 JS 键名。
mode选项控制值在 SQLite 和 JavaScript 之间的转换方式。
模式
存储为
JS 值
boolean
INTEGER 0/1
json
TEXT (JSON)
任意对象/数组
timestamp
INTEGER(Unix 秒)
Date
timestamp_ms
INTEGER(Unix 毫秒)
bytes
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 themnpx 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 initnpm 包名为 @tgcloud/cli;安装后得到的命令是 tgcloud。该 CLI 通过从当前目录向上查找最近的 .tgcloud/ 目录来定位你的项目,因此所有命令都可以在任何子文件夹中运行。
命令
用途
init
在当前文件夹中搭建一个新项目
add
搭建一个新模块(一个 handler 或 lib 模块)
login
将项目关联到一个机器人(保存 token)
status
显示本地与云端之间的变更内容
diff
显示逐行变更
push
将已变更的模块部署到云端
migrate
将 schema 变更应用到数据库
run
在平台上执行一个模块,无需部署
从云端刷新本地参考副本
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 部署新模块。
提示输入你的 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 } }'。
该平台会从你的本地项目中组装出模块空间,并在其上运行该模块(因此本地修改过的 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 流水线中。