Cookiebot

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 caseWhereWhat it really is
Five bot personas (cookiebot, bombot, pawsy, tarinbot, connectbot) chosen by a CLI argument 0..4universal_funcs.py:39-52, COOKIEBOT.py:24-32, start_cookiebot.shone tenant per brand, deployed as separate processes
Skins tied to conventions — Bombot for BrasilFurFest, Pawsy for Pawstral, Tarinbot for SCFurscore_botskins.featuretenant-scoped branding and asset packs
Per-group custom commands fetched from a GCS Custom/ prefixMiscellaneous.py:145-158tenant-supplied custom handlers, loaded at runtime
Per-group feature flags functionsFun, functionsUtility, sfw, languageConfig.java, Configurations.pyper-group overrides of tenant defaults
Partnered-convention commands /bff, /patas, /trex, …Miscellaneous.py:261-323tenant-specific content packs
Locale packs en, pt-BR, esBot/Static/locales/tenant default locale
Publisher broadcasting to approved destinations ("Mercado Furry", "Mural do Cookiebot")Publisher.py, publicador(PTBR).mdcross-tenant federation
One global ownerID for every persona.env, COOKIEBOT.py:89-105the thing that breaks first with more than one operator
Global blacklist and list_groups.json shared by all personasuniversal_funcs.py:307-329shared 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 language

Implemented in cb_core/tenancy.py and migration 0003:

  • tenants is a reference table — a handful of rows, joined on the per-update path, so replication makes every lookup node-local.
  • bots.tenant_id and groups.tenant_id carry the association.
  • tenant_monthly_cost is distributed on group_id and colocated with groups, 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:

  1. A pack adds families; it never replaces a core handler. Removing a core command is disabled_commands on the tenant row, not a different pack.
  2. Packs may not depend on another pack. Shared behaviour graduates into core — i.e. into PACKS["core"].
  3. An unknown pack name falls back to core. A typo on a tenant row must not silently delete commands; it logs packs.unknown and behaves as core.
  4. 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_events row.
  5. A pack that needs new storage adds a migration in cb-api, distributed on group_id like everything else. No per-tenant schema.
  6. Commands a pack adds are registered in command_catalog with their tenant, so /commands stays accurate per tenant. The one exception today is the legacy_custom family, whose 53 trigger names are data (v1's bucket folder names) rather than catalog rows — see docs/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 tenantsTenant-scoped
Media blobs (content-addressed; identical bytes stored once)Media references (media_objects, per group)
CAS / doomlist third-party dataLocal blacklist entries
command_catalog definitionsWhich commands a tenant enables
The Citus cluster and its schemaEvery row, via group_id → tenant_id
LLM providers and the model catalogModel 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-api server 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

StepState
tenants table, registry, per-tenant cost rolluplanded (migration 0003, cb_core/tenancy.py)
Self-hosted Bot API + polling ingestlanded
Tenant resolution in the gateway middleware (skin → tenant)next, with M1
feature_defaults merged under group configwith M1 config work
Handler packs: resolution, one dispatcher per packM3, alongside x_custom_commands
Per-tenant LLM budget enforcement in the routerM3
Owner model: owner_ids per tenant replacing the global CB_OWNER_IDM3, with x_owner_commands
Websocket ingestM4, only if a tenant actually needs it

Open questions

  1. Billing granularity — is a tenant billed for its groups' LLM spend, or is the budget a safety limit only? tenant_monthly_cost supports either; the enforcement policy is not decided.
  2. 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.
  3. 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.

On this page