Helios

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:

  1. Time staggering. After our first userbot enters a chat, the next must wait long enough to be plausible.
  2. Population cap. Never let more than a small fraction of a chat's recent joins be our userbots.
  3. 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.

KeyDefaultDescription
chat_join.min_delay_after_previous_seconds14 400 (4 h)Soft floor between two userbots joining the same chat.
chat_join.max_userbots_per_chat2Hard cap on simultaneously-present userbots per chat.
chat_join.banned_userbot_threshold_to_blacklist1If >= 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_checktrueRefuse to add a second userbot whose persona overlaps the first's beyond a similarity threshold.

The flow in apps/userbot/joiners/:

  1. Check protected_users, consent, target opt-out.
  2. 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).
  3. persona_picker selects the best fit minus personas already represented in the chat (when persona_collision_check=true).
  4. verifier confirms target presence (existing logic).
  5. 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 to banned_userbots.
  • If len(banned_userbots) >= banned_userbot_threshold_to_blacklist, set blacklisted = true, emit chat.blacklisted, alert owner.
  • Emit chat.userbot_kicked event.

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_blacklist userbots 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.py
  • tests/resilience/test_chat_join_policy.py

On this page