Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -101,21 +101,46 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key
# DISCORD_BOT_TOKEN=your-discord-bot-token
# DISCORD_PUBLIC_KEY=your-discord-public-key

# Facebook Messenger Channel Integration (Meta Graph API)
# META_APP_ID=your-meta-app-id
# META_APP_SECRET=your-meta-app-secret
# MESSENGER_VERIFY_TOKEN=your-webhook-verify-token
# ==============================================================================
# Meta Channels (Facebook Messenger / Instagram / WhatsApp)
#
# Each product can use its own Meta app. Set the per-product variables when you
# registered more than one app; the app secret signs that product's webhooks and
# a secret from the wrong app makes every delivery fail verification.
#
# If a single Meta app serves all three products, setting only FACEBOOK_APP_ID
# and FACEBOOK_APP_SECRET is enough: Instagram and WhatsApp fall back to them.
#
# Channel-level credentials entered in Dashboard -> Channels always win over the
# values below, so a single deployment can serve channels from different apps.
# ==============================================================================

# WhatsApp Cloud API Integration (Meta Graph API)
# WhatsApp credentials (Phone Number ID, WABA ID, System User Access Token and
# the per-channel webhook verify token) are stored on the channel itself, in
# Dashboard -> Channels, not in the environment. The two variables below are the
# Meta app credentials and are shared with Messenger and Instagram.
# META_APP_ID and META_APP_SECRET are required: the WhatsApp webhook rejects any
# delivery whose X-Hub-Signature-256 it cannot verify, and the OAuth connect flow
# cannot exchange a code without them.
# Facebook Messenger
# FACEBOOK_APP_ID=your-messenger-meta-app-id
# FACEBOOK_APP_SECRET=your-messenger-meta-app-secret
# FACEBOOK_VERIFY_TOKEN=your-messenger-webhook-verify-token

# Instagram Direct
# INSTAGRAM_APP_ID=your-instagram-meta-app-id
# INSTAGRAM_APP_SECRET=your-instagram-meta-app-secret
# INSTAGRAM_VERIFY_TOKEN=your-instagram-webhook-verify-token

# WhatsApp Cloud API
# Required: the WhatsApp webhook rejects any delivery whose X-Hub-Signature-256
# it cannot verify, and the OAuth connect flow cannot exchange a code without the
# app id and secret.
# WHATSAPP_APP_ID=your-whatsapp-meta-app-id
# WHATSAPP_APP_SECRET=your-whatsapp-meta-app-secret
# WHATSAPP_VERIFY_TOKEN=your-whatsapp-webhook-verify-token
#
# Phone Number ID, WABA ID and System User Access Token are not environment
# variables. They are stored per channel in Dashboard -> Channels.

# Legacy aliases, still read for the Messenger app only. Prefer FACEBOOK_APP_ID
# and FACEBOOK_APP_SECRET.
# META_APP_ID=your-meta-app-id
# META_APP_SECRET=your-meta-app-secret
# MESSENGER_VERIFY_TOKEN=your-webhook-verify-token

