Skip to content

Commit 0d1494d

Browse files
authored
ops(broadcast): ghost-user recovery script (#223)
* ops(broadcast): one-shot ghost-user recovery script Targets users registered in the last 12 months who never had a single meme delivered (no row in user_meme_reaction). They were silently locked out by onboarding bugs — most via the kitchen deep_link path that returned before init_user_languages_from_tg_user (fixed forward in PR #222), some via other early-return drift. Sega (#370728472) was case zero — registered 2026-04-01 via ?start=kitchen, no language rows, "мемы кончились" on every /start. After manual backfill + apology DM he immediately produced a healthy session (4 likes / 1 dislike / 7 sent in 22 min, 80% positive). Scope: - 66 candidates total (40 RU, 26 EN) at the time of writing - Filtered: blocked_bot_at IS NULL, type NOT IN blocked/banned/waitlist - Dedup: reuses send_broadcast Redis-set marker per broadcast_id - Default 0.5s delay (~2/s) — very conservative for a small list Run: PYTHONPATH=/src python scripts/broadcast_ghost_recovery.py \ ghost-recovery-2026-05 --dry-run PYTHONPATH=/src python scripts/broadcast_ghost_recovery.py \ ghost-recovery-2026-05 Same shape as scripts/broadcast_wrapped.py — no new infra. * ops(broadcast): drop dead 'banned' filter from ghost recovery query UserType has no 'banned' value (src/tgbot/constants.py) — the filter is a no-op. Drop it so the WHERE clause reflects actual reachable types. Branch was also rebased onto production to pull in PR #222's lazy language-init in handle_start. The recommended SE fix B (repair-on-start) is now active for both new and existing users (start.py:124-125), so the broadcast's "/start" CTA will trigger language backfill and unblock the recommendation queue for ghost recipients. Addresses Staff Engineer review on #223.
1 parent ffc0309 commit 0d1494d

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Broadcast a recovery message to ghost users — registered ≤12 months ago,
3+
never had a single meme delivered (no row in user_meme_reaction), not
4+
blocked. They were silently locked out by onboarding bugs (kitchen
5+
deep_link missed init_user_languages_from_tg_user; April 2026 cohort
6+
hit a 9.4% no-delivery rate vs ~3-5% baseline). PR #222 fixes the leak
7+
forward; this script recovers existing ghosts.
8+
9+
Reuses src.broadcasts.service.send_broadcast — same Redis dedup,
10+
Forbidden handling, rate limiting as broadcast_wrapped.py.
11+
12+
PYTHONPATH=/src python scripts/broadcast_ghost_recovery.py ghost-recovery-2026-05 --dry-run
13+
PYTHONPATH=/src python scripts/broadcast_ghost_recovery.py ghost-recovery-2026-05 --delay 0.5
14+
15+
Re-running with the same broadcast_id is safe (skips already-sent).
16+
"""
17+
18+
import asyncio
19+
import sys
20+
21+
from sqlalchemy import text
22+
23+
from src.broadcasts.service import send_broadcast
24+
from src.database import fetch_all
25+
from src.localizer import ALMOST_CIS_LANGUAGES
26+
27+
MESSAGE_RU = (
28+
"Привет! У нас был баг в боте: мемы тебе не приходили, хотя ты подписался. "
29+
"Только что починили. Жми /start — и поедут. 🍔"
30+
)
31+
32+
MESSAGE_EN = (
33+
"Hey! Sorry — there was a bug in the bot: you signed up but never got any memes. "
34+
"Just fixed it. Hit /start to start the flow. 🍔"
35+
)
36+
37+
38+
def _lang_group(language_code: str | None) -> str:
39+
return "ru" if language_code in ALMOST_CIS_LANGUAGES else "en"
40+
41+
42+
async def get_ghost_users() -> list[dict]:
43+
"""Registered ≤12mo, never delivered a meme, not blocked, not waitlisted."""
44+
return await fetch_all(
45+
text(
46+
"""
47+
SELECT u.id AS user_id,
48+
COALESCE(
49+
(SELECT ul.language_code FROM user_language ul
50+
WHERE ul.user_id = u.id LIMIT 1),
51+
ut.language_code,
52+
'en'
53+
) AS language_code
54+
FROM "user" u
55+
LEFT JOIN user_tg ut ON ut.id = u.id
56+
WHERE u.created_at > NOW() - INTERVAL '12 months'
57+
AND u.blocked_bot_at IS NULL
58+
AND u.type NOT IN ('blocked_bot', 'waitlist')
59+
AND NOT EXISTS (
60+
SELECT 1 FROM user_meme_reaction r WHERE r.user_id = u.id
61+
)
62+
ORDER BY u.created_at DESC
63+
"""
64+
)
65+
)
66+
67+
68+
async def main():
69+
args = [a for a in sys.argv[1:] if not a.startswith("--")]
70+
if not args:
71+
print("Usage: broadcast_ghost_recovery.py <broadcast_id> [--dry-run] [--delay 0.5]")
72+
sys.exit(1)
73+
74+
broadcast_id = args[0]
75+
dry_run = "--dry-run" in sys.argv
76+
77+
delay = 0.5
78+
if "--delay" in sys.argv:
79+
idx = sys.argv.index("--delay")
80+
delay = float(sys.argv[idx + 1])
81+
82+
users = await get_ghost_users()
83+
await send_broadcast(
84+
broadcast_id=broadcast_id,
85+
users=users,
86+
messages={"ru": MESSAGE_RU, "en": MESSAGE_EN},
87+
language_fn=_lang_group,
88+
delay=delay,
89+
dry_run=dry_run,
90+
)
91+
92+
93+
if __name__ == "__main__":
94+
asyncio.run(main())

0 commit comments

Comments
 (0)