Appearance
Phase 4 — Targeted Telegram Outreach
Status: implemented, GATED OFF. The engine, templates, inline-button handlers (surveys + help-ticket + CTAs), the scheduled batch Lambda, and the admin Bot Messages editor are all built. Nothing sends to a user until an operator (a) turns a per-segment template ON and (b) flips OUTREACH_ENABLED=true — and the EventBridge schedule ships DISABLED. Until then, scheduled runs are dry-run only; the admin's manual send delivers to a single chat for staging validation.
Goal: turn each computed segment (from the nightly classifier, src/utils/database/segments.mjs) into the right message to the right admin, in Telegram, with 1-tap surveys and a real support path — to recover stuck groups, nudge free-with-value groups, and learn why groups don't convert. Objective: increase free→paid conversion.
Prime directive (this is a live, paying product)
Every send path is gated OFF by default via OUTREACH_ENABLED (env, default "false"). When off, the engine runs in dry-run: it logs exactly what it would send and writes nothing to any user. Nothing reaches a real group until the flag is flipped, per-segment, after a staging test.
| Control | Mechanism |
|---|---|
| Master kill switch | OUTREACH_ENABLED env (default false → dry-run) |
| Per-segment enable | MESSAGE_TEMPLATES.ENABLED (bool per template) |
| Frequency cap | ≤ 1 outreach / chat / 7d; never send the same template key twice to a chat |
| Don't upsell payers | skip PRO/ENTERPRISE chats for cap/upsell segments |
| Opt-out | SETTINGS.OUTREACH_OPTOUT + a "stop these" survey option |
| Rate limit | 1 msg/sec staggered batch (well under Telegram limits) |
| Staging first | validate on a test group before enabling any prod segment |
1. Data model
MESSAGE_TEMPLATES(table already created in.deploy/IaC/db/db.yml):TEMPLATE_KEY(PK = segment id, e.g.filtered_no_report),BODY_HTML,BUTTONS(JSON: survey options + CTA + help),ENABLED(bool),UPDATED_AT,UPDATED_BY. Hardcoded defaults insrc/utils/outreachTemplates.mjsare the fallback when no row exists.- No new outreach-log table — reuse
EVENTS(src/utils/database/events.mjs):OUTREACH_SENT,SURVEY_SHOWN,SURVEY_RESPONSEtypes already exist. Frequency/dedup reads from these plus two new fields onSEGMENTS:LAST_OUTREACH_AT,LAST_OUTREACH_KEY. - Opt-out: new
SETTINGS.OUTREACH_OPTOUTflag, respected everywhere.
2. Send engine — src/utils/outreach.mjs (new)
Built on the existing rails:
- Sending:
XBotMessages.sendMessage(chatId, html, keyboard)(Telegraf). - Targeting:
SETTINGS.NOTIFICATION_ADMINS(admin user IDs to DM); fallback in-chat if none designated.
Functions:
resolveTemplate(key)→MESSAGE_TEMPLATESrow or hardcoded default.renderVars(chatId)→(and any future vars) fromSETTINGS. No numeric plan/usage values in copy (see §4).buildOutreach(chatId, segment)→ rendered HTML + Telegraf inline keyboard (survey buttons + a CTA deep-link into/buyor/setup+ a💬 Get helpbutton).sendOutreach(chatId, key, { dryRun, force })— the gated core:OUTREACH_ENABLEDcheck → else dry-run (log only).OUTREACH_OPTOUTcheck → skip.- Frequency guard: ≤ 1/chat/7d; never repeat a
key; skip upsell keys for PRO chats. - Resolve target (
NOTIFICATION_ADMINSDM → fallback in-chat). XBotMessages.sendMessage(...).recordEvent(OUTREACH_SENT / SURVEY_SHOWN)+ setSEGMENTS.LAST_OUTREACH_*.- Throttle: global 1 msg/sec stagger across a batch.
3. Micro-surveys + the "Get help" ticket path (inline buttons)
New src/commands/outreach.mjs registers the Telegraf action handlers:
Surveys — buttons carry
callback_data = survey:<key>:<optionId>(e.g. capped →[Too expensive][Don't need more][Missing feature]).bot.action(/^survey:([^:]+):(.+)$/)→recordEvent(SURVEY_RESPONSE,{key,option})→ edit the message to a thank-you. A "Missing feature" tap can open a free-text follow-up; a "stop these" option setsSETTINGS.OUTREACH_OPTOUT.💬 Get help→ real support ticket (not a bare "reply here"). Free-text replies in Telegram get lost, so the help button opens a real ticket using the existing system (src/commands/setup/handlers/support.mjs,src/utils/database/tickets.mjs):bot.action(/^outreach:help:(.+)$/)sets the same ticket-input state the/setup → Help & Support → New ticketflow uses. The admin's next message is captured byapplySupportNewTicket()→ a realTGXBOTTICKETSrow, a ticket id returned, admin-visible in the/ticketsSPA, and DM-tracked on reply.- The ticket is seeded with context:
{ source:'outreach', segment, templateKey }in the payload, so the admin sees "opened from the installed-no-setup nudge" and knows exactly where the group got stuck. - Reuses the existing input-state machine, so it works identically in a group or a DM, survives across messages, and needs no fragile reply-capture logic.
Every template that offers help carries the
💬 Get helpbutton — never a plain-text "reply here and I'll help" invitation.
4. Default template copy (ships in src/utils/outreachTemplates.mjs)
Tone: kind, accurate expectations, no numeric plan/usage values. First pass:
installed_no_setupThanks for adding X Bot to ! 👋 Whenever you're ready, run /setup — it's a quick guided walkthrough where you choose what you'd like to track (a hashtag, cashtag, or an account). Once you've picked your criteria, I'll build your first leaderboard from it.
Buttons:
[ 🚀 Run setup ] [ 💬 Get help ]Rationale: leads with thanks (no guilt framing); makes clear/setupis a guided walkthrough where you choose the criteria first — the leaderboard is built from that choice, not posted automatically. Help routes to a ticket.filtered_no_reportYou're all set up on — reply /report to get your first leaderboard now.
Buttons:
[ 📊 Run report ] [ 💬 Get help ]near_capYour community's on a roll! 🚀 You're getting close to your free monthly limit. Upgrade to PRO to keep your reports flowing without interruption.
Buttons:
[ ⬆️ Upgrade ] [ 💬 Get help ]Rationale: no plan number ("for 1,000" removed) and no usage counts — the message carries no digits at all; it reads as a gentle heads-up, never a metered warning that could quote a stale figure.capped/churned/whale_agency: paywall-on-blocked-report / win-back-with-fresh-data / annual + referral. (Copy TBD, same tone + no digits.)
5. Admin "Bot Messages" page (fills the current stub)
- Template editor per segment: HTML body + variable palette + button builder
ENABLEDtoggle + a fake-Telegram-bubble preview.
- Backend:
GET/PUT /admin/message-templates[/{key}]inXBotAdmin_Handler. - "Send preview to me" (dry-run to the operator's own chat) and, from ChatDetail, a "Send outreach now" one-click (manual, still gated + logged).
- Outreach dashboard: sends + survey responses (from
OUTREACH_SENT/SURVEY_RESPONSEevents).
6. Trigger modes (roll out in this order)
- Manual one-click from ChatDetail/Behavior — safest; operator picks the chat.
- Scheduled batch — new
src/Outreach_Handler.mjsLambda (mirror the classifier Lambda innode-lambdas.yml), daily after the classifier, gated byOUTREACH_ENABLED. Enable per-segment, starting with the two biggest leaks (filtered_no_report,installed_no_setup). - Real-time behavioral (overlaps Phase 5) — e.g. a rescue DM on
SETUP_ERRORED. Hot-path; defer until batch is proven.
7. File-level task list
src/utils/outreachTemplates.mjs— hardcoded default templates (copy in §4).src/utils/outreach.mjs— send engine (§2).src/commands/outreach.mjs—survey:*+outreach:help:*actions (§3).src/Outreach_Handler.mjs— scheduled batch Lambda (gated) + IaC.src/utils/database/messageTemplates.mjs—MESSAGE_TEMPLATESCRUD.src/XBotAdmin_Handler.mjs—message-templatesendpoints + preview/send.admin/src/pages/BotMessagesPage.tsx+admin/src/api/client.tsmethods.SEGMENTSfield additions:LAST_OUTREACH_AT,LAST_OUTREACH_KEY.SETTINGS.OUTREACH_OPTOUTflag + respect it insendOutreach.
8. Success metrics
Outreach reply rate, survey completion, and the real prize — LICENSE_CHANGED within 14d of an outreach per segment (measurable thanks to Phase 1 instrumentation). Kill any template whose segment doesn't lift.
9. Rollout sequence
Build gated → staging-group test → enable manual sends to a handful → enable scheduled for filtered_no_report + installed_no_setup → measure reply + conversion → expand per-segment. Disabling OUTREACH_ENABLED stops everything instantly.