# Slack Bot Integration (Slack Web API & Events API)
# SLACK_CLIENT_ID=your-slack-client-id
Expand Down
45 changes: 35 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the `sha256=` prefix all passed, so anyone who learned a webhook URL could write
into a customer conversation, trigger AI replies and burn paid message quota.
Rejections now return `401` without echoing the reason. **Breaking for
deployments that never set `META_APP_SECRET` or a per-channel `appSecret`: the
WhatsApp webhook stops accepting messages until one is configured.**
- `ChannelGetWhatsAppOAuthURL` no longer falls back to a fabricated app id, which
sent operators to a Meta error page that looked like an application bug. The
authorization URL now includes `response_type=code`; without it Meta returns a
token fragment the server never sees.
deployments that never set `WHATSAPP_APP_SECRET` or a per-channel `appSecret`:
the WhatsApp webhook stops accepting messages until one is configured.**
- **Each Meta product now resolves its own app credentials.** Messenger, Instagram
and WhatsApp all read a single `META_APP_SECRET`, which cannot work for a
deployment that registered a separate Meta app per product: one variable holds
one secret, and a signature verified against the wrong app's secret always
fails. Credentials now resolve channel-level first, then the product's own
`FACEBOOK_APP_*` / `INSTAGRAM_APP_*` / `WHATSAPP_APP_*`, then the shared
Messenger values, so a single-app deployment keeps working unchanged. The
WhatsApp OAuth exchange previously sent the Messenger app id and secret for a
code issued by the WhatsApp app, which Meta rejects outright.
- `ChannelGetWhatsAppOAuthURL`, `ChannelGetMessengerOAuthURL` and
`ChannelGetInstagramOAuthURL` no longer fall back to a fabricated app id, which
sent operators to a Meta error page that looked like an application bug, and now
report which variable to set. The authorization URL includes
`response_type=code`; without it Meta returns a token fragment the server never
sees.
- The Channels dialog advertised `/api/third/whatsapp/webhook` as the webhook URL,
but verification requires a bound channel id, so the documented URL always
failed. It now shows the real per-channel URL with a copy action.
Expand All @@ -43,8 +54,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
discovers the reachable WABAs and sender numbers, and saves the credentials onto
the target channel while preserving its existing webhook verify token. The
Channels dialog opens Meta in a popup and prefills the form from the result.
- The WhatsApp channel form gains Meta App ID and Meta App Secret fields. The
backend already read a per-channel `appSecret` but no field existed to set one,
so a channel belonging to a second Meta app could only be configured by editing
the database.
- Focused tests for signature rejection, structured inbound types, media storage,
media-failure fallback, and the OAuth connect flow including discovery failure.
media-failure fallback, the OAuth connect flow including discovery failure, and
per-product Meta credential resolution.

### Changed

Expand All @@ -55,9 +71,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- In-app brand strings in `en-US` and `zh-CN` now read `Crove Desk`; `vi-VN`
already did. The widget SDK's public `AgentDesk*` globals are unchanged, because
renaming them would break every site that has already integrated it.
- `.env.example` no longer documents four `WHATSAPP_*` variables that nothing in
the codebase reads. It points at the Meta app credentials and at the channel
form instead.
- `.env.example` groups the Meta variables per product — `FACEBOOK_APP_*`,
`INSTAGRAM_APP_*`, `WHATSAPP_APP_*` — matching the naming already used by Crove
Post, and keeps `META_APP_*` documented as a Messenger-only legacy alias. The
three `WHATSAPP_*` names it listed before were never read by anything; they are
real bindings now.
- The product backlog marks the WhatsApp integration as shipped, with the
template-message and delivery-receipt gaps listed explicitly.

Expand All @@ -71,6 +89,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
conversations outside the 24-hour customer service window are not possible, and
the webhook `statuses` field is not consumed, so delivery and read receipts are
not reflected.
- Messenger and Instagram webhook verification is still conditional: it only runs
when an app secret is configured *and* a signature header arrived, and
`verifyMessengerSignature` returns `true` for a header without the `sha256=`
prefix. WhatsApp was closed in this release; the same fix for those two was
deliberately held back because it would start rejecting live traffic on any
deployment whose secret is not yet correct, and that needs to be sequenced
against the credential split above.
- `pnpm lint` fails on pre-existing `react-hooks/set-state-in-effect` and
ref-access errors across the dashboard. The WhatsApp files added here are
lint-clean.
Expand Down
166 changes: 69 additions & 97 deletions internal/handlers/dashboard/channel_oauth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"fmt"
"net/url"
"os"
"strconv"
"strings"

"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
Expand Down Expand Up @@ -62,94 +64,85 @@ func ChannelGetDiscordOAuthURL(ctx *gin.Context) {
}))
}

