feat(channels): align whatsapp calling toggles
This commit is contained in:
@@ -48,11 +48,15 @@ func NewWhatsAppService(repository *Repository) *WhatsAppService {
|
||||
if apiVersion == "" {
|
||||
apiVersion = "v22.0"
|
||||
}
|
||||
cloudBase := os.Getenv("WHATSAPP_CLOUD_BASE_URL")
|
||||
if cloudBase == "" {
|
||||
cloudBase = "https://graph.facebook.com"
|
||||
}
|
||||
|
||||
return &WhatsAppService{
|
||||
client: client,
|
||||
repository: repository,
|
||||
graphAPIBase: "https://graph.facebook.com/" + apiVersion,
|
||||
graphAPIBase: fmt.Sprintf("%s/%s", cloudBase, apiVersion),
|
||||
dialogAPIBase: "https://waba.360dialog.io",
|
||||
}
|
||||
}
|
||||
@@ -400,6 +404,12 @@ func buildExpectedWhatsAppWebhookURL(phoneNumber string) string {
|
||||
// Cloud API: POST /v18.0/{business_account_id}/subscribed_apps
|
||||
// 360dialog: Webhook is configured via 360dialog Hub dashboard
|
||||
func (s *WhatsAppService) SetupWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string) error {
|
||||
return s.SetupWebhookFields(ctx, channel, webhookURL, nil)
|
||||
}
|
||||
|
||||
// SetupWebhookFields subscribes the app and overrides the callback URL/fields for a WABA.
|
||||
// Reference: Whatsapp::FacebookApiClient#subscribe_waba_webhook.
|
||||
func (s *WhatsAppService) SetupWebhookFields(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error {
|
||||
if channel.IsCloudAPI() {
|
||||
url := fmt.Sprintf("%s/%s/subscribed_apps", s.graphAPIBase, channel.BusinessAccountID)
|
||||
resp, err := s.client.R().
|
||||
@@ -409,9 +419,29 @@ func (s *WhatsAppService) SetupWebhook(ctx context.Context, channel *channelmode
|
||||
if err != nil {
|
||||
return fmt.Errorf("WhatsApp Cloud API webhook subscription failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode() != 200 {
|
||||
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
|
||||
return fmt.Errorf("WhatsApp Cloud API webhook subscription returned status %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"messages", "smb_message_echoes", "calls"}
|
||||
}
|
||||
body := map[string]any{
|
||||
"override_callback_uri": webhookURL,
|
||||
"verify_token": channel.WebhookVerifyToken,
|
||||
"subscribed_fields": fields,
|
||||
}
|
||||
resp, err = s.client.R().
|
||||
SetContext(ctx).
|
||||
SetAuthToken(channel.AccessToken).
|
||||
SetBody(body).
|
||||
Post(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WhatsApp Cloud API webhook callback override failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
|
||||
return fmt.Errorf("WhatsApp Cloud API webhook callback override returned status %d", resp.StatusCode())
|
||||
}
|
||||
applogger.L().Info("WhatsApp Cloud API webhook subscription created",
|
||||
"business_account_id", channel.BusinessAccountID,
|
||||
)
|
||||
@@ -425,6 +455,45 @@ func (s *WhatsAppService) SetupWebhook(ctx context.Context, channel *channelmode
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCallingStatus enables or disables Meta's WhatsApp Calling setting for a phone number.
|
||||
// Reference: Enterprise::Whatsapp::Providers::WhatsappCloudService#update_calling_status.
|
||||
func (s *WhatsAppService) UpdateCallingStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp, status string) error {
|
||||
if !channel.IsCloudAPI() {
|
||||
return fmt.Errorf("WhatsApp calling requires a whatsapp_cloud inbox")
|
||||
}
|
||||
url := fmt.Sprintf("%s/%s/settings", s.graphAPIBase, channel.PhoneNumberID)
|
||||
resp, err := s.client.R().
|
||||
SetContext(ctx).
|
||||
SetAuthToken(channel.AccessToken).
|
||||
SetBody(map[string]any{"calling": map[string]any{"status": status}}).
|
||||
Post(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WhatsApp calling status update failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode() >= 200 && resp.StatusCode() < 300 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", extractWhatsAppCallingError(resp.Body()))
|
||||
}
|
||||
|
||||
func extractWhatsAppCallingError(body []byte) string {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "Failed to update calling status"
|
||||
}
|
||||
errorValue, ok := parsed["error"].(map[string]any)
|
||||
if !ok {
|
||||
return "Failed to update calling status"
|
||||
}
|
||||
if value, ok := errorValue["error_user_msg"].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
if value, ok := errorValue["message"].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
return "Failed to update calling status"
|
||||
}
|
||||
|
||||
// === Internal Helper Methods ===
|
||||
|
||||
// validateAccessToken validates the WhatsApp access token by making a test API call.
|
||||
|
||||
@@ -45,6 +45,37 @@ func TestWhatsAppService_FetchHealthStatusFormatsChatwootPayload(t *testing.T) {
|
||||
assert.NotContains(t, payload, "healthy")
|
||||
}
|
||||
|
||||
func TestWhatsAppService_UpdateCallingStatusPostsSettings(t *testing.T) {
|
||||
svc := NewWhatsAppService(nil)
|
||||
svc.graphAPIBase = "https://graph.example.test/v22.0"
|
||||
svc.client.SetTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
require.Equal(t, "/v22.0/phone-123/settings", r.URL.Path)
|
||||
assert.Equal(t, "Bearer token-abc", r.Header.Get("Authorization"))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"calling":{"status":"ENABLED"}}`, string(body))
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"success":true}`))}, nil
|
||||
}))
|
||||
channel := &channelmodel.ChannelWhatsApp{Provider: "whatsapp_cloud", PhoneNumberID: "phone-123", AccessToken: "token-abc"}
|
||||
|
||||
err := svc.UpdateCallingStatus(context.Background(), channel, "ENABLED")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWhatsAppService_UpdateCallingStatusUsesMetaErrorMessage(t *testing.T) {
|
||||
svc := NewWhatsAppService(nil)
|
||||
svc.graphAPIBase = "https://graph.example.test/v22.0"
|
||||
svc.client.SetTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := `{"error":{"error_user_msg":"Calling is not available for this number","message":"fallback"}}`
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(bytes.NewBufferString(body))}, nil
|
||||
}))
|
||||
channel := &channelmodel.ChannelWhatsApp{Provider: "whatsapp_cloud", PhoneNumberID: "phone-123", AccessToken: "token-abc"}
|
||||
|
||||
err := svc.UpdateCallingStatus(context.Background(), channel, "ENABLED")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "Calling is not available for this number", err.Error())
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -749,6 +749,60 @@ func (h *InboxHandler) RegisterWebhook(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Webhook registered successfully"})
|
||||
}
|
||||
|
||||
// EnableWhatsAppCalling enables WhatsApp Calling for a Cloud API inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/enable_whatsapp_calling
|
||||
// Reference: Enterprise::Api::V1::Accounts::InboxesController#enable_whatsapp_calling
|
||||
func (h *InboxHandler) EnableWhatsAppCalling(c *gin.Context) {
|
||||
h.handleWhatsAppCallingToggle(c, true)
|
||||
}
|
||||
|
||||
// DisableWhatsAppCalling disables WhatsApp Calling for a Cloud API inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/disable_whatsapp_calling
|
||||
// Reference: Enterprise::Api::V1::Accounts::InboxesController#disable_whatsapp_calling
|
||||
func (h *InboxHandler) DisableWhatsAppCalling(c *gin.Context) {
|
||||
h.handleWhatsAppCallingToggle(c, false)
|
||||
}
|
||||
|
||||
func (h *InboxHandler) handleWhatsAppCallingToggle(c *gin.Context, enable bool) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update WhatsApp calling")
|
||||
return
|
||||
}
|
||||
|
||||
var svcErr error
|
||||
if enable {
|
||||
svcErr = h.svc.EnableWhatsAppCalling(c.Request.Context(), accountID, inboxID)
|
||||
} else {
|
||||
svcErr = h.svc.DisableWhatsAppCalling(c.Request.Context(), accountID, inboxID)
|
||||
}
|
||||
if svcErr != nil {
|
||||
if errors.Is(svcErr, service.ErrInboxWhatsAppCallingUnsupported) || errors.Is(svcErr, service.ErrInboxWhatsAppCallingFeatureRequired) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
if strings.Contains(strings.ToLower(svcErr.Error()), "not found") {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetAgentBot retrieves the currently active agent bot for an inbox.
|
||||
// GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot
|
||||
// Reference: Chatwoot InboxesController#agent_bot
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -32,6 +33,8 @@ func setupInboxMemberActionRouter(handler *InboxHandler) *gin.Engine {
|
||||
r.GET("/api/v1/accounts/:account_id/inboxes/:inbox_id/health", handler.Health)
|
||||
r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates", handler.SyncTemplates)
|
||||
r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/register_webhook", handler.RegisterWebhook)
|
||||
r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/enable_whatsapp_calling", handler.EnableWhatsAppCalling)
|
||||
r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/disable_whatsapp_calling", handler.DisableWhatsAppCalling)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -39,6 +42,28 @@ func newNilInboxHandler() *InboxHandler {
|
||||
return NewInboxHandler(&service.InboxService{})
|
||||
}
|
||||
|
||||
type fakeInboxHandlerWhatsAppService struct{}
|
||||
|
||||
func (f *fakeInboxHandlerWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeInboxHandlerWhatsAppService) FetchHealthStatus(context.Context, *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeInboxHandlerWhatsAppService) SetupWebhook(context.Context, *channelmodel.ChannelWhatsApp, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeInboxHandlerWhatsAppService) SetupWebhookFields(context.Context, *channelmodel.ChannelWhatsApp, string, []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeInboxHandlerWhatsAppService) UpdateCallingStatus(context.Context, *channelmodel.ChannelWhatsApp, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseJSONResponse extracts the "error" key from a JSON response body.
|
||||
func parseJSONError(body []byte) string {
|
||||
var resp map[string]interface{}
|
||||
@@ -193,3 +218,58 @@ func TestInboxRegisterWebhook_BadInboxID(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, parseJSONError(w.Body.Bytes()), "invalid inbox id")
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// WhatsApp calling — param and response parity tests
|
||||
// ========================================
|
||||
|
||||
func TestInboxWhatsAppCalling_BadParams(t *testing.T) {
|
||||
handler := newNilInboxHandler()
|
||||
router := setupInboxMemberActionRouter(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/inboxes/5/enable_whatsapp_calling", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, parseJSONError(w.Body.Bytes()), "invalid account id")
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/v1/accounts/1/inboxes/xyz/disable_whatsapp_calling", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, parseJSONError(w.Body.Bytes()), "invalid inbox id")
|
||||
}
|
||||
|
||||
func TestInboxWhatsAppCalling_EnableDisableReturnEmptyOK(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:inbox_whatsapp_calling_handler?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, dbErr := db.DB()
|
||||
if dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &channelmodel.ChannelWhatsApp{}))
|
||||
account := &model.Account{Name: "Calling", Locale: "en", Active: true, FeatureFlags: `{"channel_voice":true}`}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
channel := &channelmodel.ChannelWhatsApp{AccountID: account.ID, InboxID: inbox.ID, PhoneNumber: "+1555010000", PhoneNumberID: "phone-1", BusinessAccountID: "waba-1", AccessToken: "token", Provider: "whatsapp_cloud"}
|
||||
require.NoError(t, db.Create(channel).Error)
|
||||
|
||||
svc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, &fakeInboxHandlerWhatsAppService{}, whatsappchannel.NewRepository(db))
|
||||
handler := NewInboxHandler(svc)
|
||||
router := setupInboxMemberActionRouter(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/inboxes/1/enable_whatsapp_calling", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Empty(t, w.Body.String())
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/v1/accounts/1/inboxes/1/disable_whatsapp_calling", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Empty(t, w.Body.String())
|
||||
}
|
||||
|
||||
@@ -710,6 +710,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
inboxes.POST("/:inbox_id/sync_templates", h.Inbox.SyncTemplates)
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/register_webhook — register channel webhook
|
||||
inboxes.POST("/:inbox_id/register_webhook", h.Inbox.RegisterWebhook)
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/enable_whatsapp_calling — enable WhatsApp Calling
|
||||
inboxes.POST("/:inbox_id/enable_whatsapp_calling", middleware.RoleCheck("administrator"), h.Inbox.EnableWhatsAppCalling)
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/disable_whatsapp_calling — disable WhatsApp Calling
|
||||
inboxes.POST("/:inbox_id/disable_whatsapp_calling", middleware.RoleCheck("administrator"), h.Inbox.DisableWhatsAppCalling)
|
||||
|
||||
// GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot — get currently active agent bot
|
||||
inboxes.GET("/:inbox_id/agent_bot", h.Inbox.GetAgentBot)
|
||||
|
||||
@@ -25,16 +25,22 @@ const InboxLimitExceededMessage = "Account limit exceeded. Upgrade to a higher p
|
||||
const InboxHealthWhatsAppCloudOnlyMessage = "Health data only available for WhatsApp Cloud API channels"
|
||||
const InboxTemplateSyncInitiatedMessage = "Template sync initiated successfully"
|
||||
const InboxTemplateSyncWhatsAppOnlyMessage = "Template sync is only available for WhatsApp channels"
|
||||
const InboxWhatsAppCallingUnsupportedMessage = "Inbox does not support WhatsApp calling"
|
||||
const InboxWhatsAppCallingFeatureRequiredMessage = "WhatsApp calling requires the channel_voice feature"
|
||||
const TaskTypeInboxSyncTemplates = "inbox:sync_templates"
|
||||
|
||||
var ErrInboxLimitExceeded = errors.New(InboxLimitExceededMessage)
|
||||
var ErrInboxHealthWhatsAppCloudOnly = errors.New(InboxHealthWhatsAppCloudOnlyMessage)
|
||||
var ErrInboxTemplateSyncWhatsAppOnly = errors.New(InboxTemplateSyncWhatsAppOnlyMessage)
|
||||
var ErrInboxWhatsAppCallingUnsupported = errors.New(InboxWhatsAppCallingUnsupportedMessage)
|
||||
var ErrInboxWhatsAppCallingFeatureRequired = errors.New(InboxWhatsAppCallingFeatureRequiredMessage)
|
||||
|
||||
type WhatsAppChannelService interface {
|
||||
FetchMessageTemplates(ctx context.Context, channel *channelmodel.ChannelWhatsApp) ([]interface{}, error)
|
||||
FetchHealthStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error)
|
||||
SetupWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string) error
|
||||
SetupWebhookFields(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error
|
||||
UpdateCallingStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp, status string) error
|
||||
}
|
||||
|
||||
// InboxService implements business logic for Inbox operations.
|
||||
@@ -1725,6 +1731,84 @@ func (s *InboxService) RegisterWebhook(ctx context.Context, accountID, inboxID u
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableWhatsAppCalling matches Chatwoot Enterprise InboxesController#enable_whatsapp_calling.
|
||||
// It enables Meta calling remotely, registers the calls webhook field, then persists calling_enabled.
|
||||
func (s *InboxService) EnableWhatsAppCalling(ctx context.Context, accountID, inboxID uint) error {
|
||||
account, inbox, waChannel, err := s.whatsAppCallingPrereqs(ctx, accountID, inboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !featureFlagStringEnabled(account.FeatureFlags, "channel_voice") {
|
||||
return ErrInboxWhatsAppCallingFeatureRequired
|
||||
}
|
||||
if s.whatsappService == nil {
|
||||
return fmt.Errorf("WhatsApp service not available")
|
||||
}
|
||||
if err := s.whatsappService.UpdateCallingStatus(ctx, waChannel, "ENABLED"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.setupWhatsAppWebhookFields(ctx, waChannel, whatsappWebhookCallbackURL(waChannel.PhoneNumber), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
setWhatsAppCallingEnabled(waChannel, true)
|
||||
if err := s.whatsappRepo.Update(ctx, waChannel); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.refreshWhatsAppInboxConfig(ctx, inbox, waChannel, true)
|
||||
}
|
||||
|
||||
// DisableWhatsAppCalling matches Chatwoot Enterprise InboxesController#disable_whatsapp_calling.
|
||||
// It only gates calling locally; webhook re-registration without calls is best-effort.
|
||||
func (s *InboxService) DisableWhatsAppCalling(ctx context.Context, accountID, inboxID uint) error {
|
||||
_, inbox, waChannel, err := s.whatsAppCallingPrereqs(ctx, accountID, inboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setWhatsAppCallingEnabled(waChannel, false)
|
||||
if err := s.whatsappRepo.Update(ctx, waChannel); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.refreshWhatsAppInboxConfig(ctx, inbox, waChannel, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.setupWhatsAppWebhookFields(ctx, waChannel, whatsappWebhookCallbackURL(waChannel.PhoneNumber), []string{"messages", "smb_message_echoes"}); err != nil {
|
||||
applogger.L().Warnf("WhatsApp calling disable webhook re-subscribe failed for inbox %d: %v", inbox.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboxService) whatsAppCallingPrereqs(ctx context.Context, accountID, inboxID uint) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp, error) {
|
||||
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("inbox not found: %w", err)
|
||||
}
|
||||
if inbox.ChannelType != "whatsapp" {
|
||||
return nil, nil, nil, ErrInboxWhatsAppCallingUnsupported
|
||||
}
|
||||
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
|
||||
if err != nil || !waChannel.IsCloudAPI() {
|
||||
return nil, nil, nil, ErrInboxWhatsAppCallingUnsupported
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("account not found: %w", err)
|
||||
}
|
||||
return &account, inbox, waChannel, nil
|
||||
}
|
||||
|
||||
func setWhatsAppCallingEnabled(channel *channelmodel.ChannelWhatsApp, enabled bool) {
|
||||
config := parseJSONMap(channel.ProviderConfig)
|
||||
config["calling_enabled"] = enabled
|
||||
channel.ProviderConfig = marshalInboxJSON(config)
|
||||
}
|
||||
|
||||
func (s *InboxService) refreshWhatsAppInboxConfig(ctx context.Context, inbox *model.Inbox, channel *channelmodel.ChannelWhatsApp, voiceEnabled bool) error {
|
||||
config := whatsappInboxChannelConfig(channel)
|
||||
config["voice_enabled"] = voiceEnabled
|
||||
inbox.ChannelConfig = marshalInboxJSON(config)
|
||||
return s.repo.Update(ctx, inbox)
|
||||
}
|
||||
|
||||
// DeleteAvatar removes the avatar URL from an inbox and dispatches an update event.
|
||||
// Reference: Chatwoot InboxesController#destroy_avatar
|
||||
func (s *InboxService) DeleteAvatar(ctx context.Context, accountID, inboxID uint) (*model.Inbox, error) {
|
||||
@@ -1842,6 +1926,13 @@ func (s *InboxService) setupWhatsAppWebhook(ctx context.Context, waChannel *chan
|
||||
return s.whatsappService.SetupWebhook(ctx, waChannel, webhookURL)
|
||||
}
|
||||
|
||||
func (s *InboxService) setupWhatsAppWebhookFields(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error {
|
||||
if s.whatsappService == nil {
|
||||
return fmt.Errorf("WhatsApp service not available")
|
||||
}
|
||||
return s.whatsappService.SetupWebhookFields(ctx, waChannel, webhookURL, fields)
|
||||
}
|
||||
|
||||
// generateInboxSecret creates a random HMAC secret for webhook verification.
|
||||
func generateInboxSecret() string {
|
||||
b := make([]byte, 32)
|
||||
|
||||
@@ -64,7 +64,10 @@ type fakeInboxWhatsAppService struct {
|
||||
templateErr error
|
||||
fetchCalls int
|
||||
webhookURL string
|
||||
webhookFields []string
|
||||
webhookErr error
|
||||
callingStatus string
|
||||
callingErr error
|
||||
}
|
||||
|
||||
func (f *fakeInboxWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
|
||||
@@ -84,6 +87,17 @@ func (f *fakeInboxWhatsAppService) SetupWebhook(_ context.Context, _ *channelmod
|
||||
return f.webhookErr
|
||||
}
|
||||
|
||||
func (f *fakeInboxWhatsAppService) SetupWebhookFields(_ context.Context, _ *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error {
|
||||
f.webhookURL = webhookURL
|
||||
f.webhookFields = fields
|
||||
return f.webhookErr
|
||||
}
|
||||
|
||||
func (f *fakeInboxWhatsAppService) UpdateCallingStatus(_ context.Context, _ *channelmodel.ChannelWhatsApp, status string) error {
|
||||
f.callingStatus = status
|
||||
return f.callingErr
|
||||
}
|
||||
|
||||
// createInboxTestPrereqs creates prerequisite Account and Inbox for service tests.
|
||||
func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*model.Account, *model.Inbox) {
|
||||
t.Helper()
|
||||
@@ -447,3 +461,67 @@ func TestInboxService_RegisterWebhook_NonCloudWhatsAppRejected(t *testing.T) {
|
||||
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{})
|
||||
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// WhatsApp calling service tests
|
||||
// ========================================
|
||||
|
||||
func TestInboxService_EnableWhatsAppCalling_SetsProviderConfigAndWebhook(t *testing.T) {
|
||||
svc, db := setupInboxServiceTest(t)
|
||||
account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
|
||||
account.FeatureFlags = `{"channel_voice":true}`
|
||||
require.NoError(t, db.Save(account).Error)
|
||||
fake := &fakeInboxWhatsAppService{}
|
||||
svc.whatsappService = fake
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
|
||||
err := svc.EnableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ENABLED", fake.callingStatus)
|
||||
assert.Equal(t, "https://app.example.test/webhooks/whatsapp/+1555010000", fake.webhookURL)
|
||||
assert.Empty(t, fake.webhookFields)
|
||||
|
||||
var updated channelmodel.ChannelWhatsApp
|
||||
require.NoError(t, db.First(&updated, channel.ID).Error)
|
||||
providerConfig := parseJSONMap(updated.ProviderConfig)
|
||||
assert.Equal(t, true, providerConfig["calling_enabled"])
|
||||
|
||||
var updatedInbox model.Inbox
|
||||
require.NoError(t, db.First(&updatedInbox, inbox.ID).Error)
|
||||
channelConfig := parseJSONMap(updatedInbox.ChannelConfig)
|
||||
assert.Equal(t, true, channelConfig["voice_enabled"])
|
||||
}
|
||||
|
||||
func TestInboxService_EnableWhatsAppCalling_RequiresCloudAndFeature(t *testing.T) {
|
||||
svc, db := setupInboxServiceTest(t)
|
||||
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "360dialog")
|
||||
account.FeatureFlags = `{"channel_voice":true}`
|
||||
require.NoError(t, db.Save(account).Error)
|
||||
svc.whatsappService = &fakeInboxWhatsAppService{}
|
||||
|
||||
err := svc.EnableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
|
||||
require.ErrorIs(t, err, ErrInboxWhatsAppCallingUnsupported)
|
||||
|
||||
account, inbox, _ = createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
|
||||
err = svc.EnableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
|
||||
require.ErrorIs(t, err, ErrInboxWhatsAppCallingFeatureRequired)
|
||||
}
|
||||
|
||||
func TestInboxService_DisableWhatsAppCalling_PersistsFalseAndIgnoresWebhookFailure(t *testing.T) {
|
||||
svc, db := setupInboxServiceTest(t)
|
||||
account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
|
||||
channel.ProviderConfig = `{"calling_enabled":true,"phone_number_id":"phone-1","business_account_id":"waba-1"}`
|
||||
require.NoError(t, db.Save(channel).Error)
|
||||
fake := &fakeInboxWhatsAppService{webhookErr: fmt.Errorf("meta unavailable")}
|
||||
svc.whatsappService = fake
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
|
||||
err := svc.DisableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"messages", "smb_message_echoes"}, fake.webhookFields)
|
||||
|
||||
var updated channelmodel.ChannelWhatsApp
|
||||
require.NoError(t, db.First(&updated, channel.ID).Error)
|
||||
providerConfig := parseJSONMap(updated.ProviderConfig)
|
||||
assert.Equal(t, false, providerConfig["calling_enabled"])
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ func (f *fakeWhatsAppAuthorizationChannelService) SetupWebhook(_ context.Context
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWhatsAppAuthorizationChannelService) SetupWebhookFields(_ context.Context, _ *channelmodel.ChannelWhatsApp, webhookURL string, _ []string) error {
|
||||
f.webhookURL = webhookURL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWhatsAppAuthorizationChannelService) UpdateCallingStatus(context.Context, *channelmodel.ChannelWhatsApp, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupWhatsAppAuthorizationService(t *testing.T) (*InboxService, *gorm.DB, *fakeWhatsAppAuthorizationChannelService) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
|
||||
Reference in New Issue
Block a user