Skip to content

Comment-triggered private reply: getUserData never receives the commentAnchor — a question-first flow never claims the anchored DM, and a question after a sendText bypasses assertCommentPrivateReplyFollowUpDeliverable #1186

Description

@rawdaymx

Summary

In a flow started by an Instagram (or Messenger) comment automation with Private reply = Flow, a getUserData step never receives the commentAnchor. It is left out on two independent counts, and both are needed for it to work:

  1. getUserData is not in MESSAGE_PRODUCING_STEP_TYPES, so executeMultipleStepsGenerator hands it commentAnchor: undefined and never marks the anchor spent. The set is defined as "the step types mapped to sendFlowMessage", and getUserData has its own handler — but it does send a message: its prompt.
  2. Its prompt is enqueued through enqueueFlowStepMessage without commentAnchor. The field is optional on the job type, so nothing flags it.

Two consequences:

  • A question after a sendText fails with no explanation. This is what we hit. The sendText claims the anchored DM; the getUserData prompt reaches the Instagram handler with no anchor, isCommentPrivateRun is false, and assertCommentPrivateReplyFollowUpDeliverable — added in feat(comment-automation): harden reply delivery and gate media steps per channel #1121 for exactly this case — is never evaluated. The contact never sees the question and the failure is recorded as a generic window rejection. The guard's own docblock describes the symptom: "a two-message flow looks like it half worked with no explanation".
  • A flow whose first message is a question delivers nothing. Nobody claims the anchor, so the one comment-anchored DM Meta grants per comment is never used; the prompt goes out as a normal DM, which a comment does not open a window for. We established this with a test, not on a phone — see What we did not verify.

The 24-hour window itself is not the problem: that is Meta's rule, and your docs and guard handle it. The control below isolates it.

Environment

Deployment self-hosted, chatbotx-docker-compose
chatbotx-worker ghcr.io/chatbotxio/chatbotx-worker@sha256:6515be1c2e7025b5e6674149e6a877240a9ba2324dcf58a6e2bb325ae1053ca1 (image created 2026-09-12 07:10 UTC)
Channel Instagram, Business Login
Runs 2026-09-14 22:54 UTC and 2026-09-15 00:40 / 00:48 UTC
Source upstream/main @ 8db86f00a for the code quoted below. We did not diff the whole image against it; we checked the two pieces that matter in /app/apps/worker/dist/integration/worker.mjs: the set at line 1192 (14 entries, no getUserData) and the getUserData prompt at line 4146 (no commentAnchor) — both identical to 8db86f00a. Re-checked right before opening this: upstream/main is still 8db86f00a.

What we observed: three runs, same flow version, same day — one variable

Flow: node 1 = sendText (greeting) → getUserData (name) → getUserDatagetUserDataaddContactTagdisableBot. Triggered by commenting on a post.

Run (UTC) Contact 24h window sendText (anchored) getUserData prompt — Message.sendError
22:54:16 A closed — last DM 16 days earlier delivered Cannot send an Instagram automated message outside the 24-hour response window
00:40:21 B closed — brand-new contact delivered #(10 - 2534022) This message is sent outside of allowed window.
00:48:28 A open — they had messaged at 23:02 delivered (none — delivered)
  • Same flow version, same workspace, same day. The only thing that changes between the failing runs and the control is whether the contact had an open window.
  • The two failures take different shapes for the same reason: for A the local resolveInstagramMessagingPolicy rejects it (stale lastIncomingMessageAt); for B it reaches Meta, which rejects it. Neither is the guard's message. ErrorLog holds 0 rows matching %one private reply per comment% and 2 matching %outside%window%, out of 3 in total.
  • In both failing runs the answer was still captured, because the contact was told what to type. The challenge is written before the send (get-user-data.ts:469, send at :522) and sendFlowStep's catch swallows the channel error (send-flow-step.ts:848-870), so the step still returns wait. That is what makes the failure silent: nothing breaks, the question just never arrives.

How to see it: a deterministic test

We could not reproduce the question-first case on Instagram the same day: an open window hides it, and both of our test contacts had one. So we wrote four tests. They fail on 8db86f00a and pass with the fix below.

✗ flow.test.ts > executeMultipleSteps — getUserData in a comment-triggered private-reply flow
    > hands the unspent private anchor to a getUserData step that is the first message, then marks it spent
    AssertionError: expected undefined to deeply equal { commentId: 'comment-1', …(1) }
✗ flow.test.ts > … > hands the spent anchor to a getUserData step that follows a sendText
    AssertionError: expected undefined to deeply equal { commentId: 'comment-1', …(2) }
✗ get-user-data.test.ts > getUserData — comment-triggered private reply
    > forwards commentAnchor to the first prompt, so the channel can send it comment-anchored
    AssertionError: expected undefined to deeply equal { commentId: 'comment-1', …(1) }
✗ get-user-data.test.ts > … > forwards a spent commentAnchor too, so the follow-up guard can explain a closed window
    AssertionError: expected undefined to deeply equal { commentId: 'comment-1', …(2) }

 Tests  4 failed | 144 passed (148)

A fifth test is a negative control: when the run did not start from a comment, no commentAnchor key is sent. It passes before and after.

