Facebook & Instagram Comment Automation
Full reference: docs/fb-comment-automation.md.
Read it before non-trivial changes. This skill is the quick map + the traps.
Where things live
| Concern | Path |
|---|---|
| Automation loop, filters, dispatch | apps/worker/src/integration/handlers/comment-automation/index.ts |
| AI-agent reply (generate + deliver) | apps/worker/src/integration/handlers/comment-automation/ai-reply.ts |
| Per-channel private DM dispatch | apps/worker/src/integration/handlers/comment-automation/private-reply.ts (PRIVATE_REPLY_TEXT_SENDERS) |
| Supported channels union | apps/worker/src/integration/handlers/comment-automation/channel-type.ts (CommentAutomationChannelType) |
| Attachment info (image/video for hide) | apps/worker/src/integration/handlers/comment-automation/comment-attachment.ts |
| Receive comment + enqueue automation | apps/worker/src/integration/handlers/received-message.ts (receiveComment) |
| Webhook parse + enqueue | integrations/messenger/src/handlers/webhook.ts, integrations/instagram/src/handlers/webhook.ts, integrations/instagram-facebook/src/handlers/webhook.ts |
| Webhook value schema | integrations/messenger/src/schema.ts (messengerFeedCommentValueSchema), integrations/instagram{,-facebook}/src/schemas.ts (instagramCommentEventValueSchema) |
| DB queries (match/dedup/schedule) | packages/business/src/fb-comment-automation/service.ts |
| Schema + option/reply Zod partials | packages/database/src/schema/fb-comment-automation.ts, .../partials/fb-comment-automation.ts |
| Dedup ledger | packages/database/src/schema/fb-comment-automation-reply.ts |
| Job types | packages/worker-config/src/queues/integration/index.ts |
| Builder feature (form, actions) | apps/builder/src/features/fb-comments/ (Facebook), apps/builder/src/features/ig-comments/ (Instagram) |
| Analytics event table | packages/database/src/schema/fb-comment-automation-event.ts |
| Analytics read/write service | packages/analytics/src/services/comment-automation-analytics.service.ts |
| Analytics dashboard | packages/analytics-nextjs/src/components/comment-automation-analytics.tsx |
| Delivery-stat columns + dialog | apps/builder/src/features/shared/comment-automation/comment-automation-stat-{columns,cell}.tsx, comment-automation-contacts-dialog.tsx |
| Miss (declined comment) table | packages/database/src/schema/fb-comment-automation-miss.ts, .../partials/fb-comment-automation-miss.ts |
| Miss read/write | packages/analytics/src/repositories/postgres/comment-automation-miss.repository.ts, commentAutomationAnalyticsService.recordMisses |
| Cross-queue delivery/failure anchor | apps/worker/src/lib/comment-automation-anchor.ts |
| Tests | apps/worker/__tests__/comment-automation.test.ts |
Data-flow in one line
feed webhook (verb "add") / instagram comments webhook → incomingComment → receiveComment → processCommentAutomation → (AIAgent) commentAIReply.
The traps (read before editing)
parent_idis ALWAYS present, but it does NOT always equalpost_idon a top-level comment. A truthyparentIddoes not mean "reply", and neither doesparentId !== postId— Facebook varies the composite per post type. A reel sendsparent_idbyte-identical topost_id; a photo post sends{albumId}_{storyId}, where only the trailing story id agrees. UseisCommentReply(parentId, postId, commentId), which compares the trailing ids vianormalizePostId. Testing the raw strings + defaultignoreCommentReplies: truesilently drops every top-level comment on a photo post. A reply is still safe to spot becausecomment_idstays anchored to the story ({storyId}_{replyId}) even for a reply, so it can never collide with its ownparent_id's trailing half.Post ids are composite
{pageId}_{storyId}; the picker stores 3 different formats (published/ads composite, reels bare id, manual free-text). Always compare throughnormalizePostId(trailing story id). Neverpost.value.includes(rawPostId).Every skip must log AND record a miss. The loop uses
logAutomationSkipped(..., reason)before eachcontinue, immediately followed bycollectMiss(automation.id, <reason>).processCommentAutomationreturnsvoid→ BullMQ always logsreturnValue: null, so a skip with no log is undebuggable in production; and a skip with nocollectMissis a filter whose declines the Misses column silently never counts — no compile error, the number is just quietly too low. A new filter therefore needs three things landed together: the guard, a new value incommentAutomationMissReasons(packages/database/src/partials/fb-comment-automation-miss.ts, which needs a migration for the pgEnum), and a case in thegateCasestable inapps/worker/__tests__/comment-automation.test.ts. Misses go to their OWN table (FBCommentAutomationMiss), neverFBCommentAutomationEvent— see trap 16.AIAgent reply ≠ DM auto-responder.
publicReply/privateReplyof typeAIAgentstore the selected agent id invalue. Generation usesgenerateAIReplyText(tools + rich OFF, returns text only); the comment handler routes public → public comment reply (type:"comment"+replyToCommentId), private → DM. Do NOT route throughprocessAutomatedResponse— it uses the workspace default agent and always sends a DM.Dedup ledger is dual-purpose.
fbCommentAutomationReplyModelrows (automationId, contactId, postId) are written after every successful reply and read by bothreplyOncePerUserPerPost(same post) andreplyToUsersWhoCommentedOnOtherPosts(other post). The unique indexFBCommentAutomationReply_dedup_idxalready serves(automationId, contactId)+postId != ?queries — no new index needed; use aLIMIT 1existence check, not$count.Three channels, one loop.
typeismessenger|instagram(Instagram Login) |instagramFacebook(Instagram via Facebook Login) — seeCommentAutomationChannelType— andfindActiveAutomationsfilters on it, so every capability must be routed per channel. Private DM text works on all three viaPRIVATE_REPLY_TEXT_SENDERS(comment_id-anchored Send API); comment liking exists only onmessenger+instagramFacebook(Instagram Login'slikeCommentis a logged no-op); the attachment lookup behindhideComments.hasImage/hasVideois messenger-only. Hide an unsupported toggle in the builder form instead of shipping a dead switch.A
privateflow reply runs on the DM conversation; apublicone does not. The comment conversation is anchored to the post (sourceId = postId), but DM replies land on the DM conversation (sourceId IS NULL). EnqueuesendFlowwith the comment conversation and the flow parks where no reply can reach it — first message delivers, then the flow stalls at its first waiting step with no error anywhere (no throw, no queue error, nosendError;resolveIncomingTextRoutingjust finds no challenge and falls through toautomatedResponse). Private usesresolveDirectMessageConversationId; public deliberately keepsctx.conversationId, because the contact's next comment resolves back to that same conversation. Never "unify" the two branches.commentAnchoris orthogonal — it decides delivery: aprivateanchor is one-shot (Meta allows one comment_id-anchored DM per comment, so the first message-producing step claims it), apublicone is never consumed and every step of the run posts as a comment reply. A claimed private anchor is not dropped — it rides on asspent: trueso each channel'ssendFlowStepcan tell a comment-triggered follow-up from a plain flow message and gate it oncontact.lastIncomingMessageAtviaassertCommentPrivateReplyFollowUpDeliverable(@chatbotx.io/sdk): inside the 24h window it sends as a normal DM, outside it throwscomment_private_reply_already_used→ visiblesendError. Never "restore" the drop; that turns the failure back into a Send API rejection swallowed bysendFlowStep.options.trackUserTagsis the one option that is not a filter, and the two channels resolve it by completely different mechanisms. It never skips — it stampstotalTagged/totalNewTaggedonto the comment message'scontentAttributes, which is where{{total_tagged}}/{{total_new_tagged}}read them from viagetLastUserComment(the same path as{{last_post_id}}, so both are last-comment scoped, not lifetime totals, and the write must stayawaited before the reply dispatches or the first comment renders an empty value and only a retry looks right). Facebook gets real user ids from the webhook'smessage_tags(Graph fallback when absent) and matchesContactInbox.sourceId; Instagram has no tagged-user data at all — no webhook field, nomessage_tagson the IG Comment node — so it regexes@handleout of the text and matchesContactInbox.sourceUsername. Don't "fix" the IG branch by looking for a structured field; it does not exist (verified against production payloads). An absent key resolving tonullrather than0is deliberate: a flow must be able to tell "nobody was tagged" from "this automation never tracked". Seecomment-tags.tsand the docs' Tag tracking section for the two IG accuracy caveats.Instagram comment replies carry text only.
POST /{ig-comment-id}/replieshas noattachment_url— that is Facebook-Page-only (integrations/messenger). Both Instagram variants'sendCommentthrowChannelError(PAYLOAD_INVALID)when the message has attachments, so a media step of a public reply flow surfaces asendErrorin the inbox instead of disappearing behind alogger.warn. Never "fix" that back into an empty{ messageIds: [] }return.Instagram-via-Facebook private replies use the Page node, not the IG node.
sendPrivateReplyMessage(integrations/instagram-facebook/src/apis/comment.ts) must post to/{pageId}/messages./{igId}/messagesreturns(#3) Application does not have the capability to make this API call.even withinstagram_manage_messages,pages_messagingand Human Agent at Advanced Access — code 3 means "this edge does not exist on this node", so it is NOT an App-dashboard problem. Already regressed twice (#875 fixed it, #945 reverted it to green a stale test whose fixture had nopageId, making the URL/undefined/messages). It breaks every private reply on the channel:text,AIAgent, aflowreply's first message, and the agent's manual inbox private reply (which enters viahandlers/comment/outgoing-private-reply, not the automation loop). Instagram Login is different on purpose —me/messagesongraph.instagram.com. Keep thesend-private-reply.test.tsguard that asserts the IG node is never called. Also note both Instagram packages logmodule=integration-instagram, so attribute production failures by request host, not module name.The list columns read counters, the dialog reads events — never swap them. The Sent/Delivered/Seen/Clicked/Failed columns come from lifetime
*Countcolumns onFBCommentAutomation, NOT from aggregatingFBCommentAutomationEventthe way broadcast aggregatesContactOnBroadcast: a nightly cron purges the FAILED event rows afterCOMMENT_AUTOMATION_ERROR_RETENTION_DAYS(successful rows are kept for the life of the automation), so an aggregate would shrinkfailedCountevery night. What keeps the counters exact is that every increment counts the rows a conditionalUPDATE ... WHERE "<col>At" IS NULL RETURNING "automationId"actually returned — a redelivered webhook or a BullMQ retry returns nothing and moves nothing. If you add an outcome, add BOTH the timestamp column (for the drill-down and the guard) and the counter, and drive the counter off the returned rows. Never increment on a call count.A multi-step
flowreply is ONE reply — its steps can settle in either order.sendFlowStepswallows a step's error and runs the next one, so one event row can take several outcomes. Any step through means delivered and NOT failed, whichever way round they land:settleEventrefuses to fail an already-delivered row, andmarkDeliveredclears an earlierfailedAtand reportsclearedFailureso the service takesfailedCountback down. Only every step failing counts as a failure. Remove either half and a 3-step reply reports delivered + failed for the same reply, pushing the column percentages (measured against attempts) past 100%.Two independent button-payload encoders — patching the worker's is NOT enough.
convertButtonsToTemplate(apps/worker/src/chat/handlers/send-flow-step.ts) only writes theMessagerow'scontentAttributes. The payload the contact actually TAPS is encoded again by each channel, becausesendFlowStephands the integration the raw step. GrepencodeButtonPayloadunderintegrations/{messenger,instagram,instagram-facebook}/srcand mirror EVERY hit on a flow-send path — messenger alone has three (send-button.ts,send-quick-reply.ts,send-messenger-template.ts), andsend-carousel.tsonly looks absent because it sharesgetButtonTemplate. The two hits that are correctly excluded aremessenger-ads-json.tsandlib/persistent-menu.ts, neither of which sends a flow reply. Attribution added to only one side compiles, passes the worker tests, renders correctly in the inbox — and the click still reports nothing. The carrier ismetadata(COMMENT_AUTOMATION_PAYLOAD_TYPE, read withextractMetadata("commentAutomationId", metadata)), neverCommentAnchor.automationId: the anchor never reaches the encoders, is withheld frominstagramFacebookand from non-message steps, and is lost across a Wait, whilemetadatasurvives all three (ContactOnSmartDelay.metadatais a real column). Node-level quick replies are the one exception — they already ship the canonical postback viagetCanonicalReplyPayload. The guard tests areintegrations/*/__tests__/comment-automation-button-payload.test.ts.Delivery is settled at each send site, not on the event bus. There are four, and a new reply type needs whichever apply:
send-message.ts(public text/AI, via thecontentAttributes.commentAutomationanchor),send-flow-step.ts(both flow branches, same anchor),executePrivateReplyandprocessCommentAIReply(private, sent inline through the Send API and leaving noMessagerow for a webhook to match). Only Seen and Clicked ride the bus, because only they arrive later and name something other than the reply. Aflowreply carries its automation inCommentAnchor.automationId— that is also what puts the id intoencodeButtonPayload's 7th field so clicks can be attributed at all.Retention is per OUTCOME, and the analytics date filter is unbounded because of it.
purgeFailedCommentAutomationEventsdeletesstatus = 'failed'rows only, afterCOMMENT_AUTOMATION_ERROR_RETENTION_DAYS; a successful row lives as long as the automation, which is what lets the filter offerlifeTime. Two things follow. (a) A new query that must survive the purge cannot read failed rows — and any new purge predicate needs its own partial index, the wayFBCommentAutomationEvent_failed_createdAt_idxkeeps the oldest-first chunk scan off the kept rows. (b) An unbounded range means the replies series is bucketed by MONTH past 60 days: the query and the zero-fill both take the width fromresolveRangeGranularity, so changing one without the other yields one real point followed by a run of zeroes. Monthly keys stayYYYY-MM-01so the client parses them like daily ones — and the client parses aYYYY-MM-DDkey as a LOCAL day (the server already resolved it in the viewer's timezone);new Date(key)reads it as UTC midnight and renders the previous month west of Greenwich.A
textpublic reply is a LIST, and it is still ONE reply.publicReply.valuesholds up toFB_COMMENT_REPLY_MAX_TEXTSmessages, each posted as its own comment reply. Never readreply.valuedirectly —resolveReplyTextsis the only thing that knows the fallback to the legacy single-string shape, andwillSendReplyreads through it too; disagreeing there makes an automation go silent with no skip log. Write throughnormalizeReplyTextssovalueandvaluescannot drift (a caller PATCHing onlyvalueon a row that hasvaluesis otherwise ignored without a word). Bookkeeping treats the set as ONE reply — one analytics event,repliesCount+1 — becauseFBCommentAutomationEventis unique on(automationId, commentId, replyChannel)and every settle helper names a row by that triple. Sends are staggered byPUBLIC_REPLY_SPACING_MS; equal delays let the chat queue'sconcurrency: 5reorder them under the comment. Private reply stays single-message on purpose. In the form, the editor rows MUST be keyed byfield.id— see trap 15.TiptapEditorFieldinside auseFieldArraymust be keyed byfield.id. It snapshots its content once in auseEffectkeyed on the form path. Removing an entry shifts the later ones but leaves the path at a given position unchanged, so an index key shows the removed entry's text — no error, just wrong content saved over the user's. Seefeatures/shared/comment-automation/reply-texts-field.tsx.A declined comment goes to
FBCommentAutomationMiss, never to the event table.FBCommentAutomationEventonly ever holds work the automation attempted: it is unique on(automationId, commentId, replyChannel)withreplyChannel/replyTypeNOT NULL, and a decline has neither; every analytics-page query aggregates it directly, so a row type none of them want would have to be excluded from each one forever; and misses outnumber replies by however many automations the workspace runs (findActiveAutomationsscopes by workspace + channel, not by post, so ONE comment is shown to every active automation on the channel). Three consequences. (a) The whole run's declines are flushed in onerecordMissescall after the loop — never one insert per automation, or a busy Page fires N statements per comment. (b)missedCountis driven off the rowsINSERT ... ON CONFLICT DO NOTHING RETURNINGactually returned, exactly like the delivery counters, so a retry counts nothing. (c) These rows are never purged, by product decision — do not add a retention cron without asking, and if one is ever added it needs its own partial index the wayFBCommentAutomationEvent_failed_createdAt_idxdoes. The percentage on the column divides byrepliesCount + missedCount, NOTsentCount: a decline is not an attempt, andsentCountcounts private DMs only. A blocked private reply stays afailedevent — it was attempted.
Adding a new filter option (recipe)
- Add the field to
fbCommentOptionsSchema(partials) + DB default in the schema file (jsonbdefault string). - If it needs a DB lookup, add a method to
fbCommentAutomationService(reuse the dedup table + its index where possible; preferLIMIT 1existence checks). - Add the guard inside the loop in
processCommentAutomation, with alogAutomationSkipped(..., reason)AND acollectMiss(automation.id, <reason>)beforecontinue— plus the new value incommentAutomationMissReasons(pgEnum → migration). - Surface the toggle in
apps/builder/src/features/fb-comments/components/fb-comment-form.tsxand add i18n keys to every locale file inapps/builder/messages/(the i18n parity check inpnpm lintfails on a missing key in any of the 20 locales). - Extend
apps/worker/__tests__/comment-automation.test.ts— including a row in thegateCasestable underdescribe("processCommentAutomation misses").
Adding a new reply type (recipe)
Extend
fbCommentReplyTypes(partials) —fbCommentReplySchema.typeand thecommentAutomationReplyTypepgEnum onFBCommentAutomationEventboth derive from it, so a new value needs a database migration too.Handle it in BOTH
executePublicReply(public-reply.ts) andexecutePrivateReply(private-reply.ts). Public = messagetype:"comment"+replyToCommentIdviasendChannelMessage; private = the channel's entry inPRIVATE_REPLY_TEXT_SENDERS, so a new type has to work for all three channels (messenger, instagram, instagramFacebook).Update
willSendReplyso dedup/repliesCountonly count when a reply is actually dispatchable (e.g. requirevalue).Return a
CommentReplyOutcome(reply-outcome.ts) with the text the customer will actually see — that is what the analytics "Bot replies to comments" table groups on. Returningnullstill means "declined to send", exactly as the old booleanfalsedid.If it needs async work (like AIAgent), add a dedicated job in worker-config, a handler, and a
caseinapps/worker/src/integration/worker.ts(theneverexhaustiveness guard forces this — type + dispatch + handler land together).An async reply type records its analytics event in TWO places.
textandfloware settled by the dispatcher inindex.tsthe moment they return an outcome, butAIAgentcannot be — its text does not exist yet. SoexecutePublicReply/executePrivateReplyopen the row withreplyText: null, andprocessCommentAIReply(ai-reply.ts) callscommentAutomationAnalyticsService.settleEventto land the generated text, or afailedrow carrying the bail-out reason. EveryrollbackCommentDedupin that file is paired with a settle viaabandonAIReply— miss one and the analytics page reports a silent non-reply as a success. Any new async reply type has to do the same on both sides.
Verify
pnpm --filter worker vitest run __tests__/comment-automation.test.ts
pnpm --filter worker check-types
pnpm lint
Production sanity after deploy: comment on (a) a normal post, (b) a reel, (c) a comment with a bare-domain link + hide-link on, (d) an automation with reply = AI Agent — and confirm each fires or logs a clear skip reason.