Telegram Serverless를 사용하면 서버를 프로비저닝하거나, 활성 상태로 유지할 컨테이너를 관리하거나, 확장성에 대해 걱정할 필요 없이 Telegram의 인프라에서 직접 봇 및 미니 앱의 백엔드 코드를 실행할 수 있습니다. 일반 JavaScript 모듈을 작성하고, 단일 명령으로 배포하면 Telegram이 Bot API 바로 옆에 있는 빠르고 격리된 V8 샌드박스에서 실행합니다.
VPS, 클라우드 함수 또는 호스팅 패널에 봇을 연결하여 /start에 응답하는 데 시간을 보낸 적이 있다면, 이제 더 이상 할 필요 없는 부분입니다.
서버리스란 무엇인가요?
Telegram 봇은 본질적으로 업데이트에 반응하는 프로그램입니다. 전통적으로 이 프로그램을 항상 켜져 있고, 접근 가능하며, 안전한 곳에 호스팅해야 했으며, 이를 계속 유지해야 했습니다. Telegram Serverless는 이 계층을 완전히 제거합니다.
- 인프라 불필요. 임대, 패치 또는 모니터링할 기계가 없습니다. 코드는 필요에 따라 실행되며 봇과 함께 자동으로 확장됩니다.
- 자격 증명 포함. Telegram Bot API, SQLite 기반 데이터베이스 및 아웃바운드 HTTP는 모든 모듈에서 즉시 사용할 수 있습니다. 설치할 것도 없고, 연결할 자격 증명도 없습니다.
- 빠르고 격리된 실행. 각 호출은 Telegram 자체 시스템과 가까운 경량 V8 격리에서 실행되므로 Bot API 및 데이터베이스 호출이 빠르고 안정적입니다.
- 진정한 개발 워크플로우. 프로젝트는 버전 관리 하에 로컬 기기의 폴더에 저장됩니다. 파일을 편집하고, 변경된 내용을 정확히 확인하고, 원자적으로 배포하고, 검토된 마이그레이션으로 데이터베이스 스키마를 앞으로 이동합니다. 이미 모든 것과 함께 작동하는 방식입니다.
정신 모델: 프로젝트가 작동하는 방식
세 곳에서 작업하며, 이 세 곳은 서로 깨끗하게 매핑됩니다.
| 위치 | 거기에 있는 것 |
|---|---|
| 프로젝트 폴더 | JavaScript 모듈 — 스키마, 공유 코드, 업데이트 핸들러, 미니 앱 엔드포인트 — 그리고 미니 앱이 있다면 미니 앱의 프런트엔드 |
| 클라우드 | 해당 모듈의 배포된 복사본, 봇의 데이터베이스, 미니 앱 파일 |
tgcloud CLI |
다리 — 차이를 보여주고 동기화합니다. |
절대로 아무 곳에도 SSH로 접속하지 않습니다. 로컬에서 파일을 편집하고 npx tgcloud push를 실행하면 플랫폼이 알아서 처리합니다. 봇의 트래픽은 배포된 모듈에서 처리되며, 데이터베이스는 호출 간에 유지됩니다.
프로젝트에는 tgcloud/ 폴더 안에 단 네 가지 종류의 코드만 있습니다.
tgcloud/
├─ endpoints/ # 미니 앱이 호출하는 함수
├─ handlers/ # Telegram 업데이트 유형에 반응하는 함수
├─ lib/ # 어디에서나 가져올 수 있는 공유 코드
└─ schema.js # 데이터베이스 테이블
메시지, 버튼 누름, 인라인 쿼리와 같은 업데이트가 도착하면 Telegram이 일치하는 핸들러(handlers/message.js, handlers/callback_query.js 등)로 라우팅하고 기본 내보내기를 호출합니다. 해당 함수는 SDK를 통해 Bot API 및 데이터베이스와 통신하고 반환합니다. 이것이 전체 루프입니다. 일치하는 핸들러가 없는 업데이트는 무시되므로 필요한 핸들러만 추가합니다.
미니 앱은 선택 사항입니다. 봇에 미니 앱이 있다면, 해당 프런트엔드는 tgcloud/ 옆에 있는 동일한 프로젝트에 있습니다. Serverless는 빌드 출력을 호스팅하고 호출하는 endpoints/를 실행합니다.
미니 앱 프런트엔드
를 참조하세요.
빠른 데모
완벽하게 작동하는 데모 봇입니다. 모든 메시지에 응답하고 각 채팅에서 본 메시지 수를 기억합니다.
tgcloud/schema.js
import { table, integer } from 'sdk/db';
export const counters = table('counters', {
chatId: integer('chat_id').primaryKey() ,
seen: integer('seen').notNull().default(0) ,
});
tgcloud/handlers/message.js
import { api, db } from 'sdk';
import { sql } from 'sdk/db';
import { counters } from '../schema.js';
export default async function (message) {
const chatId = message.chat.id;
// 카운터를 삽입하거나, 해당 채팅에 이미 카운터가 있다면 증가시키고, .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: `안녕하세요! 당신으로부터 ${row.seen}개의 메시지를 보았습니다.`,
});
}
배포하세요:
npx tgcloud push # 모듈 업로드
npx tgcloud migrate # `counters` 테이블 생성
서버 없이 지속적인 상태를 가진 라이브 봇입니다. 봇 안의 모든 것 — api, db, table() DSL —은 아래 섹션에서 설명합니다.
Serverless는 템플릿이 아니라 Telegram 봇 및 미니 앱을 위한 일반적인 백엔드입니다. 다음의 경우에 이상적입니다.
- 대화형 AI 봇 — 데이터베이스에 사용자별 상태를 저장해야 하는 봇
- 미니 앱 백엔드 — 사용자 데이터를 저장하고 동적 콘텐츠를 제공하는 백엔드
- 게임 및 도구 — 순위표, 퀴즈 등
- 자동화 및 통합 — 타사 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/
├─ tgcloud/ # 플랫폼이 실행하는 모든 것
│ ├─ handlers/
│ │ └─ message.js # 시작 메시지 핸들러 (텍스트를 다시 에코)
│ ├─ lib/ # 공유 모듈이 여기에 저장됩니다 (시작 시 비어 있음)
│ └─ schema.js # 데이터베이스 테이블
├─ docs/
│ └─ tgcloud-sdk.md # SDK 참조 (당신과 AI 도구용)
├─ AGENTS.md # AI 코딩 도우미용 지침
├─ package.json
└─ tgcloud.jsonc # 프로젝트 구성
스캐폴딩된 파일은 자체 설명적이므로 각 파일에는 다음에 할 수 있는 일에 대한 주석 처리된 예제가 포함되어 있습니다.
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을 실행하는 데 편리하며, 셸 탭 자동 완성이 필요합니다. 어느 쪽이든 동일한 프로젝트를 얻게 됩니다.
미니 앱을 구축하시나요? 프런트엔드부터 시작하세요. 일반 도구를 사용하여 생성한 다음 해당 폴더에서 프로젝트 생성기를 실행하세요. 미니 앱 프런트엔드 를 참조하세요.
2. 봇 연결
각 프로젝트는 하나의 봇에 연결됩니다. login 명령으로 연결하며, CLI 액세스 토큰을 요청합니다 (
@BotFather
→ 봇 → Serverless → CLI Access → Access token —
봇 API 토큰
과 별도의 토큰) 그리고 이를 로컬에 저장합니다.
npx tgcloud login
토큰은 app<id>:<secret> 형식을 가집니다. app<id> 부분은 공개될 수 있으며 인쇄될 수 있습니다. CLI는 이를 .tgcloud/에 저장하고, 이 파일은 git에 무시되며, 비밀 부분은 절대 인쇄되지 않습니다. 로그인 시에만 요청합니다. CI에서의 토큰 확인 방법은
인증
을 참조하세요.
3. 둘러보기
두 가지 명령으로 언제든지 상황을 확인할 수 있으며, 둘 다 완전히 오프라인으로 작동합니다.
npx tgcloud status # 로컬 변경 사항과 배포된 복사본 비교
npx tgcloud diff # 줄별 변경 사항 보기
init 직후에는 모든 것이 새롭고 아무것도 배포되지 않은 상태입니다. status는 업로드 대기 중인 시작 파일을 보여줍니다.
4. 배포
코드를 클라우드로 전송합니다.
npx tgcloud push
push는 변경된 모든 모듈을 하나의 원자적 배치로 업로드하고 클라우드에 있는 내용의 로컬 기록을 업데이트합니다. 봇이 라이브 상태가 됩니다. Telegram에서 봇을 열고 메시지를 보내세요 — 시작 핸들러가 그대로 다시 보냅니다.
배포는 데이터베이스를 절대 건드리지 않습니다. 코드 푸시와 데이터베이스 스키마 변경은 의도적으로 분리되어 있으므로 코드 배포가 데이터 마이그레이션으로 놀라게 할 수 없습니다. 다음 단계가 바로 그것입니다.
5. 데이터베이스 테이블 추가
봇이 무언가를 기억하도록 만들어 봅시다. tgcloud/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 # 새 스키마 업로드
npx tgcloud migrate # `messages` 테이블 생성
push는 스키마가 동기화되지 않았음을 보고하고 보류 중인 변경 사항을 보여주지만, 아무것도 적용하지 않습니다. migrate는 변경 사항을 안내하고, 확인 시 테이블을 생성합니다. 이 두 단계 모델 — 그리고 더 위험한 변경(예: 삭제)이 어떻게 처리되는지는
마이그레이션
섹션에서 다룹니다.
6. 데이터 저장 및 읽기
이제 핸들러에서 테이블을 사용합니다. tgcloud/handlers/message.js를 편집합니다.
import { api, db } from 'sdk';
import { eq } from 'sdk/db';
import { messages } from '../schema.js';
export default async function (message) {
// 이 메시지를 저장합니다.
await db.insert(messages)
.values({ chatId: message.chat.id, text: message.text })
.run();
// 이 채팅에 대해 저장한 메시지 수를 셉니다.
const count = await db.$count(messages, eq(messages.chatId, message.chat.id));
await api.sendMessage({
chat_id: message.chat.id,
text: `저장되었습니다. 이 채팅에서 지금까지 ${count}개의 메시지를 보냈습니다.`,
});
}
npx tgcloud push로 업데이트된 핸들러를 배포한 다음 봇에 몇 가지 메시지를 보내고 카운트가 올라가는 것을 지켜보세요. 데이터베이스는 호출 간에 유지됩니다. 이것이 봇의 메모리입니다.
7. 배포 없이 테스트
변경 사항을 테스트하기 위해 배포할 필요는 없습니다. npx tgcloud run은 코드를 게시하지 않고 플랫폼에서 로컬 파일을 사용하여 핸들러를 실행합니다.
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hello" }'
인수는 핸들러가 받는 페이로드입니다 — handlers/message의 경우
Message
—
JSON5
형식 (따라서 키에 따옴표를 생략할 수 있음) 입니다. 이 명령은 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 패키지 없음 — 과 함께 제공되면 일반 코드 생성기가 일반적으로 놓치는 여기의 관례에 대해 에이전트가 시작하는 데 도움이 됩니다. 상대 가져오기 .js, 외래 키 없음, 모든 db 호출은 비동기, 업데이트 유형당 하나의 핸들러, 두 단계 push/migrate 흐름.
시도해 보세요:
npm create @tgcloud/bot my-bot
cd my-bot
opencode # 또는 Claude Code, Cursor 등 — AGENTS.md를 읽는 모든 에이전트
그런 다음 일반 언어로 요청하십시오.
각 사람의 할 일 목록을 기억하는 봇을 작성하세요 — 텍스트를 보낼 때 항목을 추가하고
/list를 보낼 때 전체 목록을 표시합니다.
어시스턴트가 schema.js와 핸들러를 편집합니다. 검토하고, npx tgcloud run으로 즉시 변경 사항을 테스트한 다음, npx tgcloud push 및 npx tgcloud migrate로 라이브로 전환합니다. AGENTS.md는 프로젝트의 일부입니다 — 봇이 성장함에 따라 가이드가 정확하게 유지되도록 편집하세요.
BotFather와 함께 이동 중
휴대폰만 가지고 계신가요? 전체 프로젝트는 @BotFather 에도 있습니다 — 봇 → Serverless를 열면 CLI가 관리하는 모든 것을 터치스크린에서 볼 수 있습니다.
- 핸들러 — 업데이트 핸들러를 생성, 편집 및 테스트 실행합니다. BotFather는 웹훅을 가지고 있는 핸들러와 동기화합니다(CLI가 보고하는 것과 동일한 동기화됨 / 동기화되지 않음).
- 엔드포인트 — 미니 앱이 호출하는 함수입니다.
- 라이브러리 — 공유
lib/모듈입니다. - 데이터베이스 — 동일한 Drizzle와 유사한 구문으로
schema.js를 편집하고, 보류 중인 변경 사항을 검토하고, 적용합니다. 저장하면 배포됩니다. - 미니 앱 파일 — 사용자가 Telegram에서 여는 프런트엔드입니다.
- CLI 액세스 — 키보드로 돌아갔을 때 여기서 CLI 액세스 토큰을 가져옵니다.
하나의 클라우드 프로젝트이므로 휴대폰에서 핸들러를 시작하고 나중에 노트북으로 npx tgcloud pull할 수 있습니다 — 아무것도 단일 클라이언트에 연결되지 않습니다. 핸들러 실행조차도
npx tgcloud run
과 마찬가지로 채팅에 콘솔 출력을 표시합니다.
프로젝트 및 모듈
서버리스 프로젝트는 버전 관리 하의 일반 폴더입니다. 플랫폼이 실행하는 모든 것은 tgcloud/ 폴더에 있습니다. 나머지 프로젝트는 사용자의 것입니다 — 미니 앱 프런트엔드, 구성, 문서. 봇에 대한 빌드 단계, 런타임의 node_modules, 서버 진입점이 없습니다.
프로젝트 구조
example_bot/
├─ tgcloud/ # 배포되는 부분 — 그 외는 아무것도 아님
│ ├─ handlers/ # 업데이트 핸들러 — 플랫, 한 레벨만
│ │ ├─ message.js
│ │ └─ callback_query.js
│ ├─ endpoints/ # 미니 앱이 호출하는 함수 — 플랫, 한 레벨만
│ │ └─ getProfile.js
│ ├─ lib/ # 공유 모듈; 하위 디렉토리 허용
│ │ ├─ reply.js
│ │ └─ internal/util.js
│ └─ schema.js # 데이터베이스 스키마 — 한 파일
└─ .tgcloud/ # CLI 상태, git에 무시됨
tgcloud/ 내의 .js 파일만 배포됩니다 — schema.js, lib/, handlers/, endpoints/. 나머지 — Markdown, 구성 파일, .tgcloud/ 폴더, tgcloud/ 외부의 모든 것 —은 로컬에 남아 있습니다. 모듈 이름은 tgcloud/를 기준으로 합니다. tgcloud/handlers/message.js 파일은 handlers/message 모듈이며, npx tgcloud run이 사용하는 이름입니다.
이전 프로젝트에서 업그레이드하시나요? CLI 0.2.0 이전 버전에서 생성된 프로젝트는
schema.js,lib/및handlers/를 프로젝트 루트에 유지했습니다.npx tgcloud upgrade를 한 번 실행하여tgcloud/로 이동하세요.
schema.js — 데이터베이스입니다. 스키마 DSL을 사용하여 명명된 내보내기로 테이블을 선언하며, tgcloud/에 단일 파일로 저장됩니다. 다른 모듈과 마찬가지로 배포되지만, 이를 배포해도 데이터베이스가 변경되지는 않습니다. 스키마 변경은 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>를 실행하세요.
endpoints/ — 미니 앱의 서버 측입니다. 각 파일은 미니 앱이 이름으로 호출하는 함수입니다. 따라서 endpoints/getProfile.js는 POST /api/getProfile에 응답합니다. 플랫폼은 호출이 미니 앱 내의 Telegram 사용자의 것인지 확인한 후 코드를 실행하고 해당 사용자를 전달합니다. endpoints/는 handlers/와 마찬가지로 플랫합니다.
엔드포인트
를 참조하세요.
.tgcloud/ — CLI가 완전히 관리하는 로컬 상태, 저장된 자격 증명을 포함합니다. git에 무시되며, 수동으로 읽거나 쓰지 마세요. 대신 CLI 명령을 사용하세요.
모듈 시스템
런타임에 모듈은 플랫폼 SDK와 프로젝트의 다른 모듈이라는 두 가지를 볼 수 있습니다. npm 패키지, 파일 시스템, SDK의 fetch를 통한 네트워크 연결은 없습니다.
JavaScript 프로젝트에서와 같이 상대 경로와 .js 확장자를 사용하여 자체 모듈을 가져오고, SDK는 해당 이름으로 가져옵니다.
// tgcloud/handlers/message.js
import { users } from '../schema.js'; // 스키마 모듈
import { addItem } from '../lib/cart.js'; // lib 모듈
import { format } from '../lib/internal/fmt.js'; // 중첩 lib 모듈
import { db, api, fetch } from 'sdk'; // 플랫폼 SDK
// tgcloud/lib/cart.js
import { format } from './internal/fmt.js'; // 이웃 모듈
import { users } from '../schema.js';
상대 가져오기는 .js로 끝나야 하며 tgcloud/ 외부로 나갈 수 없습니다.
import { users } from '../schema'; // .js를 추가하세요
import config from '../../config.js'; // tgcloud/ 외부
import { z } from 'zod'; // npm 패키지 없음
각 모듈에는 이름이 있습니다 — 확장자를 제외한 tgcloud/ 내의 경로, 예를 들어 schema, lib/cart 또는 handlers/message. 이 이름은 npx tgcloud run 및 CLI 출력에서 사용되며, 모듈은 해당 이름으로 가져올 수도 있습니다 (from 'lib/cart', 확장자 없음). 코드에서 상대 가져오기를 선호하세요. 편집기에서 이해하는 것이므로 정의로 이동, 자동 완성, 이름 변경이 가능합니다.
런타임에서 사용 가능한 것은 정확히 두 가지입니다: sdk 및 해당 하위 모듈 (sdk/db, sdk/api, sdk/fetch) — 전체 플랫폼 표면,
SDK
참조 — 및 자체 모듈 (tgcloud/에 있음). 이것이 전체 목록입니다. 코드가 다른 것을 import하면 해결되지 않습니다. 이 제약 조건은 모듈을 빠르게 로드하고 안전하게 실행할 수 있게 합니다.
핸들러
핸들러는 handlers/에 있는 모듈이며, 일치하는 업데이트가 도착할 때 플랫폼이 호출하는 기본 내보내기입니다.
// handlers/message.js
import { api } from 'sdk';
export default async function (message) {
await api.sendMessage({
chat_id: message.chat.id,
text: `당신이 말하길: ${message.text ?? '(텍스트 없음)'}`,
});
}
핸들러는 업데이트의 페이로드를 인수로 받습니다 — 플랫폼이 Telegram
Update
를 자동으로 풀어줍니다. handlers/message.js는
Message
(즉, update.message)를 받습니다. handlers/callback_query.js는
CallbackQuery
를 받습니다. 등등. 핸들러의 두 번째 인수는 호출당 컨텍스트 객체인 ctx입니다. 이는 ctx.update로 원시 Update를 전달합니다 — 페이로드 외부의 무언가(예: update_id)가 필요할 때 사용하세요.
핸들러는 async일 수 있으며 (보통 그렇습니다) 값을 반환할 수 있습니다. 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
을 참조하세요.
엔드포인트
엔드포인트는 미니 앱이 호출하는 endpoints/의 모듈입니다 — 프런트엔드가 자체적으로 할 수 없는 것, 예를 들어 데이터베이스를 읽고 쓰거나, Bot API를 호출하거나, 비밀 정보를 사용하여 외부 서비스와 통신하는 곳입니다.
// endpoints/getProfile.js
import { db, EndpointError } from 'sdk';
import { eq } from 'sdk/db';
import { profiles } from '../schema.js';
export default async function (input, ctx) {
const user = ctx.initData.user;
const profile = await db.select().from(profiles).where(eq(profiles.userId, user.id)).get();
if (!profile) throw new EndpointError('프로필을 찾을 수 없습니다', { userId: user.id });
return profile;
}
첫 번째 인수 input은 미니 앱이 보낸 JSON 객체입니다. 두 번째 인수 ctx는 **ctx.initData**를 포함합니다 — 미니 앱의
init data
(user, chat, start_param, auth_date 등) — 이미 플랫폼에서 확인됨. 클라이언트의 Telegram.WebApp.initDataUnsafe와 동일한 모양이지만, 여기서는 신뢰할 수 있습니다. 유효한 init 데이터가 없는 호출은 코드가 실행되기 전에 401로 거부되므로 ctx.initData.user는 실제로 미니 앱을 연 사람입니다. 엔드포인트는 미니 앱을 통해 Telegram 사용자만 호출할 수 있으므로 공개 API를 보호할 필요도 없고 로그인도 구축할 필요가 없습니다.
엔드포인트가 반환하는 모든 것은 JSON으로 반환됩니다. 호출을 거부하려면 throw new EndpointError(description, parameters?)를 사용하세요. 미니 앱은 설명과 parameters 객체를 그대로 받습니다. 프런트엔드에서 오류 유형에 따라 분기해야 하는 경우 parameters에 코드를 넣으세요 — { code: 'OUT_OF_STOCK' }. 다른 예외는 일반 내부 오류로 보고되며 실제 메시지는 로그에만 기록되므로 내부 정보가 클라이언트로 유출되지 않습니다.
미니 앱에서 엔드포인트 호출
미니 앱은 공식
미니 앱 SDK
의 Telegram.WebApp.Serverless.call을 사용하여 이름으로 엔드포인트를 호출합니다. 이 SDK는 telegram-web-app.js이며 — 미니 앱에 이미 포함된 스크립트입니다. init 데이터를 자동으로 보내고 오류 또는 엔드포인트 결과로 콜백합니다.
Telegram.WebApp.Serverless.call('getProfile', { lang: 'en' }, function (err, profile) {
if (err) {
// err.message — 설명; err.parameters — EndpointError에서 전달한 것
if (err.type === 'ENDPOINT_ERROR') showMessage(err.message);
return;
}
render(profile);
});
입력은 선택 사항이며 일반 객체여야 합니다. 입력이 없으면 호출은 Serverless.call('getProfile', callback)입니다.
err.type은 엔드포인트가 EndpointError를 발생시켰을 때 'ENDPOINT_ERROR'입니다. 다른 값은 플랫폼이 호출을 거부했거나 엔드포인트가 예상치 못하게 실패했음을 의미합니다. 예를 들어 init 데이터가 누락되었거나 유효하지 않을 때 'UNAUTHORIZED'입니다.
엔드포인트 이름은 JavaScript 식별자와 같습니다 — 문자, 숫자 및 _, 숫자로 시작하지 않음 (getProfile, place_order).
npx tgcloud add endpoints/<name>
로 스캐폴딩하고, 미니 앱 없이 run으로 테스트하세요. init 데이터를 직접 전달합니다.
npx tgcloud run endpoints/getProfile '{}' --ctx '{ initData: { user: { id: 1, first_name: "Ann" } } }'
배포되는 내용
npx tgcloud push를 실행하면 CLI는 schema.js, lib/, handlers/, endpoints/ 아래의 모든 .js 파일을 수집하여 프로젝트의 모듈 공간으로 보냅니다 — 미니 앱의 빌드 폴더와 함께 ( tgcloud.jsonc가 이름을 지정하는 경우, 아래 참조). 클라우드에 존재하지만 프로젝트에 없는 모든 것은 제거되므로 배포된 상태는 항상 폴더를 정확히 반영합니다 — 삭제도 포함합니다. tgcloud/ 외부의 어떤 것도 코드로 배포되지 않습니다. 프로젝트 루트 외부의 나머지 부분은 사용자 소유입니다. tgcloud/ 내부의, 해당 위치에 없는 .js 파일 (tgcloud/util.js, tgcloud/helpers/x.js)은 배포되지 않도록 표시됩니다. Markdown, 점 파일 및 .tgcloud/는 절대 배포되지 않습니다.
미니 앱 프런트엔드
프로젝트 루트는 사용자의 것이며, 미니 앱의 프런트엔드 — React, Vue, Svelte 또는 일반 HTML로 Vite 또는 다른 번들러로 빌드된 — 를 위한 자연스러운 장소입니다. Serverless는 이를 빌드 출력을 봇 옆에 호스팅합니다. 하나의 push로 코드와 정적 파일을 함께 배포하며, 앱은 https://app<app_id>.tgcloud.ai/에서 제공됩니다 (push 명령이 정확한 주소를 출력합니다 — 이를
@BotFather
에서 미니 앱의 URL로 사용).
악용 신고. tgcloud.ai의 미니 앱은 독립 개발자가 게시합니다.
app<app_id>.tgcloud.ai하위 도메인에서 불법 콘텐츠 또는 악용 사례를 신고하려면 [email protected] 로 이메일을 보내세요.
시작하려면 먼저 프런트엔드를 원하는 도구로 생성한 다음 그 위에 Serverless를 추가하세요.
npm create vite@latest example_app # 또는 create-vue, …
cd example_app
npm create @tgcloud/bot .
프로젝트 생성기는 파일을 덮어쓰지 않습니다. tgcloud/ 폴더를 프런트엔드 옆에 추가하고, 빌드 폴더를 인식하며, 앱을 봇과 함께 배포하는 npm run deploy 스크립트를 추가합니다.
호스팅되는 내용은 프로젝트 루트의 구성 파일인 **tgcloud.jsonc**에 설정됩니다.
{
"$schema": "./node_modules/@tgcloud/cli/schema/tgcloud.json",
"static": {
"source": "dist",
"spa": true,
"immutable": ["/assets/*"],
"headers": [{ "path": "/*", "set": { "Referrer-Policy": "no-referrer" } }],
"redirects": [{ "from": "/old", "to": "/new", "status": 301 }]
}
}
"source"만 필수입니다. 프로젝트 루트에 상대적인 빌드 폴더입니다. "static"의 다른 키는 (아래에서) 서비스 방식을 구성합니다. init은 자동으로 tgcloud.jsonc를 생성합니다. 프런트엔드를 인식하면 "source"를 빌드 폴더로 설정합니다.
| 프런트엔드 | init이 "source"에 설정하는 값 |
|---|---|
| Vite — React, Vue, Svelte, Solid, … | dist |
| Next.js, Nuxt, SvelteKit, React Router, TanStack Start, Angular | 없음 — 먼저 정적 내보내기를 구성한 다음 "source"를 해당 폴더로 직접 설정하세요. |
다른 설정의 경우 빌드 폴더를 "source"로 직접 설정하세요.
$schema 줄은 편집기에 모든 키에 대한 자동 완성 및 유효성 검사를 제공합니다. 파일은 주석이 허용되는 JSON입니다. 다른 도구가 일반 JSON으로 읽어야 하는 경우 tgcloud.json으로 이름을 바꾸면 엄격한 JSON이어야 합니다.
푸시 전에 빌드하세요. push는 디스크상의 폴더를 정확히 그대로 업로드합니다 — 점으로 시작하는 파일이나 폴더는 제외됩니다. 빌드를 실행하지 않으므로 프런트엔드를 편집한 후 npx tgcloud push를 그냥 실행하면 이전 빌드가 배포됩니다. npm run deploy를 사용하세요 (빌드 스크립트를 실행한 다음 tgcloud push를 실행).
서비스 옵션:
spa— 파일 확장자가 없고 경로가 존재하지 않는 경우index.html을 서비스합니다. 따라서 클라이언트 측 라우터가 새로고침 시 작동합니다. 확장자가 있는 경로는 여전히404를 받습니다.immutable— 브라우저에서 1년 동안 캐시할 수 있는 콘텐츠 해시 파일(Vite의/assets/*)의 패턴입니다. 콘텐츠 해시 파일에만 사용하세요. 여기에 일치하는 다른 파일은 브라우저에서 1년 동안 오래된 상태로 유지되며 이를 되돌릴 방법이 없습니다.headers— 규칙이 일치하는 경로(정확한 경로 또는/*로 끝나는 접두사)에 대한 응답 헤더입니다.null값은 헤더를 제거합니다.redirects— 리디렉션 규칙이며, 첫 번째 일치가 우선합니다. 기존 파일은 항상 규칙보다 우선합니다.status는 설정하지 않으면301입니다."static": "dist"— 다른 것이 필요하지 않을 때{ "source": "dist" }의 약어입니다."static": false— 다음 푸시 시 배포된 사이트를 제거합니다. 대신 키를 삭제하면 이 프로젝트가 정적 파일을 관리하지 않게 되고 배포된 내용은 그대로 유지됩니다.
사이트는 자체 호스트의 루트에서 제공되므로 번들러의 기본 base (/)가 올바르게 설정됩니다. Telegram Web은 미니 앱을 iframe으로 열므로 X-Frame-Options 또는 제한적인 frame-ancestors 정책을 추가하지 마세요.
동일한 호스트는
엔드포인트
를 /api/ 아래에서 응답합니다 — Telegram.WebApp.Serverless.call('getProfile', …)이 이동하는 곳입니다. 빌드 폴더의 /api/ 아래에 자체 파일을 두지 마세요.
데이터베이스
모든 봇은 자체 데이터베이스를 받습니다. 이는 호출 간에 지속되고 db를 통해 모든 모듈에서 사용할 수 있는 SQLite 기반 저장소입니다. schema.js에서 작은 형식화된 DSL로 테이블을 설명하고, 유창한 쿼리 빌더로 테이블을 읽고 쓰며, 검토된 마이그레이션으로 테이블을 발전시킵니다.
Drizzle ORM 을 아시나요? 그렇다면 여기서 데이터베이스와 통신하는 방법을 이미 알고 있을 것입니다. 스키마 DSL과 쿼리 빌더는 Drizzle를 따릅니다. 즉, 열 빌더,
select().from().where(), 연산자,onConflictDoUpdate,.returning(),sql태그는 예상대로 작동합니다.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 ms) | 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 대신.
요약:
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' }) // 또는 'virtual'
text('email').deprecated('replaced by login') // 열을 제거 대상으로 표시
json()열의.default()는 값을 자동으로 인코딩합니다.sql…``default는 그대로 전달됩니다..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})`) , // 표현식 인덱스
active: index('idx_active').on(t.userId).where(sql`done = 0`) , // 부분 인덱스
}));
테이블 수준 수정자는 table(...) 뒤에 체인으로 연결됩니다: .strict(), .withoutRowid(), .deprecated('reason').
외래 키 없음
런타임은 PRAGMA foreign_keys 비활성화 상태로 실행됩니다. 선언된 외래 키는 조용히 무시됩니다 — 캐스케이딩도, 고아 보호도 없으며, 이것이 없는 것보다 나쁩니다. 따라서 DSL은 이를 불가능하게 만듭니다. 즉, .references() 및 테이블 수준 foreignKey()는 선언 시 예외를 발생시키며, 이를 사용하는 스키마는 배포되지 않습니다.
일반 열 (userId: integer('user_id'))로 관계를 모델링하고 무결성을 애플리케이션 코드에서 강제해야 합니다. 부모를 자식 앞에 삽입하고, 자식을 부모 앞에 삭제하고, 오류를 올바르게 처리하고, 필요할 때 LEFT JOIN … WHERE parent.id IS NULL로 고아를 청소해야 합니다.
외래 키가 없는 것은 의도적인 제약이지 간과한 것이 아닙니다. 봇을 계획할 때 초기에 이를 고려하세요.
쿼리
db는 유창한 쿼리 빌더입니다. 모든 쿼리는 비동기입니다 — 최종 메서드(.all(), .get(), .values(), .run())는 Promise를 반환하므로 항상 await하세요.
import { db } from 'sdk';
import { eq, and, desc, asc, count, sql } from 'sdk/db';
import { users, todos } from '../schema.js';
await db.select().from(todos).all(); // 모든 행
await db.select().from(todos).where(eq(todos.id, 1)).get(); // 첫 번째 행 또는 null
await db.select().from(todos).values(); // 값 배열로서의 행
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();
// 사용자 정의 프로젝션: { 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();
// 행 수 — 도우미, 빌더 최종 항목이 아님:
await db.$count(todos); // 모든 행
await db.$count(todos, eq(todos.done, false)); // 필터 사용
- 체인 가능:
.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(); // 배치
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, 위치 배열로서의 행에는 db.values.
await db.run('UPDATE todos SET done = 1 WHERE id = :id', { ':id': 5 });
// → { rowsAffected: 3, lastInsertRowid: 0, rows: [] }
await db.all(sql`SELECT * FROM todos WHERE done = ${false}`);
await db.get(sql`SELECT count(*) AS c FROM todos`);
await db.values(sql`SELECT id, text FROM todos`); // → [[1, 'buy milk']]
쓰기는 실행 결과로 해결됩니다 — rowsAffected, lastInsertRowid, 그리고 RETURNING 절이 있는 경우 해당 절을 포함하는 rows. 쿼리가 두 개의 동일한 이름의 열을 프로젝션하는 경우 (예: SELECT u.id, o.id와 같은 조인) db.values를 사용합니다. db.all은 행을 열 이름으로 키화하므로 두 번째 id가 첫 번째 id를 덮어씁니다.
sql`…` 태그는 ${value}를 바인딩된 매개변수로, ${table.column}을 식별자로 변환하며, 중첩된 sql 조각을 분할합니다. 리터럴에 매개변수가 없는 경우 sql.raw('…')를 사용합니다.
원본 쿼리는 테이블에 바인딩되지 않으므로 행은 모드 변환 없이 반환됩니다 — boolean은
0/1, JSON 열은 문자열, 타임스탬프는 숫자입니다. 테이블 바운드 빌더만 값을 변환합니다.
마이그레이션
봇이 성장함에 따라 데이터베이스가 변경됩니다. 플랫폼은 코드 배포와 데이터 변경을 분리하고 각 스키마 변경을 위험도에 따라 분류하여 이를 안전하게 유지합니다.
배포는 데이터베이스를 절대 건드리지 않습니다. 변경된 schema.js를 npx tgcloud push하면 플랫폼이 새 스키마를 기록하고 데이터베이스가 어떻게 변경될지 알려줍니다 — 하지만 아무것도 적용하지 않습니다.
npx tgcloud push # schema.js 배포; 보류 중인 DB 변경 사항 보고
npx tgcloud migrate # 검토 및 적용
npx tgcloud migrate는 스키마와 라이브 데이터베이스 간의 차이를 계산하고 단계별로 안내합니다. 진행률은 [N/M] 카운터로 표시됩니다.
- 모든 보류 중인 변경 사항에 대한 간략한 요약.
- 안전한 변경 사항, 확인 시 한 번에 적용됩니다.
- 경고 (삭제, 느린 작업) , 개별적으로 확인합니다.
- 수동 변경 사항, 이유와 제안된 조치가 표시됩니다. 자동으로 적용되지 않습니다.
- 문서화되지 않은 객체 (데이터베이스에는 있지만 스키마에는 없음) , 인식을 위해 표시됩니다.
모든 변경 사항은 위험도에 따라 다음 상태 중 하나를 갖습니다.
| 상태 | 의미 | migrate에서 |
|---|---|---|
| 안전 | 추가적이고 차단되지 않음 — 새 테이블, 열 또는 인덱스 | 한 번에 하나씩, 확인 후 적용 |
| 경고 | 잠재적으로 파괴적이거나 느림 — 무언가를 삭제하거나 매우 큰 테이블에 대한 인덱스 | 개별적으로, 각 확인 후 적용 |
| 수동 | 자동으로 할 수 없음 — 예를 들어 열 유형 변경 | 지침과 함께 표시; 직접 수행 |
| 문서화되지 않음 | 데이터베이스에 있지만 스키마에는 없음 | 인식을 위해 표시; 적용되지 않음 |
안전한 변경은 빠르고 개념적으로 되돌릴 수 있으므로 함께 처리됩니다. 각 경고는 의도적인 개별 확인입니다 — 파괴적인 변경에 대한 “모두 적용"은 없습니다.
migrate섹션에서 플래그 (--dry-run,--safe,--yes,--local)를 참조하세요.
삭제
schema.js에서 테이블이나 열을 삭제한다고 해서 해당 테이블이나 열이 삭제되는 것은 아닙니다. 이는 실수로 삭제하는 것이 치명적일 수 있기 때문입니다. 무언가를 제거하려면 비활성화 대상으로 표시하세요.
// 열 삭제
text('email').deprecated('replaced by login')
// 테이블 전체 삭제
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, InputFile, BotApiError, EndpointError } from 'sdk'; // 전체 표면
// 또는 하위 모듈에서:
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 → 아래 |
InputFile |
업로드할 파일 (바이트 + 이름) — api 및 fetch 모두에 작동 →
파일
|
EndpointError |
미니 앱의 호출을 메시지로 거부 — 표시 가능 → 엔드포인트 |
자체 프로젝트 모듈은 .js 확장자를 가진 상대 경로로 가져옵니다 (from '../schema.js', 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(); // → 비포장 결과
await api.sendMessage({ chat_id: id, text: '안녕하세요!' });
await api.editMessageText({ chat_id, message_id, text: '업데이트됨' });
await api.answerCallbackQuery({ callback_query_id, text: '완료' });
응답 봉투가 비포장됩니다. Bot API는 일반적으로 응답을 { ok: true, result: … }로 래핑합니다. api는 result를 직접 반환합니다 — getMe()는 사용자 객체로 해결되며, 래퍼로는 해결되지 않습니다. 매개변수는 봇 API 자체의 스네이크_케이스 이름 (chat_id, message_id, reply_markup 등)을 사용합니다.
실패는 BotApiError를 발생시킵니다. Bot API가 { ok: false }를 반환하면, 호출은 falsy 값을 반환하는 대신 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 = 메시지가 이미 사라짐; 여기서는 괜찮습니다.
} else {
throw e;
}
}
파일
file_id뿐만 아니라 바이트로 파일 작업 — 새 파일을 업로드하고 기존 파일을 다운로드합니다.
업로드 — InputFile. InputFile (바이트, 파일 이름, 선택적 MIME type을 가진 간단한 컨테이너)로 원시 바이트를 래핑하고 파일이 필요한 Bot API 메서드 어디에나 전달합니다.
import { api, InputFile } from 'sdk';
const doc = new InputFile(bytes, 'report.pdf', { type: 'application/pdf' });
await api.sendDocument({ chat_id, document: doc });
// 매개변수 내부에 중첩된 파일도 작동합니다 — 예를 들어 앨범:
await api.sendMediaGroup({ chat_id, media: [
{ type: 'photo', media: new InputFile(a, 'a.jpg', { type: 'image/jpeg' }) },
{ type: 'photo', media: new InputFile(b, 'b.jpg', { type: 'image/jpeg' }) },
]});
바이트는 실제 멀티파트 업로드로 Telegram에 스트리밍됩니다 — 파일이 단일 플랫폼 호출에 전체적으로 버퍼링되지 않습니다 — 하지만 Telegram의 Bot API 제한은 여전히 적용됩니다. 봇은 현재 파일당 최대 50MB (그리고 사진은 10MB)를 업로드할 수 있습니다.
다운로드 — file_id로. 두 개의 도우미가 파일의 바이트를 다시 가져옵니다. 플랫폼이 file_id를 자동으로 해결해 주므로 (내부적으로 getFile을 실행하고 콘텐츠를 가져옵니다) 봇 토큰이나 다운로드 URL을 건드릴 필요가 없습니다.
const bytes = await api.getFileContent(file_id); // → Uint8Array (전체 파일)
const { file, body } = await api.getFileStream(file_id); // 대신 스트리밍
file.file_unique_id; // getFile 정보
for await (const chunk of body) { /* Uint8Array */ } // 또는 버퍼링: await stream.bytes()
Telegram의 Bot API는
getFile다운로드를 20MB로 제한하므로 두 도우미 모두 이 제한이 적용됩니다.
HTTP
fetch는 외부 세계 — 타사 API, 웹훅, HTTP를 통한 모든 것 — 를 호출하는 fetch와 유사한 클라이언트입니다.
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();
응답은 웹 플랫폼과 유사합니다: 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의 토큰별 출력을 소비하는 방식입니다.
본문 도우미는 일치하는 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
요청 본문은 파일 및 원시 바이트도 전달할 수 있습니다. Uint8Array, InputFile 또는 FormData( multipart/form-data용)를 body로 전달하면 바이트가 스트리밍됩니다 — 따라서 Telegram에 파일을 보내는 것과 동일한 방식으로 타사 API에 업로드할 수 있습니다.
import { fetch, FormData, InputFile } from 'sdk';
const form = new FormData();
form.append('file', new InputFile(bytes, 'photo.jpg', { type: 'image/jpeg' }));
form.append('caption', 'from my bot');
const res = await fetch('https://api.example.com/upload', { method: 'POST', body: form });
기본적으로 표준 fetch와 동일하게 작동하며, 두 가지 제약 조건이 있습니다.
- 응답은 텍스트로 읽힙니다 —
await res.json()/await res.text(), 또는res.body로 청크별 스트리밍. - 총 응답은 30MB로 제한됩니다. 이 제한은 전체 응답을 포함합니다 —
res.body로 스트리밍하면 대용량 본문을 점진적으로 처리할 수 있지만, 제한을 높이지는 않습니다.
로깅
표준 전역 console을 사용할 수 있습니다 — 가져올 것이 없고, 다른 JavaScript와 마찬가지로 그냥 존재합니다. 출력은 캡처되어 npx tgcloud run에 표시되며, 개발 중 기본 디버깅 도구가 됩니다.
console.log('processing', { chatId: id }); // 로그 / 디버그 — 일반
console.info('started'); // info — 파란색
console.warn('rate limited'); // warn — 노란색
console.error(err); // error — 빨간색, 스택 추적 포함
각 줄은 발생한 [file:line]으로 태그됩니다. console.error 및 console.trace는 전체 스택을 추가하는 반면 console.warn은 추가하지 않습니다. npx tgcloud run으로 모듈을 실행하면 이러한 줄은 각 수준별 색상 접두사, 실행 시작부터 경과된 시간 및 출처와 함께 인쇄됩니다 —
run
을 참조하세요.
명령줄 인터페이스
tgcloud는 프로젝트 폴더와 클라우드 간의 다리입니다. 프로젝트를 스캐폴딩하고, 변경된 내용을 보여주고, 배포하고, 모듈을 실행하고, 데이터베이스 마이그레이션을 적용합니다.
Node.js
18 이상이 필요합니다. 두 가지 방법으로 설치할 수 있습니다.
# 권장 — CLI가 설치된 프로젝트 생성:
npm create @tgcloud/bot example_bot
# 또는 CLI를 전역으로 설치하고 빈 폴더를 초기화:
npm install -g @tgcloud/cli
tgcloud init
npm 패키지는 @tgcloud/cli이며 설치되는 명령은 tgcloud입니다. 모든 명령은 프로젝트의 모든 하위 폴더에서 작동합니다.
| 명령 | 목적 |
|---|---|
init
|
현재 폴더에 새 프로젝트 스캐폴딩 |
add
|
새 모듈 스캐폴딩 (핸들러, 엔드포인트 또는 lib 모듈) |
login
|
프로젝트를 봇에 연결 (토큰 저장) |
status
|
로컬 변경 사항과 클라우드 비교 |
diff
|
줄별 변경 사항 표시 |
push
|
변경된 모듈과 미니 앱 빌드를 클라우드에 배포 |
migrate
|
스키마 변경 사항을 데이터베이스에 적용 |
run
|
배포 없이 플랫폼에서 모듈 실행 |
fetch
|
로컬 파일을 변경하지 않고 배포된 내용 확인 |
pull
|
로컬 파일을 클라우드와 동기화 |
reset
|
로컬 변경 사항 삭제; 클라우드 상태에서 복원 |
webhook
|
플랫폼 관리 웹훅 검사 및 재동기화 |
completion
|
셸 완료 스크립트 출력 (bash/zsh/fish) |
upgrade
|
0.2.0 이전 프로젝트 레이아웃을 tgcloud/로 이동 |
인증
프로젝트는 토큰으로 하나의 봇에 연결되며, 토큰은 app<id>:<secret> 형식을 가집니다. app<id> 부분은 공개될 수 있으며 인쇄될 수 있습니다. 비밀은 로그나 오류에 절대 나타나지 않습니다.
토큰은 다음 순서로 확인됩니다.
TGCLOUD_TOKEN환경 변수 — CI용; 디스크에 기록되지 않습니다..tgcloud/credentials—npx tgcloud login으로 작성됩니다.- 둘 다 아님 →
npx tgcloud login을 가리키는 오류.
CLI는 명령 중에 토큰을 요청하지 않습니다. 로그인은 항상 명시적인 login 단계입니다. 저장된 토큰이 유효하지 않게 되면 CLI가 제거하고 다시 login하라고 요청합니다.
init
npx tgcloud init
현재 디렉토리에 새 프로젝트를 스캐폴딩합니다. tgcloud/ 폴더에는 schema.js, lib/, handlers/ 및 시작 핸들러, 그리고 tgcloud.jsonc, AGENTS.md, docs/, .tgcloud/ 상태 폴더가 있습니다. 기존 프런트엔드 프로젝트의 루트에서도 실행하면 tgcloud/ 폴더와 문서를 추가하고 파일을 덮어쓰지 않습니다. 프런트엔드를 인식하면 tgcloud.jsonc를 빌드 폴더로 지정하고 package.json에 deploy 스크립트(빌드 후 푸시)를 추가합니다 — 이미 deploy 스크립트가 없는 경우에만 해당됩니다.
미니 앱 프런트엔드
를 참조하세요.
init은 다른 프로젝트 내부에 프로젝트를 생성하는 것을 거부합니다. 프로젝트 자체의 루트에서 다시 실행하는 것은 괜찮으며 누락된 것만 추가합니다.
add
npx tgcloud add <target>
연결되고 편집 준비가 된 단일 새 모듈 (핸들러, 엔드포인트 또는 lib/ 모듈)을 스캐폴딩합니다.
npx tgcloud add handlers/callback_query # 새 업데이트 핸들러
npx tgcloud add endpoints/getProfile # 미니 앱용 새 엔드포인트
npx tgcloud add lib/cart # 새 공유 모듈
add는 기존 파일을 덮어쓰지 않는다는 점에 유의하세요.
<target>은 모듈의 경로입니다 (마지막 .js는 선택 사항). handlers/의 경우 이름은 Telegram 업데이트 유형이어야 합니다. 플랫폼이 유효한 세트를 광고하므로 잘못된 이름은 즉시 거부됩니다. handlers/ 및 endpoints/는 플랫하고, lib/는 중첩될 수 있습니다 (lib/payments/stripe).
모듈 이름은 필수입니다. 디렉토리만 제공하는 것은 오류입니다 — 하지만 도움이 되는 오류입니다. handlers/의 경우, 이미 없는 업데이트 유형 목록을 표시하므로 하나를 복사할 수 있습니다.
$ npx tgcloud add handlers
Error: Specify a name, e.g. "handlers/callback_query". Available handlers/ types: callback_query, inline_query, chat_member, …
<Tab>자동 완성은 동일한 세트를 제공합니다 —completion을 참조하세요.
생성된 파일에는 라이브 export default가 있으므로 핸들러는 배포하는 즉시 활성화됩니다 — 주석 해제할 것이 없습니다.
push
로 새 모듈을 배포하세요.
login
npx tgcloud login
CLI 액세스 토큰을 요청합니다 —
@BotFather
→ 봇 → Serverless → CLI Access → Access token 에서 가져옵니다. 이는
봇 API 토큰
과는 별도의 토큰입니다 — 토큰을 확인하고 .tgcloud/credentials에 저장합니다.
login은 토큰을 요청하는 유일한 명령이며 대화형 터미널이 필요합니다. CI에서는 대신TGCLOUD_TOKEN을 설정하세요.
status
npx tgcloud status
파일별로 작업 디렉토리와 배포된 복사본 간에 변경된 내용을 보여줍니다: 수정됨, 새로 추가됨, 삭제됨, 변경 없음. 마지막 push, pull 또는 fetch 시점의 클라우드 상태를 비교하여 오프라인으로 작동합니다. 또한 tgcloud/ 내의 배포되지 않을 .js 파일에 대한 경고도 표시합니다.
미니 앱이 있는 프로젝트의 경우 status는 배포된 후 빌드 폴더의 변경 사항도 보여줍니다.
diff
npx tgcloud diff
status와 유사하지만 변경된 모듈에 대한 실제 줄별 차이를 보여줍니다. 오프라인으로 작동합니다.
push
npx tgcloud push [files...]
프로젝트를 클라우드에 원자적으로 배포합니다.
인수 없이 호출하면 전체 프로젝트를 배포하고, 배포된 상태는 폴더를 정확히 반영합니다 — 로컬에서 삭제한 모듈은 클라우드에서도 제거됩니다.
파일 또는 디렉토리 인수 (npx tgcloud push tgcloud/handlers/message.js, npx tgcloud push handlers/) 와 함께 호출하면 해당 파일의 변경 사항만 배포됩니다. 대상 푸시는 다른 모듈을 삭제하지 않습니다. 대상은 파일 경로 (tgcloud/handlers/message.js), tgcloud/ 내부 경로 (handlers/message.js), 또는 모듈 이름 (handlers/message)으로 철자할 수 있습니다 — 모두 동일한 모듈을 의미합니다.
옵션으로 --force가 있습니다 — 동시성 확인을 건너뛰고 클라우드 내용을 덮어씁니다. 확신할 때만 사용하세요 (
동기화 유지
참조).
tgcloud.jsonc가 정적 폴더를 명시하는 경우, 전체 push는 이를 동일한 배치로 배포합니다 — 변경된 파일만 업로드되며 — 사이트 주소를 출력합니다. push는 빌드를 실행하지 않으므로 먼저 빌드하세요 (npm run deploy는 둘 다 실행). npx tgcloud push dist/는 정적 파일만 배포하며, 모듈만 명시하는 푸시는 이를 그대로 둡니다.
배포 후, schema.js가 변경되었고 데이터베이스가 동기화되지 않은 경우 push는 보류 중인 변경 사항 요약을 인쇄하고 npx tgcloud migrate를 제안합니다. 자체적으로 적용하지는 않습니다.
migrate
npx tgcloud migrate
스키마 변경 사항을 데이터베이스에 적용합니다. schema.js와 라이브 데이터베이스 간의 차이를 계산한 다음 단계별로 안내합니다. 진행률은 [N/M] 카운터로 표시됩니다.
- 모든 보류 중인 변경 사항에 대한 간략한 요약.
- 안전한 변경 사항, 확인 시 한 번에 적용됩니다.
- 경고 (삭제, 느린 작업) , 개별적으로 확인합니다.
- 수동 변경 사항, 이유와 제안된 조치가 표시됩니다. 자동으로 적용되지 않습니다.
- 문서화되지 않은 객체 (데이터베이스에는 있지만 스키마에는 없음) , 인식을 위해 표시됩니다.
마지막에는 요약이 나옵니다. 적용됨, 건너뜀, 수동 수정 대기 중, 스키마에 없음.
마이그레이션 섹션에서 모델을 참조하세요.
옵션: --dry-run (모든 것을 인쇄하고 아무것도 적용하지 않음), --safe (안전한 변경 사항 자동 적용, 경고 건너뜀), --yes (안전한 변경 사항 및 모든 경고 자동 적용, 수동 건너뜀 — 주의해서 사용) , --local (배포된 스키마 대신 로컬 schema.js와 비교). 플래그가 없으면 migrate는 터미널을 요구하고 대화형이 아닌 환경에서는 추측하는 대신 오류를 발생시킵니다.
run
npx tgcloud run <module> [args] [--ctx <json5>]
배포 없이 플랫폼에서 핸들러 또는 엔드포인트를 실행합니다. 현재 로컬 파일을 사용합니다. 이는 로직 테스트를 위한 빠른 내부 루프입니다.
<module>— 기본 이름 (handlers/및endpoints/에서 검색) 또는handlers/message와 같은 경로입니다. 두 디렉토리에 모두 존재하는 기본 이름은 디렉토리와 함께 철자해야 합니다.[args]— 핸들러에 전달되는 페이로드, JSON5 형식으로 작성되어 따옴표를 생략할 수 있습니다. 핸들러가 받는 업데이트 유형 객체 (예:handlers/message의 Message) 또는 엔드포인트의input이며 객체여야 합니다 — 배열이나 기본 문자열이 아닙니다.--ctx <json5>— 모듈의 컨텍스트 객체 (두 번째 인수) , JSON5 형식입니다. 이를 사용하여 코드에서ctx를 읽는 것을 제공합니다 — 핸들러의 원시 업데이트,--ctx '{ update: { update_id: 1 } }', 또는 엔드포인트의 init 데이터,--ctx '{ initData: { user: { id: 1 } } }'.run은 테스트하는 것이 로직이지 플랫폼의 확인이 아니므로 이를 확인하지 않습니다.
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hi" }'
플랫폼은 로컬 프로젝트에서 조립된 모듈 공간 (따라서 로컬에서 변경된 lib/ 코드도 사용됨)을 사용하여 모듈을 실행하고 반환 값, console.*로 기록된 모든 것 및 경과 시간을 반환합니다. 큰 인수는 파일을 통해 읽습니다 npx tgcloud run handlers/message "$(cat message.json5)".
fetch
npx tgcloud fetch
로컬 파일을 변경하지 않고 현재 배포된 내용을 확인합니다. 이후 status 및 diff는 최신 클라우드 상태와 비교합니다. 충돌을 확인하고 해결 방법을 결정할 때 유용합니다.
pull
npx tgcloud pull
작업 파일을 배포된 상태로 업데이트하여 로컬 프로젝트를 클라우드와 동기화합니다.
pull은 미니 앱의 빌드 폴더를 절대 쓰지 않습니다.
reset
npx tgcloud reset
로컬 변경 사항을 삭제하고 마지막으로 알려진 클라우드 상태에서 모듈을 복원합니다. 실험을 버릴 때 사용하세요. 미니 앱의 빌드 폴더는 건드리지 않습니다.
webhook
npx tgcloud webhook
npx tgcloud webhook sync [--drop-pending]
Telegram은 웹훅을 통해 봇에 업데이트를 전달하며, 이는 플랫폼에서 관리합니다 — 손으로 직접 가리킬 필요가 없습니다. npx tgcloud webhook은 URL, allowed_updates 목록, 대기 중인 업데이트 수, 마지막 전달 오류 (있는 경우), 그리고 핸들러와 동기화 상태인지 여부를 보여줍니다.
“동기화됨”은 웹훅이 플랫폼을 가리키고 allowed_updates가 배포된 핸들러와 일치함을 의미합니다 — 따라서 Telegram은 처리하는 업데이트 유형만 전달하고 나머지는 전달하지 않습니다. 새 핸들러를 배포하거나 제거하면 웹훅이 동기화되지 않을 수 있으며, 새로 고칠 때까지는 그렇습니다. npx tgcloud status도 이를 표시합니다.
npx tgcloud webhook sync는 이를 수정합니다 — 웹훅을 플랫폼으로 다시 가리키고 배포된 핸들러에서 allowed_updates를 다시 빌드합니다. --drop-pending을 추가하면 동기화 전에 Telegram이 이미 큐에 넣은 업데이트를 삭제합니다 (그렇지 않으면 웹훅이 다시 정상 상태가 되면 전달됩니다).
completion
참고: 탭 자동 완성은
tgcloud명령만PATH에 있을 때 작동합니다 — 따라서 전역으로 설치하거나 (npm install -g @tgcloud/cli) 이진 파일을PATH에 두어야 합니다.npx와는 통합될 수 없습니다.
tgcloud completion <bash|zsh|fish>
표준 출력으로 셸 완료 스크립트를 출력합니다. 한 번 활성화하면 <Tab> 키로 명령, 플래그, 모듈 디렉토리, 아직 없는 핸들러 업데이트 유형, 그리고 로컬 실행 가능한 모듈을 자동 완성합니다.
# bash — bash-completion 패키지가 필요합니다:
echo 'eval "$(tgcloud completion bash)"' >> ~/.bashrc
# zsh — ~/.zshrc에 `autoload -U compinit && compinit`가 실행되는지 확인하세요:
echo 'eval "$(tgcloud completion zsh)"' >> ~/.zshrc
# fish:
tgcloud completion fish > ~/.config/fish/completions/tgcloud.fish
이후 셸을 다시 시작하거나 (또는 파일을 다시 소싱) 하세요. 셸 없이 tgcloud completion을 실행하면 이 지침이 다시 표시됩니다.
upgrade
npx tgcloud upgrade [--dry-run]
CLI 0.2.0 이전 버전에서 생성된 프로젝트의 일회성 마이그레이션입니다. schema.js, lib/ 및 handlers/를 프로젝트 루트에 유지했습니다. 이 명령은 이를 tgcloud/로 이동시킵니다. 봇과의 연결은 유지되며 다시 배포할 필요가 없습니다. --dry-run은 아무것도 건드리지 않고 계획된 이동을 인쇄합니다. 다른 명령은 업그레이드할 때까지 이전 레이아웃에서는 실행되지 않습니다.
동기화 유지
모든 프로젝트는 클라우드에 단조적으로 증가하는 리비전을 가지며, 각 배포마다 증가합니다. CLI는 마지막으로 동기화한 리비전을 기억하고 각 push 시마다 이를 보냅니다. 클라우드가 앞으로 나아갔다면 — 다른 기기나 팀원이 배포했기 때문 — 푸시는 거부되며, 실수로 다른 사람의 작업을 덮어쓰는 대신 CLI가 세 가지 방법을 제공합니다.
npx tgcloud fetch # 클라우드에서 변경된 내용 확인, 그런 다음 다시 확인
npx tgcloud pull # 최신 버전을 작업 파일로 가져오기
npx tgcloud push --force # 클라우드 상태 덮어쓰기 (위험)
이 낙관적 동시성 확인 덕분에 잠금 단계 배포 프로세스 없이 팀 전체에서 봇을 공유할 수 있습니다. 명령은 실패 시 0이 아닌 값으로 종료됩니다 — 거부된 배포, 실패한 마이그레이션, 인증 오류, run 중에 오류가 발생한 모듈 — 따라서 스크립트 및 CI 파이프라인에서 깔끔하게 구성됩니다.