Where it comes from

Quoted from upstream/main @ 8db86f00a.

apps/worker/src/integration/handlers/flow.ts:591-594:

const isMessageProducingStep = MESSAGE_PRODUCING_STEP_TYPES.has(
  step.stepType as StepType,
)
const stepAnchor = isMessageProducingStep ? anchorAvailable : undefined

apps/worker/src/integration/handlers/flow-utils.ts:66-88 — the set's docblock says "keep this set in sync with every entry mapped to sendFlowMessage", and the pinned test (flow.test.ts:590, "matches exactly the step types mapped to sendFlowMessage in flowStepHandlers") enforces exactly that — which is why getUserData can never be added without changing the definition.

apps/worker/src/integration/handlers/get-user-data.ts:522-530:

await enqueueFlowStepMessage({
  conversationId: conversation.id,
  contactInboxId: contactInbox.id,
  flowId: flowVersion.flowId,
  flowVersionId,
  step: promptStep,
  metadata: props.metadata,
  ...(props.appointmentId ? { appointmentId: props.appointmentId } : {}),
})

integrations/instagram/src/handlers/message/outgoing-message/index.ts:322-336 — with no anchor, isCommentPrivateRun is false and the guard is skipped:

const isCommentPrivateRun = commentAnchor?.replyChannel === "private"
// …
if (isCommentPrivateRun && !anchorCommentId) {
  assertCommentPrivateReplyFollowUpDeliverable({})
}

This goes back to #831: the same stepAnchor line and the same 14-entry set are in e54607d2d and in 674c90f1f^. Before #1121 a claimed anchor was dropped (anchorAvailable = undefined); #1121 made it travel as spent so follow-ups fail with a readable reason. getUserData bypasses that.

Why we think this is a defect rather than the intended design

  • The known gaps you document for the anchor — handleWait (step.ts:196), handleFollowUp (follow-up.ts:24), questionnaires (engine.ts:123) — are all steps that wait before the first message, where losing the anchor is unavoidable without a schema change. getUserData is the opposite case: it sends first and waits afterwards. It is not on that list, and we found no comment, doc or test that excludes it on purpose (searched getUserData together with anchor|private|comment in apps/worker/src, packages/sdk/src, integrations/{instagram,messenger}/src and docs; the same search without the filter returns 42 hits in apps/worker/src).
  • The pinned set test encodes "mapped to sendFlowMessage" as a stand-in for "produces an outgoing message". For every other step those two coincide; for getUserData they don't.
  • "Comment → DM that asks a question" is the most natural shape of this feature, and today it is the one shape that silently delivers nothing.

Suggested fix

Two edits. Neither works on its own: forwarding the anchor does nothing while the generator still hands undefined to getUserData, and adding it to the set does nothing while the prompt drops it.

--- a/apps/worker/src/integration/handlers/flow-utils.ts
+++ b/apps/worker/src/integration/handlers/flow-utils.ts
   stepTypes.enum.whatsappFlow,
+  stepTypes.enum.getUserData,
 ])
--- a/apps/worker/src/integration/handlers/get-user-data.ts
+++ b/apps/worker/src/integration/handlers/get-user-data.ts
     step: promptStep,
     metadata: props.metadata,
     ...(props.appointmentId ? { appointmentId: props.appointmentId } : {}),
+    ...(props.commentAnchor ? { commentAnchor: props.commentAnchor } : {}),
   })

…plus the docblock of the set and the pinned test updated to say "mapped to sendFlowMessage, plus getUserData".

We built and ran it on 8db86f00a before opening this:

  • the 4 new tests go from red to green (148 passed), along with the updated set test;
  • the neighbouring worker tests pass: comment-automation, send-flow-message, send-flow-step, send-multiple-images-step6 files, 303 tests;
  • the channel side is untouched and its tests pass: Instagram send-flow-step-comment-anchor 8/8, Messenger 13/13. Among them, your existing "a spent anchor outside the 24h window reports the one-reply-per-comment limit" is exactly what run B should have reported;
  • ultracite check on the 4 files and tsc --noEmit for apps/worker are clean.

It is safe to mark the anchor spent on a resumed getUserData (the contact replied and the step completes without sending) because a resume never carries an anchor: the challenge has no field for it, and the resume path (routing.tshandlers/challenge.ts) never references it.

Happy to open the PR (220 lines: the two edits above plus the tests) if you want it.

Not covered: on Messenger and Telegram, a date/datetime getUserData sends its webview prompt through sendChatMessage, and ChatJobSendChatMessage has no commentAnchor field (packages/worker-config/src/queues/chat/index.ts:103-114). Instagram does not take that path. Extending that job type is a larger change, so we left it out.

Workaround for anyone hitting this

Put a sendText before the first getUserData, and ask the first question in that sendText. The sendText claims the anchored DM, so the question reaches a new commenter. The getUserData prompt after it will not be delivered until the contact has written, but its challenge is already set, so their reply is still captured and the rest of the flow runs inside the window that reply opens.

Verified on the deployment above: the capture after an undelivered prompt worked in both failing runs. The part about wording the question inside the sendText is what we are rolling out now; it is not yet verified on a phone.

What we did not verify

Related issues — none of these describes it

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions