feat(channels): align whatsapp calling toggles

This commit is contained in:
2026-06-06 20:10:06 +08:00
parent 0277898979
commit e2fdb8a171
12 changed files with 436 additions and 10 deletions
+91
View File
@@ -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)
+78
View File
@@ -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{})