Compass · Instagram DM bot
Every Instagram DM the bot sends, what it looked like before, and what it sends now (shipped 2026-06-05). Two-bubble result DMs are a platform constraint, not a style choice — see section 2b.
Implemented · on main · not yet committedA single shared reel used to trigger 5-6 message bubbles:
pulling photos → places → insight → milestone → new-country → nudge.
Each "rich" DM also split into a text bubble plus a separate button bubble, so the real count was even higher.
Every save resolves to at most three logical DMs, in order:
Got it - pulling the travel spots from this one. One sec (reel) /
Got it - let me pull all the photos. One sec (carousel).milestone > new-country > nudge). The old insight DM is gone — that one-liner now
leads the Result DM as the summary, so it never costs its own bubble.insight field; the fast (webhook) path added a summary field to the
caption-only call. Null → the bubble just opens with the header.A plain text message carries the long content (summary + header + places) up to ~1000 chars, but
cannot have a tappable button. A "special" message (button / generic template) is the only way to
attach a tappable web_url button, but its text is an ~80-char title — it cannot hold the long
places list.
So the button is always its own bubble. A Result DM is physically two bubbles: the plain-text list, then a separate special message carrying just the CTA. Below, the dashed bubble labelled special message is that detached CTA — never part of the text bubble above it.
| Layer | File | What it sent |
|---|---|---|
| Webhook (instant) | webhooks.routes.ts |
carousel pulling photos ack; fast places DM (sets webhookDmSent); acks; dup
replies; OTP / greeting / quick-reply / guest-cap |
| Consumer (~5-45s) | content-consumer.ts |
the fan: places → insight (+500ms) → milestone (+1s) → new-country (+1s) → nudge (+2s).
Follow-ups were not gated by webhookDmSent, so they fired even after the webhook
already replied |
| Transport | instagram.service.ts |
text bubble, wait 500ms, then a separate button bubble — doubles every rich DM |
| Case | Before | Now |
|---|---|---|
| Plain reel, places found | 1-2 (+ up to 4 more) | 2 ack + result |
| Reel + new country | up to 4 | 3 ack + result + moment |
| Reel + milestone | up to 4 | 3 |
| Reel + country + milestone + nudge | up to 6 | 3 (one moment wins) |
| First save ever | 2-4 | 3 |
| Itinerary reel | 2-5 | 2-3 |
| Carousel | 2-6 | 2 ack + result |
| Carousel + milestone | up to 6 | 3 (ceiling) |
| Same-user re-share | 1 | 1 result only |
| Cross-user cache hit | 1-4 | 1 |
| Bookmark / failure / reject | 1 | 1 |
Beforeup to 5 logical DMs
Now2 DMs · 3 bubbles
Now: 2 logical DMs (ack + result) → 3 visible bubbles.
Now2 DMs · 3 bubbles
Now3 DMs · 5 bubbles
The new-country line is the single Moment DM — milestone > new-country > nudge, one wins.
Now3 DMs
Nowwelcome moment
Guests get โจ Open Compass (reveal token) spliced into the CTA instead of the bare collection URL.
Now2 DMs · CTA = 2 buttons in one special msg
Now"5 places to visit in Italy"
Nowack instant, result ~30s later (summary = V8 insight)
Nowcarousel ack already existed — same shape
Nowthe maximum any save reaches
A11 · caption gave โฅ2 spotstext only
A12 · nothing usabletext only
These two are plain text with no button — a single bubble each, no special message.
A13 · same-user re-share1 DM · no ack, no moment
A14 · cross-user cache hit1 DM
Re-shares skip the ack (detected during dedup, before extraction) and carry no Moment DM.
A16 · bookmark1 DM
A17 · extraction failed1 DM
Because the voice stayed sentence case, most copy is unchanged. The real diffs are the new reel ack, the summary lead line, the reworded new-country moment, and Title-Case button labels dropped to sentence case.
| Trigger | Before | Now |
|---|---|---|
| Fresh reel ack (NEW) | none โ result sent inline, no ack | Got it - pulling the travel spots from this one. One sec ๐ธ |
| Carousel ack | Got it - let me pull all the photos. One sec! ๐ธ | Got it - let me pull all the photos. One sec ๐ธ |
| Result DM lead | header first (Found them ๐) | summary sentence first, then header + places |
| Result body extras | First India spots โจ / Gold mine. / 8 India spots total now. | removed — the moment carries that |
| Insight | separate DM, +500ms | folded into the Result summary line |
| Pri | Trigger | DM text | CTA |
|---|---|---|---|
| 1 | Milestone 1 | Your first spot is saved ๐ This is where your collection begins. | ๐ |
| 1 | Milestone 5 | 5 spots and counting. A few more and I can start building you trip itineraries. | ๐ |
| 1 | Milestone 10 | Double digits! Open the app to see these on a map ๐บ๏ธ | ๐ |
| 1 | Milestone 25 | 25 spots across {c} countries. Your itinerary builder has serious material to work with. | ๐ |
| 1 | Milestone 50 | 50 spots. You're building a proper travel guide at this point. | ๐ |
| 1 | Milestone 100 | 100 spots. Genuinely impressed. You might know more hidden gems than most travel bloggers. | ๐ |
| 2 | New country | {flag} That's country #{N} in your collection. (was {flag} Country #{N}!) | ๐บ๏ธ Plan a trip |
| 3 | 15+ saves, 0 trips | You've got {n} spots across {c} countries but no trips yet. Ready to turn these into an itinerary? | ๐บ๏ธ Create a trip |
| 3 | Inactive 7d+ | Hey, been a while! Your collection has {n} spots across {c} countries. Ready to add more? | ๐ My collection |
| โ | none | (no Moment DM) | โ |
Nudges keep the 1-per-24h cooldown (bot:nudge_sent:{userId}), so the nudge rarely wins the slot.
| Before | Now |
|---|---|
๐ My Collection | ๐ My collection |
๐ฌ Send a Reel | ๐ฌ Send a reel |
โจ Get Started | โจ Get started |
๐บ๏ธ See Your Spots | ๐บ๏ธ See your spots |
โจ Create Account | โจ Create account |
โจ Open in App | โจ Open in app |
๐บ๏ธ Create a Trip | ๐บ๏ธ Create a trip |
๐บ๏ธ Build Full Trip | ๐บ๏ธ Build full trip |
๐บ๏ธ Plan in App | ๐บ๏ธ Plan in app |
โจ Open Compass · ๐ Connect Instagram | unchanged (proper nouns) |
B4 · Everything else (unchanged): onboarding / OTP, the not-linked greeting, conversational greetings, quick-reply responses, guest caps, carousel failures, and the edge / failure messages keep their existing sentence-case copy — already terse and correctly capitalized, so the re-voice left them as-is.
| File | Change |
|---|---|
services/ai.service.ts | extractActivitiesFast gained a summary field (schema + prompt RULE 6) — same Gemini call, $0. Slow path reuses the existing V8 insight. |
lib/bot-messages.ts | formatSmartProcessingComplete leads with a summary line and drops the milestone-flavored context lines; new pickMoment helper; new-country reworded; new reelWorking ack; labels to sentence case. |
queues/content-consumer.ts | the +500ms insight DM is gone (insight → Result summary); the milestone / new-country / nudge fan collapses to one pickMoment send; the setTimeout chain is dropped. |
routes/webhooks.routes.ts | fires the instant reelWorking ack (once per batch, before the fast extraction); threads the fast summary into the result DM. |
services/instagram.service.ts | unchanged transport — still text-then-button. (Menu label sentence-cased.) |
__tests__/dm-summary-and-moment.test.ts | new: summary leads the bubble, no moment flourishes in the body, pickMoment priority. |
Verified: tsc --noEmit clean · eslint clean (incl. the
no-AI-tell rule) · vitest run → 1171 passed, 1 skipped, including the 9 new assertions.