You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:
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.
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) → getUserData → getUserData → addContactTag → disableBot. 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.
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.
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.
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.ts → handlers/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
The question-first flow was not run on Instagram. It is established by the engine test, your channel test for a non-anchored send, and run B, which takes the same send path (no anchor → normal Send API → rejected). We did not publish a flow starting with getUserData and trigger it with a closed window.
Messenger was not run. It shares the anchor path (send-flow-step.ts:752-757) and its channel test passes, but we have no Messenger run.
The test files are not type-checked by check-types: apps/worker/tsconfig.json includes src/**/*.ts only, same as the existing tests.
Summary
In a flow started by an Instagram (or Messenger) comment automation with Private reply = Flow, a
getUserDatastep never receives thecommentAnchor. It is left out on two independent counts, and both are needed for it to work:getUserDatais not inMESSAGE_PRODUCING_STEP_TYPES, soexecuteMultipleStepsGeneratorhands itcommentAnchor: undefinedand never marks the anchor spent. The set is defined as "the step types mapped tosendFlowMessage", andgetUserDatahas its own handler — but it does send a message: its prompt.enqueueFlowStepMessagewithoutcommentAnchor. The field is optional on the job type, so nothing flags it.Two consequences:
sendTextfails with no explanation. This is what we hit. ThesendTextclaims the anchored DM; thegetUserDataprompt reaches the Instagram handler with no anchor,isCommentPrivateRunis false, andassertCommentPrivateReplyFollowUpDeliverable— 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".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
chatbotx-docker-composechatbotx-workerghcr.io/chatbotxio/chatbotx-worker@sha256:6515be1c2e7025b5e6674149e6a877240a9ba2324dcf58a6e2bb325ae1053ca1(image created 2026-09-12 07:10 UTC)upstream/main@8db86f00afor 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, nogetUserData) and thegetUserDataprompt at line 4146 (nocommentAnchor) — both identical to8db86f00a. Re-checked right before opening this:upstream/mainis still8db86f00a.What we observed: three runs, same flow version, same day — one variable
Flow: node 1 =
sendText(greeting) →getUserData(name) →getUserData→getUserData→addContactTag→disableBot. Triggered by commenting on a post.sendText(anchored)getUserDataprompt —Message.sendErrorCannot send an Instagram automated message outside the 24-hour response window#(10 - 2534022) This message is sent outside of allowed window.resolveInstagramMessagingPolicyrejects it (stalelastIncomingMessageAt); for B it reaches Meta, which rejects it. Neither is the guard's message.ErrorLogholds 0 rows matching%one private reply per comment%and 2 matching%outside%window%, out of 3 in total.get-user-data.ts:469, send at:522) andsendFlowStep's catch swallows the channel error (send-flow-step.ts:848-870), so the step still returnswait. 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
8db86f00aand pass with the fix below.A fifth test is a negative control: when the run did not start from a comment, no
commentAnchorkey 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:apps/worker/src/integration/handlers/flow-utils.ts:66-88— the set's docblock says "keep this set in sync with every entry mapped tosendFlowMessage", and the pinned test (flow.test.ts:590,"matches exactly the step types mapped to sendFlowMessage in flowStepHandlers") enforces exactly that — which is whygetUserDatacan never be added without changing the definition.apps/worker/src/integration/handlers/get-user-data.ts:522-530:integrations/instagram/src/handlers/message/outgoing-message/index.ts:322-336— with no anchor,isCommentPrivateRunis false and the guard is skipped:This goes back to #831: the same
stepAnchorline and the same 14-entry set are ine54607d2dand in674c90f1f^. Before #1121 a claimed anchor was dropped (anchorAvailable = undefined); #1121 made it travel asspentso follow-ups fail with a readable reason.getUserDatabypasses that.Why we think this is a defect rather than the intended design
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.getUserDatais 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 (searchedgetUserDatatogether withanchor|private|commentinapps/worker/src,packages/sdk/src,integrations/{instagram,messenger}/srcanddocs; the same search without the filter returns 42 hits inapps/worker/src).sendFlowMessage" as a stand-in for "produces an outgoing message". For every other step those two coincide; forgetUserDatathey don't.Suggested fix
Two edits. Neither works on its own: forwarding the anchor does nothing while the generator still hands
undefinedtogetUserData, and adding it to the set does nothing while the prompt drops it.…plus the docblock of the set and the pinned test updated to say "mapped to
sendFlowMessage, plusgetUserData".We built and ran it on
8db86f00abefore opening this:148 passed), along with the updated set test;comment-automation,send-flow-message,send-flow-step,send-multiple-images-step— 6 files, 303 tests;send-flow-step-comment-anchor8/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 checkon the 4 files andtsc --noEmitforapps/workerare 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.ts→handlers/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
getUserDatasends its webview prompt throughsendChatMessage, andChatJobSendChatMessagehas nocommentAnchorfield (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
sendTextbefore the firstgetUserData, and ask the first question in thatsendText. ThesendTextclaims the anchored DM, so the question reaches a new commenter. ThegetUserDataprompt 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
sendTextis what we are rolling out now; it is not yet verified on a phone.What we did not verify
getUserDataand trigger it with a closed window.send-flow-step.ts:752-757) and its channel test passes, but we have no Messenger run.check-types:apps/worker/tsconfig.jsonincludessrc/**/*.tsonly, same as the existing tests.Private reply = Flowstrands the flow on the post-anchored conversation; the user's reply lands on the DM conversation and is never processed #1063's first run (2026-08-29) usedgetUserDataas the first step and reported the DM as delivered. We cannot measure that contact's window any more — the contact was deleted. But no code since fix(comment-automation): deliver flow-type private replies via comment-anchored send #831 hands the anchor togetUserData, so if that DM arrived, it arrived as a normal DM inside an open window. That is the confounder that hid this at the time.Related issues — none of these describes it
Private reply = Flowstrands the flow on the post-anchored conversation; the user's reply lands on the DM conversation and is never processed #1063 (ours) — the flow parked on the post-anchored conversation. Fixed in fix(worker): run comment-triggered private flows on the DM conversation #1105. Different defect; that issue ruled outMESSAGE_PRODUCING_STEP_TYPESas the cause of that bug, which still holds.spentflag and the guard; feat(comment-automation): delivery stats, miss tracking, tag counters, and multi-text replies #1171 touchedflow.tsandsend-flow-step.tson the same path since. None of the three mentionsgetUserData.--state all) forgetUserData commentAnchor,private reply getUserData,comment private reply question,MESSAGE_PRODUCING_STEP_TYPES,private reply follow-up,commentAnchor: only the above come back.