ADR-0005 — Anti-detection chat-join policy
**Status.** Accepted.
Status. Accepted. Date. 2026-05-18.
Context
When more than one of our userbots joins the same chat, the chat's admins see two new members. Two members within minutes look natural; two members within seconds, or six members in a day, do not. A diligent admin who notices the pattern can ban every account they recognise — that wipes us out of the chat entirely and may even propagate (admin gossip, screenshots in other communities).
We need three concurrent guarantees:
- Time staggering. After our first userbot enters a chat, the next must wait long enough to be plausible.
- Population cap. Never let more than a small fraction of a chat's recent joins be our userbots.
- Per-chat ban budget. If admins start banning our userbots in a chat, we stop joining that chat immediately — we accept the loss rather than burn the rest of the pool.
Decision — three knobs and one global guard
All four values are parameters in the registry, scoped GLOBAL | CHAT.
| Key | Default | Description |
|---|---|---|
chat_join.min_delay_after_previous_seconds | 14 400 (4 h) | Soft floor between two userbots joining the same chat. |
chat_join.max_userbots_per_chat | 2 | Hard cap on simultaneously-present userbots per chat. |
chat_join.banned_userbot_threshold_to_blacklist | 1 | If >= this many of our userbots get kicked/banned from a chat, the chat is blacklisted for joining (existing subscribers keep what they had via the survivors, if any). |
chat_join.persona_collision_check | true | Refuse to add a second userbot whose persona overlaps the first's beyond a similarity threshold. |
The flow in apps/userbot/joiners/:
- Check
protected_users,consent, target opt-out. - Check
chat_join_state[chat_id]:- if blacklisted → refuse, alert owner.
- if
count_userbots_present >= max_userbots_per_chat→ refuse (delegate to existing userbot for fan-out, if applicable). - if
now < last_join_at + min_delay_after_previous_seconds→ defer (schedule a job at that timestamp).
persona_pickerselects the best fit minus personas already represented in the chat (whenpersona_collision_check=true).verifierconfirms target presence (existing logic).- Humanizer pacing (ADR — humanizer ceilings) applies on top.
The chat_join_state is a small table:
CREATE TABLE chat_join_state (
chat_id BIGINT PRIMARY KEY,
userbot_ids BIGINT[],
last_join_at TIMESTAMPTZ,
banned_userbots BIGINT[],
blacklisted BOOLEAN NOT NULL DEFAULT false,
blacklist_reason TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
When a userbot reports USER_NOT_PARTICIPANT (kick) or CHANNEL_PRIVATE (banned) for a chat where it was previously present:
- Remove from
userbot_ids, add tobanned_userbots. - If
len(banned_userbots) >= banned_userbot_threshold_to_blacklist, setblacklisted = true, emitchat.blacklisted, alert owner. - Emit
chat.userbot_kickedevent.
Decision — fan-out replaces re-join
When the first userbot in a chat is fine, additional subscribers for new targets in that chat reuse the same userbot's stream. Only when the target is in a chat where we have no present userbot do we enter the join flow above. This is the key reason the cap of 2 is plenty: one userbot streams updates for all clients tracking that chat.
Decision — emergency global override
The owner can flip chat_join.max_userbots_per_chat = 0 globally from the admin panel — instant freeze on all new chat joins until the cause of an admin sweep is understood. The setting hot-reloads through the bus; no deploy.
Consequences
- Even in a worst case where one admin starts banning, we lose at most
banned_userbot_threshold_to_blacklistuserbots in that chat before pulling back. - The cap of 2 lets one userbot remain after the first ban — clients still get data — without ever showing more than two of us at once.
- Owners gain a single emergency lever.
See also
- ADR-0004 (resilience)
core/policies/chat_join.pytests/resilience/test_chat_join_policy.py