Skip to content

feat(threads): add comment auto-reply automation - #1020

Open
AlanSyue wants to merge 9 commits into
ChatbotXIO:mainfrom
AlanSyue:feat/threads-comment-automation
Open

feat(threads): add comment auto-reply automation#1020
AlanSyue wants to merge 9 commits into
ChatbotXIO:mainfrom
AlanSyue:feat/threads-comment-automation

Conversation

@AlanSyue

@AlanSyue AlanSyue commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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/

  • Threads OAuth (authorize → short-lived → long-lived exchange) plus refreshAuth for the 60-day token
  • Webhook handler for comment events, with error sanitizing/mapping consistent with the sibling Meta integrations
  • Registers the comment channel only. Threads has no Messaging API, so no message channel is declared rather than declaring one that always fails.

Database

  • New IntegrationThreads table (one row per connected account; unique on threadsUserId and on inboxId)
  • fbCommentAutomationType gains a threads value

Worker — comment automation extended to Threads

The existing pipeline now runs on Threads behind explicit capability predicates rather than silent no-ops:

capability messenger / instagram threads
public comment reply yes yes
private reply (DM) yes no — Threads has no private-reply API
like the user's comment yes no — no POST /{comment-id}/likes equivalent
hide comment yes no

An 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 refreshChannelTokens adapter on the daily 02:00 run. Threads tokens are 60-day, so it deliberately does not join the midday short-lived pass.

Builder

  • Threads entry in CHANNEL_CAPABILITIES, so it appears in the channel create picker and settings without any new hardcoded channel list
  • Platform-credential screen for the Threads app (app id/secret + webhook URL) and the connect / disconnect / reconnect flows
  • threads-comments CRUD pages mirroring the existing Facebook and Instagram comment-automation screens
  • All strings go through useTranslations(); 20 locale files updated

End-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 values as a single {field, value} object. Live deliveries send an array of those entries. The schema followed the docs, so every real webhook failed safeParse, 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.withIntegrations eager-loads integrationThreads, but no such relation was defined on inboxModel. Drizzle threw Cannot read properties of undefined (reading 'targetTable') on every InboxService.list with integrations — so the workspace dashboard was unreachable for all workspaces, not only those using Threads. InboxWithIntegrations already declared the field, which is why tsc stayed quiet.

3. Teardown and variables were missing the channel

disconnectWorkspaceInbox had no threads case and fell through to default, leaving IntegrationThreads rows behind on workspace teardown — even though the provider's own disconnect documents that row deletion is the business layer's job. getChannelIntegrationId and the page_user_name resolver 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 used fields.version.label, likewise absent everywhere. Both threw MISSING_MESSAGE at render.

The status column now uses the same Switch the Facebook and Instagram tables use, which removes the need for those two keys rather than inventing them in twenty files. fields.version.label is added to all locales.

Worth noting: i18n:check runs in lint and was green throughout, because it compares locales against each other. A key missing from every locale — including en — is invisible to it.

What the live run confirms

scenario result
comment matching the keyword public reply published to Threads, repliesCount incremented
comment matching no keyword inbox message created, no reply, Comment automation skipped: keywords do not match
the bot's own reply, redelivered as a webhook filtered by the self-comment check — no reply loop
Meta redelivering the same comment single reply; the existing sourceId dedup, the isNew guard and the deterministic BullMQ jobId each cover it
Reproducing 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.

  1. Platform credentials → Threads: app id, app secret, version, and a verify
    token you choose.
  2. In the Meta app, add {origin}/integrations/threads/callback to the
    redirect callback URLs, then subscribe moderate / replies to
    {origin}/integrations/threads/webhook?appId={app-id} with the same verify
    token. Save the credential in ChatbotX first — the webhook resolves the
    credential by appId and answers 404 before it ever compares the token.
  3. Connect the account (Channels → Threads), then create a threads-comments
    automation with a keyword and a public reply.
  4. Post a thread from the connected account and comment on it from a
    different Threads account
    .

Two things that cost time if you hit them blind:

  • The handler drops any comment whose author is the post author, so testing
    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.
  • While the Meta app is in development mode the dashboard warns that only
    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:

  • every relation 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
  • disconnectWorkspaceChannels deletes each channel's integration row instead of falling through to default
  • the variables channel maps resolve every channel
  • HMAC verification over multi-byte UTF-8 bodies, which no existing case exercised

Each 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

workspace result
apps/worker 190 files / 1826 tests
apps/builder 313 files / 2027 tests
@chatbotx.io/integration-threads 5 files / 64 tests
@chatbotx.io/business 142 files / 1494 tests
@chatbotx.io/variables 14 files / 192 tests
@chatbotx.io/database 28 files / 526 tests

pnpm lint is clean, as are check-types for worker, builder, business, variables and database.

Notes for review

  • Existing channels are untouched. Every new supports* predicate returns true for messenger / instagram / instagramFacebook, so all the new guards are no-ops on those channels.
  • Migrations are generated on top of 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.
  • The diagnostic logging added to the webhook handler (raw body, top-level keys, values type 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.
  • One pre-existing gap surfaced while writing the channel-coverage tests and is not addressed here: the api channel has no integration* relation on inboxModel, is absent from withIntegrations, 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 from en.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.
  • Happy to split the builder surface out of the integration + worker core if you would prefer to review this in smaller pieces.

🤖 Generated with Claude Code

AlanSyue and others added 2 commits August 22, 2026 19:03
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
@github-actions github-actions Bot added the feature New feature or request label Aug 22, 2026
@sung17

sung17 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

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
@AlanSyue

Copy link
Copy Markdown
Contributor Author

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.

@sung17

sung17 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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.
@AlanSyue
AlanSyue marked this pull request as ready for review September 1, 2026 09:59
@AlanSyue

AlanSyue commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@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.

  • Meta sends values as an array, not the single object their docs describe. Every real webhook failed schema validation and returned silently. The unit tests passed because their fixtures were written from that same documentation.
  • A missing Drizzle relation broke InboxService.list for every workspace, not just Threads users — the dashboard was unreachable. InboxWithIntegrations already declared the field, so tsc stayed quiet.
  • Workspace teardown left IntegrationThreads rows behind, and page_user_name returned null on the channel.
  • Two translation keys did not exist in any locale, so both threw at render. Worth flagging separately: i18n:check was green throughout, because it compares locales against each other — a key missing from every locale including en is invisible to it.

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 Record<ChannelType, …> maps from #1054 and #1074, plus the features/common/schemasschema rename. All textually clean merges, which is why they only showed up under a type check.

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.

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

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants