AI HOT 精选

Telegram 无服务器架构

7.1内容质量

TL;DR · AI 摘要

Telegram 无服务器架构 · AI HOT Hacker News 热门(buzzing.cc 中文翻译) 精选 77 导出 Markdown Telegram 无服务器架构 2026-07-15 23:37 · 3小时前 sohei...

核心要点

  • 主题聚焦:Telegram 无服务器架构
  • 来源:AI HOT 精选,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#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,平台就会接手后续工作。你机器人的流量由已部署的模块处理;你的数据库在多次调用之间持久存在。

一个项目只有三种代码:

code
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

code
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

code
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.`,
  });
}

部署它:

code
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:

code
npm create @tgcloud/bot example_bot
cd example_bot

参数是目标文件夹:传入 . 可在当前文件夹中搭建,或传入任意路径。它也可以在已有文件夹中工作,并且永远不会覆盖你已有的文件。

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

code
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 deploynpm run status)来运行。默认情况下,你的 PATH 中不会有全局的 tgcloud

你也可以全局安装它——npm install -g @tgcloud/cli——如果你更愿意在任何地方直接输入 tgcloud 的话。这对于在任何空文件夹中运行 tgcloud init 很方便,并且这也是 shell 选项卡补全所需要的。无论哪种方式,你都会得到相同的项目。

#### 2. 关联你的机器人

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

code
npx tgcloud login

令牌的格式为 app<id>:<secret>。CLI 会将其保存在 .tgcloud/ 目录中,该目录已被 git 忽略,并且永远不会打印出 secret 部分。你只需要在登录时提供它——关于如何在 CI 中解析令牌,请参阅身份验证部分。

#### 3. 查看情况

有两个命令可以让你随时了解当前状态,两者都完全离线运行:

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

init 之后,一切都是全新的,尚未部署任何内容。status 会显示等待上传的起始文件。

#### 4. 部署

将你的模块发送到云端:

code
npx tgcloud push

push 会以原子批处理的方式上传每个已更改的模块,并更新你本地关于云端当前内容的记录。你的机器人现已上线:在 Telegram 中打开它并发送一条消息——起始处理器会将其原样回复。

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

#### 5. 添加数据库表

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

code
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())`),
});

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

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

push 会报告模式不同步,并显示待处理的更改,但不会应用任何内容。migrate 会引导你完成更改,并在你确认后创建该表。这种两步模型——以及对于删除等风险较高的更改会发生什么——在迁移部分有详细说明。

#### 6. 存储和读取数据

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

code
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,而不会发布这些文件:

code
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.mddocs/tgcloud-sdk.md 参考文件,智能体编码工具会自动读取它们。

再加上一个小巧、自包含的运行时——一个 SDK,无需处理 npm 包——这能让助手快速上手那些通用代码生成往往遗漏的约定:通过裸名称导入、无外键、每个数据库调用都是异步的、每个更新类型对应一个 handler、以及两步式的 push/migrate 流程。

试试看:

code
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 pushnpx 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,也没有服务器入口点。

#### 项目结构解析

code
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 扩展名——而不是通过其在磁盘上的位置。始终使用这个裸名称进行导入:

code
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

相对路径和文件扩展名不起作用——平台在其模块空间中解析名称,而不是目录中的文件。

code
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/ 下的一个模块,当匹配的更新到达时,平台会调用该模块的默认导出。

code
// 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 会在平台上用你提供的载荷和你当前的本地代码来执行它:

code
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 时,平台会发现导出的数据表,并对数据库进行迁移以匹配。

code
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() 使用 Uint8Arraymode 控制编码方式,而非存储方式,因此 blob(..., { mode: 'json' }) 将 JSON 存储为 BLOB,而 json() 将其存储为 TEXT。

##### 列修饰符

列修饰符可以链式追加到列上。

code
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 回调函数中声明,在该回调中列处于作用域内。

code
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

code
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())。

插入、更新、删除:

code
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 导入:

code
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 用于单行查询。

code
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 时,平台会记录新的模式,并告知你数据库将发生哪些变化——但不会应用任何更改:

code
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 中删除表或列并不会实际删除它们——否则意外删除将造成灾难性后果。要删除某个对象,请将其标记为已弃用:

code
// 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 对象。

code
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。

code
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)。捕获它以处理预期的失败,并重新抛出其余部分:

code
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 的内容。

code
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:

code
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 捕获并显示,这使其成为你在开发过程中的主要调试工具。

code
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 或更高版本。有两种方式获取它:

code
# 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

在平台上执行一个模块,无需部署

从云端刷新本地参考副本

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

code
npx tgcloud init

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

init 命令拒绝嵌套在另一个项目内部——即上级目录中如果已存在 .tgcloud/ 文件夹,则无法执行——这样你就不会意外覆盖已有项目;在项目自身根目录重新运行 init 是没问题的,只会补全缺失的内容。

#### add

code
npx tgcloud add <target>

搭建一个全新的模块,连接好即可编辑——可以是 handler 或 lib/ 模块。

code
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/,它会列出你尚未拥有的更新类型,方便你直接复制一个。

code
$ 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

code
npx tgcloud status

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

#### diff

code
npx tgcloud diff

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

#### push

code
npx tgcloud push [files...]

以一次原子批量操作将你的项目部署到云端。

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

如果指定了文件或目录参数(如 npx tgcloud push handlers/message.jsnpx tgcloud push handlers/),则会缩小发送变更的范围,但仍会发送完整的清单文件,因此定向推送不会删除未触及的模块。

它的一个选项是 --force——用于跳过并发检查并覆盖云端的任何内容。仅在你确定时使用(参见“保持同步”部分)。

部署后,如果 schema.js 发生了更改且数据库不同步,push 会打印出待处理更改的摘要,并建议执行 npx tgcloud migrate。它本身从不应用这些更改。

#### migrate

code
npx tgcloud migrate

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

  • 所有待处理更改的简要摘要。
  • 安全更改,在你确认后一次性全部应用。
  • 警告(删除操作、慢速操作),逐条确认,每条单独确认。
  • 手动更改,会显示原因和建议操作,不会自动应用。
  • 未记录的对象(存在于数据库中但不在你的 schema 中),会显示出来以供了解。

最后会给出一个摘要:已应用、已跳过、等待手动修复、不在 schema 中。

参见该模型的迁移文档。

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

#### run

code
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

code
npx tgcloud fetch

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

#### pull

code
npx tgcloud pull

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

#### reset

code
npx tgcloud reset

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

#### webhook

code
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。

code
tgcloud completion <bash|zsh|fish>

将 shell 补全脚本输出到标准输出。启用一次后,按 <Tab> 键即可补全命令、标志、模块目录、你尚未拥有的 handler 更新类型以及你的本地可运行模块——这些建议是实时计算的,因此它们会反映当前项目和该平台公布的更新类型。

code
# 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 提供了三种处理方式:

code
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 流水线中。