Multi-tenant
Many bots, one core: how v2 models tenancy that v1 had five names for
v1 already did this — it just had no name for it
Reading COOKIEBOT-Telegram-Group-Bot, the multi-tenant requirements are already
there, implemented five different ways:
| v1 use case | Where | What it really is |
|---|---|---|
Five bot personas (cookiebot, bombot, pawsy, tarinbot, connectbot) chosen by a CLI argument 0..4 | universal_funcs.py:39-52, COOKIEBOT.py:24-32, start_cookiebot.sh | one tenant per brand, deployed as separate processes |
| Skins tied to conventions — Bombot for BrasilFurFest, Pawsy for Pawstral, Tarinbot for SCFurs | core_botskins.feature | tenant-scoped branding and asset packs |
Per-group custom commands fetched from a GCS Custom/ prefix | Miscellaneous.py:145-158 | tenant-supplied custom handlers, loaded at runtime |
Per-group feature flags functionsFun, functionsUtility, sfw, language | Config.java, Configurations.py | per-group overrides of tenant defaults |
Partnered-convention commands /bff, /patas, /trex, … | Miscellaneous.py:261-323 | tenant-specific content packs |
Locale packs en, pt-BR, es | Bot/Static/locales/ | tenant default locale |
| Publisher broadcasting to approved destinations ("Mercado Furry", "Mural do Cookiebot") | Publisher.py, publicador(PTBR).md | cross-tenant federation |
One global ownerID for every persona | .env, COOKIEBOT.py:89-105 | the thing that breaks first with more than one operator |
Global blacklist and list_groups.json shared by all personas | universal_funcs.py:307-329 | shared vs tenant-scoped state, never distinguished |
So the work is not "add multi-tenancy". It is: name the concept, give it one configuration surface, and separate what is genuinely shared (blacklist, CAS data, media blobs) from what must not be (owners, branding, budgets, commands).
The model
A tenant is a bot brand. Not a group, not a customer account.
tenant ──1:N──▶ bots (tokens/skins) ──N:M──▶ groups
│
├── handler pack which commands exist
├── owner_ids who may run owner commands
├── feature_defaults what a group gets before it configures anything
├── llm_overrides which model each task uses, and a monthly budget
├── storage_prefix where its media lives
└── default_locale branding and languageImplemented in cb_core/tenancy.py and migration 0003:
tenantsis a reference table — a handful of rows, joined on the per-update path, so replication makes every lookup node-local.bots.tenant_idandgroups.tenant_idcarry the association.tenant_monthly_costis distributed ongroup_idand colocated withgroups, so per-tenant spend aggregates per shard.
The shard key does not change. group_id stays the distribution column;
tenancy is a logical boundary layered on the physical one. Distributing by
tenant_id instead would be worse: a handful of tenants means a handful of
shards, so the biggest tenant becomes the hot shard and no per-group query gets
faster. Nothing in the query plans changes to add tenancy.
Custom implementations: handler packs
Tenants that need bespoke behaviour get a handler pack: a name on the tenant
row that maps to a set of command families the tenant receives on top of the
core ones. cb_gateway/packs.py is the registry, and a family is gated by one
filter on the router that implements it.
# cb_gateway/packs.py
LEGACY_CUSTOM = "legacy_custom" # v1's Custom/ picture pools
PACKS = {
"core": frozenset({LEGACY_CUSTOM}), # what the Cookiebot brand has always had
"minimal": frozenset(), # a brand that wants none of it
}
# cb_gateway/handlers/custom_command.py
@router.message(PackProvides(LEGACY_CUSTOM), CustomCommandName())
async def custom_command(...): ...This started out sketched as "one dispatcher per pack, built at startup", which
is not what shipped. A filter reaches the same observable rule — a tenant whose
pack lacks the family falls through as if the handler were never registered —
while cb-gateway keeps one Dispatcher, one webhook route and one
resolve_used_update_types() across every skin. Per-pack dispatchers would have
to be built from tenant rows the registry loads lazily, and rebuilt whenever a
tenant's pack changed. The point at which they become the right shape is a pack
that needs to replace a core handler — which rule 1 below says should not
exist.
Rules that keep this from becoming a fork:
- A pack adds families; it never replaces a core handler. Removing a core
command is
disabled_commandson the tenant row, not a different pack. - Packs may not depend on another pack. Shared behaviour graduates into
core — i.e. into
PACKS["core"]. - An unknown pack name falls back to core. A typo on a tenant row must not
silently delete commands; it logs
packs.unknownand behaves as core. - Packs get the same middleware. Dedupe, telemetry and the analytics row are
attached to the dispatcher, not the router, so a pack cannot opt out of
observability or skip the
message_eventsrow. - A pack that needs new storage adds a migration in
cb-api, distributed ongroup_idlike everything else. No per-tenant schema. - Commands a pack adds are registered in
command_catalogwith their tenant, so/commandsstays accurate per tenant. The one exception today is thelegacy_customfamily, whose 53 trigger names are data (v1's bucket folder names) rather than catalog rows — seedocs/contracts/x_custom_commands.md.
This is the supported successor to v1's GCS Custom/ prefix, which had no
review, no tests and no telemetry.
Isolation: what is shared and what is not
| Shared across tenants | Tenant-scoped |
|---|---|
| Media blobs (content-addressed; identical bytes stored once) | Media references (media_objects, per group) |
| CAS / doomlist third-party data | Local blacklist entries |
command_catalog definitions | Which commands a tenant enables |
| The Citus cluster and its schema | Every row, via group_id → tenant_id |
| LLM providers and the model catalog | Model choice, budget, spend |
Blob sharing is deliberate — dedupe is the point — but it means a tenant's blob may outlive its own reference. That is why deletion drops references and GC removes a blob only when the last reference across all tenants is gone.
Transport: why websocket is reserved now
CB_TELEGRAM_INGEST already accepts webhook, polling and websocket, and
cb_gateway/ingest.py has a class for each. The first two are implemented; the
third raises rather than silently receiving nothing.
It is declared early because multi-tenancy is what will need it:
- Self-hosted Bot API per tenant. A tenant running its own
telegram-bot-apiserver can already point us at it (CB_TELEGRAM_API_BASE+CB_TELEGRAM_API_LOCAL), which also lifts the 20 MB download / 50 MB upload caps to 2 GB and removes per-bot rate limits. - Tenants that will not hand over a token. A persistent connection lets a tenant-operated edge push updates to us and receive actions back, without us holding their bot token or exposing a public webhook per tenant.
- Operator console. Live tail of a tenant's updates, and interactive approval of publisher posts, both want a socket rather than polling.
Reserving the enum value now is what keeps main.py from growing a second update
path later; the seam already exists.
Rollout
| Step | State |
|---|---|
tenants table, registry, per-tenant cost rollup | landed (migration 0003, cb_core/tenancy.py) |
| Self-hosted Bot API + polling ingest | landed |
Tenant resolution in the gateway middleware (skin → tenant) | next, with M1 |
feature_defaults merged under group config | with M1 config work |
| Handler packs: resolution, one dispatcher per pack | M3, alongside x_custom_commands |
| Per-tenant LLM budget enforcement in the router | M3 |
Owner model: owner_ids per tenant replacing the global CB_OWNER_ID | M3, with x_owner_commands |
| Websocket ingest | M4, only if a tenant actually needs it |
Open questions
- Billing granularity — is a tenant billed for its groups' LLM spend, or is
the budget a safety limit only?
tenant_monthly_costsupports either; the enforcement policy is not decided. - Group ↔ tenant conflicts — one Telegram group can host two tenants' bots. Today each bot sees the group under its own tenant. Whether config is shared or duplicated in that case needs a product decision.
- Pack distribution — in-repo packages (reviewed, versioned with the core) or separately installed distributions (independent release cadence, weaker review). In-repo until someone actually needs otherwise.