A local Telegram your bot can actually talk to.
A real Bot API surface, a control plane, a Telegram-shaped web client and a pytest test kit — so you can drive your bot by hand, or in CI, and see exactly what it asked Telegram to do.
A passing unit test tells you a handler ran. This tells you what a user
sees — and, through the API-call log, what the bot actually asked Telegram to
do, including the calls a chat window can never show you: deleteMessage,
restrictChatMember, banChatMember, answerCallbackQuery.
Nothing about your bot is mocked or modified. Point its API base at the sandbox, tell it to long-poll, and every message you send from the web client runs your production stack — the same routers, the same middlewares, the same database.
web client (:3001) ──REST + SSE──► telegram-sandbox (:8083)
├── /bot<token>/<method> ← your bot polls this
└── /api/... ← the client and the test kit drive this
your bot (unchanged) ──API base = http://localhost:8083, long polling──►
- Quick start · The web client · The test kit
- Making it your bot's sandbox · Seeing your tests by feature
- Bot API coverage · Configuration reference · Test kit · Control API
pip install "telegram-sandbox @ git+https://github.com/Cookiebot-Team/telegram-sandbox"
telegram-sandbox serve # :8083With no configuration at all you get a working world: a group, the bot as its administrator, a creator, a plain member, an admin with anonymity switched on, and a private chat. That is enough to drive most of a group bot by hand.
Then point your bot at it. With aiogram:
from aiogram import Bot
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
session = AiohttpSession(api=TelegramAPIServer.from_base("http://localhost:8083"))
bot = Bot(token="424242:SANDBOX", session=session)
# ...and start polling, not webhooks.With python-telegram-bot:
app = (
ApplicationBuilder()
.token("424242:SANDBOX")
.base_url("http://localhost:8083/bot")
.base_file_url("http://localhost:8083/file/bot")
.build()
)
app.run_polling()Important
The token's numeric prefix must match the sandbox's configured bot id
(default 424242). Most client libraries derive bot.id from that prefix
without ever calling getMe, so a mismatch makes "did I send this message?"
answer differently on the two sides of the same message.
GET /healthz reports which config it loaded and which bot it thinks it is —
the two facts behind almost every "the sandbox is running but the bot does
nothing" report. telegram-sandbox config answers the same question without
binding a port.
| Variable | What it overrides |
|---|---|
TG_SANDBOX_CONFIG |
The config file path (an explicit path that doesn't exist is an error, not a fallback) |
TG_SANDBOX_DB |
Where the DuckDB file lives (default sandbox.duckdb) |
TG_SANDBOX_BOT_ID / _BOT_USERNAME / _BOT_FIRST_NAME |
Identity, per run |
TG_SANDBOX_CORS_ORIGINS |
Extra browser origins allowed to drive /api/... |
A Telegram-Desktop-shaped UI in web/ (Next.js + Tailwind): switch
users, join a group, send a command, press an inline button, attach a real
photo, watch a sticker flood get deleted.
cd web
bun install
bun run dev # :3001, proxies /api/* to the sandboxThree panes, each answering a different question:
| Pane | Question it answers |
|---|---|
| Chat timeline | What does a user actually see? Real media, real entities, real inline keyboards |
| API-call log | What did the bot ask Telegram to do? Filterable by method, every payload expandable — including the calls a chat window cannot show |
| Feature rail | Which behaviours were exercised, and how did they end? The row worth looking for reads untested |
Plus: seed presets (one click puts you in front of a specific question), a
command palette built from your parser, an anonymous-admin switch, ×N
repeat sends for flood limits, and Alt+… shortcuts for everything. See
web/README.md.
Nothing in the client knows which bot it is driving — identity, seeds,
features, presets and the whole palette arrive at runtime from GET /api/kit.
Installing the package registers a pytest plugin. No conftest wiring:
import pytest
from tg_sandbox.testkit import calls_to, wait_for
@pytest.mark.feature("rules")
def test_rules_answers(sandbox, sandbox_bot_id):
chat = sandbox.create_chat("rules test")
user = sandbox.create_user("Ana", "ana")
sandbox.join(chat["id"], user["id"])
sandbox.join(chat["id"], sandbox_bot_id)
since = len(sandbox.state()["api_calls"])
sandbox.send_message(chat["id"], user["id"], text="/rules")
wait_for(
lambda: next(iter(calls_to(sandbox.state(), "sendMessage", since)), None),
timeout=10,
description="answer /rules",
)The fixtures start (or reuse) a sandbox, open one scenario per test, and close it with the test's real outcome — so the run leaves a DuckDB file you can reopen in the web client afterwards and read back what your suite actually did, filterable to one test. Full guide: docs/TESTKIT.md.
Drop a sandbox.config.json in your repository root. Everything in it is
optional; what you leave out keeps the built-in default.
Discovery walks up from the working directory; TG_SANDBOX_CONFIG (or
--config) beats it and is what a process launcher or a test session should
set. Full reference, including every field and how to generate the file from
your own command parser: docs/CONFIGURATION.md.
A per-test result list answers which check failed. It cannot answer is this behaviour correct, because that is a question about one feature and every scenario that touched it — and it cannot answer did we check this at all, because a feature nobody exercised has no row in a report of tests that ran.
So the sandbox records scenarios (a named span of activity; every message
and API call made while one is active carries its id) and files each under a
feature. GET /api/features returns one row per declared feature with the
run folded in:
{
"id": "rules", "title": "Group rules", "status": "done",
"scenario_ids": ["test_rules.test_pt", "test_rules.test_en"],
"scenario_count": 2,
"status_counts": { "passed": 1, "failed": 1 },
"message_count": 8, "api_call_count": 6
}In the web client that becomes the top pane, sorted so failures and untested rows come first. A scenario gets its feature from what the caller set, or from any of its tags matching a declared feature — which is what lets an existing suite light up without being rewritten.
The sandbox exists to make testing trustworthy: if it disagrees with real
api.telegram.org, a scenario that passes here proves nothing about
production. So every response payload is validated against aiogram's own
pydantic models in tests/test_telegram_api.py — not eyeballed against the
docs, because a fake Bot API that is only eyeballed eventually certifies a
broken bot.
49 methods, with the semantics handlers branch on: parse_mode genuinely
parsed into text + entities (UTF-16 offsets), confirm-by-offset
getUpdates with 409 on a second poll, real permission implications on
restrictChatMember, the real copyMessage/forwardMessage difference, per
(scope, language) command storage, real media bytes with sniffed dimensions.
Every divergence and every unimplemented family is written down, with reasons: docs/BOT-API.md.
src/tg_sandbox/
config.py what makes this *your* bot's sandbox — identity, seeds, features, commands
state.py users, chats, messages, files, the update queue — the shared world
files.py real media bytes, content-addressed, with mime/dimension sniffing
telegram_api.py /bot<token>/<method> — the surface your bot consumes
control_api.py /api/... — the surface the client and the test kit drive, plus SSE
persistence.py the durable DuckDB copy, so a run outlives the process
testkit/ SandboxClient, SandboxProcess, and the pytest plugin
cli.py `telegram-sandbox serve` / `config`
app.py assembly
web/ the Telegram-shaped client (Next.js + Tailwind, bun)
tests/ 259 tests, including the aiogram-model payload validation
State lives in memory for reads and in DuckDB for durability, so a run is an artefact you can reopen. A failed write logs and carries on in memory: a workbench that refuses to run because its notebook is locked is worse than one that forgets a row.
uv sync --all-groups
uv run pytest # 259 tests, no network, no database
uv run ruff check . && uv run ruff format --check .
uv run mypy -p tg_sandboxSee CONTRIBUTING.md — in particular for how to add a Bot API method, which is one handler plus one payload test.
Apache 2.0 — see LICENSE.
{ "bot": { "id": 424242, "username": "my_bot", "first_name": "My Bot" }, // Named starting worlds. One per situation worth reaching in one click. "seeds": [{ "name": "default", "title": "Group with an anonymous admin", "users": [{ "key": "alice", "first_name": "Alice", "username": "alice" }], "chats": [{ "key": "main", "title": "Test Group", "bot_role": "administrator", "members": [{ "user": "alice", "role": "creator" }] }] }], // What the bot does, as a validator would name it — the axis a whole test // run gets grouped by. "features": [{ "id": "rules", "title": "Group rules", "status": "done", "commands": ["/rules"] }], // The command palette. Generate it from your own parser if you can. "commands": [{ "primary": "/rules", "aliases": ["/regras"], "feature_id": "rules" }], // One click that puts a tester in front of a specific question. "presets": [{ "id": "anon-admin", "button": "Anonymous admin sends a command", "seed": "default", "what_to_do": "Acting as Carol, send /rules.", "what_to_look_for": "It should be accepted — an anonymous admin arrives as GroupAnonymousBot." }] }