Skip to content

Reply to DMs that match a campaign's keywords - #9

Merged
diwenne merged 1 commit into
diwenne:mainfrom
kimthangk:feat/dm-keyword-autoreply-upstream
Jul 31, 2026
Merged

Reply to DMs that match a campaign's keywords#9
diwenne merged 1 commit into
diwenne:mainfrom
kimthangk:feat/dm-keyword-autoreply-upstream

Conversation

@kimthangk

Copy link
Copy Markdown
Contributor

What

A campaign can now fire on inbound DMs as well as comments. Turning on "also reply when someone DMs these words" makes the worker answer any DM whose text matches the campaign's keywords with the same reveal message.

The motivation: people who see a reel often skip the comment and just DM the keyword directly. Today that goes unanswered.

Story replies come through too — Instagram delivers them via the same messages field as an ordinary message with text.

How

  • parseMessageEvents (lib/meta/webhook.ts) — subscribed_fields already included messages; only the parser was missing. Echoes are dropped, which matters here: without that, an autoreply whose text contains its own keyword would trigger itself. Deletions, unsupported messages, and attachment-only messages with no text are dropped too.
  • processMessage (lib/queue/dm-worker.ts) — new process-message job. Dedup is per inbound message id (DmLog.commentId = dm:<mid>), so a job retry never double-sends.
  • Automation.dmTriggerEnabled — additive column, defaults false, so existing campaigns are untouched.
  • Builder toggle plus a conditional DM trigger preview tab.

Two decisions worth reviewing

The opening DM is skipped on this path. It exists to work around the one-private-reply-per-comment limit — the button tap is what opens a messaging window. A user who DMs you has already opened that window, so the button buys nothing and costs a send plus a drop-off point. The follow gate is preserved, because its button does real work (re-verifying the follow).

The follow-up fires here too. A DM-triggered link delivery is the same "link delivered" moment as a button tap, so it schedules the appreciation follow-up identically — but only on the reveal branch, not behind the follow prompt, where no link has gone out yet.

Both are judgement calls; happy to change either.

Refactor

processPostback and processMessage both send a reveal into an already-open conversation, so the button-template-with-inline-link-fallback logic is now one shared sendRevealDirectMessage. This keeps the isTemplateRejection guard from 99600c2 in a single place rather than duplicated. processComment is untouched — it still uses the private-reply variants.

Not included

  • Story replies aren't distinguishable from DMs in the log and can't be given their own message. That needs reply_to.story plumbed through plus another campaign field, which felt like a separate change.
  • No per-user cooldown: five DMs in a row get five replies, since each is a distinct message id. Easy to add if you'd want it.

Checks

All four from CONTRIBUTING pass on this branch:

  • npm run typecheck — clean
  • npm run lint — clean
  • npm test — 132 passed, including 16 new (parser: echoes, deletions, attachment-only, self-sends, non-instagram payloads; worker: match, no-match, dedup, tracked-link buttons, follow gate both ways, plan limit, send failure)
  • npm run build — clean

Not verified against live Instagram traffic for the story-reply case specifically — that claim is based on the payload shape, not an observed webhook.

A campaign can now fire on inbound DMs as well as comments. Turning on
"also reply when someone DMs these words" makes the worker answer any DM
whose text matches the campaign's keywords with the same reveal message,
honouring the follow gate, the appreciation follow-up, and the monthly
send limit.

The `messages` webhook field was already subscribed; only the parser was
missing. Echoes are dropped so an autoreply containing its own keyword
cannot trigger itself, and each inbound message id dedupes to one reply.

The opening DM is skipped on this path: the user has already opened the
conversation, so the private-reply workaround it exists for does not
apply. The postback and DM-trigger paths now share one reveal sender,
which keeps the template-rejection fallback in a single place.

The builder gets a toggle and a conditional "DM trigger" preview tab
showing that thread — their message, the follow prompt if gated, then
the reveal — since the existing DM tab always opens with the account's
opening DM, which never happens on this path.
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

@kimthangk is attempting to deploy a commit to the diwenne's projects Team on Vercel.

A member of the Team first needs to authorize it.

@diwenne
diwenne merged commit bd26d08 into diwenne:main Jul 31, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants