feat(threads): add comment auto-reply automation - #1020
Conversation
The threads workspace re-exported the node vitest preset verbatim, and MSW is opt-in there (see packages/vitest-config/src/node.ts): without `mswSetupFiles` the shared `setupServer()` is never started, so every `server.use(...)` handler in `__tests__/auth.test.ts` and `__tests__/comment.test.ts` was registered against a server that was not listening. Those 11 tests reached the real graph.threads.com and failed on live OAuth errors. Opt into the MSW lifecycle the same way instagram, instagram-facebook and messenger do. No test assertion is changed; the handlers already existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jr3BRqH3woCzbcePEBPira
|
Hey @AlanSyue, this is a really solid draft — thanks for putting in the work! 🙌 Extending comment automation to Threads by mirroring the Messenger/Instagram pipeline (with honest capability predicates instead of silently no-op'ing unsupported actions like private reply/like/hide) is exactly the right way to add a new channel here. The test coverage across the integration package, worker, and builder is impressive for a draft, and keeping every string translated across 20 locales is a nice touch. Looking forward to seeing this move out of draft — let us know if there's anything blocking you or any part of the review process (schema, webhook handling, etc.) you'd like early eyes on before you mark it ready. Keep it up! |
Resolve conflicts in callback.ts imports, create-message.action.ts (keep threads attempts:1 opts with upstream targetConversation) and fb-comment-automation service (keep both threads methods and deleteMany). Restore buildBrokerCallbackUrl import and test mock dropped by the auto-merge of upstream's buildProviderCallbackUrl rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jr3BRqH3woCzbcePEBPira
|
Thanks for the kind words, @sung17 — much appreciated! 🙏 Nothing is blocking me on the implementation side. I'd just like to find some time to run a proper end-to-end test first — connecting a real Threads account and verifying the full comment → keyword match → public reply path against the live API — so I can be confident the feature actually holds up before asking for your time. Once that's done I'll take it out of draft and ping you for review. |
|
Thanks, @AlanSyue! Totally understand — taking the time to verify against a live Threads account is the right call, better to ship something solid. Looking forward to it, whenever you're ready. Let me know if there's anything I can help test on my end. |
Live Meta deliveries send `values` as an array of `{field, value}` entries,
not the single object the published docs describe. Every real webhook was
therefore failing `safeParse`, logging a schema mismatch and returning
silently — the comment automation never ran once against a real payload.
Accept both shapes via a union that normalizes to an array in the schema, so
downstream code has no type branch, and apply the `field === "replies"` and
self-comment filters per entry instead of once for the whole payload.
Also log the raw body, the top-level keys and the type of `values` when a
payload cannot be parsed. The previous log carried only zod issue paths,
which is not enough to identify a wire-format change in production — that gap
is why this took a live capture to diagnose.
Tests cover the array form, the documented object form, multiple entries,
non-reply entries, self replies, and the anonymized structure of a real
delivery captured on 2026-09-01. Two further cases pin HMAC verification over
multi-byte UTF-8 bodies, which no existing case exercised.
Three registrations were skipped when the channel was added. None of them produce a compile error, so all three shipped green. `inboxRelations.inboxModel` had no `integrationThreads` relation while `InboxService.withIntegrations` eager-loads it, so Drizzle threw `Cannot read properties of undefined (reading 'targetTable')` on every `InboxService.list` with integrations — the workspace dashboard was unreachable for all workspaces, not just those using Threads. `InboxWithIntegrations` already declared the field, which is why the type checker stayed quiet. `disconnectWorkspaceInbox` had no threads case and fell through to `default`, leaving `IntegrationThreads` rows behind on workspace teardown. The provider's own disconnect explicitly documents that row deletion is the business layer's job. `getChannelIntegrationId` and the `page_user_name` resolver both omitted threads, so that variable returned null on the channel.
The status column called `t("status.active")` / `t("status.inactive")`, neither
of which exists in any locale, so the column threw MISSING_MESSAGE on every
render. It also rendered a button-wrapped badge where the Facebook and
Instagram tables both use a plain `Switch` — adopting the sibling markup drops
the two keys entirely rather than inventing them in twenty files.
Separately, `fields.version.label` was used by the Threads platform-credential
form but absent from all twenty locales, so that field's label threw too. The
existing `i18n:check` compares locales against `en` and stays green when a key
is missing everywhere, which is how this reached a release branch.
Each of the bugs fixed in this branch passed CI, because the surfaces they broke are either resolved at runtime by Drizzle or reachable only through a switch that has a `default` arm. These assert the invariants directly rather than the behaviour of one channel, so a future channel that forgets a registration fails here instead of in production. - every relation `InboxService.withIntegrations` eager-loads is defined on `inboxModel`, and the converse - every statically resolvable `t()` key in the builder source exists in `en.json` — the gap `i18n:check` leaves open, since it only compares locales against each other - `disconnectWorkspaceChannels` deletes each channel's integration row rather than falling through to `default` - the variables channel maps resolve every channel Each was verified by reverting its fix and confirming the test fails, so none of them is vacuous. The i18n test additionally asserts its own scan reached the source tree, so a broken matcher cannot silently turn it into a no-op. `api` is outside the matrix by construction: it has no `integration*` relation on `inboxModel` to derive from. That is a pre-existing gap, noted in both test files and reported separately.
The merge is textually clean but breaks in two ways that only a type check or a running build surfaces. Two `Record<ChannelType, ...>` maps landed upstream after this branch last merged and neither has a threads entry, so both fail exhaustiveness: `errorLogProviderLabels` (ChatbotXIO#1054) and `contactProfileNameCapabilities` (ChatbotXIO#1074). Threads is a comment-only channel — no message webhook parses an `IncomingContact` and the integration exposes no `contact.getProfile` handler — so its profile capabilities are `{ inbound: null, onDemand: false }`. Upstream also renamed `features/common/schemas` to `features/common/schema`. Four threads files still imported the old path, which left the builder unable to compile the routes that import them; the webhook endpoint answered 500 until this was fixed.
|
@sung17 this is out of draft and ready whenever your team can fit it in 🙏 I finally ran the end-to-end test I mentioned — connected a real Threads account and drove the whole path against the live API. It was worth doing: it turned up four defects that CI could not have caught, the first of which meant the feature had never actually worked.
All fixed, each with a regression test that asserts the invariant rather than the one channel, so the next channel to forget a registration fails in CI instead of production. Every test was verified by reverting its fix and confirming it goes red first. I also merged latest main, which needed three more fixes — two The PR description has the full write-up, plus a collapsed section with reproduction steps if you want to verify the live path yourself — including the two things that cost me the most time (self-comments are filtered silently, and dev-mode apps do still deliver to the owner's own account). Happy to split this into smaller PRs if the size is awkward to review — the integration + worker core is separable from the builder surface. Just say the word. |
Adds Threads as a comment-automation channel: connect a Threads account, define keyword-matched automations against your posts, and auto-reply publicly to matching comments — the same flow Facebook and Instagram comment automations already offer.
What's included
New integration package —
integrations/threads/refreshAuthfor the 60-day tokencommentchannel only. Threads has no Messaging API, so nomessagechannel is declared rather than declaring one that always fails.Database
IntegrationThreadstable (one row per connected account; unique onthreadsUserIdand oninboxId)fbCommentAutomationTypegains athreadsvalueWorker — comment automation extended to Threads
The existing pipeline now runs on Threads behind explicit capability predicates rather than silent no-ops:
POST /{comment-id}/likesequivalentAn automation configured with an action the channel cannot perform logs an unsupported-capability line instead of enqueuing a job that could only fail, and the dedup/counter accounting only records a reply that actually went out.
Token refresh registers into the existing
refreshChannelTokensadapter on the daily 02:00 run. Threads tokens are 60-day, so it deliberately does not join the midday short-lived pass.Builder
CHANNEL_CAPABILITIES, so it appears in the channel create picker and settings without any new hardcoded channel listthreads-commentsCRUD pages mirroring the existing Facebook and Instagram comment-automation screensuseTranslations(); 20 locale files updatedEnd-to-end verification against the live Threads API
I connected a real Threads account and drove the full path — Meta webhook → keyword match → published public reply — before asking for review. That surfaced four defects that CI could not catch. All are fixed in this branch; the last four commits are those fixes plus their regression tests.
1. The webhook payload shape does not match the published docs
Meta's documentation shows
valuesas a single{field, value}object. Live deliveries send an array of those entries. The schema followed the docs, so every real webhook failedsafeParse, logged a mismatch and returned silently.This meant the feature had never actually run against a real payload. The unit tests passed because their fixtures were hand-written from the same documentation.
The schema now accepts both shapes and normalizes to an array, and the reply/self-comment filters apply per entry. A test pins the anonymized structure of a real captured delivery, including the fields the schema does not declare (
has_uid_field,media_type,permalink,shortcode,is_verified,profile_picture_url).2. A missing Drizzle relation broke the dashboard for everyone
InboxService.withIntegrationseager-loadsintegrationThreads, but no such relation was defined oninboxModel. Drizzle threwCannot read properties of undefined (reading 'targetTable')on everyInboxService.listwith integrations — so the workspace dashboard was unreachable for all workspaces, not only those using Threads.InboxWithIntegrationsalready declared the field, which is whytscstayed quiet.3. Teardown and variables were missing the channel
disconnectWorkspaceInboxhad nothreadscase and fell through todefault, leavingIntegrationThreadsrows behind on workspace teardown — even though the provider's owndisconnectdocuments that row deletion is the business layer's job.getChannelIntegrationIdand thepage_user_nameresolver also omitted the channel, so that variable returned null.4. Two translation keys did not exist
The comments table called
t("status.active")/t("status.inactive"), which exist in no locale, and the platform-credential form usedfields.version.label, likewise absent everywhere. Both threwMISSING_MESSAGEat render.The status column now uses the same
Switchthe Facebook and Instagram tables use, which removes the need for those two keys rather than inventing them in twenty files.fields.version.labelis added to all locales.Worth noting:
i18n:checkruns inlintand was green throughout, because it compares locales against each other. A key missing from every locale — includingen— is invisible to it.What the live run confirms
repliesCountincrementedComment automation skipped: keywords do not matchisNewguard and the deterministic BullMQjobIdeach cover itReproducing the live verification
Needs a Meta app with the Threads API use case (scopes
threads_basic,threads_read_replies,threads_manage_replies,threads_content_publish)and a Threads account added as a tester on it. The builder must be reachable
over a public HTTPS origin, since both the OAuth callback and the webhook are
built from
NEXT_PUBLIC_BROKER_URL ?? NEXT_PUBLIC_BUILDER_URL.token you choose.
{origin}/integrations/threads/callbackto theredirect callback URLs, then subscribe
moderate/repliesto{origin}/integrations/threads/webhook?appId={app-id}with the same verifytoken. Save the credential in ChatbotX first — the webhook resolves the
credential by
appIdand answers 404 before it ever compares the token.threads-commentsautomation with a keyword and a public reply.
different Threads account.
Two things that cost time if you hit them blind:
with the connected account itself produces no reply, no log and no error —
it looks identical to a broken webhook. This filter is also what stops the
bot's own reply from re-triggering the automation.
test webhooks are delivered. In practice deliveries to the app owner's own
authorized account do arrive, so a full run is possible without going live.
Regression tests
Every defect above shipped green, so the new tests assert the invariant rather than one channel's behaviour — a future channel that forgets a registration fails here rather than in production:
withIntegrationseager-loads is defined oninboxModel, and the converset()key in the builder source exists inen.json— the gapi18n:checkleaves opendisconnectWorkspaceChannelsdeletes each channel's integration row instead of falling through todefaultEach was verified by reverting its fix and confirming the test fails first. The i18n test also asserts that its own scan reached the source tree, so a broken matcher cannot quietly turn it into a no-op.
Testing
apps/workerapps/builder@chatbotx.io/integration-threads@chatbotx.io/business@chatbotx.io/variables@chatbotx.io/databasepnpm lintis clean, as arecheck-typesfor worker, builder, business, variables and database.Notes for review
supports*predicate returnstrueformessenger/instagram/instagramFacebook, so all the new guards are no-ops on those channels.20260820152925_create_channel_api. The enum value is a separate migration because Postgres cannot use a newly added enum value inside the transaction that adds it.valuestype on a parse failure) is deliberate: the previous log carried only zod issue paths, which is why identifying the wire-format change required a live capture. It runs only on the failure path.apichannel has nointegration*relation oninboxModel, is absent fromwithIntegrations, and has no teardown case. It is therefore outside the derived test matrix by construction, which both test files note. Reported to the maintainers separately.messages.loading(ads-campaign/components/messaging-ads-box.tsx) is missing fromen.json. It predates this branch, so it sits in a small allowlist in the i18n test, alongside an assertion that the allowlist has no stale entries — the entry disappears the moment the key is added.🤖 Generated with Claude Code