Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

telegram-sandbox

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.

Python 3.13+ License: Apache 2.0 Bot API coverage Framework: pytest


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──►

Contents


Quick start

pip install "telegram-sandbox @ git+https://github.com/Cookiebot-Team/telegram-sandbox"
telegram-sandbox serve            # :8083

With 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.

Environment

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/...

The web client

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 sandbox

Three 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.


The test 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.


Making it your bot's sandbox

Drop a sandbox.config.json in your repository root. Everything in it is optional; what you leave out keeps the built-in default.

{
  "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."
  }]
}

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.


Seeing your tests by feature

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.


Bot API compatibility

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.


How it is put together

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.

Development

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_sandbox

See CONTRIBUTING.md — in particular for how to add a Bot API method, which is one handler plus one payload test.

Licence

Apache 2.0 — see LICENSE.

About

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.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages