feat(channels): align facebook callbacks
This commit is contained in:
@@ -59,6 +59,10 @@ var criticalRoutes = []route{
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/agents/bulk_create", Controller: "api/v1/accounts/agents#bulk_create", Source: "routes.rb:60"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/assignable_agents", Controller: "api/v1/accounts/assignable_agents#index", Source: "routes.rb:104"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/audit_logs/", Controller: "api/v1/accounts/audit_logs#show", Source: "routes.rb:105"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/callbacks/register_facebook_page", Controller: "api/v1/accounts/callbacks#register_facebook_page", Source: "routes.rb:108-109"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/callbacks/register_facebook_page", Controller: "api/v1/accounts/callbacks#register_facebook_page", Source: "routes.rb:108-109"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/callbacks/facebook_pages", Controller: "api/v1/accounts/callbacks#facebook_pages", Source: "routes.rb:110"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/callbacks/reauthorize_page", Controller: "api/v1/accounts/callbacks#reauthorize_page", Source: "routes.rb:111"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/agent_bots", Controller: "api/v1/accounts/agent_bots#index", Source: "routes.rb:94"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/agent_bots", Controller: "api/v1/accounts/agent_bots#create", Source: "routes.rb:94"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/agent_bots/:agent_bot_id", Controller: "api/v1/accounts/agent_bots#show", Source: "routes.rb:94"},
|
||||
|
||||
@@ -49,16 +49,16 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 account WhatsApp calls API parity checkpoint, prepared as `feat(channels): align whatsapp calls api`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(channels): align whatsapp calls api`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update for P3.37 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Current tracking checkpoint: 2026-06-06 Facebook callbacks route/payload parity checkpoint, prepared as `feat(channels): align facebook callbacks`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(channels): align facebook callbacks`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update for P3.38 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
|
||||
- Worktree status at this implementation checkpoint: account WhatsApp calls API from `reference/chatwoot/config/routes.rb:237-245`, enterprise `WhatsappCallsController`, WhatsApp call Jbuilder views/specs, `Whatsapp::CallService`, `Call`, and reused dashboard `whatsappCallsAPI.js` is implemented. GoChat now registers the account-level show/initiate/accept/reject/terminate/upload-recording routes used by the frontend, persists WhatsApp calls in the Chatwoot `calls` model boundary, creates linked `voice_call` messages for outbound calls, updates call/message/conversation status for accept/reject/terminate, handles permission-request and recording idempotency payloads, and keeps the old conversation-nested tracking routes as compatibility aliases. Account onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- Worktree status at this implementation checkpoint: Facebook callback routes from `reference/chatwoot/config/routes.rb:106-111`, `Api::V1::Accounts::CallbacksController`, callback Jbuilder views/specs, and reused dashboard `fbChannel.js`/`endPoints.js` are implemented. GoChat now registers account-level `callbacks/register_facebook_page`, `callbacks/facebook_pages`, `callbacks/facebook_pages.json`, and `callbacks/reauthorize_page`; exchanges short-lived Facebook login tokens through a fakeable provider boundary; returns Chatwoot-shaped page discovery payloads with `exists`; creates Facebook inboxes from callback payloads; persists `user_access_token`; reauthorizes matching Facebook page inboxes; refreshes page token/channel config; and preserves the existing channel-specific compatibility routes. Account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- Next executable implementation checkpoint: continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence.
|
||||
- `go test ./...` passes when run outside the restricted socket sandbox; focused WhatsApp call service/handler/router/route-parity tests pass in the sandbox.
|
||||
- Route dump succeeds with `958` registered routes after account WhatsApp calls route tracking.
|
||||
- `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox.
|
||||
- Route dump succeeds with `963` registered routes after Facebook callback route tracking.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
- Tracked frontend-critical route audit covers 421 Chatwoot routes: 403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing. The 18 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, WhatsApp call `:id` route names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
|
||||
- Tracked frontend-critical route audit covers 425 Chatwoot routes: 407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing. The 18 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, WhatsApp call `:id` route names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
|
||||
- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers.
|
||||
- Handler test stability fixes are committed into the baseline before feature parity work continues.
|
||||
- `.codegraph/` is generated indexing output and is not part of tracked product code.
|
||||
@@ -140,6 +140,7 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
|
||||
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0 | P3.38 Facebook callbacks route/payload parity | Implemented for reused dashboard Facebook login and reauthorization flows: account-level `GET/POST /api/v1/accounts/:account_id/callbacks/register_facebook_page`, `POST /callbacks/facebook_pages`, frontend `.json` alias, and `POST /callbacks/reauthorize_page` are registered and tracked from `routes.rb:106-111`; page discovery exchanges `omniauth_token` for a long-lived user token through a fakeable Facebook provider boundary and returns `{ data: { page_details, user_access_token } }` with Chatwoot `exists` flags; register creates a Facebook inbox/channel from callback payloads and returns the raw callback Jbuilder fields; reauthorize validates account-scoped Facebook inboxes, refreshes the matching page token, clears `reauthorization_required`, updates inbox channel config, and returns `{ data: inbox }`. | Keep in Review; reopen only if live Facebook login smoke exposes Koala pagination/body drift, avatar import side-effect requirements, Facebook page subscription setup drift, or inbox serializer fields beyond the inspected callbacks controller/Jbuilder/frontend contract. | Focused Facebook callback handler tests, router/route-parity tests, route dump/parity regeneration (`963` routes; `407 exact`, `18 parameter-compatible`, `0 missing out of 425`), full `go test ./...`, and `git diff --check` must pass. |
|
||||
| 0 | P3.37 account WhatsApp calls API parity | Implemented for reused dashboard WhatsApp WebRTC call controls: account-level `GET /api/v1/accounts/:account_id/whatsapp_calls/:id`, `POST /whatsapp_calls/initiate`, `POST /whatsapp_calls/:id/accept`, `reject`, `terminate`, and `upload_recording` are registered and tracked from `routes.rb:237-245`; initiate validates enabled WhatsApp Cloud calling, `sdp_offer`, and contact phone; outbound calls persist as Chatwoot `calls` with linked `voice_call` messages and return `{ status: "calling", call_id }`; accept/reject/terminate update call/message/conversation status through a fakeable Meta provider boundary; permission-request and upload-recording idempotency responses match the inspected controller/spec contract. | Keep in Review; reopen only if live WhatsApp calling smoke exposes Meta call action body drift, ActionCable broadcast requirements beyond persisted state, permission-template throttling drift, recording storage URL drift, or call bubble serializer drift beyond the inspected enterprise controller/service/Jbuilder/frontend contract. | Focused WhatsApp call service/handler tests, router/route-parity tests, route dump/parity regeneration (`958` routes; `403 exact`, `18 parameter-compatible`, `0 missing out of 421`), full `go test ./...`, and `git diff --check` must pass. |
|
||||
| 0 | P3.36 account onboarding route/payload parity | Implemented for reused dashboard onboarding: `PATCH /api/v1/accounts/:account_id/onboarding` is registered and tracked from `routes.rb:58`, administrator-gated like Chatwoot `OnboardingsController#update`, accepts raw `name`, `locale`, `website`, `industry`, `company_size`, `timezone`, `referral_source`, and `user_role`, persists account `custom_attributes` through `accounts.custom_attributes`, clears `onboarding_step` when current account details are finalized, and returns the raw account serializer with selected custom-attribute fields. | Keep in Review; reopen only if live onboarding smoke exposes additional account serializer fields, empty-string/null strong-param drift, admin policy drift, or onboarding step behavior beyond the inspected controller/Jbuilder/frontend contract. | Focused Account handler/service tests, router/route-parity tests, route dump/parity regeneration (`952` routes; `402 exact`, `13 parameter-compatible`, `0 missing out of 415`), full `go test ./...`, and `git diff --check` must pass. |
|
||||
| 0 | P3.35 account notification scoping hardening | Implemented for reused dashboard notification actions: account notifications now follow Chatwoot `fetch_notification` scope (`current_user.notifications.where(account_id: Current.account.id)`) for show, update/read, destroy, snooze, unread, and unread count. `snoozed_until` is parsed as Unix seconds like `DateRangeHelper`, snooze merges `meta.last_snoozed_at: null`, missing `snoozed_until` returns the scoped notification without mutation, destroy returns empty `200 OK`, and cross-user/cross-account mutations return `404`. | Keep in Review; reopen only if live notification-center smoke exposes list metadata, snooze timestamp, read/unread, destroy_all async, or notification serializer drift beyond the inspected controller/finder/Jbuilder/frontend contract. | Focused Notification handler/repository/service tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change because only handler/repository/service behavior changed. |
|
||||
@@ -156,7 +157,7 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
| 0 | P3.24 Slack integration parity | Implemented for reused dashboard Slack settings flow: no-trailing and trailing singleton routes are registered for create/update/delete, `PUT` and `PATCH` update both work, create accepts frontend `code` and exchanges it for a Slack access token, hooks are persisted with `app_id: slack` and disabled status, update accepts frontend `reference_id`, fetches real private/public Slack channels with pagination, joins public channels, persists `reference_id/settings.channel_name/status`, create/update return raw Chatwoot app payloads with hooks, list-all returns raw channel arrays, invalid channels return Chatwoot's `422 { error }`, and delete returns empty `200 OK`. | Keep in Review; reopen only if live Slack OAuth/channel smoke exposes OAuth redirect, provider error, app serializer, or Slack channel pagination drift beyond the inspected controller/builder/spec/frontend contract. | Focused Slack handler/service tests, route dump/parity regeneration, sandbox focused `go test`, escalated full `go test ./...`, and `git diff --check` passed. |
|
||||
| 0 | P3.23 nested contact inbox creation API | Implemented for Chatwoot nested contact inbox creation: raw JSON/form/query params are accepted, contact and inbox are account-scoped, missing source IDs are generated through Chatwoot channel rules, duplicate contact+inbox+source rows are returned idempotently, `hmac_verified` is persisted on creation, and the response is raw `{ source_id, inbox }` rather than the local model/envelope. | Keep in Review; reopen only if live CRM/new-conversation smoke exposes inbox access-policy, unsupported channel, or serializer drift beyond the inspected controller/builder/Jbuilder contract. | Focused nested ContactInbox handler/service/repository tests, route parity check, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 421-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, account WhatsApp call routes from `routes.rb:237-245`, account onboarding route from `routes.rb:58`, API v2 year-in-review route from `routes.rb:505`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers plus current-user/current-account mutation scoping, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, account onboarding custom-attribute payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, account WhatsApp call lifecycle/recording behavior, API v2 year-in-review raw/cache/aggregate behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.37 WhatsApp calls or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 425-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account Facebook callback routes from `routes.rb:106-111`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, account WhatsApp call routes from `routes.rb:237-245`, account onboarding route from `routes.rb:58`, API v2 year-in-review route from `routes.rb:505`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers plus current-user/current-account mutation scoping, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, account onboarding custom-attribute payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account Facebook callback page discovery/register/reauthorization behavior, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, account WhatsApp call lifecycle/recording behavior, API v2 year-in-review raw/cache/aggregate behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.38 Facebook callbacks or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. |
|
||||
| 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 5 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
@@ -183,6 +184,7 @@ These rows are the executable development plan from this point forward. A checkp
|
||||
|
||||
| ID | Owner files | Reference files | Work to land | Exit gate |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| P3.38 Facebook callbacks route/payload parity | `internal/handler/api/v1/facebook_channel_handler.go`, `internal/channel/facebook/provider.go`, `internal/model/channel/facebook.go`, `internal/service/inbox_service.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000035_align_facebook_callback_fields.*`, Facebook callback handler/router tests | `reference/chatwoot/config/routes.rb:106-111`, `reference/chatwoot/app/controllers/api/v1/accounts/callbacks_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/callbacks/*.json.jbuilder`, `reference/chatwoot/spec/controllers/api/v1/accounts/callbacks_controller_spec.rb`, `reference/chatwoot/app/javascript/dashboard/api/channel/fbChannel.js`, `reference/chatwoot/app/javascript/dashboard/api/endPoints.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/inboxes.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue` | Done. Facebook callbacks now match the reused dashboard route family: register page, page discovery, `.json` page discovery alias, and reauthorize routes are tracked and registered; callback payloads accept raw frontend params; short-lived login tokens are exchanged through a fakeable provider boundary; page discovery marks existing account pages; register creates Facebook inbox/channel rows and stores user/page tokens; reauthorize refreshes the matching page token and inbox channel config, clears `reauthorization_required`, and returns `{ data: inbox }`. | Review by `feat(channels): align facebook callbacks`; focused handler tests cover register, page discovery `.json`, successful reauthorization, and missing-page `422`; route artifacts cover the four Chatwoot callback routes; full `go test ./...` and `git diff --check` must pass. |
|
||||
| P3.37 account WhatsApp calls API parity | `internal/model/call.go`, `internal/service/whatsapp_call_service.go`, `internal/handler/api/v1/whatsapp_call_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000034_align_calls_whatsapp_fields.*`, WhatsApp call service/handler/router tests | `reference/chatwoot/config/routes.rb:237-245`, `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb`, `reference/chatwoot/enterprise/app/views/api/v1/models/_whatsapp_call.json.jbuilder`, `reference/chatwoot/enterprise/app/views/api/v1/accounts/whatsapp_calls/*.json.jbuilder`, `reference/chatwoot/enterprise/app/services/whatsapp/call_service.rb`, `reference/chatwoot/enterprise/app/services/voice/call_message_builder.rb`, `reference/chatwoot/enterprise/app/models/call.rb`, `reference/chatwoot/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb`, `reference/chatwoot/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js` | Done. Account WhatsApp calls now match the reused dashboard route family: account-level show/initiate/accept/reject/terminate/upload-recording routes are tracked and registered; initiate resolves conversations by display ID, validates WhatsApp Cloud calling and contact phone, calls a fakeable Meta provider boundary, creates outbound `calls` rows and linked `voice_call` messages, and returns `{ status, call_id }`; accept/reject/terminate update call/message/conversation status; permission requests record conversation attributes; recording upload is idempotent over audio attachments. | Review by `feat(channels): align whatsapp calls api`; focused service tests cover outbound creation, permission request, accept/terminate/recording, guard errors, and no-answer termination; handler tests cover account route payloads; route artifacts cover the six enterprise routes; full `go test ./...` and `git diff --check` must pass. |
|
||||
| P3.36 account onboarding route/payload parity | `internal/model/account.go`, `internal/service/account_service.go`, `internal/handler/api/v1/account_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000033_add_accounts_custom_attributes.*`, account handler/service/router tests | `reference/chatwoot/config/routes.rb:58`, `reference/chatwoot/app/controllers/api/v1/accounts/onboardings_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/update.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_account.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/onboarding.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/accounts.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue` | Done. Account onboarding update now matches the reused dashboard path: `PATCH /api/v1/accounts/:account_id/onboarding` is tracked and registered with administrator authorization; the handler accepts the raw frontend onboarding payload; the service merges permitted onboarding attributes into persisted account `custom_attributes`, mirrors timezone for existing account consumers, clears `onboarding_step` only when finalizing account details, and the account serializer emits the Chatwoot selected custom-attribute fields while preserving subscription/deletion placeholders. | Review by `feat(accounts): align onboarding update`; focused account handler tests cover finalizing and non-finalizing onboarding merges plus raw account response shape; service/router/route-parity tests and route artifacts cover the new account route; full `go test ./...` and `git diff --check` must pass. |
|
||||
| P3.35 account notification scoping hardening | `internal/repository/notification_repo.go`, `internal/service/notification_service.go`, `internal/handler/api/v1/notification_handler.go`, notification handler/repository/service tests | `reference/chatwoot/config/routes.rb:283-291`, `reference/chatwoot/app/controllers/api/v1/accounts/notifications_controller.rb`, `reference/chatwoot/app/finders/notification_finder.rb`, `reference/chatwoot/app/views/api/v1/accounts/notifications/index.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/notifications.js` | Done. Account notification show/update/destroy/snooze/unread/unread_count now use current-user/current-account scope; cross-account and cross-user rows do not leak; `snoozed_until` accepts Unix seconds; snooze merges `meta.last_snoozed_at`; absent `snoozed_until` is a scoped no-op; destroy keeps Chatwoot empty `200 OK`. | Review by `feat(notifications): tighten chatwoot scoping`; focused handler tests cover mutation scoping, account unread count, Unix snooze, no-body snooze, and destroy route coverage; repository/service notification tests cover scoped helpers; full `go test ./...` and `git diff --check` passed. No route artifacts change. |
|
||||
@@ -258,6 +260,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(channels): align facebook callbacks` | Advances P3.38 Facebook callbacks route/payload parity by matching Chatwoot `Api::V1::Accounts::CallbacksController`, callback Jbuilder views/specs, routes `106-111`, and reused dashboard `fbChannel.js`/`endPoints.js`/Facebook reauthorize store flow. GoChat now exposes account-level register/page-discovery/reauthorize callback routes, supports the frontend `.json` page-discovery alias, exchanges short-lived login tokens through a fakeable provider boundary, marks existing Facebook pages, creates Facebook inboxes from callback payloads, persists `user_access_token`, refreshes page tokens and inbox channel config on reauthorize, and returns Chatwoot-shaped raw callback payloads. | `go test ./internal/handler/api/v1 ./internal/channel/facebook ./internal/router ./cmd/route_parity -run 'Facebook\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 963`; tracked route parity is `407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425`. | P3.38 moves to Review for current Facebook callback evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(channels): align whatsapp calls api` | Advances P3.37 account WhatsApp calls API parity by matching Chatwoot enterprise `Api::V1::Accounts::WhatsappCallsController`, `Whatsapp::CallService`, `Voice::CallMessageBuilder`, `Call`, WhatsApp call Jbuilder views/specs, route `237-245`, and reused dashboard `whatsappCallsAPI.js`. GoChat now exposes account-level show/initiate/accept/reject/terminate/upload-recording routes, persists WhatsApp calls through the `calls` model boundary, creates linked `voice_call` messages, updates call/message/conversation status for lifecycle actions, records permission-request attributes, and makes recording upload idempotent. | `go test ./internal/service ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'WhatsAppCall\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 958`; tracked route parity is `403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421`. | P3.37 moves to Review for current WhatsApp calls evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(accounts): align onboarding update` | Advances P3.36 account onboarding route/payload parity by matching Chatwoot `Api::V1::Accounts::OnboardingsController#update`, account update/model Jbuilder payloads, route `58`, and reused dashboard onboarding API/store/Vue flow. GoChat now persists `accounts.custom_attributes`, exposes administrator-gated `PATCH /api/v1/accounts/:account_id/onboarding`, accepts the raw onboarding payload, merges permitted custom attributes, clears `onboarding_step` when finalizing account details, and returns the raw account serializer shape. | `go test ./internal/handler/api/v1 -run AccountHandler -count=1`; `go test ./internal/service -run AccountService -count=1`; `go test ./cmd/route_parity ./internal/router -run 'RouteParity\|RegisterRoutes' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 952`; tracked route parity is `402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 415`. | P3.36 moves to Review for current onboarding/account serializer evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(notifications): tighten chatwoot scoping` | Advances P3.35 account notification scoping hardening by matching Chatwoot `NotificationsController#fetch_notification`, `NotificationFinder`, notification routes `283-291`, index Jbuilder behavior, and reused dashboard `notifications.js`. GoChat now scopes show, update/read, destroy, snooze, unread, and unread_count to the current user and account; parses snooze timestamps as Unix seconds; merges `meta.last_snoozed_at: null`; treats missing `snoozed_until` as a scoped no-op; and keeps destroy as empty `200 OK`. | `go test ./internal/handler/api/v1 -run Notification -count=1`; `go test ./internal/repository -run Notification -count=1`; `go test ./internal/service -run Notification -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.35 moves to Review for current notification action scoping evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
@@ -2276,6 +2279,7 @@ Verification milestone gates:
|
||||
|
||||
## Progress Log
|
||||
|
||||
- 2026-06-06: P3.38 Facebook callbacks checkpoint prepared as `feat(channels): align facebook callbacks`; audited Chatwoot account callback routes `106-111`, `CallbacksController`, callback Jbuilder views, controller specs, reused dashboard `fbChannel.js`, `endPoints.js`, inbox store reauthorization action, and Facebook reauthorize screen. GoChat now registers account-level Facebook callback register/page-discovery/reauthorize routes plus the frontend `.json` page-discovery alias, exchanges login tokens through a fakeable Facebook provider boundary, returns page discovery payloads with `exists`, creates callback Facebook inbox/channel records with `user_access_token`, refreshes matching page tokens and inbox channel config during reauthorization, and returns Chatwoot-shaped callback payloads. Focused Facebook callback handler/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 963` and tracked route parity is `407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425`.
|
||||
- 2026-06-06: P3.37 account WhatsApp calls checkpoint prepared as `feat(channels): align whatsapp calls api`; audited Chatwoot enterprise WhatsApp call routes `237-245`, `WhatsappCallsController`, WhatsApp call Jbuilder views, `Whatsapp::CallService`, `Voice::CallMessageBuilder`, `Call`, controller specs, and reused dashboard `whatsappCallsAPI.js`. GoChat now registers account-level WhatsApp call show/initiate/accept/reject/terminate/upload-recording routes, persists calls through the `calls` model, creates linked `voice_call` messages for outbound calls, updates message/conversation call status through lifecycle actions, records permission-request conversation attributes, and returns idempotent recording upload status. Focused WhatsApp call service/handler/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 958` and tracked route parity is `403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421`.
|
||||
- 2026-06-06: P3.36 account onboarding checkpoint prepared as `feat(accounts): align onboarding update`; audited Chatwoot account onboarding route `58`, `Api::V1::Accounts::OnboardingsController#update`, account update/model Jbuilder views, and reused dashboard `api/onboarding.js`, account store `finishOnboarding`, and onboarding Vue payload. GoChat now registers administrator-gated `PATCH /api/v1/accounts/:account_id/onboarding`, adds persisted `accounts.custom_attributes`, accepts the raw frontend payload, merges permitted onboarding custom attributes, clears `onboarding_step` when finalizing account details, and returns the raw account serializer with Chatwoot selected custom-attribute fields. Focused account handler/service tests, router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 952` and tracked route parity is `402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 415`.
|
||||
- 2026-06-06: P3.35 account notification scoping checkpoint prepared as `feat(notifications): tighten chatwoot scoping`; audited Chatwoot notification routes `283-291`, `NotificationsController`, `NotificationFinder`, notification index Jbuilder, and reused dashboard `api/notifications.js`. Account notification show/update/destroy/snooze/unread/unread_count now use current-user/current-account scope, cross-account and cross-user rows return `404`, `snoozed_until` is parsed as Unix seconds, snooze merges `meta.last_snoozed_at: null`, absent `snoozed_until` is a scoped no-op returning the notification, and destroy returns empty `200 OK`. Focused notification handler/repository/service tests, full `go test ./...`, and `git diff --check` passed. No route artifacts changed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
|
||||
@@ -162,6 +162,7 @@ GET /api/v1/accounts/:account_id/automation_rules/
|
||||
GET /api/v1/accounts/:account_id/automation_rules/:automation_id
|
||||
GET /api/v1/accounts/:account_id/banners
|
||||
GET /api/v1/accounts/:account_id/cache_keys
|
||||
GET /api/v1/accounts/:account_id/callbacks/register_facebook_page
|
||||
GET /api/v1/accounts/:account_id/campaigns
|
||||
GET /api/v1/accounts/:account_id/campaigns/
|
||||
GET /api/v1/accounts/:account_id/campaigns/:campaign_id
|
||||
@@ -590,6 +591,10 @@ POST /api/v1/accounts/:account_id/automation_rules/
|
||||
POST /api/v1/accounts/:account_id/automation_rules/:automation_id/clone
|
||||
POST /api/v1/accounts/:account_id/automation_rules/:automation_id/toggle_active
|
||||
POST /api/v1/accounts/:account_id/bulk_actions
|
||||
POST /api/v1/accounts/:account_id/callbacks/facebook_pages
|
||||
POST /api/v1/accounts/:account_id/callbacks/facebook_pages.json
|
||||
POST /api/v1/accounts/:account_id/callbacks/reauthorize_page
|
||||
POST /api/v1/accounts/:account_id/callbacks/register_facebook_page
|
||||
POST /api/v1/accounts/:account_id/campaigns
|
||||
POST /api/v1/accounts/:account_id/campaigns/
|
||||
POST /api/v1/accounts/:account_id/campaigns/:campaign_id/start
|
||||
@@ -956,4 +961,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 958
|
||||
TOTAL: 963
|
||||
|
||||
@@ -7,7 +7,7 @@ Generated from:
|
||||
|
||||
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
|
||||
|
||||
Summary: 403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421 tracked critical routes.
|
||||
Summary: 407 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 425 tracked critical routes.
|
||||
|
||||
## Missing Critical Routes
|
||||
|
||||
@@ -121,6 +121,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| GET | `/api/v1/accounts/:account_id/automation_rules/` | `/api/v1/accounts/:account_id/automation_rules/` | `api/v1/accounts/automation_rules#index` | `routes.rb:115` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/automation_rules/:automation_id` | `/api/v1/accounts/:account_id/automation_rules/:automation_id` | `api/v1/accounts/automation_rules#show` | `routes.rb:115` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/cache_keys` | `/api/v1/accounts/:account_id/cache_keys` | `api/v1/accounts#cache_keys` | `routes.rb:50` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/callbacks/register_facebook_page` | `/api/v1/accounts/:account_id/callbacks/register_facebook_page` | `api/v1/accounts/callbacks#register_facebook_page` | `routes.rb:108-109` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/canned_responses/` | `/api/v1/accounts/:account_id/canned_responses/` | `api/v1/accounts/canned_responses#index` | `routes.rb:114` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/captain/assistant_responses/` | `/api/v1/accounts/:account_id/captain/assistant_responses/` | `api/v1/accounts/captain/assistant_responses#index` | `routes.rb:74` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/captain/assistant_responses/:response_id` | `/api/v1/accounts/:account_id/captain/assistant_responses/:response_id` | `api/v1/accounts/captain/assistant_responses#show` | `routes.rb:74` | exact |
|
||||
@@ -300,6 +301,9 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| POST | `/api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes` | `/api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes` | `api/v1/accounts/assignment_policies/inboxes#create` | `routes.rb:307` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/automation_rules/` | `/api/v1/accounts/:account_id/automation_rules/` | `api/v1/accounts/automation_rules#create` | `routes.rb:115` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/automation_rules/:automation_id/clone` | `/api/v1/accounts/:account_id/automation_rules/:automation_id/clone` | `api/v1/accounts/automation_rules#clone` | `routes.rb:116` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/callbacks/facebook_pages` | `/api/v1/accounts/:account_id/callbacks/facebook_pages` | `api/v1/accounts/callbacks#facebook_pages` | `routes.rb:110` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/callbacks/reauthorize_page` | `/api/v1/accounts/:account_id/callbacks/reauthorize_page` | `api/v1/accounts/callbacks#reauthorize_page` | `routes.rb:111` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/callbacks/register_facebook_page` | `/api/v1/accounts/:account_id/callbacks/register_facebook_page` | `api/v1/accounts/callbacks#register_facebook_page` | `routes.rb:108-109` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/canned_responses/` | `/api/v1/accounts/:account_id/canned_responses/` | `api/v1/accounts/canned_responses#create` | `routes.rb:114` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/captain/assistant_responses/` | `/api/v1/accounts/:account_id/captain/assistant_responses/` | `api/v1/accounts/captain/assistant_responses#create` | `routes.rb:74` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/captain/assistants/` | `/api/v1/accounts/:account_id/captain/assistants/` | `api/v1/accounts/captain/assistants#create` | `routes.rb:64` | exact |
|
||||
|
||||
@@ -41,8 +41,8 @@ import (
|
||||
"github.com/go-resty/resty/v2"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -460,7 +460,7 @@ func (p *FacebookProvider) Capabilities() channel.ChannelCapabilities {
|
||||
SupportsTemplates: true,
|
||||
SupportsEmailHeaders: false,
|
||||
MaxAttachmentSize: 25 * 1024 * 1024, // 25MB
|
||||
MaxTextLength: 2000, // Messenger limit
|
||||
MaxTextLength: 2000, // Messenger limit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,14 +521,102 @@ func (p *FacebookProvider) ExchangeToken(ctx context.Context, code string, redir
|
||||
|
||||
tokenResult := resp.Result().(*FBLongLivedTokenResponse)
|
||||
return &channel.OAuthTokenResult{
|
||||
AccessToken: tokenResult.AccessToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(tokenResult.ExpiresIn) * time.Second),
|
||||
AccessToken: tokenResult.AccessToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(tokenResult.ExpiresIn) * time.Second),
|
||||
Extra: channel.ChannelConfig{
|
||||
"token_type": tokenResult.TokenType,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExchangeLongLivedUserToken exchanges the short-lived Facebook login token used by
|
||||
// Chatwoot's callbacks controller for a long-lived user token.
|
||||
func (p *FacebookProvider) ExchangeLongLivedUserToken(ctx context.Context, omniauthToken string) (string, error) {
|
||||
if strings.TrimSpace(omniauthToken) == "" {
|
||||
return "", fmt.Errorf("omniauth_token is required")
|
||||
}
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetQueryParams(map[string]string{
|
||||
"grant_type": "fb_exchange_token",
|
||||
"client_id": p.appID,
|
||||
"client_secret": p.appSecret,
|
||||
"fb_exchange_token": omniauthToken,
|
||||
}).
|
||||
SetResult(&FBLongLivedTokenResponse{}).
|
||||
Get(fmt.Sprintf("%s/oauth/access_token", p.graphAPIBase))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
|
||||
return "", fmt.Errorf("facebook token exchange returned HTTP %d", resp.StatusCode())
|
||||
}
|
||||
result := resp.Result().(*FBLongLivedTokenResponse)
|
||||
if result.AccessToken == "" {
|
||||
return "", fmt.Errorf("facebook token exchange returned empty access_token")
|
||||
}
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
// ListFacebookPages returns all pages visible to the user token, following
|
||||
// Facebook Graph pagination just like Koala's next_page loop in Chatwoot.
|
||||
func (p *FacebookProvider) ListFacebookPages(ctx context.Context, userAccessToken string) ([]FBPageInfo, error) {
|
||||
if strings.TrimSpace(userAccessToken) == "" {
|
||||
return nil, fmt.Errorf("user_access_token is required")
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/me/accounts", p.graphAPIBase)
|
||||
pages := []FBPageInfo{}
|
||||
for endpoint != "" {
|
||||
var result struct {
|
||||
Data []FBPageInfo `json:"data"`
|
||||
Paging struct {
|
||||
Next string `json:"next"`
|
||||
} `json:"paging"`
|
||||
}
|
||||
req := p.client.R().SetContext(ctx).SetResult(&result)
|
||||
if !strings.Contains(endpoint, "access_token=") {
|
||||
req.SetQueryParam("access_token", userAccessToken)
|
||||
}
|
||||
resp, err := req.Get(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
|
||||
return nil, fmt.Errorf("facebook pages API returned HTTP %d", resp.StatusCode())
|
||||
}
|
||||
pages = append(pages, result.Data...)
|
||||
endpoint = result.Paging.Next
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// FetchInstagramBusinessAccountID returns the Instagram Business account ID
|
||||
// connected to a Facebook page token, if any.
|
||||
func (p *FacebookProvider) FetchInstagramBusinessAccountID(ctx context.Context, pageAccessToken string) (string, error) {
|
||||
if strings.TrimSpace(pageAccessToken) == "" {
|
||||
return "", nil
|
||||
}
|
||||
var result FBPageInfo
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetQueryParams(map[string]string{
|
||||
"fields": "instagram_business_account",
|
||||
"access_token": pageAccessToken,
|
||||
}).
|
||||
SetResult(&result).
|
||||
Get(fmt.Sprintf("%s/me", p.graphAPIBase))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
|
||||
return "", fmt.Errorf("facebook page instagram API returned HTTP %d", resp.StatusCode())
|
||||
}
|
||||
if result.InstagramBusinessAccount == nil {
|
||||
return "", nil
|
||||
}
|
||||
return result.InstagramBusinessAccount.ID, nil
|
||||
}
|
||||
|
||||
func (p *FacebookProvider) RefreshToken(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (*channel.OAuthTokenResult, error) {
|
||||
// Facebook long-lived tokens can be refreshed before they expire
|
||||
// Reference: https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived
|
||||
@@ -731,8 +819,8 @@ func (p *FacebookProvider) processMessagingEvent(inbox *model.Inbox, event *FBMe
|
||||
|
||||
if len(event.Message.Attachments) > 0 {
|
||||
attachments := p.processFBAttachments(event.Message.Attachments)
|
||||
incoming.ContentType = channel.ContentType(attachments[0].ContentType)
|
||||
incoming.Content = attachments[0].URL
|
||||
incoming.ContentType = channel.ContentType(attachments[0].ContentType)
|
||||
incoming.Content = attachments[0].URL
|
||||
|
||||
// Store all attachments as IncomingMessage.Attachments
|
||||
for _, att := range attachments {
|
||||
@@ -839,9 +927,9 @@ func (p *FacebookProvider) setupWebhookSubscription(ctx context.Context, appID s
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]interface{}{
|
||||
"object": "page",
|
||||
"callback_url": webhookURL,
|
||||
"verify_token": verifyToken,
|
||||
"object": "page",
|
||||
"callback_url": webhookURL,
|
||||
"verify_token": verifyToken,
|
||||
"subscribed_fields": []string{
|
||||
"messages", "messaging_postbacks", "messaging_optins",
|
||||
"message_deliveries", "message_reads", "messaging_referrals",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
facebookchannel "github.com/gochat/gochat/internal/channel/facebook"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
|
||||
type fakeFacebookCallbackProvider struct {
|
||||
longToken string
|
||||
pages []facebookchannel.FBPageInfo
|
||||
instagramID string
|
||||
}
|
||||
|
||||
func (f *fakeFacebookCallbackProvider) ExchangeLongLivedUserToken(context.Context, string) (string, error) {
|
||||
return f.longToken, nil
|
||||
}
|
||||
|
||||
func (f *fakeFacebookCallbackProvider) ListFacebookPages(context.Context, string) ([]facebookchannel.FBPageInfo, error) {
|
||||
return f.pages, nil
|
||||
}
|
||||
|
||||
func (f *fakeFacebookCallbackProvider) FetchInstagramBusinessAccountID(context.Context, string) (string, error) {
|
||||
return f.instagramID, nil
|
||||
}
|
||||
|
||||
func setupFacebookCallbackTest(t *testing.T, provider *fakeFacebookCallbackProvider) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelFacebook{}))
|
||||
|
||||
inboxRepo := repository.NewInboxRepo(db)
|
||||
fbRepo := repository.NewChannelFacebookRepo(db)
|
||||
inboxSvc := service.NewInboxService(inboxRepo, nil, nil, nil, nil, nil, nil)
|
||||
fbSvc := service.NewChannelFacebookService(fbRepo)
|
||||
handler := NewFacebookChannelHandler(fbSvc, nil, inboxSvc, fbRepo, provider)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/callbacks/register_facebook_page", handler.RegisterFacebookPage)
|
||||
r.GET("/api/v1/accounts/:account_id/callbacks/register_facebook_page", handler.RegisterFacebookPage)
|
||||
r.POST("/api/v1/accounts/:account_id/callbacks/facebook_pages", handler.FacebookPages)
|
||||
r.POST("/api/v1/accounts/:account_id/callbacks/facebook_pages.json", handler.FacebookPages)
|
||||
r.POST("/api/v1/accounts/:account_id/callbacks/reauthorize_page", handler.ReauthorizePage)
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestFacebookCallbacks_RegisterFacebookPage(t *testing.T) {
|
||||
router, db := setupFacebookCallbackTest(t, &fakeFacebookCallbackProvider{instagramID: "ig-123"})
|
||||
account := model.Account{Name: "Acme", Active: true}
|
||||
require.NoError(t, db.Create(&account).Error)
|
||||
|
||||
body := map[string]any{"user_access_token": "user-token", "page_access_token": "page-token", "page_id": "page-1", "inbox_name": "Facebook"}
|
||||
rec := performFacebookCallbackRequest(t, router, http.MethodPost, "/api/v1/accounts/1/callbacks/register_facebook_page", body)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
assert.Equal(t, "Facebook", payload["name"])
|
||||
assert.Equal(t, "facebook", payload["channel_type"])
|
||||
assert.Equal(t, "page-1", payload["page_id"])
|
||||
|
||||
var channel channelmodel.ChannelFacebook
|
||||
require.NoError(t, db.First(&channel).Error)
|
||||
assert.Equal(t, account.ID, channel.AccountID)
|
||||
assert.Equal(t, "user-token", channel.UserAccessToken)
|
||||
assert.Equal(t, "page-token", channel.PageAccessToken)
|
||||
assert.Equal(t, "ig-123", channel.InstagramBusinessAccountID)
|
||||
}
|
||||
|
||||
func TestFacebookCallbacks_FacebookPagesMarksExistingPages(t *testing.T) {
|
||||
provider := &fakeFacebookCallbackProvider{longToken: "long-user-token", pages: []facebookchannel.FBPageInfo{{ID: "page-1", Name: "Existing", AccessToken: "token-1"}, {ID: "page-2", Name: "New", AccessToken: "token-2"}}}
|
||||
router, db := setupFacebookCallbackTest(t, provider)
|
||||
account := model.Account{Name: "Acme", Active: true}
|
||||
require.NoError(t, db.Create(&account).Error)
|
||||
require.NoError(t, db.Create(&channelmodel.ChannelFacebook{AccountID: account.ID, InboxID: 10, PageID: "page-1", PageAccessToken: "old"}).Error)
|
||||
|
||||
rec := performFacebookCallbackRequest(t, router, http.MethodPost, "/api/v1/accounts/1/callbacks/facebook_pages.json", map[string]any{"omniauth_token": "short-token"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
UserAccessToken string `json:"user_access_token"`
|
||||
PageDetails []struct {
|
||||
ID string `json:"id"`
|
||||
Exists bool `json:"exists"`
|
||||
} `json:"page_details"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
assert.Equal(t, "long-user-token", payload.Data.UserAccessToken)
|
||||
require.Len(t, payload.Data.PageDetails, 2)
|
||||
assert.True(t, payload.Data.PageDetails[0].Exists)
|
||||
assert.False(t, payload.Data.PageDetails[1].Exists)
|
||||
}
|
||||
|
||||
func TestFacebookCallbacks_ReauthorizePageUpdatesMatchingPage(t *testing.T) {
|
||||
provider := &fakeFacebookCallbackProvider{longToken: "long-user-token", instagramID: "ig-456", pages: []facebookchannel.FBPageInfo{{ID: "page-1", Name: "Renamed", AccessToken: "new-page-token"}}}
|
||||
router, db := setupFacebookCallbackTest(t, provider)
|
||||
account := model.Account{Name: "Acme", Active: true}
|
||||
require.NoError(t, db.Create(&account).Error)
|
||||
inbox := createTestChannelInbox(t, db, account.ID, 0, "Facebook", "facebook", map[string]any{"page_id": "page-1", "page_access_token": "old-page-token"})
|
||||
channel := channelmodel.ChannelFacebook{AccountID: account.ID, InboxID: inbox.ID, PageID: "page-1", PageAccessToken: "old-page-token", ReauthorizationRequired: true}
|
||||
require.NoError(t, db.Create(&channel).Error)
|
||||
inbox.ChannelID = channel.ID
|
||||
require.NoError(t, db.Save(inbox).Error)
|
||||
|
||||
rec := performFacebookCallbackRequest(t, router, http.MethodPost, "/api/v1/accounts/1/callbacks/reauthorize_page", map[string]any{"omniauth_token": "short-token", "inbox_id": inbox.ID})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var updated channelmodel.ChannelFacebook
|
||||
require.NoError(t, db.First(&updated, channel.ID).Error)
|
||||
assert.Equal(t, "long-user-token", updated.UserAccessToken)
|
||||
assert.Equal(t, "new-page-token", updated.PageAccessToken)
|
||||
assert.Equal(t, "Renamed", updated.PageName)
|
||||
assert.Equal(t, "ig-456", updated.InstagramBusinessAccountID)
|
||||
assert.False(t, updated.ReauthorizationRequired)
|
||||
|
||||
var updatedInbox model.Inbox
|
||||
require.NoError(t, db.First(&updatedInbox, inbox.ID).Error)
|
||||
var config map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(updatedInbox.ChannelConfig), &config))
|
||||
assert.Equal(t, "new-page-token", config["page_access_token"])
|
||||
}
|
||||
|
||||
func TestFacebookCallbacks_ReauthorizePageReturns422WhenPageMissing(t *testing.T) {
|
||||
provider := &fakeFacebookCallbackProvider{longToken: "long-user-token", pages: []facebookchannel.FBPageInfo{{ID: "other-page", AccessToken: "token"}}}
|
||||
router, db := setupFacebookCallbackTest(t, provider)
|
||||
account := model.Account{Name: "Acme", Active: true}
|
||||
require.NoError(t, db.Create(&account).Error)
|
||||
inbox := createTestChannelInbox(t, db, account.ID, 0, "Facebook", "facebook", map[string]any{"page_id": "page-1"})
|
||||
require.NoError(t, db.Create(&channelmodel.ChannelFacebook{AccountID: account.ID, InboxID: inbox.ID, PageID: "page-1", PageAccessToken: "old"}).Error)
|
||||
|
||||
rec := performFacebookCallbackRequest(t, router, http.MethodPost, "/api/v1/accounts/1/callbacks/reauthorize_page", map[string]any{"omniauth_token": "short-token", "inbox_id": inbox.ID})
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
}
|
||||
|
||||
func performFacebookCallbackRequest(t *testing.T, router *gin.Engine, method, path string, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(encoded))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
@@ -15,23 +15,35 @@ package v1
|
||||
// - GET /api/v1/accounts/:id/channels/facebook_channel/authorization → OAuth authorize URL
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
facebookchannel "github.com/gochat/gochat/internal/channel/facebook"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
type FacebookCallbackProvider interface {
|
||||
ExchangeLongLivedUserToken(ctx context.Context, omniauthToken string) (string, error)
|
||||
ListFacebookPages(ctx context.Context, userAccessToken string) ([]facebookchannel.FBPageInfo, error)
|
||||
FetchInstagramBusinessAccountID(ctx context.Context, pageAccessToken string) (string, error)
|
||||
}
|
||||
|
||||
// FacebookChannelHandler handles Facebook Messenger channel management.
|
||||
// Follows InstagramChannelHandler pattern: uses top-level ChannelFacebookService for CRUD,
|
||||
// internal FacebookProvider for OAuth/authorization.
|
||||
type FacebookChannelHandler struct {
|
||||
fbChannelSvc *service.ChannelFacebookService
|
||||
fbProvider *facebookchannel.FacebookProvider
|
||||
fbCallbacks FacebookCallbackProvider
|
||||
inboxSvc *service.InboxService
|
||||
fbRepo *repository.ChannelFacebookRepo
|
||||
}
|
||||
@@ -42,10 +54,16 @@ func NewFacebookChannelHandler(
|
||||
fbProvider *facebookchannel.FacebookProvider,
|
||||
inboxSvc *service.InboxService,
|
||||
fbRepo *repository.ChannelFacebookRepo,
|
||||
callbackProviders ...FacebookCallbackProvider,
|
||||
) *FacebookChannelHandler {
|
||||
var callbacks FacebookCallbackProvider = fbProvider
|
||||
if len(callbackProviders) > 0 && callbackProviders[0] != nil {
|
||||
callbacks = callbackProviders[0]
|
||||
}
|
||||
return &FacebookChannelHandler{
|
||||
fbChannelSvc: fbChannelSvc,
|
||||
fbProvider: fbProvider,
|
||||
fbCallbacks: callbacks,
|
||||
inboxSvc: inboxSvc,
|
||||
fbRepo: fbRepo,
|
||||
}
|
||||
@@ -61,11 +79,8 @@ type FacebookAuthorizationRequest struct {
|
||||
// Authorization generates a Facebook OAuth authorize URL.
|
||||
// GET /api/v1/accounts/:id/channels/facebook_channel/authorization
|
||||
func (h *FacebookChannelHandler) Authorization(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Invalid account_id: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +95,7 @@ func (h *FacebookChannelHandler) Authorization(c *gin.Context) {
|
||||
req.RedirectURL = redirectURL
|
||||
}
|
||||
|
||||
authURL, err := h.fbProvider.BuildAuthURL(c.Request.Context(), uint(accountID), req.RedirectURL)
|
||||
authURL, err := h.fbProvider.BuildAuthURL(c.Request.Context(), accountID, req.RedirectURL)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to build Facebook auth URL: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to generate authorization URL"})
|
||||
@@ -99,14 +114,164 @@ type FacebookOAuthCallbackRequest struct {
|
||||
RedirectURL string `json:"redirect_url" validate:"required,url"`
|
||||
}
|
||||
|
||||
type FacebookCallbackRegisterRequest struct {
|
||||
OmniauthToken string `json:"omniauth_token" form:"omniauth_token"`
|
||||
UserAccessToken string `json:"user_access_token" form:"user_access_token"`
|
||||
PageAccessToken string `json:"page_access_token" form:"page_access_token"`
|
||||
PageID string `json:"page_id" form:"page_id"`
|
||||
InboxName string `json:"inbox_name" form:"inbox_name"`
|
||||
PageName string `json:"page_name" form:"page_name"`
|
||||
EnableAutoAssignment bool `json:"enable_auto_assignment" form:"enable_auto_assignment"`
|
||||
}
|
||||
|
||||
type FacebookCallbackPagesRequest struct {
|
||||
OmniauthToken string `json:"omniauth_token" form:"omniauth_token"`
|
||||
}
|
||||
|
||||
type FacebookCallbackReauthorizeRequest struct {
|
||||
OmniauthToken string `json:"omniauth_token" form:"omniauth_token"`
|
||||
InboxID uint `json:"inbox_id" form:"inbox_id"`
|
||||
}
|
||||
|
||||
// RegisterFacebookPage matches Chatwoot CallbacksController#register_facebook_page.
|
||||
func (h *FacebookChannelHandler) RegisterFacebookPage(c *gin.Context) {
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req FacebookCallbackRegisterRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.UserAccessToken) == "" && strings.TrimSpace(req.OmniauthToken) != "" {
|
||||
userToken, err := h.fbCallbacks.ExchangeLongLivedUserToken(c.Request.Context(), req.OmniauthToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.UserAccessToken = userToken
|
||||
}
|
||||
inboxName := strings.TrimSpace(req.InboxName)
|
||||
if inboxName == "" {
|
||||
inboxName = strings.TrimSpace(req.PageName)
|
||||
}
|
||||
if inboxName == "" || strings.TrimSpace(req.PageID) == "" || strings.TrimSpace(req.PageAccessToken) == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "inbox_name, page_id and page_access_token are required"})
|
||||
return
|
||||
}
|
||||
inbox, err := h.inboxSvc.CreateFacebookInbox(c.Request.Context(), accountID, service.CreateFacebookInboxRequest{
|
||||
Name: inboxName,
|
||||
PageID: req.PageID,
|
||||
PageAccessToken: req.PageAccessToken,
|
||||
UserAccessToken: req.UserAccessToken,
|
||||
PageName: req.PageName,
|
||||
EnableAutoAssignment: req.EnableAutoAssignment,
|
||||
}, h.fbRepo)
|
||||
if err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.setFacebookInstagramID(c.Request.Context(), inbox.ChannelID, req.PageAccessToken)
|
||||
c.JSON(http.StatusOK, h.facebookRegisterPayload(c.Request.Context(), inbox))
|
||||
}
|
||||
|
||||
// FacebookPages matches Chatwoot CallbacksController#facebook_pages.
|
||||
func (h *FacebookChannelHandler) FacebookPages(c *gin.Context) {
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req FacebookCallbackPagesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
userToken, err := h.fbCallbacks.ExchangeLongLivedUserToken(c.Request.Context(), req.OmniauthToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pages, err := h.fbCallbacks.ListFacebookPages(c.Request.Context(), userToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
existing := h.existingFacebookPages(c.Request.Context(), accountID)
|
||||
pageDetails := make([]gin.H, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
pageDetails = append(pageDetails, gin.H{
|
||||
"id": page.ID,
|
||||
"name": page.Name,
|
||||
"access_token": page.AccessToken,
|
||||
"exists": existing[page.ID],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"page_details": pageDetails, "user_access_token": userToken}})
|
||||
}
|
||||
|
||||
// ReauthorizePage matches Chatwoot CallbacksController#reauthorize_page.
|
||||
func (h *FacebookChannelHandler) ReauthorizePage(c *gin.Context) {
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req FacebookCallbackReauthorizeRequest
|
||||
if err := c.ShouldBind(&req); err != nil || req.InboxID == 0 {
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), accountID, req.InboxID)
|
||||
if err != nil || inbox.ChannelType != "facebook" {
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
channel, err := h.fbRepo.FindByAccountAndInboxID(c.Request.Context(), accountID, inbox.ID)
|
||||
if err != nil {
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
userToken, err := h.fbCallbacks.ExchangeLongLivedUserToken(c.Request.Context(), req.OmniauthToken)
|
||||
if err != nil {
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
pages, err := h.fbCallbacks.ListFacebookPages(c.Request.Context(), userToken)
|
||||
if err != nil {
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
var matched *facebookchannel.FBPageInfo
|
||||
for i := range pages {
|
||||
if pages[i].ID == channel.PageID {
|
||||
matched = &pages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil || matched.AccessToken == "" {
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
channel.UserAccessToken = userToken
|
||||
channel.PageAccessToken = matched.AccessToken
|
||||
channel.ReauthorizationRequired = false
|
||||
if matched.Name != "" {
|
||||
channel.PageName = matched.Name
|
||||
}
|
||||
h.setFacebookInstagramIDOnChannel(c.Request.Context(), channel, matched.AccessToken)
|
||||
if err := h.fbRepo.Update(c.Request.Context(), channel); err != nil {
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
h.updateFacebookInboxConfig(c.Request.Context(), inbox, channel)
|
||||
c.JSON(http.StatusOK, gin.H{"data": serializeInbox(inbox)})
|
||||
}
|
||||
|
||||
// OAuthCallback exchanges the Facebook OAuth code for a long-lived access token.
|
||||
// POST /api/v1/accounts/:id/channels/facebook_channel/oauth_callback
|
||||
func (h *FacebookChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Invalid account_id: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,11 +311,8 @@ type CreateFacebookPageRequest struct {
|
||||
// CreateFacebookPage creates a new Facebook Messenger inbox for a Facebook Page.
|
||||
// POST /api/v1/accounts/:id/channels/facebook_channel
|
||||
func (h *FacebookChannelHandler) CreateFacebookPage(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Invalid account_id: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +335,7 @@ func (h *FacebookChannelHandler) CreateFacebookPage(c *gin.Context) {
|
||||
EnableAutoAssignment: req.EnableAutoAssignment,
|
||||
}
|
||||
|
||||
inbox, err := h.inboxSvc.CreateFacebookInbox(ctx, uint(accountID), inboxReq, h.fbRepo)
|
||||
inbox, err := h.inboxSvc.CreateFacebookInbox(ctx, accountID, inboxReq, h.fbRepo)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create Facebook inbox: %v", err)
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
@@ -198,10 +360,8 @@ func (h *FacebookChannelHandler) CreateFacebookPage(c *gin.Context) {
|
||||
// GetFacebookChannel retrieves a Facebook channel by ID.
|
||||
// GET /api/v1/accounts/:id/channels/facebook_channel/:fb_id
|
||||
func (h *FacebookChannelHandler) GetFacebookChannel(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +382,7 @@ func (h *FacebookChannelHandler) GetFacebookChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Verify account ownership
|
||||
if ch.AccountID != uint(accountID) {
|
||||
if ch.AccountID != accountID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Facebook channel does not belong to this account"})
|
||||
return
|
||||
}
|
||||
@@ -241,16 +401,14 @@ func (h *FacebookChannelHandler) GetFacebookChannel(c *gin.Context) {
|
||||
// ListFacebookChannels retrieves all Facebook channels for an account.
|
||||
// GET /api/v1/accounts/:id/channels/facebook_channel
|
||||
func (h *FacebookChannelHandler) ListFacebookChannels(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
channels, err := h.fbChannelSvc.ListByAccount(ctx, uint(accountID))
|
||||
channels, err := h.fbChannelSvc.ListByAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to list Facebook channels: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Facebook channels"})
|
||||
@@ -279,10 +437,8 @@ func (h *FacebookChannelHandler) ListFacebookChannels(c *gin.Context) {
|
||||
// DeleteFacebookPage removes a Facebook Page channel and its inbox.
|
||||
// DELETE /api/v1/accounts/:id/channels/facebook_channel/:fb_id
|
||||
func (h *FacebookChannelHandler) DeleteFacebookPage(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -302,7 +458,7 @@ func (h *FacebookChannelHandler) DeleteFacebookPage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Verify account ownership
|
||||
if ch.AccountID != uint(accountID) {
|
||||
if ch.AccountID != accountID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Facebook channel does not belong to this account"})
|
||||
return
|
||||
}
|
||||
@@ -316,7 +472,7 @@ func (h *FacebookChannelHandler) DeleteFacebookPage(c *gin.Context) {
|
||||
|
||||
// Delete the associated inbox (if exists)
|
||||
if ch.InboxID > 0 {
|
||||
if delErr := h.inboxSvc.DeleteByAccount(ctx, uint(accountID), ch.InboxID); delErr != nil {
|
||||
if delErr := h.inboxSvc.DeleteByAccount(ctx, accountID, ch.InboxID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to delete inbox for Facebook channel: %v", delErr)
|
||||
}
|
||||
}
|
||||
@@ -334,10 +490,8 @@ type ReauthorizeFacebookPageRequest struct {
|
||||
// ReauthorizeFacebookPage refreshes an expired Facebook Page access token.
|
||||
// POST /api/v1/accounts/:id/channels/facebook_channel/reauthorize
|
||||
func (h *FacebookChannelHandler) ReauthorizeFacebookPage(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -351,7 +505,7 @@ func (h *FacebookChannelHandler) ReauthorizeFacebookPage(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Find all Facebook channels for this account and update their tokens
|
||||
channels, listErr := h.fbChannelSvc.ListByAccount(ctx, uint(accountID))
|
||||
channels, listErr := h.fbChannelSvc.ListByAccount(ctx, accountID)
|
||||
if listErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Facebook channels"})
|
||||
return
|
||||
@@ -378,10 +532,8 @@ func (h *FacebookChannelHandler) GetFacebookPage(c *gin.Context) {
|
||||
// UpdateFacebookPage updates a Facebook channel page configuration.
|
||||
// PATCH /api/v1/accounts/:id/channels/facebook_channel/:fb_id
|
||||
func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) {
|
||||
accountIDStr := c.Param("id")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
accountID, ok := h.parseFacebookAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -402,7 +554,7 @@ func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Verify account ownership
|
||||
if ch.AccountID != uint(accountID) {
|
||||
if ch.AccountID != accountID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Facebook channel does not belong to this account"})
|
||||
return
|
||||
}
|
||||
@@ -421,7 +573,7 @@ func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) {
|
||||
ReauthorizationRequired: req.ReauthorizationRequired,
|
||||
}
|
||||
|
||||
updated, err := h.fbChannelSvc.Update(ctx, uint(accountID), ch.InboxID, updateReq)
|
||||
updated, err := h.fbChannelSvc.Update(ctx, accountID, ch.InboxID, updateReq)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to update Facebook channel: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update Facebook channel"})
|
||||
@@ -437,3 +589,83 @@ func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) {
|
||||
"reauthorization_required": updated.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FacebookChannelHandler) parseFacebookAccountID(c *gin.Context) (uint, bool) {
|
||||
accountIDStr := c.Param("account_id")
|
||||
if accountIDStr == "" {
|
||||
accountIDStr = c.Param("id")
|
||||
}
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(accountID), true
|
||||
}
|
||||
|
||||
func (h *FacebookChannelHandler) facebookRegisterPayload(ctx context.Context, inbox *model.Inbox) gin.H {
|
||||
var channel channelmodel.ChannelFacebook
|
||||
if inbox.ChannelID != 0 {
|
||||
if ch, err := h.fbRepo.FindByID(ctx, inbox.ChannelID); err == nil {
|
||||
channel = *ch
|
||||
}
|
||||
}
|
||||
return gin.H{
|
||||
"id": inbox.ID,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"avatar_url": nil,
|
||||
"page_id": channel.PageID,
|
||||
"enable_auto_assignment": inbox.EnableAutoAssignment,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FacebookChannelHandler) existingFacebookPages(ctx context.Context, accountID uint) map[string]bool {
|
||||
existing := map[string]bool{}
|
||||
channels, err := h.fbChannelSvc.ListByAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return existing
|
||||
}
|
||||
for _, channel := range channels {
|
||||
existing[channel.PageID] = true
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func (h *FacebookChannelHandler) setFacebookInstagramID(ctx context.Context, channelID uint, pageAccessToken string) {
|
||||
channel, err := h.fbRepo.FindByID(ctx, channelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.setFacebookInstagramIDOnChannel(ctx, channel, pageAccessToken)
|
||||
_ = h.fbRepo.Update(ctx, channel)
|
||||
}
|
||||
|
||||
func (h *FacebookChannelHandler) setFacebookInstagramIDOnChannel(ctx context.Context, channel *channelmodel.ChannelFacebook, pageAccessToken string) {
|
||||
if h.fbCallbacks == nil || channel == nil {
|
||||
return
|
||||
}
|
||||
instagramID, err := h.fbCallbacks.FetchInstagramBusinessAccountID(ctx, pageAccessToken)
|
||||
if err != nil || instagramID == "" {
|
||||
return
|
||||
}
|
||||
channel.InstagramBusinessAccountID = instagramID
|
||||
}
|
||||
|
||||
func (h *FacebookChannelHandler) updateFacebookInboxConfig(ctx context.Context, inbox *model.Inbox, channel *channelmodel.ChannelFacebook) {
|
||||
config := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(inbox.ChannelConfig), &config)
|
||||
config["page_id"] = channel.PageID
|
||||
config["page_access_token"] = channel.PageAccessToken
|
||||
config["page_name"] = channel.PageName
|
||||
config["webhook_verify_token"] = channel.WebhookVerifyToken
|
||||
encoded, _ := json.Marshal(config)
|
||||
inbox.ChannelConfig = string(encoded)
|
||||
updated, err := h.inboxSvc.Update(ctx, inbox.AccountID, inbox.ID, service.UpdateInboxRequest{Channel: config})
|
||||
if err == nil && updated != nil {
|
||||
*inbox = *updated
|
||||
} else {
|
||||
inbox.ChannelConfig = string(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ type ChannelFacebook struct {
|
||||
// Requires pages_messaging permission for Messenger
|
||||
PageAccessToken string `gorm:"size:512;not null" json:"page_access_token"`
|
||||
|
||||
// Long-lived user access token used to discover and reauthorize pages.
|
||||
UserAccessToken string `gorm:"size:512" json:"user_access_token,omitempty"`
|
||||
|
||||
// Display name of the Facebook Page
|
||||
PageName string `gorm:"size:255" json:"page_name,omitempty"`
|
||||
|
||||
|
||||
@@ -809,6 +809,16 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
accountScoped.POST("/contacts/merge", h.Contact.Merge)
|
||||
accountScoped.POST("/contact_merge", h.Contact.Merge)
|
||||
|
||||
// Facebook callback routes used by reused dashboard fbChannel/endPoints clients.
|
||||
callbacks := accountScoped.Group("/callbacks")
|
||||
{
|
||||
callbacks.GET("/register_facebook_page", h.FacebookChannel.RegisterFacebookPage)
|
||||
callbacks.POST("/register_facebook_page", h.FacebookChannel.RegisterFacebookPage)
|
||||
callbacks.POST("/facebook_pages", h.FacebookChannel.FacebookPages)
|
||||
callbacks.POST("/facebook_pages.json", h.FacebookChannel.FacebookPages)
|
||||
callbacks.POST("/reauthorize_page", h.FacebookChannel.ReauthorizePage)
|
||||
}
|
||||
|
||||
// Facebook channel routes (ref: Chatwoot channels/facebook_pages_controller)
|
||||
// POST /accounts/:id/channels/facebook_channel → create FB Messenger inbox
|
||||
fbChannels := accountScoped.Group("/channels/facebook_channel")
|
||||
|
||||
@@ -1427,6 +1427,7 @@ type CreateFacebookInboxRequest struct {
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
PageID string `json:"page_id" validate:"required"`
|
||||
PageAccessToken string `json:"page_access_token" validate:"required"`
|
||||
UserAccessToken string `json:"user_access_token,omitempty"`
|
||||
PageName string `json:"page_name,omitempty"`
|
||||
WebhookVerifyToken string `json:"webhook_verify_token,omitempty"` // auto-generated if empty
|
||||
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
||||
@@ -1458,6 +1459,7 @@ func (s *InboxService) CreateFacebookInbox(ctx context.Context, accountID uint,
|
||||
AccountID: accountID,
|
||||
PageID: req.PageID,
|
||||
PageAccessToken: req.PageAccessToken,
|
||||
UserAccessToken: req.UserAccessToken,
|
||||
PageName: req.PageName,
|
||||
WebhookVerifyToken: verifyToken,
|
||||
ReauthorizationRequired: false,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
DROP INDEX IF EXISTS idx_channel_facebook_pages_page_id;
|
||||
DROP INDEX IF EXISTS idx_channel_facebook_pages_account_id;
|
||||
|
||||
ALTER TABLE channel_facebook_pages
|
||||
DROP COLUMN IF EXISTS reauthorization_required,
|
||||
DROP COLUMN IF EXISTS instagram_business_account_id,
|
||||
DROP COLUMN IF EXISTS webhook_verify_token,
|
||||
DROP COLUMN IF EXISTS app_id,
|
||||
DROP COLUMN IF EXISTS account_id,
|
||||
DROP COLUMN IF EXISTS user_access_token;
|
||||
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE channel_facebook_pages
|
||||
ADD COLUMN IF NOT EXISTS user_access_token VARCHAR(512),
|
||||
ADD COLUMN IF NOT EXISTS account_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS app_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS webhook_verify_token VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS instagram_business_account_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS reauthorization_required BOOLEAN DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_facebook_pages_account_id ON channel_facebook_pages(account_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_facebook_pages_page_id ON channel_facebook_pages(page_id) WHERE deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user