// ChannelGetMessengerOAuthURL returns the 1-Click OAuth authorization URL for Meta Messenger.
func ChannelGetMessengerOAuthURL(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
// metaOAuthDialogURL is the Facebook Login authorization endpoint. The Graph API
// version is pinned so an authorization URL and the code exchange that follows it
// cannot drift onto different versions.
const metaOAuthDialogURL = "https://www.facebook.com/v21.0/dialog/oauth"

appID := ""
if cfg := config.GetCurrent(); cfg != nil {
appID = strings.TrimSpace(cfg.Messenger.AppID)
}
if appID == "" {
appID = strings.TrimSpace(os.Getenv("META_APP_ID"))
}
if appID == "" {
appID = strings.TrimSpace(os.Getenv("FB_APP_ID"))
}
if appID == "" {
appID = strings.TrimSpace(ctx.Query("app_id"))
}
redirectURI := strings.TrimSpace(ctx.Query("redirect_uri"))
// Scopes each Meta product needs to send and receive support messages.
const (
messengerOAuthScope = "pages_show_list,pages_messaging,pages_manage_metadata"
instagramOAuthScope = "instagram_basic,instagram_manage_messages,pages_show_list,pages_manage_metadata"
whatsAppOAuthScope = "whatsapp_business_management,whatsapp_business_messaging"
)

// writeMetaOAuthURL builds an authorization URL for one Meta product.
//
// appID must already be resolved for that specific product: a deployment may
// register a separate Meta app per product, and a code issued by one app cannot
// be exchanged with another app's secret.
func writeMetaOAuthURL(ctx *gin.Context, appID, appIDEnvName, redirectURI, state, scope string) {
appID = strings.TrimSpace(appID)
redirectURI = strings.TrimSpace(redirectURI)

// A fabricated app id would send the operator to a Meta error page that looks
// like our bug, so an unconfigured deployment says so instead.
if appID == "" {
appID = "123456789012345"
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0353", appIDEnvName))
return
}

state := strings.TrimSpace(ctx.Query("state"))
if state == "" {
state = "crove_messenger_connect"
if redirectURI == "" {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri"))
return
}
if state = strings.TrimSpace(state); state == "" {
state = "crove_meta_connect"
}

authURL := fmt.Sprintf(
"https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=pages_show_list,pages_messaging,pages_manage_metadata&state=%s",
url.QueryEscape(appID),
url.QueryEscape(redirectURI),
url.QueryEscape(state),
)
query := url.Values{}
query.Set("client_id", appID)
query.Set("redirect_uri", redirectURI)
query.Set("state", state)
query.Set("scope", scope)
// response_type=code is what makes Meta redirect back with an authorization
// code; without it the dialog returns a token fragment the server never sees.
query.Set("response_type", "code")

httpx.WriteJSON(ctx, web.JsonData(gin.H{
"authUrl": authURL,
"authUrl": metaOAuthDialogURL + "?" + query.Encode(),
"appId": appID,
"redirectUri": redirectURI,
}))
}

// ChannelGetInstagramOAuthURL returns the 1-Click OAuth authorization URL for Instagram Messaging.
func ChannelGetInstagramOAuthURL(ctx *gin.Context) {
// ChannelGetMessengerOAuthURL returns the 1-Click OAuth authorization URL for Meta Messenger.
func ChannelGetMessengerOAuthURL(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil {
httpx.WriteJSON(ctx, err)
return
}

appID := ""
if cfg := config.GetCurrent(); cfg != nil {
appID = strings.TrimSpace(cfg.Messenger.AppID)
}
if appID == "" {
appID = strings.TrimSpace(os.Getenv("META_APP_ID"))
}
if appID == "" {
appID = strings.TrimSpace(os.Getenv("FB_APP_ID"))
}
if appID == "" {
appID = strings.TrimSpace(ctx.Query("app_id"))
appID := config.ResolveMessengerApp(ctx.Query("app_id"), "").AppID
state := strings.TrimSpace(ctx.Query("state"))
if state == "" {
state = "crove_messenger_connect"
}
redirectURI := strings.TrimSpace(ctx.Query("redirect_uri"))
writeMetaOAuthURL(ctx, appID, "FACEBOOK_APP_ID", ctx.Query("redirect_uri"), state, messengerOAuthScope)
}

if appID == "" {
appID = "123456789012345"
// ChannelGetInstagramOAuthURL returns the 1-Click OAuth authorization URL for Instagram Messaging.
func ChannelGetInstagramOAuthURL(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil {
httpx.WriteJSON(ctx, err)
return
}

appID := config.ResolveInstagramApp(ctx.Query("app_id"), "").AppID
state := strings.TrimSpace(ctx.Query("state"))
if state == "" {
state = "crove_instagram_connect"
}

authURL := fmt.Sprintf(
"https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=instagram_basic,instagram_manage_messages,pages_show_list,pages_manage_metadata&state=%s",
url.QueryEscape(appID),
url.QueryEscape(redirectURI),
url.QueryEscape(state),
)

httpx.WriteJSON(ctx, web.JsonData(gin.H{
"authUrl": authURL,
"appId": appID,
"redirectUri": redirectURI,
}))
writeMetaOAuthURL(ctx, appID, "INSTAGRAM_APP_ID", ctx.Query("redirect_uri"), state, instagramOAuthScope)
}

// ChannelGetWhatsAppOAuthURL returns the 1-Click Embedded Signup / OAuth URL for WhatsApp Cloud API.
Expand All @@ -159,48 +152,27 @@ func ChannelGetWhatsAppOAuthURL(ctx *gin.Context) {
return
}

appID := ""
if cfg := config.GetCurrent(); cfg != nil {
appID = strings.TrimSpace(cfg.Messenger.AppID)
}
if appID == "" {
appID = strings.TrimSpace(os.Getenv("META_APP_ID"))
}
if appID == "" {
appID = strings.TrimSpace(ctx.Query("app_id"))
}
redirectURI := strings.TrimSpace(ctx.Query("redirect_uri"))

// A fabricated app id would send the operator to a Meta error page that
// looks like our bug, so an unconfigured deployment says so instead.
if appID == "" {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0351"))
return
// An existing channel may belong to a different Meta app than the deployment
// default, and the authorization code can only be exchanged by the app that
// issued it, so the channel's own app id wins.
channelAppID := ""
if channelID, parseErr := strconv.ParseInt(strings.TrimSpace(ctx.Query("channel_id")), 10, 64); parseErr == nil && channelID > 0 {
if channel := services.ChannelService.Get(channelID); channel != nil && channel.Status != enums.StatusDeleted {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should verify that the retrieved channel is actually of type WhatsApp before attempting to parse its configuration as a WhatsApp channel configuration. If a channel of a different type (e.g., Messenger) is passed, parsing its configuration as a WhatsApp configuration could lead to unexpected behavior or incorrect credential resolution.

Suggested change
if channel := services.ChannelService.Get(channelID); channel != nil && channel.Status != enums.StatusDeleted {
if channel := services.ChannelService.Get(channelID); channel != nil && channel.Status != enums.StatusDeleted && strings.TrimSpace(channel.ChannelType) == enums.ChannelTypeWhatsApp {

if cfg, cfgErr := services.ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON); cfgErr == nil && cfg != nil {
channelAppID = cfg.AppID
}
}
}
if redirectURI == "" {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri"))
return
if channelAppID == "" {
channelAppID = strings.TrimSpace(ctx.Query("app_id"))
}

appID := config.ResolveWhatsAppApp(channelAppID, "").AppID
state := strings.TrimSpace(ctx.Query("state"))
if state == "" {
state = "crove_whatsapp_connect"
}

query := url.Values{}
query.Set("client_id", appID)
query.Set("redirect_uri", redirectURI)
query.Set("state", state)
// response_type=code is what makes Meta redirect back with an authorization
// code; without it the dialog returns a token fragment the server never sees.
query.Set("response_type", "code")
query.Set("scope", "whatsapp_business_management,whatsapp_business_messaging")

httpx.WriteJSON(ctx, web.JsonData(gin.H{
"authUrl": "https://www.facebook.com/v21.0/dialog/oauth?" + query.Encode(),
"appId": appID,
"redirectUri": redirectURI,
}))
writeMetaOAuthURL(ctx, appID, "WHATSAPP_APP_ID", ctx.Query("redirect_uri"), state, whatsAppOAuthScope)
}

// ChannelPostWhatsAppOAuthCallback exchanges the authorization code Meta
Expand Down
Loading
Loading