Sandbox
Driving the real bot by hand against a local Telegram
A Telegram-shaped client and a fake Bot API, so a person can drive the bot by
hand: switch users, join a group, send /rules, press a config button, watch a
sticker flood get deleted. It exists because reading a passing test tells you
the handler did something; this tells you what a user sees.
The important property: the bot is not modified or mocked. cb-gateway points its Telegram API base at the sandbox and uses its polling ingest, so every click runs the production stack — the same routers, filters, middlewares, Postgres and Valkey. If the sandbox shows the wrong thing, the bot is wrong.
The sandbox itself knows nothing about Cookiebot. It is a general-purpose,
open-source tool in its own repository —
Cookiebot-Team/telegram-sandbox,
whose README, Bot API coverage,
configuration reference
and test kit guide
are the reference for everything the tool itself does. It arrives here as a dev
dependency (telegram-sandbox = { git = ... } in pyproject.toml); the web
client is part of that repository too, so cb.py sandbox-web expects it cloned
next to this one.
What makes it this bot's sandbox is one generated file — see Configuration below.
telegram-sandbox/web (Next.js, :3001) ──REST + SSE──► telegram-sandbox (:8083)
├── /bot<token>/<method> ← cb-gateway polls this
└── /api/... ← the client drives this
cb-gateway (unchanged) ──CB_TELEGRAM_API_BASE + CB_TELEGRAM_INGEST=polling──►Running it
python scripts/cb.py sandbox-up # prints the exact wiring for three terminalsIn short: cb.py up (Postgres + Valkey), cb.py sandbox, the gateway with
CB_TELEGRAM_API_BASE=http://localhost:8083 \
CB_TELEGRAM_INGEST=polling \
CB_BOT_TOKENS='{"cookiebot": "424242:SANDBOX"}' \
python scripts/cb.py gatewayand cb.py sandbox-web. Open http://localhost:3001 and press a seed button.
The client is not in this repository. Clone it once, next to this one:
git clone https://github.com/Cookiebot-Team/telegram-sandbox ../telegram-sandboxThe server half needs no clone — cb.py install pulls it from git as a dev
dependency, and cb.py sandbox runs its telegram-sandbox serve entry point.
To pick up a new version of the tool:
uv lock --upgrade-package telegram-sandbox && python scripts/cb.py installThe gateway must be started with both variables. Pointed at the real api.telegram.org it will sit there politely doing nothing, and the UI will look broken for a reason that has nothing to do with the sandbox.
Configuration
sandbox.config.json at the repository root is what turns the generic tool
into Cookiebot's workbench: the bot identity (424242 / CookieMWbot, which
must match CB_BOT_TOKENS' prefix), the seed worlds, every feature from
scripts/spec.py, and the whole command palette.
It is generated, not hand-written:
python scripts/cb.py sandbox-configRe-run it after adding a command alias in cb_core.textmatch.COMMAND_ALIASES
or changing a feature's status in scripts/spec.py. Otherwise the palette and
the feature view describe the bot as it was — worse than describing nothing,
because a tester trusts them.
scripts/cb.py sandbox passes --config explicitly rather than relying on
discovery: the sandbox looks for the file from its working directory upwards,
and a shell started in a subdirectory would silently get the built-in defaults,
with nothing to explain why the bot's username no longer matches.
The one hand-written part of that generator is the seeds and presets at the
bottom of scripts/gen_sandbox_config.py — including doomlist, which seeds a
raider account whose name carries a glyph check_local_blacklist matches
(cb_gateway/handlers/doomlist.py:_FORBIDDEN_NAME_CHARS). That check runs
entirely off User.full_name against a fixed table, so it fires
deterministically the moment the account self-joins, unlike the
cas.chat/burrbot branches of the same handler, which depend on a vendor's
live opinion of a sandbox-only id.
What it is for
| Question | How to answer it here |
|---|---|
Does /rules answer in Portuguese for a pt group? | switch the group's language in /config, send /regras |
| Does an anonymous admin still get the config menu? | press the "Anonymous admin -> /config" preset — the v1 defect the port fixed |
| Does sticker spam actually delete? | send stickers past the limit, watch the API-call log fill with deleteMessage |
| Is a new member's media really restricted? | press "Newcomer media restriction", join, immediately send a photo |
| Does a doomlisted account get banned on join? | press "Doomlisted join", then "+ Join as me" |
| Did the bot answer the callback? | press an inline button, look for answerCallbackQuery in the log |
| Does the image handler read this picture correctly? | attach a real file in the composer — the bot receives real bytes and real dimensions |
The right-hand API call log is the validation surface: it shows what the
bot actually asked Telegram to do, including the calls a chat window cannot
show — restrictChatMember, banChatMember, deleteMessage.
Above it sits the feature rail, which is the other half of the answer: one
row per feature in scripts/spec.py, showing how many scenarios exercised it
and how they ended. The row worth looking for is the one reading untested — a
feature nobody checked looks exactly like a passing feature in every per-test
report ever written.
Running the BDD suite through it
TG_SANDBOX_DB=/tmp/qa-sandbox.duckdb CB_QA_SANDBOX=1 python scripts/cb.py testOpt-in. With CB_QA_SANDBOX unset the acceptance suite runs against
qa/mock_telegram.py exactly as before — same pass count, same speed — because
that suite is the CI gate and must not get slower or flakier to feed a viewer.
With it set, the scenarios drive tg_sandbox instead, so the run leaves a
DuckDB file you can open (or point the sandbox server at) and read back what
the scenarios actually did.
Known gap, worth understanding before reading a failure here: this mode feeds
updates straight into the aiogram dispatcher rather than through the control
plane, so qa/sandbox_harness.py mirrors by hand everything the control plane
would otherwise have recorded — the join/leave service message a captcha
replies to, the callback-query id answerCallbackQuery is validated against.
Three core_welcome scenarios still fail in this mode, and they fail for a
real reason: WELCOME_PROMPT contains a literal <user>, the bot's
default parse mode is HTML, and real Telegram rejects <user> as an
unsupported start tag. qa/mock_telegram.py never parsed entities, so it never
noticed. That is the sandbox doing exactly the job it exists for; the fix
belongs in welcome.py, not here.
End-to-end suite
qa/e2e/ runs cb-gateway and telegram-sandbox as two real subprocesses over HTTP and
uses the sandbox's own pytest plugin (tg_sandbox.testkit) for scenario
bookkeeping — this repository no longer carries its own copy. Each module
declares which feature it exercises (_MODULE_FEATURES in
qa/e2e/conftest.py), each test is tagged with its language, and the run
leaves sandbox-e2e.duckdb behind:
python scripts/cb.py test-e2e
TG_SANDBOX_DB=sandbox-e2e.duckdb python scripts/cb.py sandbox # then open :3001See E2E.md.
Shape
What lives here, now that the tool is its own repository:
sandbox.config.json generated — identity, seeds, features, commands, presets
scripts/gen_sandbox_config.py the generator (reads COMMAND_ALIASES and spec.py)
scripts/cb.py sandbox… the four tasks that wire it to this bot
qa/sandbox_harness.py acceptance suite driven through the sandbox, in-process
qa/e2e/ the real two-subprocess suiteEverything else — the server, the Bot API surface, the web client, the test kit — is in telegram-sandbox. A behaviour that is wrong in the sandbox is a bug to file there; a behaviour that is wrong in the bot is one to fix here. Telling them apart is what the Bot API coverage document is for.
Reset clears the world and reseeds the default. It is meant to be pressed
often, and it is safe: the update-id counter deliberately survives it, because
cb-gateway's Valkey dedupe has no idea the sandbox was reset and would
otherwise drop the next batch as redeliveries — see
SandboxStore.next_update_id, which is the single most confusing failure this
tool can produce if it ever goes backwards.