Files
gochat/backend/internal/router/router.go
T

2612 lines
119 KiB
Go

package router
import (
"encoding/json"
"errors"
"fmt"
"html"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "github.com/gochat/gochat/docs" // swaggo: trigger docs init() to register SwaggerInfo
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/config"
v1 "github.com/gochat/gochat/internal/handler/api/v1"
"github.com/gochat/gochat/internal/handler/webhook"
"github.com/gochat/gochat/internal/handler/widget"
ws "github.com/gochat/gochat/internal/handler/ws"
"github.com/gochat/gochat/internal/middleware"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
wspkg "github.com/gochat/gochat/internal/ws"
"gorm.io/gorm"
)
// startTime records when the application process launched, used for uptime in /health.
var startTime = time.Now()
// Handlers holds all instantiated handler structs for route registration.
// Passed from bootstrap to avoid global state and keep dependency wiring explicit.
type Handlers struct {
Auth *v1.AuthHandler
MFA *v1.MFAHandler
SAML *v1.SAMLHandler
Account *v1.AccountHandler
EnterpriseAccount *v1.EnterpriseAccountHandler
Contact *v1.ContactHandler
Conversation *v1.ConversationHandler
Inbox *v1.InboxHandler
InboxMember *v1.InboxMemberHandler
Message *v1.MessageHandler
Profile *v1.ProfileHandler
Notification *v1.NotificationHandler
PlatformApp *v1.PlatformAppHandler
Team *v1.TeamHandler
CaptainAssistant *v1.CaptainAssistantHandler
CaptainDocument *v1.CaptainDocumentHandler
CaptainScenario *v1.CaptainScenarioHandler
CaptainCustomTool *v1.CaptainCustomToolHandler
CaptainTask *v1.CaptainTaskHandler
CaptainPreference *v1.CaptainPreferenceHandler
CaptainTaskExtended *v1.CaptainTaskExtendedHandler
CaptainAssistantResponse *v1.CaptainAssistantResponseHandler
CaptainBulkAction *v1.CaptainBulkActionHandler
RAG *v1.RAGHandler
CaptainConversation *v1.CaptainConversationHandler
AutoReplyRule *v1.AutoReplyRuleHandler
BulkAction *v1.BulkActionHandler
Copilot *v1.CopilotHandler
WebWidget *v1.WebWidgetHandler
WebWidgetTheme *v1.WebWidgetThemeHandler
WebWidgetPreChat *v1.WebWidgetPreChatHandler
WebWidgetOffline *v1.WebWidgetOfflineHandler
Analytics *v1.AnalyticsHandler
LiveReport *v1.LiveReportHandler
YearInReview *v1.YearInReviewHandler
DashboardApp *v1.DashboardAppHandler
Portal *v1.PortalHandler
Category *v1.CategoryHandler
Article *v1.ArticleHandler
Folder *v1.FolderHandler
PortalMember *v1.PortalMemberHandler
AutomationRule *v1.AutomationRuleHandler
Macro *v1.MacroHandler
CsatSurvey *v1.CsatSurveyHandler
CannedResponse *v1.CannedResponseHandler
PushSubscription *v1.PushSubscriptionHandler
NotificationSetting *v1.NotificationSettingHandler
NotificationSubscription *v1.NotificationSubscriptionHandler
WebhookSubscription *v1.WebhookSubscriptionHandler
TelegramWebhook *webhook.TelegramWebhookHandler
FacebookWebhook *webhook.FacebookWebhookHandler
WhatsAppWebhook *webhook.WhatsAppWebhookHandler
TikTokWebhook *webhook.TikTokWebhookHandler
LineWebhook *webhook.LineWebhookHandler
TwilioWebhook *webhook.TwilioWebhookHandler
ShopifyWebhook *webhook.ShopifyWebhookHandler
FakeWebhook *webhook.FakeWebhookHandler
AssignmentPolicy *v1.AssignmentPolicyHandler
Label *v1.LabelHandler
Search *v1.SearchHandler
Campaign *v1.CampaignHandler
Widget *widget.WidgetHandler
// M13: SSO/SAML enterprise authentication handlers
AccountSamlSettings *v1.AccountSamlSettingsHandler
SSOSession *v1.SSOSessionHandler
// M13: LDAP/OIDC enterprise authentication handlers
LDAP *v1.LDAPHandler
OIDC *v1.OIDCHandler
SSOMiddleware *auth.SSOMiddleware
InstagramChannel *v1.InstagramChannelHandler
FacebookChannel *v1.FacebookChannelHandler
TwitterChannel *v1.TwitterChannelHandler
MicrosoftChannel *v1.MicrosoftChannelHandler
GoogleChannel *v1.GoogleChannelHandler
// New channel handlers: TikTok, LINE, Twilio SMS, Email
TikTokChannel *v1.TikTokChannelHandler
LINEChannel *v1.LINEChannelHandler
TwilioSMSChannel *v1.TwilioChannelHandler
EmailChannel *v1.EmailChannelHandler
EmailWebhook *webhook.EmailWebhookHandler
// M12: AgentBot handlers (platform-level + account-level bots)
AgentBot *v1.AgentBotHandler
InstallationConfig *v1.InstallationConfigHandler
WidgetTest *v1.WidgetTestHandler
AgentBotInbox *v1.AgentBotInboxHandler
// P9: AgentBot rule engine + trigger config handlers
BotRule *v1.BotRuleHandler
BotTriggerConfig *v1.BotTriggerConfigHandler
// M12: SSE streaming + conversation insight handlers
SSEStream *v1.SSEStreamHandler
ConversationInsight *v1.ConversationInsightHandler
// M4 G3: ContactInbox handler (filter endpoint at account scope)
ContactInboxFilter *v1.ContactInboxHandler
// Conversation participant + draft message handlers
ConversationParticipant *v1.ConversationParticipantHandler
DraftMessage *v1.DraftMessageHandler
// G4: Company module handler (CRUD + search + nested contacts/conversations/notes)
Company *v1.CompanyHandler
// Custom attributes + custom filters (attribute definitions, attribute values, saved filters)
CustomAttributeDefinition *v1.CustomAttributeDefinitionHandler
CustomAttributeValue *v1.CustomAttributeValueHandler
CustomFilter *v1.CustomFilterHandler
// M11: SLA Policy handler (CRUD + applied SLA metrics/download + inbox associations)
SlaPolicy *v1.SlaPolicyHandler
// M11: Assignment Policy V2 handler (enhanced with Type field + inbox join model)
AssignmentPolicyV2 *v1.AssignmentPolicyV2Handler
// Enterprise: AuditLog, CustomRole, AgentCapacityPolicy, CsatMetrics
Audit *v1.AuditHandler
CustomRole *v1.CustomRoleHandler
AgentCapacity *v1.AgentCapacityHandler
CsatMetrics *v1.CsatMetricsHandler
// G16: Third-party integration handlers (IntegrationHook CRUD + Slack/Shopify/Linear/Notion)
IntegrationHook *v1.IntegrationHookHandler
SlackIntegration *v1.SlackIntegrationHandler
ShopifyIntegration *v1.ShopifyIntegrationHandler
LinearIntegration *v1.LinearIntegrationHandler
NotionIntegration *v1.NotionIntegrationHandler
DyteIntegration *v1.DyteIntegrationHandler
PlatformUserSSO *v1.PlatformUserSSOHandler
// Platform API AccessToken-authenticated handlers (distinct from SuperAdmin routes)
PlatformUser *v1.PlatformUserHandler
PlatformAccount *v1.PlatformAccountHandler
PlatformAgentBot *v1.PlatformAgentBotHandler
PlatformAccountUser *v1.PlatformAccountUserHandler
// Upload handler (account-level file upload + widget direct uploads)
Upload *v1.UploadHandler
// Lane B: AssignableAgent (find agents available for assignment to conversations)
AssignableAgent *v1.AssignableAgentHandler
AgentBulk *v1.AgentBulkHandler
Agent *v1.AgentHandler
// Lane C: CSAT template (singular per inbox) + Inbox limits
InboxCsatTemplate *v1.InboxCsatTemplateHandler
InboxLimit *v1.InboxLimitHandler
WorkingHour *v1.WorkingHourHandler
// Delivery status per message per recipient
DeliveryStatus *v1.DeliveryStatusHandler
// ReportingEvent handler (P11 — raw analytics events)
ReportingEvent *v1.ReportingEventHandler
// WhatsAppCall handler (WhatsApp voice call tracking)
WhatsAppCall *v1.WhatsAppCallHandler
// Banner handler (Platform CRUD + account read-only)
Banner *v1.BannerHandler
// EmailChannelMigration handler (account-scoped create-only)
EmailChannelMigration *v1.EmailChannelMigrationHandler
// SummaryReport handler (read-only reporting resource — agent/team/inbox/label summaries)
SummaryReport *v1.SummaryReportHandler
}
// RegisterRoutes sets up all HTTP routes on the Gin engine.
// Reference: Chatwoot config/routes.rb — the central routing definition
// that maps 327+ routes across API v1, platform, widget, and webhook namespaces.
func RegisterRoutes(
engine *gin.Engine,
jwtService *auth.JWTService,
refreshStore *auth.RefreshTokenStore,
webhookRegistry *auth.WebhookTokenRegistry,
handlers *Handlers,
hub *ws.Hub,
wsAuthenticator *wspkg.WSAuthenticator,
jwtCfg *config.JWTConfig,
corsCfg middleware.CORSConfig,
db *gorm.DB,
) {
// Health check endpoint (ref: Chatwoot health_check route)
engine.GET("/health", healthCheck)
// Swagger UI — interactive API documentation
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// External service verification routes.
// Reference: Chatwoot routes.rb `.well-known/*` controllers.
engine.GET("/.well-known/assetlinks.json", androidAssetlinks)
engine.GET("/.well-known/apple-app-site-association", appleAppSiteAssociation)
engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation)
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
// Root OAuth callbacks used by Chatwoot integration app redirects.
// Reference: Chatwoot routes.rb linear/shopify/notion callback routes.
engine.GET("/linear/callback", linearIntegrationCallback(db))
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
engine.GET("/notion/callback", notionIntegrationCallback(db))
engine.GET("/twitter/callback", twitterChannelCallback(db))
engine.GET("/google/callback", googleEmailCallback(db))
engine.GET("/microsoft/callback", microsoftEmailCallback(db))
engine.GET("/instagram/callback", instagramChannelCallback(db))
engine.GET("/tiktok/callback", tiktokChannelCallback(db))
// Dashboard shell routes used by Chatwoot mailer and push deep links.
// Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index.
engine.GET("/app", dashboardIndex)
engine.GET("/app/*params", dashboardIndex)
// Auth routes — PUBLIC, no AuthRequired middleware
v1.RegisterAuthRoutes(engine.Group("/api/v1"), handlers.Auth)
v1.RegisterChatwootAuthRoutes(engine.Group("/auth"), handlers.Auth)
// SAML routes — PUBLIC, no AuthRequired middleware (SAML flow is external)
v1.RegisterSAMLRoutes(engine.Group("/api/v1"), handlers.SAML)
// LDAP routes — PUBLIC, no AuthRequired middleware (LDAP bind is external)
v1.RegisterLDAPRoutes(engine.Group("/api/v1"), handlers.LDAP)
// OIDC routes — mixed: public auth flow + admin config routes (OIDC flow is external)
v1.RegisterOIDCRoutes(engine.Group("/api/v1"), handlers.OIDC, middleware.AuthMiddleware(jwtCfg))
// MFA routes — require authentication for enable/verify/disable
mfaPublic := engine.Group("/api/v1")
mfaPublic.Use(middleware.AuthMiddleware(jwtCfg))
v1.RegisterMFARoutes(mfaPublic, handlers.MFA)
// API v1 routes — authenticated, account-scoped
apiV1 := engine.Group("/api/v1")
apiV1.Use(middleware.AuthMiddleware(jwtCfg))
registerV1Routes(apiV1, handlers)
// Enterprise API routes consumed by the reused Chatwoot dashboard.
// Reference: Chatwoot routes.rb namespace :enterprise/:api/:v1.
enterpriseV1 := engine.Group("/enterprise/api/v1")
enterpriseV1.Use(middleware.AuthMiddleware(jwtCfg))
registerEnterpriseRoutes(enterpriseV1, handlers)
// Platform API routes — super admin only (ref: Chatwoot namespace :platform_app)
// Chatwoot uses a single /platform/api/v1 prefix with AccessTokenable concern in controller layer
// for auth differentiation. We merge SuperAdmin and AccessToken routes into one group.
platform := engine.Group("/platform/api/v1")
platform.Use(middleware.AuthMiddleware(jwtCfg))
// SuperAdmin-only routes use additional middleware; AccessToken routes are open to platform app tokens
// Register both sets of routes in the same group (Gin does not allow duplicate prefix groups)
registerPlatformRoutes(platform, handlers)
registerPlatformTokenRoutes(platform, handlers)
// Widget API routes — public + CORS for embed (ref: Chatwoot namespace :widget_api)
widget := engine.Group("/widget")
widget.Use(middleware.CORS(corsCfg))
registerWidgetRoutes(widget, handlers.Widget)
// Widget direct file upload — visitor uploads file before conversation starts
// Reference: Chatwoot POST /widget/direct_uploads
widget.POST("/direct_uploads", handlers.Upload.DirectUpload)
widget.PUT("/direct_uploads/:upload_uuid", handlers.Upload.CompleteWidgetDirectUpload)
// Chatwoot widget API routes — public + CORS for reused Chatwoot frontend/widget.
// Reference: Chatwoot namespace :api/:v1/:widget at /api/v1/widget/*.
widgetV1 := engine.Group("/api/v1/widget")
widgetV1.Use(middleware.CORS(corsCfg))
registerChatwootWidgetRoutes(widgetV1, handlers)
// Public CSAT survey routes — no auth required (customer submits rating)
// Reference: Chatwoot /public/api/v1/conversations/:uuid/csats
publicAPI := engine.Group("/public/api/v1")
publicAPI.Use(middleware.CORS(corsCfg))
{
publicAPI.GET("/conversations/:conversation_uuid/csats", handlers.CsatSurvey.PublicGet)
publicAPI.POST("/conversations/:conversation_uuid/csats", handlers.CsatSurvey.PublicUpdate)
publicAPI.GET("/csat_survey/:id", handlers.CsatSurvey.PublicGet)
publicAPI.PUT("/csat_survey/:id", handlers.CsatSurvey.PublicUpdate)
publicAPI.PATCH("/csat_survey/:id", handlers.CsatSurvey.PublicUpdate)
// Public API compatibility routes used by Chatwoot public inbox/contact flows.
publicInboxes := publicAPI.Group("/inboxes")
{
publicInboxes.GET("/:inbox_id", handlers.Widget.PublicInboxShow)
contacts := publicInboxes.Group("/:inbox_id/contacts")
{
contacts.POST("", handlers.Widget.PublicCreateContact)
contacts.GET("/:contact_id", handlers.Widget.PublicGetContact)
contacts.PUT("/:contact_id", handlers.Widget.PublicUpdateContact)
contacts.PATCH("/:contact_id", handlers.Widget.PublicUpdateContact)
conversations := contacts.Group("/:contact_id/conversations")
{
conversations.GET("", handlers.Widget.PublicListConversations)
conversations.POST("", handlers.Widget.PublicCreateConversation)
conversations.GET("/:conversation_id", handlers.Widget.PublicGetConversation)
conversations.POST("/:conversation_id/toggle_status", handlers.Widget.PublicToggleStatus)
conversations.POST("/:conversation_id/toggle_typing", handlers.Widget.PublicToggleTyping)
conversations.POST("/:conversation_id/update_last_seen", handlers.Widget.PublicUpdateLastSeen)
messages := conversations.Group("/:conversation_id/messages")
{
messages.GET("", handlers.Widget.PublicListMessages)
messages.POST("", handlers.Widget.PublicCreateMessage)
messages.PUT("/:message_id", handlers.Widget.PublicUpdateMessage)
messages.PATCH("/:message_id", handlers.Widget.PublicUpdateMessage)
}
}
}
}
}
// Public help-center routes consumed by the reused Chatwoot widget and portal.
// Reference: Chatwoot /hc/:slug and /hc/:slug/:locale/*.
helpCenter := engine.Group("/hc")
helpCenter.Use(middleware.CORS(corsCfg))
{
helpCenter.GET("/:slug", handlers.Portal.PublicRedirectDefaultLocale)
helpCenter.GET("/:slug/sitemap.xml", handlers.Portal.PublicSitemap)
helpCenter.GET("/:slug/articles/:article_slug", handlers.Article.PublicArticle)
helpCenter.GET("/:slug/:locale", handlers.Portal.PublicGet)
helpCenter.GET("/:slug/:locale/search", handlers.Article.PublicSearch)
helpCenter.GET("/:slug/:locale/articles", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/articles.json", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/categories", handlers.Category.PublicList)
helpCenter.GET("/:slug/:locale/categories.json", handlers.Category.PublicList)
helpCenter.GET("/:slug/:locale/categories/:category_slug", handlers.Category.PublicGet)
helpCenter.GET("/:slug/:locale/categories/:category_slug/articles", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/categories/:category_slug/articles.json", handlers.Article.PublicList)
}
// API v2 routes — authenticated, account-scoped report APIs.
apiV2 := engine.Group("/api/v2")
apiV2.Use(middleware.AuthMiddleware(jwtCfg))
registerV2Routes(apiV2, handlers)
// Webhook callback routes.
// Reference: Chatwoot routes provider-specific public webhook paths and lets
// each provider handler verify its own token/signature contract.
webhookGroup := engine.Group("/webhooks")
// Facebook/Instagram webhook — Meta Business Suite combined endpoint
// GET: webhook verification (hub.mode=subscribe, hub.verify_token, hub.challenge)
// POST: incoming message/event processing (X-Hub-Signature-256 validated)
// Reference: Chatwoot routes at /webhooks/facebook/:page_id
if handlers.FacebookWebhook != nil {
fbGroup := webhookGroup.Group("/facebook")
fbGroup.GET("/:page_id", handlers.FacebookWebhook.HandleFacebookVerification)
fbGroup.POST("/:page_id", handlers.FacebookWebhook.HandleFacebookWebhook)
}
// Telegram webhook — bot token in URL path for routing
// Reference: Chatwoot routes at /webhooks/telegram/:bot_token
tgGroup := webhookGroup.Group("/telegram")
tgGroup.POST("/:bot_token", func(c *gin.Context) {
if handlers == nil || handlers.TelegramWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TelegramWebhook.HandleTelegramWebhook(c)
})
// WhatsApp webhook — Cloud API webhook verification + incoming events
// GET: webhook verification (hub.mode=subscribe, hub.verify_token, hub.challenge)
// POST: incoming message/event processing (X-Hub-Signature-256 validated)
// Reference: Chatwoot routes at /webhooks/whatsapp/:phone_number
// Reference: WhatsApp Cloud API https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#verify-webhook
waGroup := webhookGroup.Group("/whatsapp")
waGroup.GET("/:phone_number", func(c *gin.Context) {
if handlers == nil || handlers.WhatsAppWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.WhatsAppWebhook.HandleWhatsAppVerification(c)
})
waGroup.POST("/:phone_number", func(c *gin.Context) {
if handlers == nil || handlers.WhatsAppWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.WhatsAppWebhook.HandleWhatsAppWebhook(c)
})
// TikTok webhook — Business API event notifications
// GET: webhook URL verification request
// POST: incoming message/event processing (X-TikTok-Signature validated)
// Reference: TikTok Business API https://business-api.tiktok.com/portal/docs?id=1739584855420928
ttGroup := webhookGroup.Group("/tiktok")
ttGroup.POST("", func(c *gin.Context) {
if handlers == nil || handlers.TikTokWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TikTokWebhook.HandleTikTokWebhook(c)
})
ttGroup.GET("/:business_id", func(c *gin.Context) {
if handlers == nil || handlers.TikTokWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TikTokWebhook.HandleTikTokVerification(c)
})
ttGroup.POST("/:business_id", func(c *gin.Context) {
if handlers == nil || handlers.TikTokWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TikTokWebhook.HandleTikTokWebhook(c)
})
// LINE webhook — Messaging API event notifications
// POST: incoming message/event processing (X-Line-Signature HMAC-SHA256 validated)
// Reference: LINE Messaging API https://developers.line.biz/en/docs/messaging-api/receiving-messages/
lineGroup := webhookGroup.Group("/line")
lineGroup.POST("/:line_channel_id", func(c *gin.Context) {
if handlers == nil || handlers.LineWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.LineWebhook.HandleLineWebhook(c)
})
// Twilio SMS webhook — inbound SMS/MMS + delivery status callbacks
// POST /sms/:phone_number: inbound SMS/MMS messages (form-encoded body, TwiML response)
// POST /status/:phone_number: delivery status callbacks (delivered/undelivered/failed)
// Reference: Twilio SMS API https://www.twilio.com/docs/sms/api/message-resource
webhookGroup.POST("/sms/:phone_number", func(c *gin.Context) {
if handlers == nil || handlers.TwilioWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwilioWebhook.HandleTwilioInboundSMS(c)
})
twilioGroup := webhookGroup.Group("/twilio")
twilioGroup.POST("/sms/:phone_number", func(c *gin.Context) {
if handlers == nil || handlers.TwilioWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwilioWebhook.HandleTwilioInboundSMS(c)
})
twilioGroup.POST("/status/:phone_number", func(c *gin.Context) {
if handlers == nil || handlers.TwilioWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c)
})
twilioGroup.POST("/delivery_status", func(c *gin.Context) {
if handlers == nil || handlers.TwilioWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c)
})
engine.POST("/twilio/delivery_status", func(c *gin.Context) {
if handlers == nil || handlers.TwilioWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c)
})
engine.POST("/twilio/callback", func(c *gin.Context) {
if handlers == nil || handlers.TwilioWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwilioWebhook.HandleTwilioCallback(c)
})
// Enterprise Twilio voice callback routes.
// Reference: Chatwoot enterprise Twilio::VoiceController routes.rb:643-646.
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
engine.POST("/twilio/voice/status/:phone", twilioVoiceStatus(db))
engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db))
engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db))
// Fake webhook — FakeMessagePlatform integration test channel.
// GET: webhook URL verification (echo challenge)
// POST: incoming message/event processing (X-Fake-Token header validated)
// Reference: GoChat FakeMessagePlatform (channels/fake) posts to this
// endpoint to simulate external channel messages.
// Routes are registered unconditionally (like Telegram) so the route table
// is stable for parity tests; nil handlers respond with 503.
fakeGroup := webhookGroup.Group("/fake")
fakeGroup.GET("/:identifier", func(c *gin.Context) {
if handlers == nil || handlers.FakeWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.FakeWebhook.HandleFakeWebhookVerification(c)
})
fakeGroup.POST("/:identifier", func(c *gin.Context) {
if handlers == nil || handlers.FakeWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.FakeWebhook.HandleFakeWebhook(c)
})
// Twitter webhook — Account Activity API CRC validation + event processing
// GET: CRC challenge response (crc_token query param)
// POST: incoming DM/event processing
// Reference: Twitter API v2 Account Activity API https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/account-activity-api
twWebhookGroup := webhookGroup.Group("/twitter")
twWebhookGroup.GET("", func(c *gin.Context) {
if handlers == nil || handlers.TwitterChannel == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwitterChannel.WebhookCRC(c)
})
twWebhookGroup.POST("", func(c *gin.Context) {
if handlers == nil || handlers.TwitterChannel == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwitterChannel.WebhookEvent(c)
})
// Legacy GoChat aliases kept for already configured Twitter webhooks.
twWebhookGroup.GET("/webhook", func(c *gin.Context) {
if handlers == nil || handlers.TwitterChannel == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwitterChannel.WebhookCRC(c)
})
twWebhookGroup.POST("/webhook", func(c *gin.Context) {
if handlers == nil || handlers.TwitterChannel == nil {
webhookProviderUnavailable(c)
return
}
handlers.TwitterChannel.WebhookEvent(c)
})
webhookGroup.GET("/instagram", func(c *gin.Context) {
if handlers == nil || handlers.FacebookWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.FacebookWebhook.HandleInstagramVerification(c)
})
webhookGroup.POST("/instagram", func(c *gin.Context) {
if handlers == nil || handlers.FacebookWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.FacebookWebhook.HandleInstagramWebhook(c)
})
webhookGroup.POST("/shopify", func(c *gin.Context) {
if handlers == nil || handlers.ShopifyWebhook == nil {
webhookProviderUnavailable(c)
return
}
handlers.ShopifyWebhook.HandleShopifyWebhook(c)
})
// Microsoft webhook — Graph API subscription validation + notifications
// POST: validation request (returns validationToken) + change notifications
// Reference: Microsoft Graph API https://learn.microsoft.com/en-us/graph/api/resources/webhooks
if handlers.MicrosoftChannel != nil {
msWebhookGroup := webhookGroup.Group("/microsoft")
msWebhookGroup.POST("/validation", handlers.MicrosoftChannel.WebhookValidation)
msWebhookGroup.POST("/events", handlers.MicrosoftChannel.WebhookEvent)
}
// Email webhook — IMAP/SMTP inbound email processing + verification
// POST: inbound email processing (parsed from IMAP or SMTP relay)
// GET: email verification (MX/DNS record validation for custom domain setup)
// Reference: Chatwoot email channel webhook https://www.chatwoot.com/docs/product/channels/email
if handlers.EmailWebhook != nil {
emailWebhookGroup := webhookGroup.Group("/email")
emailWebhookGroup.POST("/:inbox_id", handlers.EmailWebhook.HandleEmailWebhook)
emailWebhookGroup.GET("/:inbox_id/verification", handlers.EmailWebhook.HandleEmailVerification)
}
// Generic webhook success fallback was removed for P6.7: provider paths above
// should either process/verify the callback or return explicit not-implemented.
// WebSocket endpoints (ref: Chatwoot ActionCable mount at /cable)
// P9: real-time communication — WebSocket upgrade with JWT/pubsub_token auth
wsHandler := ws.NewHandler(hub, wsAuthenticator)
engine.GET("/ws", wsHandler.ServeWS) // Primary WebSocket endpoint
engine.GET("/cable", wsHandler.ServeCable) // ActionCable-compatible endpoint (Chatwoot convention)
}
func registerEnterpriseRoutes(g *gin.RouterGroup, h *Handlers) {
if h == nil || h.EnterpriseAccount == nil {
return
}
accounts := g.Group("/accounts")
{
accounts.POST("/:account_id/checkout", h.EnterpriseAccount.Checkout)
accounts.POST("/:account_id/subscription", h.EnterpriseAccount.Subscription)
accounts.GET("/:account_id/limits", h.EnterpriseAccount.Limits)
accounts.POST("/:account_id/toggle_deletion", h.EnterpriseAccount.ToggleDeletion)
accounts.POST("/:account_id/topup_checkout", h.EnterpriseAccount.TopupCheckout)
}
// EnterpriseAccountAPI uses an empty resource with accountScoped=true. In a
// mounted dashboard route ApiClient expands to /accounts/:id/*, while its
// frontend unit specs and some boot-time calls hit these literal paths and
// rely on the authenticated current account context.
g.POST("/checkout", h.EnterpriseAccount.Checkout)
g.POST("/subscription", h.EnterpriseAccount.Subscription)
g.GET("/limits", h.EnterpriseAccount.Limits)
g.POST("/toggle_deletion", h.EnterpriseAccount.ToggleDeletion)
g.POST("/topup_checkout", h.EnterpriseAccount.TopupCheckout)
}
// registerV1Routes maps all API v1 resource routes.
// Reference: Chatwoot routes.rb namespace :api, scope :v1
func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Profile routes — not scoped to account
g.GET("/profile", h.Profile.Get)
g.PATCH("/profile", h.Profile.Update)
g.PUT("/profile", h.Profile.Update)
g.PUT("/profile/avatar", h.Profile.UpdateAvatar)
g.DELETE("/profile/avatar", h.Profile.DeleteAvatar)
g.POST("/profile/availability", h.Profile.SetAvailability)
g.POST("/profile/auto_offline", h.Profile.SetAutoOffline)
g.PUT("/profile/set_active_account", h.Profile.SetActiveAccount)
g.POST("/profile/resend_confirmation", h.Profile.ResendConfirmation)
g.POST("/profile/reset_access_token", h.Profile.ResetAccessToken)
// MFA routes under profile scope (Chatwoot: scope module: 'profile' do resource :mfa)
// GET /profile/mfa — show status, POST /profile/mfa — create (enable), DELETE /profile/mfa — destroy (disable)
// POST /profile/mfa/verify — verify TOTP code, POST /profile/mfa/backup_codes — generate backup codes
g.GET("/profile/mfa", h.MFA.ProfileMFAStatus)
g.POST("/profile/mfa", h.MFA.ProfileEnableMFA)
g.DELETE("/profile/mfa", h.MFA.ProfileDisableMFA)
profileMfa := g.Group("/profile/mfa")
{
profileMfa.GET("/", h.MFA.ProfileMFAStatus)
profileMfa.POST("/", h.MFA.ProfileEnableMFA)
profileMfa.DELETE("/", h.MFA.ProfileDisableMFA)
profileMfa.POST("/verify", h.MFA.ProfileVerifyMFA)
profileMfa.POST("/backup_codes", h.MFA.ProfileBackupCodes)
}
// Notification routes — user-scoped, not account-scoped
// Reference: Chatwoot also has user-scoped notification routes (not account-scoped)
g.GET("/notifications", h.Notification.List)
g.GET("/notifications/unread_count", h.Notification.UnreadCount)
g.POST("/notifications/read_all", h.Notification.MarkAllRead)
g.PUT("/notifications/:id", h.Notification.Update)
g.DELETE("/notifications/:id", h.Notification.Destroy)
g.POST("/notifications/:id/snooze", h.Notification.Snooze)
g.POST("/notifications/:id/unread", h.Notification.Unread)
g.DELETE("/notifications/destroy_all", h.Notification.DestroyAll)
// notification_settings is now registered under account-scoped routes (see notifSettings group below)
// Push subscription routes — user-scoped push notification device management (P4 M8)
g.POST("/push_subscriptions", h.PushSubscription.Create)
g.GET("/push_subscriptions", h.PushSubscription.List)
g.DELETE("/push_subscriptions/:id", h.PushSubscription.Delete)
// Notification subscription routes — user-scoped, browser_push/fcm subscription management
// Reference: Chatwoot resource :notification_subscriptions, only: [:create, :destroy]
g.POST("/notification_subscriptions", h.NotificationSubscription.Create)
g.DELETE("/notification_subscriptions", h.NotificationSubscription.Destroy)
g.DELETE("/notification_subscriptions/:identifier", h.NotificationSubscription.Destroy)
// SSO session management routes — user-scoped, authenticated (M13)
// Reference: Chatwoot SsoSession management — list/terminate sessions
v1.RegisterSSOSessionRoutes(g.Group("/sso_sessions"), h.SSOSession)
// Webhook subscription routes — account-scoped webhook event management (P4 M8)
// Reference: Chatwoot api/v1/accounts/:account_id/webhooks
g.GET("/accounts/:account_id/webhooks", h.WebhookSubscription.List)
g.POST("/accounts/:account_id/webhooks", h.WebhookSubscription.Create)
g.GET("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Get)
g.PUT("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Update)
g.PATCH("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Update)
g.DELETE("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Delete)
// Account routes — scoped with AccountScope middleware (ref: Chatwoot namespace :accounts)
// GetAll is outside AccountScope — platform admin level listing of all accounts
g.GET("/accounts/all", h.Account.GetAll)
// Chatwoot account creation is user-scoped and the frontend posts to the
// no-trailing-slash collection path before a new account id exists.
g.POST("/accounts", h.Account.Create)
accounts := g.Group("/accounts")
accounts.Use(middleware.AccountScope())
{
accounts.GET("/", h.Account.List)
accounts.POST("/", h.Account.Create)
accounts.GET("/:account_id", h.Account.Get)
accounts.PATCH("/:account_id", h.Account.Update)
accounts.PUT("/:account_id", h.Account.Update)
accounts.DELETE("/:account_id", h.Account.Delete)
// Account onboarding update (ref: Chatwoot resource :onboarding, only: [:update])
accounts.PATCH("/:account_id/onboarding", middleware.RoleCheck("administrator"), h.Account.UpdateOnboarding)
// Account settings (ref: Chatwoot accounts#update settings subset)
accounts.PUT("/:account_id/settings", h.Account.UpdateSettings)
// Account file upload route (ref: Chatwoot api/v1/accounts/:account_id/upload)
accounts.POST("/:account_id/upload", h.Upload.Upload)
// Account direct file upload — staged upload returning blob UUID for message attachment
// Reference: Chatwoot POST /api/v1/accounts/:account_id/direct_uploads
accounts.POST("/:account_id/direct_uploads", h.Upload.AccountDirectUpload)
// M11: WebWidget offline messages account-level routes
// List all pending offline messages across inboxes for this account
accounts.GET("/:account_id/web_widgets/offline_messages", h.WebWidgetOffline.ListOfflineMessages)
accounts.PUT("/:account_id/web_widgets/offline_messages/:offline_message_id/dismiss", h.WebWidgetOffline.DismissOfflineMessage)
accounts.PUT("/:account_id/web_widgets/offline_messages/:offline_message_id/convert", h.WebWidgetOffline.ConvertOfflineMessage)
// Account agents (ref: Chatwoot agents_controller.rb — resources :agents, only: [:index, :create, :update, :destroy])
// Chatwoot: GET /api/v1/accounts/:account_id/agents (index)
accounts.GET("/:account_id/agents", h.Agent.List)
// Chatwoot: POST /api/v1/accounts/:account_id/agents (create)
accounts.POST("/:account_id/agents", h.Agent.Create)
// Chatwoot: PATCH/PUT /api/v1/accounts/:account_id/agents/:id (update)
accounts.PATCH("/:account_id/agents/:agent_id", h.Agent.Update)
accounts.PUT("/:account_id/agents/:agent_id", h.Agent.Update)
// Chatwoot: DELETE /api/v1/accounts/:account_id/agents/:id (destroy)
accounts.DELETE("/:account_id/agents/:agent_id", h.Agent.Delete)
// Chatwoot: GET /api/v1/accounts/:account_id/agents/:id (show) — was missing
accounts.GET("/:account_id/agents/:agent_id", h.Agent.Get)
// Chatwoot: POST /api/v1/accounts/:account_id/agents/bulk_create
accounts.POST("/:account_id/agents/bulk_create", h.Agent.BulkCreate)
// Bulk assign/unassign conversations to agents (ref: Chatwoot agents_controller.rb #bulk_assign, #bulk_unassign)
accounts.POST("/:account_id/agents/bulk_assign", h.AgentBulk.BulkAssign)
accounts.POST("/:account_id/agents/bulk_unassign", h.AgentBulk.BulkUnassign)
// Assignable agents — standalone resource (ref: Chatwoot resources :assignable_agents, only: [:index])
// Chatwoot: GET /api/v1/accounts/:account_id/assignable_agents (with inbox_ids[] query param)
accounts.GET("/:account_id/assignable_agents", h.AssignableAgent.List)
// Bulk actions for conversations/contacts (ref: Chatwoot bulk_actions_controller.rb)
accounts.POST("/:account_id/bulk_actions", h.BulkAction.Create)
// Account WhatsApp calls (ref: Chatwoot enterprise whatsapp_calls_controller.rb)
accounts.GET("/:account_id/whatsapp_calls/:id", h.WhatsAppCall.Show)
accounts.POST("/:account_id/whatsapp_calls/initiate", h.WhatsAppCall.Initiate)
accounts.POST("/:account_id/whatsapp_calls/:id/accept", h.WhatsAppCall.Accept)
accounts.POST("/:account_id/whatsapp_calls/:id/reject", h.WhatsAppCall.Reject)
accounts.POST("/:account_id/whatsapp_calls/:id/terminate", h.WhatsAppCall.Terminate)
accounts.POST("/:account_id/whatsapp_calls/:id/upload_recording", h.WhatsAppCall.UploadRecording)
// Account members (ref: Chatwoot namespace :account_users)
accounts.GET("/:account_id/users", h.Account.ListUsers)
accounts.POST("/:account_id/users", h.Account.AddUser)
accounts.DELETE("/:account_id/users/:user_id", h.Account.RemoveUser)
// Account extensions (ref: Chatwoot accounts_controller.rb#update_active_at, #cache_keys)
accounts.POST("/:account_id/update_active_at", h.Account.UpdateActiveAt)
accounts.GET("/:account_id/cache_keys", h.Account.CacheKeys)
// Nested resources under :id
accountScoped := accounts.Group("/:account_id")
{
// Inbox routes (ref: Chatwoot nested resources :inboxes)
inboxes := accountScoped.Group("/inboxes")
{
inboxes.GET("", h.Inbox.List)
inboxes.GET("/", h.Inbox.List)
inboxes.POST("", h.Inbox.Create)
inboxes.POST("/", h.Inbox.Create)
inboxes.GET("/:inbox_id", h.Inbox.Get)
inboxes.PUT("/:inbox_id", h.Inbox.Update)
inboxes.PATCH("/:inbox_id", h.Inbox.Update)
inboxes.DELETE("/:inbox_id", h.Inbox.Delete)
// Inbox member-action routes (ref: Chatwoot InboxesController member actions)
// POST /api/v1/accounts/:id/inboxes/:inbox_id/set_agent_bot — assign/remove agent bot
inboxes.POST("/:inbox_id/set_agent_bot", h.Inbox.SetAgentBot)
// GET /api/v1/accounts/:id/inboxes/:inbox_id/health — check channel health
inboxes.GET("/:inbox_id/health", h.Inbox.Health)
// POST /api/v1/accounts/:id/inboxes/:inbox_id/sync_templates — sync WhatsApp templates
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)
// DELETE /api/v1/accounts/:id/inboxes/:inbox_id/avatar — remove inbox avatar
inboxes.DELETE("/:inbox_id/avatar", h.Inbox.DeleteAvatar)
// GET /api/v1/accounts/:id/inboxes/:inbox_id/campaigns — list campaigns for inbox
inboxes.GET("/:inbox_id/campaigns", h.Inbox.ListCampaigns)
inboxes.POST("/:inbox_id/reset_secret", h.Inbox.ResetSecret)
// WebWidget config routes(ref: Chatwoot web_widget_config_controller.rb)
// /api/v1/accounts/:id/inboxes/web_widget — create widget inbox
inboxes.POST("/web_widget", h.WebWidget.CreateWebWidgetInbox)
// /api/v1/accounts/:id/inboxes/:inbox_id/web_widget_config — CRUD widget config
inboxes.GET("/:inbox_id/web_widget_config", h.WebWidget.GetWebWidgetConfig)
inboxes.PUT("/:inbox_id/web_widget_config", h.WebWidget.UpdateWebWidgetConfig)
// /api/v1/accounts/:id/inboxes/:inbox_id/web_widget — delete widget inbox
inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox)
// M11: WebWidget theme_config & pre_chat_form admin routes
// Extended configuration beyond basic widget_color
inboxes.GET("/:inbox_id/web_widget/theme_config", h.WebWidgetTheme.GetThemeConfig)
inboxes.PUT("/:inbox_id/web_widget/theme_config", h.WebWidgetTheme.UpdateThemeConfig)
inboxes.DELETE("/:inbox_id/web_widget/theme_config", h.WebWidgetTheme.DeleteThemeConfig)
inboxes.GET("/:inbox_id/web_widget/pre_chat_form", h.WebWidgetPreChat.GetPreChatForm)
inboxes.PUT("/:inbox_id/web_widget/pre_chat_form", h.WebWidgetPreChat.UpdatePreChatForm)
inboxes.DELETE("/:inbox_id/web_widget/pre_chat_form", h.WebWidgetPreChat.DeletePreChatForm)
// M11: WebWidget offline messages admin routes
// Agents can view pending offline messages and dismiss/convert them
inboxes.GET("/:inbox_id/web_widget/offline_messages", h.WebWidgetOffline.ListOfflineMessagesByInbox)
// InboxMember (seat assignment) routes (ref: Chatwoot inbox_members_controller)
// /api/v1/accounts/:id/inboxes/:inbox_id/members — manage agent-to-inbox assignments
inboxes.GET("/:inbox_id/members", h.InboxMember.ListMembers)
inboxes.POST("/:inbox_id/members", h.InboxMember.AddMember)
inboxes.PATCH("/:inbox_id/members/:user_id", h.InboxMember.UpdateMember)
inboxes.PATCH("/:inbox_id/members/update_multiple", h.InboxMember.UpdateMultiple)
inboxes.DELETE("/:inbox_id/members/:user_id", h.InboxMember.RemoveMember)
// Account-level inbox_members (Chatwoot: resources :inbox_members, param: :inbox_id)
inboxMembers := accountScoped.Group("/inbox_members")
{
inboxMembers.POST("/", h.InboxMember.CreateAccountScoped)
inboxMembers.PATCH("/", h.InboxMember.UpdateAccountScoped)
inboxMembers.DELETE("/", h.InboxMember.DestroyAccountScoped)
inboxMembers.GET("/:inbox_id", h.InboxMember.ShowAccountScoped)
}
// CSAT template (ref: Chatwoot resource :csat_template, singular per inbox)
// GET /api/v1/accounts/:id/inboxes/:inbox_id/csat_template — show template status
// POST /api/v1/accounts/:id/inboxes/:inbox_id/csat_template — create/update template
// POST /api/v1/accounts/:id/inboxes/:inbox_id/csat_template/analyze — analyze template quality
inboxes.GET("/:inbox_id/csat_template", h.InboxCsatTemplate.Show)
inboxes.POST("/:inbox_id/csat_template", h.InboxCsatTemplate.Create)
inboxes.POST("/:inbox_id/csat_template/analyze", h.InboxCsatTemplate.Analyze)
// Inbox limits (ref: Chatwoot resources :inbox_limits, create/update/destroy only)
// POST /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits — create limit
// PATCH /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits/:id — update limit
// DELETE /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits/:id — delete limit
inboxes.POST("/:inbox_id/inbox_limits", h.InboxLimit.Create)
inboxes.PATCH("/:inbox_id/inbox_limits/:id", h.InboxLimit.Update)
inboxes.DELETE("/:inbox_id/inbox_limits/:id", h.InboxLimit.Delete)
// Working hours (ref: Chatwoot OutOfOffisable concern)
// GET /api/v1/accounts/:id/inboxes/:inbox_id/working_hours — weekly schedule
// PUT /api/v1/accounts/:id/inboxes/:inbox_id/working_hours — update schedule
// GET /api/v1/accounts/:id/inboxes/:inbox_id/out_of_office — check out-of-office status
inboxes.GET("/:inbox_id/working_hours", h.WorkingHour.GetWeeklySchedule)
inboxes.PUT("/:inbox_id/working_hours", h.WorkingHour.UpdateWeeklySchedule)
inboxes.GET("/:inbox_id/out_of_office", h.WorkingHour.IsOutOfOffice)
// Assignable agents (ref: Chatwoot InboxesController #assignable_agents)
// GET /api/v1/accounts/:id/inboxes/:inbox_id/assignable_agents — list agents assignable to conversations
inboxes.GET("/:inbox_id/assignable_agents", h.AssignableAgent.List)
}
// Contact merge (ref: Chatwoot ContactsController #merge)
// POST /api/v1/accounts/:account_id/contacts/merge — merge two contacts
// Also: POST /api/v1/accounts/:account_id/actions/contact_merge — Chatwoot resource path
accountScoped.POST("/actions/contact_merge", h.Contact.Merge)
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")
{
fbChannels.POST("/", h.FacebookChannel.CreateFacebookPage)
fbChannels.GET("/authorization", h.FacebookChannel.Authorization)
fbChannels.POST("/oauth_callback", h.FacebookChannel.OAuthCallback)
fbChannels.POST("/reauthorize", h.FacebookChannel.ReauthorizeFacebookPage)
fbChannels.GET("/:fb_id", h.FacebookChannel.GetFacebookPage)
fbChannels.PATCH("/:fb_id", h.FacebookChannel.UpdateFacebookPage)
fbChannels.DELETE("/:fb_id", h.FacebookChannel.DeleteFacebookPage)
}
// Instagram channel routes (ref: Chatwoot channels/instagram_controller)
// Twitter channel routes (G10: OAuth 2.0 + webhook CRC)
twChannels := accountScoped.Group("/twitter_channels")
twChannels.GET("/authorization", h.TwitterChannel.Authorization)
twChannels.POST("/oauth_callback", h.TwitterChannel.OAuthCallback)
twChannels.DELETE("/:twitter_id", h.TwitterChannel.Delete)
// Microsoft channel routes (G10: Azure AD OAuth 2.0)
msChannels := accountScoped.Group("/microsoft_channels")
msChannels.GET("/authorization", h.MicrosoftChannel.Authorization)
msChannels.POST("/oauth_callback", h.MicrosoftChannel.OAuthCallback)
msChannels.DELETE("/:ms_id", h.MicrosoftChannel.Delete)
// Google channel routes (G10: Google OAuth 2.0)
goChannels := accountScoped.Group("/google_channels")
goChannels.GET("/authorization", h.GoogleChannel.Authorization)
goChannels.POST("/oauth_callback", h.GoogleChannel.OAuthCallback)
goChannels.DELETE("/:google_id", h.GoogleChannel.Delete)
igChannels := accountScoped.Group("/instagram_channels")
{
igChannels.POST("/", h.InstagramChannel.CreateInstagramChannel)
igChannels.GET("/", h.InstagramChannel.ListInstagramChannels)
}
igInboxScoped := accountScoped.Group("/inboxes/:inbox_id/instagram_channels")
{
igInboxScoped.GET("/:ig_id", h.InstagramChannel.GetInstagramChannel)
igInboxScoped.PATCH("/:ig_id", h.InstagramChannel.UpdateInstagramChannel)
igInboxScoped.DELETE("/:ig_id", h.InstagramChannel.DeleteInstagramChannel)
}
// === TikTok channel CRUD routes ===
ttChannels := accountScoped.Group("/tiktok_channels")
{
ttChannels.POST("/", h.TikTokChannel.CreateTikTokChannel)
ttChannels.GET("/", h.TikTokChannel.ListTikTokChannels)
}
ttInboxScoped := accountScoped.Group("/inboxes/:inbox_id/tiktok_channels")
{
ttInboxScoped.GET("/:tt_id", h.TikTokChannel.GetTikTokChannel)
ttInboxScoped.PATCH("/:tt_id", h.TikTokChannel.UpdateTikTokChannel)
ttInboxScoped.DELETE("/:tt_id", h.TikTokChannel.DeleteTikTokChannel)
}
// === LINE channel CRUD routes ===
lineChannels := accountScoped.Group("/line_channels")
{
lineChannels.POST("/", h.LINEChannel.Create)
lineChannels.GET("/", h.LINEChannel.List)
}
lineInboxScoped := accountScoped.Group("/inboxes/:inbox_id/line_channels")
{
lineInboxScoped.GET("/:line_id", h.LINEChannel.Get)
lineInboxScoped.PATCH("/:line_id", h.LINEChannel.Update)
lineInboxScoped.DELETE("/:line_id", h.LINEChannel.Delete)
}
// === Twilio SMS channel CRUD routes ===
twilioChannels := accountScoped.Group("/twilio_sms_channels")
{
twilioChannels.POST("/", h.TwilioSMSChannel.Create)
twilioChannels.GET("/", h.TwilioSMSChannel.List)
}
twilioInboxScoped := accountScoped.Group("/inboxes/:inbox_id/twilio_sms_channels")
{
twilioInboxScoped.GET("/:tw_id", h.TwilioSMSChannel.Get)
twilioInboxScoped.PATCH("/:tw_id", h.TwilioSMSChannel.Update)
twilioInboxScoped.DELETE("/:tw_id", h.TwilioSMSChannel.Delete)
}
// === Email channel CRUD routes ===
emailChannels := accountScoped.Group("/email_channels")
{
emailChannels.POST("/", h.EmailChannel.Create)
emailChannels.GET("/", h.EmailChannel.List)
}
emailInboxScoped := accountScoped.Group("/inboxes/:inbox_id/email_channels")
{
emailInboxScoped.GET("/:em_id", h.EmailChannel.Get)
emailInboxScoped.PATCH("/:em_id", h.EmailChannel.Update)
emailInboxScoped.DELETE("/:em_id", h.EmailChannel.Delete)
}
// EmailChannelMigration — initiate migration of email channel between inboxes
// Reference: Chatwoot resources :email_channel_migrations, only: [:create]
v1.RegisterEmailChannelMigrationRoutes(accountScoped.Group("/email_channel_migrations"), h.EmailChannelMigration)
// === Chatwoot-style OAuth + Webhook routes (G10) ===
// These follow Chatwoot's URL convention: /accounts/:id/{channel}/oauth
// and /accounts/:id/{channel}/callback, mirroring Chatwoot's
// channel_controller.rb pattern for each channel type.
// Twitter OAuth + Webhook routes (G10)
twOAuth := accountScoped.Group("/twitter")
{
twOAuth.GET("/oauth", h.TwitterChannel.Authorization) // OAuth authorize URL
twOAuth.GET("/callback", h.TwitterChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params)
twOAuth.POST("/webhooks", h.TwitterChannel.RegisterWebhook) // Register webhook
twOAuth.GET("/webhooks", h.TwitterChannel.ListWebhooks) // List webhooks
}
// Instagram OAuth + Webhook routes (G10)
igOAuth := accountScoped.Group("/instagram")
{
igOAuth.GET("/oauth", h.InstagramChannel.Authorization) // OAuth authorize URL
igOAuth.GET("/callback", h.InstagramChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params)
igOAuth.POST("/webhooks", h.InstagramChannel.RegisterWebhook) // Register webhook (Facebook Graph API subscribed_apps)
igOAuth.GET("/webhooks", h.InstagramChannel.ListWebhooks) // List webhooks
}
// Microsoft OAuth + Webhook routes (G10)
msOAuth := accountScoped.Group("/microsoft")
{
msOAuth.GET("/oauth", h.MicrosoftChannel.Authorization) // OAuth authorize URL
msOAuth.GET("/callback", h.MicrosoftChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params)
msOAuth.POST("/webhooks", h.MicrosoftChannel.RegisterWebhook) // Register webhook (Microsoft Graph API subscription)
msOAuth.GET("/webhooks", h.MicrosoftChannel.ListWebhooks) // List webhooks (Microsoft Graph API subscriptions)
}
// Google OAuth + Webhook routes (G10)
goOAuth := accountScoped.Group("/google")
{
goOAuth.GET("/oauth", h.GoogleChannel.Authorization) // OAuth authorize URL
goOAuth.GET("/callback", h.GoogleChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params)
goOAuth.POST("/webhooks", h.GoogleChannel.RegisterWebhook) // Register webhook (Google Chat API spaces.watch)
goOAuth.GET("/webhooks", h.GoogleChannel.ListWebhooks) // List webhooks
}
// Instagram comment management routes (ref: Chatwoot instagram_controller comments)
igComments := accountScoped.Group("/inboxes/:inbox_id/instagram_comments")
{
igComments.GET("/media/:media_id", h.InstagramChannel.GetComments)
igComments.GET("/:comment_id/replies", h.InstagramChannel.GetCommentReplies)
igComments.POST("/:comment_id/reply", h.InstagramChannel.ReplyToComment)
igComments.POST("/:comment_id/hide", h.InstagramChannel.HideComment)
igComments.DELETE("/:comment_id", h.InstagramChannel.DeleteComment)
}
// Conversation routes (ref: Chatwoot resources :conversations)
conversations := accountScoped.Group("/conversations")
{
conversations.GET("", h.Conversation.List)
conversations.GET("/", h.Conversation.List)
conversations.POST("", h.Conversation.Create)
conversations.POST("/", h.Conversation.Create)
conversations.GET("/search", h.Conversation.Search)
conversations.POST("/filter", h.Conversation.Filter)
conversations.GET("/:conversation_id", h.Conversation.Get)
conversations.PATCH("/:conversation_id", h.Conversation.Update)
conversations.PUT("/:conversation_id", h.Conversation.Update)
conversations.DELETE("/:conversation_id", h.Conversation.Delete)
conversations.POST("/:conversation_id/assign", h.Conversation.AssignAgent)
conversations.POST("/:conversation_id/toggle_status", h.Conversation.ToggleStatus)
conversations.POST("/:conversation_id/toggle_priority", h.Conversation.TogglePriority)
conversations.PATCH("/:conversation_id/labels", h.Conversation.UpdateLabels)
conversations.GET("/:conversation_id/labels", h.Conversation.GetLabels)
conversations.POST("/:conversation_id/labels", h.Conversation.UpdateLabels)
conversations.DELETE("/:conversation_id/labels/:tag_id", h.Label.RemoveLabelFromConversation)
conversations.POST("/:conversation_id/mute", h.Conversation.Mute)
conversations.POST("/:conversation_id/unmute", h.Conversation.Unmute)
conversations.PATCH("/:conversation_id/priority", h.Conversation.UpdatePriority)
// Conversation meta, unread, transcript, custom_attributes, unread_counts
conversations.GET("/meta", h.Conversation.Meta)
conversations.GET("/unread_counts", h.Conversation.UnreadCounts)
conversations.POST("/:conversation_id/unread", h.Conversation.Unread)
conversations.POST("/:conversation_id/transcript", h.Conversation.Transcript)
conversations.POST("/:conversation_id/custom_attributes", h.Conversation.UpdateCustomAttributes)
// Additional Conversation endpoints matching Chatwoot member routes
conversations.GET("/:conversation_id/attachments", h.Conversation.ListAttachments)
conversations.GET("/:conversation_id/inbox_assistant", h.Conversation.InboxAssistant)
conversations.GET("/:conversation_id/reporting_events", h.Conversation.ReportingEvents)
conversations.POST("/:conversation_id/direct_uploads", h.Upload.ConversationDirectUpload)
conversations.PUT("/:conversation_id/direct_uploads/:upload_uuid", h.Upload.CompleteConversationDirectUpload)
conversations.POST("/:conversation_id/toggle_typing_status", h.Conversation.ToggleTyping)
conversations.POST("/:conversation_id/update_last_seen", h.Conversation.UpdateLastSeen)
conversations.POST("/:conversation_id/assignments", h.Conversation.AssignTeam)
// Participants nested under conversation
participants := conversations.Group("/:conversation_id/participants")
{
participants.GET("", h.ConversationParticipant.List)
participants.GET("/", h.ConversationParticipant.List)
participants.POST("", h.ConversationParticipant.Add)
participants.POST("/", h.ConversationParticipant.Add)
participants.PATCH("", h.ConversationParticipant.BatchUpdate)
participants.PATCH("/", h.ConversationParticipant.BatchUpdate)
participants.PUT("", h.ConversationParticipant.BatchUpdate)
participants.PUT("/", h.ConversationParticipant.BatchUpdate)
participants.DELETE("", h.ConversationParticipant.Destroy)
participants.PATCH("/:user_id", h.ConversationParticipant.Update)
participants.DELETE("/:user_id", h.ConversationParticipant.Remove)
}
// Draft messages nested under conversation
drafts := conversations.Group("/:conversation_id/draft_messages")
{
drafts.GET("", h.DraftMessage.Show)
drafts.PATCH("", h.DraftMessage.UpdateConversationDraft)
drafts.PUT("", h.DraftMessage.UpdateConversationDraft)
drafts.DELETE("", h.DraftMessage.DeleteConversationDraft)
drafts.GET("/", h.DraftMessage.List)
drafts.POST("/", h.DraftMessage.Create)
drafts.GET("/:draft_id", h.DraftMessage.Get)
drafts.PATCH("/:draft_id", h.DraftMessage.Update)
drafts.DELETE("/:draft_id", h.DraftMessage.Delete)
}
// Messages nested under conversation
msgs := conversations.Group("/:conversation_id/messages")
{
msgs.GET("", h.Conversation.ListMessages)
msgs.GET("/", h.Conversation.ListMessages)
msgs.POST("", h.Message.Create)
msgs.POST("/", h.Message.Create)
msgs.GET("/:message_id", h.Message.Get)
msgs.PATCH("/:message_id", h.Message.Update)
msgs.PUT("/:message_id", h.Message.Update)
msgs.DELETE("/:message_id", h.Message.Delete)
msgs.POST("/:message_id/retry", h.Message.Retry)
msgs.POST("/:message_id/translate", h.Message.Translate)
// Delivery status nested under message
deliveryStatus := msgs.Group("/:message_id/delivery_status")
{
deliveryStatus.GET("/", h.DeliveryStatus.List)
}
}
// WhatsApp calls nested under conversation
whatsappCalls := conversations.Group("/:conversation_id/whatsapp_calls")
{
whatsappCalls.GET("/", h.WhatsAppCall.List)
whatsappCalls.POST("/", h.WhatsAppCall.Create)
whatsappCalls.GET("/:call_id", h.WhatsAppCall.Get)
whatsappCalls.PUT("/:call_id", h.WhatsAppCall.Update)
whatsappCalls.DELETE("/:call_id", h.WhatsAppCall.Delete)
}
}
// Tag/Label routes (ref: Chatwoot resources :labels)
tags := accountScoped.Group("/tags")
{
tags.GET("/", h.Label.ListTags)
tags.POST("/", h.Label.CreateTag)
tags.GET("/:tag_id", h.Label.GetTag)
tags.PUT("/:tag_id", h.Label.UpdateTag)
tags.DELETE("/:tag_id", h.Label.DeleteTag)
tags.GET("/:tag_id/conversations", h.Label.GetConversationsByTag)
}
// Labels alias (Chatwoot uses /labels instead of /tags)
labels := accountScoped.Group("/labels")
{
labels.GET("/", h.Label.ListTags)
labels.POST("/", h.Label.CreateTag)
labels.GET("/:tag_id", h.Label.GetTag)
labels.PUT("/:tag_id", h.Label.UpdateTag)
labels.DELETE("/:tag_id", h.Label.DeleteTag)
labels.GET("/:tag_id/conversations", h.Label.GetConversationsByTag)
}
// Batch label operations
accountScoped.POST("/labels/batch_add", h.Label.BatchAddLabel)
accountScoped.POST("/labels/batch_remove", h.Label.BatchRemoveLabel)
// Contact routes (ref: Chatwoot resources :contacts)
contacts := accountScoped.Group("/contacts")
{
contacts.GET("", h.Contact.List)
contacts.GET("/", h.Contact.List)
contacts.POST("", h.Contact.Create)
contacts.POST("/", h.Contact.Create)
contacts.GET("/search", h.Contact.Search)
contacts.POST("/filter", h.Contact.Filter)
contacts.GET("/active", h.Contact.Active)
contacts.GET("/export", h.Contact.Export)
contacts.POST("/export", h.Contact.ExportRequest)
contacts.GET("/export/:export_id/download", h.Contact.DownloadExport)
contacts.POST("/import", h.Contact.Import)
contacts.GET("/:contact_id", h.Contact.Get)
contacts.PUT("/:contact_id", h.Contact.Update)
contacts.PATCH("/:contact_id", h.Contact.Update)
contacts.DELETE("/:contact_id", h.Contact.Delete)
contacts.DELETE("/:contact_id/avatar", h.Contact.DeleteAvatar)
contacts.POST("/:contact_id/call", h.Contact.InitiateCall)
// M4 G3: Contact extension routes (active, export, import, contactable_inboxes, attachments, custom_attributes)
contacts.GET("/:contact_id/conversations", h.Contact.ListConversations)
contacts.GET("/:contact_id/contactable_inboxes", h.Contact.ContactableInboxes)
contacts.GET("/:contact_id/attachments", h.Contact.ListAttachments)
contacts.GET("/:contact_id/labels", h.Contact.ListLabels)
contacts.GET("/:contact_id/labels/", h.Contact.ListLabels)
contacts.POST("/:contact_id/labels", h.Contact.UpdateLabels)
contacts.POST("/:contact_id/labels/", h.Contact.UpdateLabels)
contacts.DELETE("/:contact_id/custom_attributes", h.Contact.DeleteCustomAttributes)
contacts.POST("/:contact_id/destroy_custom_attributes", h.Contact.DestroyCustomAttributes)
// Contact notes (ref: Chatwoot nested notes under contacts)
contacts.GET("/:contact_id/notes", h.Contact.ListNotes)
contacts.POST("/:contact_id/notes", h.Contact.CreateNote)
contacts.GET("/:contact_id/notes/:note_id", h.Contact.ShowNote)
contacts.PUT("/:contact_id/notes/:note_id", h.Contact.UpdateNote)
contacts.PATCH("/:contact_id/notes/:note_id", h.Contact.UpdateNote)
contacts.DELETE("/:contact_id/notes/:note_id", h.Contact.DestroyNote)
// Contact inboxes (ref: Chatwoot nested contact_inboxes under contacts)
contacts.GET("/:contact_id/contact_inboxes", h.Contact.ListContactInboxes)
contacts.POST("/:contact_id/contact_inboxes", h.Contact.CreateContactInbox)
contacts.POST("/:contact_id/contact_inboxes/", h.Contact.CreateContactInbox)
contacts.DELETE("/:contact_id/contact_inboxes/:inbox_id", h.Contact.DeleteContactInbox)
}
// M4 G3: ContactInbox filter route (account-scope, not nested under contact)
// Reference: Chatwoot contact_inboxes_controller#filter
contactInboxes := accountScoped.Group("/contact_inboxes")
{
contactInboxes.GET("/filter", h.ContactInboxFilter.Filter)
}
// G4: Company routes (CRUD + search + nested contacts/conversations/notes)
companies := accountScoped.Group("/companies")
{
companies.GET("", h.Company.List)
companies.GET("/", h.Company.List)
companies.POST("", h.Company.Create)
companies.POST("/", h.Company.Create)
companies.GET("/search", h.Company.Search)
companies.GET("/:company_id", h.Company.Get)
companies.PUT("/:company_id", h.Company.Update)
companies.PATCH("/:company_id", h.Company.Update)
companies.DELETE("/:company_id", h.Company.Delete)
companies.POST("/:company_id/destroy_custom_attributes", h.Company.DestroyCustomAttributes)
companies.DELETE("/:company_id/avatar", h.Company.DeleteAvatar)
// Nested contacts under a company
companies.GET("/:company_id/contacts", h.Company.ListContacts)
companies.GET("/:company_id/contacts/search", h.Company.SearchContacts)
// Nested conversations under a company (via contacts)
companies.GET("/:company_id/conversations", h.Company.ListConversations)
// Nested notes under a company
companies.GET("/:company_id/notes", h.Company.ListNotes)
companies.POST("/:company_id/notes", h.Company.CreateNote)
companies.DELETE("/:company_id/notes/:note_id", h.Company.DeleteNote)
// Nested contacts management under a company
companies.POST("/:company_id/contacts", h.Company.AddContact)
companies.POST("/:company_id/contacts/:contact_id", h.Company.AddContact)
companies.DELETE("/:company_id/contacts/:contact_id", h.Company.RemoveContact)
}
// Team routes (ref: Chatwoot resources :teams)
teams := accountScoped.Group("/teams")
{
teams.GET("", h.Team.List)
teams.GET("/", h.Team.List)
teams.POST("", h.Team.Create)
teams.POST("/", h.Team.Create)
teams.GET("/:team_id", h.Team.Get)
teams.PATCH("/:team_id", h.Team.Update)
teams.PUT("/:team_id", h.Team.Update)
teams.DELETE("/:team_id", h.Team.Delete)
// Team members (ref: Chatwoot nested resources :team_members)
teamMembers := teams.Group("/:team_id/team_members")
{
teamMembers.GET("", h.Team.ListMembers)
teamMembers.GET("/", h.Team.ListMembers)
teamMembers.POST("", h.Team.AddMembers)
teamMembers.POST("/", h.Team.AddMembers)
teamMembers.PATCH("", h.Team.UpdateMembers)
teamMembers.PATCH("/", h.Team.UpdateMembers)
teamMembers.DELETE("", h.Team.RemoveMembers)
teamMembers.DELETE("/", h.Team.RemoveMembers)
}
}
// Platform app routes (account-level access)
platformApps := accountScoped.Group("/platform_apps")
{
platformApps.GET("/", h.PlatformApp.List)
platformApps.POST("/", h.PlatformApp.Create)
platformApps.GET("/search", h.PlatformApp.Search)
platformApps.GET("/:platform_app_id", h.PlatformApp.Get)
platformApps.PUT("/:platform_app_id", h.PlatformApp.Update)
platformApps.DELETE("/:platform_app_id", h.PlatformApp.Delete)
platformApps.POST("/:platform_app_id/regenerate_access_token", h.PlatformApp.RegenerateAccessToken)
platformApps.GET("/:platform_app_id/access_tokens", h.PlatformApp.ListAccessTokens)
platformApps.GET("/:platform_app_id/permissibles", h.PlatformApp.ListPermissibles)
platformApps.POST("/:platform_app_id/permissibles", h.PlatformApp.AddPermissible)
platformApps.DELETE("/:platform_app_id/permissibles/:permissible_id", h.PlatformApp.RemovePermissible)
}
// Agent Bot routes (account-level + global bots)
// Reference: Chatwoot namespace :agent_bots under :account
agentBots := accountScoped.Group("/agent_bots")
{
agentBots.GET("", h.AgentBot.List)
agentBots.GET("/", h.AgentBot.List)
agentBots.POST("", h.AgentBot.Create)
agentBots.POST("/", h.AgentBot.Create)
agentBots.GET("/:agent_bot_id", h.AgentBot.Get)
agentBots.PUT("/:agent_bot_id", h.AgentBot.Update)
agentBots.PATCH("/:agent_bot_id", h.AgentBot.Update)
agentBots.DELETE("/:agent_bot_id", h.AgentBot.Delete)
agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken)
agentBots.POST("/:agent_bot_id/reset_secret", h.AgentBot.ResetSecret)
agentBots.POST("/:agent_bot_id/delete_avatar", h.AgentBot.DeleteAvatar)
// Chatwoot-compatible member action routes (ref: Chatwoot agent_bots member actions)
// Chatwoot: DELETE /accounts/:account_id/agent_bots/:id/avatar
agentBots.DELETE("/:agent_bot_id/avatar", h.AgentBot.DeleteAvatar)
// Chatwoot: POST /accounts/:account_id/agent_bots/:id/reset_access_token
agentBots.POST("/:agent_bot_id/reset_access_token", h.AgentBot.ResetToken)
}
// Agent Bot-Inbox binding routes
// Reference: Chatwoot namespace :agent_bot_inboxes under :account
agentBotInboxes := accountScoped.Group("/agent_bot_inboxes")
{
agentBotInboxes.POST("/", h.AgentBotInbox.Bind)
agentBotInboxes.DELETE("/:agent_bot_inbox_id", h.AgentBotInbox.Unbind)
agentBotInboxes.PATCH("/:agent_bot_inbox_id/status", h.AgentBotInbox.UpdateStatus)
agentBotInboxes.GET("/", h.AgentBotInbox.ListByInbox) // ?inbox_id=X
agentBotInboxes.GET("/by_bot", h.AgentBotInbox.ListByBot) // ?agent_bot_id=X
}
// P9: AgentBot rule engine + trigger config routes
// Reference: Chatwoot AutomationRulesController routes under :account/:agent_bot
botRules := agentBots.Group("/:agent_bot_id/bot_rules")
{
botRules.GET("/", h.BotRule.ListByBot)
botRules.POST("/", h.BotRule.Create)
botRules.GET("/:rule_id", h.BotRule.Get)
botRules.PUT("/:rule_id", h.BotRule.Update)
botRules.DELETE("/:rule_id", h.BotRule.Delete)
}
triggerConfigs := agentBots.Group("/:agent_bot_id/trigger_configs")
{
triggerConfigs.GET("/", h.BotTriggerConfig.ListByBot)
triggerConfigs.POST("/", h.BotTriggerConfig.Create)
triggerConfigs.GET("/:trigger_config_id", h.BotTriggerConfig.Get)
triggerConfigs.PUT("/:trigger_config_id", h.BotTriggerConfig.Update)
triggerConfigs.DELETE("/:trigger_config_id", h.BotTriggerConfig.Delete)
}
// Captain AI routes (ref: Chatwoot namespace :captain)
// P10 (M10): Captain Assistant + Copilot features
captain := accountScoped.Group("/captain")
{
// Assistant CRUD
assistants := captain.Group("/assistants")
{
assistants.GET("", h.CaptainAssistant.List)
assistants.GET("/", h.CaptainAssistant.List)
assistants.POST("", h.CaptainAssistant.Create)
assistants.POST("/", h.CaptainAssistant.Create)
assistants.GET("/tools", h.CaptainAssistant.Tools)
assistants.GET("/:assistant_id", h.CaptainAssistant.Get)
assistants.PUT("/:assistant_id", h.CaptainAssistant.Update)
assistants.DELETE("/:assistant_id", h.CaptainAssistant.Delete)
// Inbox bindings
assistants.GET("/:assistant_id/inboxes", h.CaptainAssistant.ListInboxes)
assistants.POST("/:assistant_id/inboxes", h.CaptainAssistant.AssociateInbox)
assistants.DELETE("/:assistant_id/inboxes/:inbox_id", h.CaptainAssistant.DissociateInbox)
assistants.POST("/:assistant_id/playground", h.CaptainAssistant.GenerateResponse)
// Documents nested under assistant
assistantDocs := assistants.Group("/:assistant_id/documents")
{
assistantDocs.GET("/", h.CaptainDocument.List)
assistantDocs.POST("/", h.CaptainDocument.Create)
assistantDocs.GET("/:document_id", h.CaptainDocument.Get)
assistantDocs.DELETE("/:document_id", h.CaptainDocument.Delete)
}
// Scenarios nested under assistant
assistantScenarios := assistants.Group("/:assistant_id/scenarios")
{
assistantScenarios.GET("/", h.CaptainScenario.List)
assistantScenarios.POST("/", h.CaptainScenario.Create)
assistantScenarios.GET("/:scenario_id", h.CaptainScenario.Get)
assistantScenarios.PUT("/:scenario_id", h.CaptainScenario.Update)
assistantScenarios.DELETE("/:scenario_id", h.CaptainScenario.Delete)
}
}
// Custom tools (account-level)
customTools := captain.Group("/custom_tools")
{
customTools.GET("/", h.CaptainCustomTool.List)
customTools.POST("/", h.CaptainCustomTool.Create)
customTools.GET("/:tool_id", h.CaptainCustomTool.Get)
customTools.PUT("/:tool_id", h.CaptainCustomTool.Update)
customTools.DELETE("/:tool_id", h.CaptainCustomTool.Delete)
// Test custom tool before enabling
customTools.POST("/test", h.CaptainCustomTool.TestTool)
}
// Flat document routes (Chatwoot: resources :documents, only: [:index, :show, :create, :destroy])
documents := captain.Group("/documents")
{
documents.GET("/", h.CaptainDocument.List)
documents.POST("/", h.CaptainDocument.Create)
documents.GET("/:document_id", h.CaptainDocument.Get)
documents.DELETE("/:document_id", h.CaptainDocument.Delete)
documents.POST("/:document_id/sync", h.CaptainDocument.SyncDocument)
}
// Flat scenario routes (Chatwoot: resources :scenarios)
scenarios := captain.Group("/scenarios")
{
scenarios.GET("/", h.CaptainScenario.List)
scenarios.POST("/", h.CaptainScenario.Create)
scenarios.GET("/:scenario_id", h.CaptainScenario.Get)
scenarios.PUT("/:scenario_id", h.CaptainScenario.Update)
scenarios.DELETE("/:scenario_id", h.CaptainScenario.Delete)
}
// Copilot features (ref: Chatwoot Captain::Copilot)
copilotThreads := captain.Group("/copilot_threads")
{
copilotThreads.GET("", h.Copilot.ListThreads)
copilotThreads.GET("/", h.Copilot.ListThreads)
copilotThreads.POST("", h.Copilot.CreateThread)
copilotThreads.POST("/", h.Copilot.CreateThread)
copilotThreads.GET("/:thread_id", h.Copilot.GetThread)
copilotThreads.DELETE("/:thread_id", h.Copilot.DeleteThread)
// Nested copilot_messages (Chatwoot: resources :copilot_messages, only: [:index, :create])
copilotThreadMessages := copilotThreads.Group("/:thread_id/copilot_messages")
{
copilotThreadMessages.GET("", h.Copilot.ListSuggestionMessages)
copilotThreadMessages.GET("/", h.Copilot.ListSuggestionMessages)
copilotThreadMessages.POST("", h.Copilot.SendMessage)
copilotThreadMessages.POST("/", h.Copilot.SendMessage)
}
copilotThreads.POST("/:thread_id/messages", h.Copilot.SendMessage)
}
// Copilot suggestion messages (conversation-level)
// GET/POST /api/v1/accounts/:account_id/copilot_messages
copilotMessages := captain.Group("/copilot_messages")
{
copilotMessages.GET("/", h.Copilot.ListSuggestionMessages)
copilotMessages.POST("/", h.Copilot.CreateSuggestionMessage)
}
// Copilot AI actions
captain.POST("/copilot/suggest_replies", h.Copilot.GetSuggestedReplies)
captain.POST("/copilot/summarize", h.Copilot.SummarizeConversation)
captain.POST("/copilot/translate", h.Copilot.TranslateMessage)
// Captain AI task endpoints (M12 — standalone AI suggestion tasks)
// Reference: Chatwoot Captain::TasksController
tasks := captain.Group("/tasks")
{
tasks.POST("/reply_suggestion", h.CaptainTask.ReplySuggestion)
tasks.POST("/summarize", h.CaptainTask.Summarize)
tasks.POST("/rewrite", h.CaptainTask.Rewrite)
tasks.POST("/label_suggestion", h.CaptainTaskExtended.LabelSuggestion)
tasks.POST("/follow_up", h.CaptainTaskExtended.FollowUp)
// M12: SSE streaming variants of Captain task endpoints
tasks.POST("/reply_suggestion/stream", h.CaptainTask.StreamReplySuggestion)
tasks.POST("/summarize/stream", h.CaptainTask.StreamSummarize)
tasks.POST("/rewrite/stream", h.CaptainTask.StreamRewrite)
}
// M12: SSE streaming for Copilot messages
captain.GET("/copilot/stream", h.SSEStream.StreamCopilotMessage)
// M12: Conversation insight endpoints
// Reference: Chatwoot Captain::ConversationInsightController
insights := captain.Group("/conversation_insights")
{
insights.POST("/:conversation_id/analyze_participants", h.ConversationInsight.AnalyzeParticipants)
insights.POST("/:conversation_id/extract_action_items", h.ConversationInsight.ExtractActionItems)
insights.POST("/:conversation_id/suggest_labels", h.ConversationInsight.SuggestLabels)
}
// M12: Captain Preferences (per-account AI configuration)
// Reference: Chatwoot Captain::PreferencesController
preferences := captain.Group("/preferences")
{
preferences.GET("", h.CaptainPreference.Get)
preferences.GET("/", h.CaptainPreference.Get)
preferences.PUT("", h.CaptainPreference.Update)
preferences.PUT("/", h.CaptainPreference.Update)
}
// M12: Label Suggestion + Follow Up (GET endpoints)
// Reference: Chatwoot Captain::ConversationInsightController
tasks.GET("/label_suggestion", h.CaptainTaskExtended.LabelSuggestion)
tasks.GET("/follow_up", h.CaptainTaskExtended.FollowUp)
// M12: Assistant Response generation (single conversation)
// Reference: Chatwoot Captain::AssistantResponsesController
assistantResponses := captain.Group("/assistant_responses")
{
// Chatwoot: resources :assistant_responses (standard CRUD)
assistantResponses.POST("/", h.CaptainAssistantResponse.Create)
assistantResponses.GET("/", h.CaptainAssistantResponse.List)
assistantResponses.GET("/:response_id", h.CaptainAssistantResponse.Get)
assistantResponses.PUT("/:response_id", h.CaptainAssistantResponse.Update)
assistantResponses.DELETE("/:response_id", h.CaptainAssistantResponse.Delete)
// Extended: ProcessResponse (AI generation + optional store)
assistantResponses.POST("/process", h.CaptainAssistantResponse.ProcessResponse)
}
// M12: Bulk AI actions (batch operations on multiple conversations)
// Reference: Chatwoot Captain::BulkActionsController
bulkActions := captain.Group("/bulk_actions")
{
bulkActions.POST("/", h.CaptainBulkAction.Execute)
}
// RAG Knowledge Base Q&A (embedding search + LLM generation)
// POST /captain/rag/query — query the knowledge base
// POST /captain/rag/index/:id — index/re-index a response embedding
rag := captain.Group("/rag")
{
rag.POST("/query", h.RAG.Query)
rag.POST("/index/:response_id", h.RAG.IndexResponse)
}
// Captain Conversation auto-response (handoff mode)
// POST /captain/conversations/:conversation_id/respond
captain.POST("/conversations/:conversation_id/respond", h.CaptainConversation.BuildResponse)
// Auto-reply rules CRUD + evaluate
// Reference: M12 PRD §Captain AI — Auto-Reply Rules
autoReplyRules := captain.Group("/auto_reply_rules")
{
autoReplyRules.GET("", h.AutoReplyRule.List)
autoReplyRules.GET("/", h.AutoReplyRule.List)
autoReplyRules.POST("", h.AutoReplyRule.Create)
autoReplyRules.POST("/", h.AutoReplyRule.Create)
autoReplyRules.GET("/:rule_id", h.AutoReplyRule.Get)
autoReplyRules.PUT("/:rule_id", h.AutoReplyRule.Update)
autoReplyRules.DELETE("/:rule_id", h.AutoReplyRule.Delete)
autoReplyRules.POST("/evaluate", h.AutoReplyRule.Evaluate)
}
}
// Draft messages — account-scoped search & count
draftMessages := accountScoped.Group("/draft_messages")
{
draftMessages.GET("/search", h.DraftMessage.Search)
draftMessages.GET("/count", h.DraftMessage.Count)
}
// Reports/Analytics routes (P11 — Reports/Analytics)
// Reference: Chatwoot reports_controller.rb + live_reports_controller.rb + summary_reports_controller.rb
reports := accountScoped.Group("/reports")
{
reports.GET("", h.Analytics.Index)
reports.GET("/summary", h.Analytics.Summary)
reports.GET("/bot_summary", h.Analytics.BotSummary)
reports.GET("/agents", h.Analytics.AgentMetrics)
reports.GET("/inboxes", h.Analytics.InboxMetrics)
reports.GET("/labels", h.Analytics.LabelMetrics)
reports.GET("/teams", h.Analytics.TeamMetrics)
reports.GET("/conversations", h.Analytics.Conversations)
reports.GET("/conversations_summary", h.Analytics.ConversationsSummary)
reports.GET("/conversation_traffic", h.Analytics.ConversationTraffic)
reports.GET("/bot_metrics", h.Analytics.BotMetrics)
reports.GET("/inbox_label_matrix", h.Analytics.InboxLabelMatrix)
reports.GET("/first_response_time_distribution", h.Analytics.FirstResponseTimeDistribution)
reports.GET("/outgoing_messages_count", h.Analytics.OutgoingMessagesCount)
}
// Live reports
// Reference: Chatwoot live_reports_controller.rb
liveReports := accountScoped.Group("/live_reports")
{
liveReports.GET("/conversation_metrics", h.LiveReport.ConversationMetrics)
liveReports.GET("/grouped_conversation_metrics", h.LiveReport.GroupedConversationMetrics)
}
// Reporting events (P11 — raw analytics events)
// GET /api/v1/accounts/:account_id/reporting_events?since=...&until=...&metric=...
accountScoped.GET("/reporting_events", h.ReportingEvent.List)
// Summary reports — read-only collection GET for agent/team/inbox/label summaries
// Reference: Chatwoot resources :summary_reports, only: [] do collection do get :agent, :team, :inbox, :label end end
v1.RegisterSummaryReportRoutes(accountScoped, h.SummaryReport)
// Banner — read-only list of active banners visible to account users
// Reference: Chatwoot api/v1/accounts/:account_id/banners — only active banners
accountScoped.GET("/banners", h.Banner.ListActive)
// Dashboard apps (custom dashboards)
dashboardApps := accountScoped.Group("/dashboard_apps")
{
dashboardApps.GET("", h.DashboardApp.List)
dashboardApps.POST("", h.DashboardApp.Create)
dashboardApps.GET("/search", h.DashboardApp.Search)
dashboardApps.GET("/:id", h.DashboardApp.Get)
dashboardApps.PUT("/:id", h.DashboardApp.Update)
dashboardApps.PATCH("/:id", h.DashboardApp.Patch)
dashboardApps.DELETE("/:id", h.DashboardApp.Delete)
// Widget management (CRUD on content jsonb array)
dashboardApps.GET("/:id/widgets", h.DashboardApp.GetWidgets)
dashboardApps.POST("/:id/widgets", h.DashboardApp.AddWidget)
dashboardApps.PUT("/:id/widgets/:widget_index", h.DashboardApp.UpdateWidget)
dashboardApps.DELETE("/:id/widgets/:widget_index", h.DashboardApp.RemoveWidget)
}
// Knowledge Base / Help Center routes (M9)
// Reference: Chatwoot knowledge_base routes. The reused Chatwoot dashboard
// unconditionally fetches the portal list during boot, so reads must return
// a Chatwoot-compatible empty payload even when the feature is disabled.
// Mutations remain feature-gated.
portals := accountScoped.Group("/portals")
{
portals.GET("", h.Portal.List)
portals.GET("/", h.Portal.List)
portals.POST("", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create)
portals.POST("/", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create)
portals.GET("/:portal_id", h.Portal.Get)
portals.PATCH("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update)
portals.PUT("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update)
portals.DELETE("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Delete)
portals.PATCH("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive)
portals.POST("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive)
portals.DELETE("/:portal_id/logo", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.RemoveLogo)
portals.POST("/:portal_id/send_instructions", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.SendInstructions)
portals.GET("/:portal_id/ssl_status", h.Portal.SSLStatus)
// Categories nested under portal
categories := portals.Group("/:portal_id/categories")
{
categories.GET("", h.Category.List)
categories.GET("/", h.Category.List)
categories.POST("", h.Category.Create)
categories.POST("/", h.Category.Create)
categories.GET("/:category_id", h.Category.Get)
categories.PATCH("/:category_id", h.Category.Update)
categories.PUT("/:category_id", h.Category.Update)
categories.DELETE("/:category_id", h.Category.Delete)
categories.POST("/reorder", h.Category.Reorder)
}
// Articles nested under portal
articles := portals.Group("/:portal_id/articles")
{
articles.GET("", h.Article.List)
articles.GET("/", h.Article.List)
articles.POST("", h.Article.Create)
articles.POST("/", h.Article.Create)
articles.GET("/search", h.Article.Search)
articles.GET("/semantic_search", h.Article.SemanticSearch)
articles.GET("/status_counts", h.Article.StatusCounts)
articles.POST("/reorder", h.Article.Reorder)
articles.PATCH("/bulk_actions/update_status", h.Article.BulkUpdateStatus)
articles.PATCH("/bulk_actions/update_category", h.Article.BulkUpdateCategory)
articles.DELETE("/bulk_actions/delete_articles", h.Article.BulkDelete)
articles.POST("/bulk_actions/translate", h.Article.BulkTranslate)
articles.POST("/bulk_update_status", h.Article.BulkUpdateStatus)
articles.POST("/bulk_delete", h.Article.BulkDelete)
articles.POST("/bulk_actions", h.Article.BulkActions)
articles.GET("/:article_id", h.Article.Get)
articles.GET("/:article_id/edit", h.Article.Edit)
articles.PATCH("/:article_id", h.Article.Update)
articles.PUT("/:article_id", h.Article.Update)
articles.DELETE("/:article_id", h.Article.Delete)
}
// Article listing by category (nested)
portals.GET("/:portal_id/categories/:category_id/articles", h.Article.ListByCategory)
// Folders nested under portal
folders := portals.Group("/:portal_id/folders")
{
folders.GET("/", h.Folder.List)
folders.POST("/", h.Folder.Create)
folders.GET("/:folder_id", h.Folder.Get)
folders.PUT("/:folder_id", h.Folder.Update)
folders.DELETE("/:folder_id", h.Folder.Delete)
}
// Portal members
members := portals.Group("/:portal_id/members")
{
members.GET("/", h.PortalMember.List)
members.POST("/", h.PortalMember.Create)
members.GET("/:member_id", h.PortalMember.Get)
members.PUT("/:member_id", h.PortalMember.Update)
members.DELETE("/:member_id", h.PortalMember.Delete)
}
}
// Automation Rules — CRUD + clone
// Reference: Chatwoot namespace :automation_rules
automationRules := accountScoped.Group("/automation_rules")
{
automationRules.GET("", h.AutomationRule.List)
automationRules.GET("/", h.AutomationRule.List)
automationRules.POST("", h.AutomationRule.Create)
automationRules.POST("/", h.AutomationRule.Create)
automationRules.GET("/:automation_id", h.AutomationRule.Get)
automationRules.PUT("/:automation_id", h.AutomationRule.Update)
automationRules.DELETE("/:automation_id", h.AutomationRule.Delete)
automationRules.POST("/:automation_id/clone", h.AutomationRule.Clone)
automationRules.POST("/:automation_id/toggle_active", h.AutomationRule.ToggleActive)
}
// Macros — CRUD + execute
// Reference: Chatwoot namespace :macros
macros := accountScoped.Group("/macros")
{
macros.GET("", h.Macro.List)
macros.GET("/", h.Macro.List)
macros.POST("", h.Macro.Create)
macros.POST("/", h.Macro.Create)
macros.GET("/:macro_id", h.Macro.Get)
macros.PUT("/:macro_id", h.Macro.Update)
macros.DELETE("/:macro_id", h.Macro.Delete)
macros.POST("/:macro_id/execute", h.Macro.Execute)
macros.POST("/:macro_id/clone", h.Macro.Clone)
macros.POST("/:macro_id/toggle_active", h.Macro.ToggleActive)
}
// Notion OAuth authorization (ref: Chatwoot namespace :notion resource :authorization)
accountScoped.POST("/notion/authorization", middleware.RoleCheck("administrator"), h.NotionIntegration.Authorization)
accountScoped.POST("/twitter/authorization", middleware.RoleCheck("administrator"), h.TwitterChannel.ChatwootAuthorization)
accountScoped.POST("/microsoft/authorization", middleware.RoleCheck("administrator"), h.MicrosoftChannel.ChatwootAuthorization)
accountScoped.POST("/google/authorization", middleware.RoleCheck("administrator"), h.GoogleChannel.ChatwootAuthorization)
accountScoped.POST("/instagram/authorization", middleware.RoleCheck("administrator"), h.InstagramChannel.ChatwootAuthorization)
accountScoped.POST("/tiktok/authorization", middleware.RoleCheck("administrator"), h.TikTokChannel.ChatwootAuthorization)
accountScoped.POST("/whatsapp/authorization", h.Inbox.WhatsAppAuthorization)
// G16: Third-party Integrations — IntegrationHook CRUD + Slack/Shopify/Linear/Notion
// Reference: Chatwoot namespace :integrations under :account
integrations := accountScoped.Group("/integrations")
{
// IntegrationHook CRUD (generic hooks)
v1.RegisterIntegrationHookRoutes(integrations, h.IntegrationHook)
// Slack integration
v1.RegisterSlackIntegrationRoutes(integrations, h.SlackIntegration)
// Shopify integration
v1.RegisterShopifyIntegrationRoutes(integrations, h.ShopifyIntegration)
// Linear integration
v1.RegisterLinearIntegrationRoutes(integrations, h.LinearIntegration)
// Notion integration
v1.RegisterNotionIntegrationRoutes(integrations, h.NotionIntegration)
// Dyte video meeting integration
v1.RegisterDyteIntegrationRoutes(integrations, h.DyteIntegration)
}
// CSAT Survey Responses — list, metrics, review notes
// Reference: Chatwoot namespace :csat_survey_responses
csats := accountScoped.Group("/csat_survey_responses")
{
csats.GET("", h.CsatSurvey.List)
csats.GET("/", h.CsatSurvey.List)
csats.GET("/metrics", h.CsatSurvey.Metrics)
csats.GET("/download", h.CsatSurvey.Download)
csats.PATCH("/:id", h.CsatSurvey.Update)
csats.POST("/:id/update_review_notes", h.CsatSurvey.UpdateReviewNotes)
}
// Enterprise: AuditLog — list + get single entry
v1.RegisterAuditRoutes(accountScoped, h.Audit)
// Enterprise: AgentCapacityPolicy — CRUD for capacity policies
v1.RegisterAgentCapacityRoutes(accountScoped, h.AgentCapacity)
// NOTE: CSAT metrics routes (/csat_survey_responses/metrics, /download) already registered
// in csats group above (line 1243-1244). RegisterCsatMetricsRoutes is NOT called here
// to avoid Gin duplicate route panic.
// Enterprise: CustomRole — CRUD for account-scoped custom roles
v1.RegisterCustomRoleRoutes(accountScoped, h.CustomRole)
// Notification settings — per-account notification preference show/update
// Reference: Chatwoot resource :notification_settings, only: [:show, :update]
notifSettings := accountScoped.Group("/notification_settings")
{
notifSettings.GET("", h.NotificationSetting.Show)
notifSettings.GET("/", h.NotificationSetting.Show)
notifSettings.PATCH("", h.NotificationSetting.Update)
notifSettings.PATCH("/", h.NotificationSetting.Update)
notifSettings.PUT("", h.NotificationSetting.Update)
notifSettings.PUT("/", h.NotificationSetting.Update)
}
// Notifications — account-scoped Chatwoot routes used by the dashboard frontend.
notifications := accountScoped.Group("/notifications")
{
// Register both "" and "/" so Gin does not 301-redirect between them.
// The dashboard frontend requests /notifications (no trailing slash);
// without the "" route Gin's RedirectTrailingSlash emits a 301 that
// the Vite dev proxy does not follow, causing an infinite-load loop.
notifications.GET("", h.Notification.List)
notifications.GET("/", h.Notification.List)
notifications.POST("/read_all", h.Notification.MarkAllRead)
notifications.GET("/unread_count", h.Notification.UnreadCount)
notifications.POST("/destroy_all", h.Notification.DestroyAll)
notifications.DELETE("/destroy_all", h.Notification.DestroyAll)
notifications.GET("/:notification_id", h.Notification.Get)
notifications.PUT("/:notification_id", h.Notification.Update)
notifications.PATCH("/:notification_id", h.Notification.Update)
notifications.DELETE("/:notification_id", h.Notification.Destroy)
notifications.POST("/:notification_id/snooze", h.Notification.Snooze)
notifications.POST("/:notification_id/unread", h.Notification.Unread)
}
// Notification subscriptions (account-scoped)
// Reference: Chatwoot resources :notification_subscriptions, only: [:create, :destroy]
notifSubs := accountScoped.Group("/notification_subscriptions")
{
notifSubs.POST("/", h.NotificationSubscription.Create)
notifSubs.DELETE("/", h.NotificationSubscription.Destroy)
notifSubs.DELETE("/:identifier", h.NotificationSubscription.Destroy)
}
// Canned Responses — CRUD + search
// Reference: Chatwoot namespace :canned_responses
cannedResponses := accountScoped.Group("/canned_responses")
{
cannedResponses.GET("", h.CannedResponse.List)
cannedResponses.GET("/", h.CannedResponse.List)
cannedResponses.POST("", h.CannedResponse.Create)
cannedResponses.POST("/", h.CannedResponse.Create)
cannedResponses.GET("/search", h.CannedResponse.Search)
cannedResponses.GET("/:id", h.CannedResponse.Get)
cannedResponses.PATCH("/:id", h.CannedResponse.Update)
cannedResponses.PUT("/:id", h.CannedResponse.Update)
cannedResponses.DELETE("/:id", h.CannedResponse.Delete)
}
// Integration hooks (Chatwoot: resources :hooks)
// Reference: Chatwoot hooks_controller — CRUD + process_event
hooks := accountScoped.Group("/hooks")
{
hooks.GET("/", h.IntegrationHook.ListHooks)
hooks.GET("/:id", h.IntegrationHook.GetHook)
hooks.POST("/", h.IntegrationHook.CreateHook)
hooks.PUT("/:id", h.IntegrationHook.UpdateHook)
hooks.DELETE("/:id", h.IntegrationHook.DeleteHook)
hooks.POST("/:id/process_event", h.IntegrationHook.ProcessHookEvent)
}
// Campaigns — CRUD + start/stop actions
// Reference: Chatwoot namespace :campaigns
campaigns := accountScoped.Group("/campaigns")
{
campaigns.GET("", h.Campaign.List)
campaigns.GET("/", h.Campaign.List)
campaigns.POST("", h.Campaign.Create)
campaigns.POST("/", h.Campaign.Create)
campaigns.GET("/:campaign_id", h.Campaign.Get)
campaigns.PATCH("/:campaign_id", h.Campaign.Update)
campaigns.PUT("/:campaign_id", h.Campaign.Update)
campaigns.DELETE("/:campaign_id", h.Campaign.Delete)
campaigns.POST("/:campaign_id/start", h.Campaign.Start)
campaigns.POST("/:campaign_id/stop", h.Campaign.Stop)
}
// Assignment Policy — account-level + inbox-level overrides
// Reference: Chatwoot assignment_policy / inbox_assignment_policy
accountScoped.GET("/assignment_policy", h.AssignmentPolicy.GetAccountPolicy)
accountScoped.POST("/assignment_policy", h.AssignmentPolicy.CreateAccountPolicy)
accountScoped.PUT("/assignment_policy/:policy_id", h.AssignmentPolicy.UpdateAccountPolicy)
accountScoped.DELETE("/assignment_policy/:policy_id", h.AssignmentPolicy.DeleteAccountPolicy)
// Assignment policies (plural, Chatwoot-compatible alias)
// Reference: Chatwoot resources :assignment_policies
assignmentPolicies := accountScoped.Group("/assignment_policies")
{
assignmentPolicies.GET("", h.AssignmentPolicy.ListAccountPolicies)
assignmentPolicies.GET("/", h.AssignmentPolicy.ListAccountPolicies)
assignmentPolicies.POST("", h.AssignmentPolicy.CreateAccountPolicy)
assignmentPolicies.POST("/", h.AssignmentPolicy.CreateAccountPolicy)
assignmentPolicies.GET("/:policy_id", h.AssignmentPolicy.GetAccountPolicy)
assignmentPolicies.PUT("/:policy_id", h.AssignmentPolicy.UpdateAccountPolicy)
assignmentPolicies.PATCH("/:policy_id", h.AssignmentPolicy.UpdateAccountPolicy)
assignmentPolicies.DELETE("/:policy_id", h.AssignmentPolicy.DeleteAccountPolicy)
// Nested inboxes (Chatwoot: resources :assignment_policy_inboxes)
assignmentPolicies.GET("/:policy_id/inboxes", h.AssignmentPolicy.ListPolicyInboxes)
assignmentPolicies.POST("/:policy_id/inboxes", h.AssignmentPolicy.AddPolicyInbox)
assignmentPolicies.DELETE("/:policy_id/inboxes/:inbox_id", h.AssignmentPolicy.RemovePolicyInbox)
}
// Inbox-level assignment policy overrides (nested under inboxes)
inboxAssignment := accountScoped.Group("/inboxes/:inbox_id/assignment_policy")
{
inboxAssignment.GET("", h.AssignmentPolicy.GetInboxPolicy)
inboxAssignment.GET("/", h.AssignmentPolicy.GetInboxPolicy)
inboxAssignment.POST("", h.AssignmentPolicy.CreateInboxPolicy)
inboxAssignment.POST("/", h.AssignmentPolicy.CreateInboxPolicy)
inboxAssignment.DELETE("", h.AssignmentPolicy.DeleteCurrentInboxPolicy)
inboxAssignment.DELETE("/", h.AssignmentPolicy.DeleteCurrentInboxPolicy)
inboxAssignment.PUT("/:policy_id", h.AssignmentPolicy.UpdateInboxPolicy)
inboxAssignment.DELETE("/:policy_id", h.AssignmentPolicy.DeleteInboxPolicy)
}
// SLA Policy — CRUD + applied SLA metrics/download + inbox associations (M11)
// Reference: Chatwoot sla_policies_controller, applied_slas_controller
accountScoped.GET("/sla_policies", h.SlaPolicy.List)
accountScoped.POST("/sla_policies", h.SlaPolicy.Create)
accountScoped.GET("/sla_policies/:id", h.SlaPolicy.Get)
accountScoped.PUT("/sla_policies/:id", h.SlaPolicy.Update)
accountScoped.DELETE("/sla_policies/:id", h.SlaPolicy.Delete)
// SLA inbox associations
accountScoped.GET("/sla_policies/:id/inboxes", h.SlaPolicy.ListInboxes)
accountScoped.POST("/sla_policies/:id/inboxes", h.SlaPolicy.AddInbox)
accountScoped.DELETE("/sla_policies/:id/inboxes/:inbox_id", h.SlaPolicy.RemoveInbox)
// Applied SLA metrics & download
accountScoped.GET("/applied_slas", h.SlaPolicy.ListAppliedSlas)
accountScoped.GET("/applied_slas/metrics", h.SlaPolicy.GetAppliedSlaMetrics)
accountScoped.GET("/applied_slas/download", h.SlaPolicy.GetAppliedSlaDownload)
// Assignment Policy V2 — enhanced with Type field + inbox join model (M11)
// Reference: Chatwoot assignment_policy V2 (round_robin / fair / best_skill_match)
accountScoped.GET("/assignment_policies_v2", h.AssignmentPolicyV2.List)
accountScoped.POST("/assignment_policies_v2", h.AssignmentPolicyV2.Create)
accountScoped.GET("/assignment_policies_v2/:id", h.AssignmentPolicyV2.Get)
accountScoped.PUT("/assignment_policies_v2/:id", h.AssignmentPolicyV2.Update)
accountScoped.DELETE("/assignment_policies_v2/:id", h.AssignmentPolicyV2.Delete)
// V2 inbox associations
accountScoped.GET("/assignment_policies_v2/:id/inboxes", h.AssignmentPolicyV2.ListInboxes)
accountScoped.POST("/assignment_policies_v2/:id/inboxes", h.AssignmentPolicyV2.AddInbox)
accountScoped.DELETE("/assignment_policies_v2/:id/inboxes/:inbox_id", h.AssignmentPolicyV2.RemoveInbox)
// V2 inbox-level assignment policy (reverse lookup: inbox → policy)
inboxAssignmentV2 := accountScoped.Group("/inboxes/:inbox_id/assignment_policy_v2")
{
inboxAssignmentV2.GET("/", h.AssignmentPolicyV2.GetInboxPolicy)
inboxAssignmentV2.POST("/", h.AssignmentPolicyV2.SetInboxPolicy)
inboxAssignmentV2.DELETE("/", h.AssignmentPolicyV2.DeleteInboxPolicy)
}
// Search routes — Global Search + Advanced Filter (M14)
// Reference: Chatwoot GlobalSearchService — cross-entity search
accountScoped.GET("/search", h.Search.GlobalSearch)
accountScoped.GET("/search/conversations", h.Search.SearchConversations)
accountScoped.GET("/search/messages", h.Search.SearchMessages)
accountScoped.GET("/search/contacts", h.Search.SearchContacts)
accountScoped.GET("/search/articles", h.Search.SearchArticles)
// Account SAML settings — account-scoped SAML config admin API (M13)
// Only accessible to account administrators.
// Reference: Chatwoot AccountSamlSettings — enterprise SSO configuration
v1.RegisterAccountSamlSettingsRoutes(accountScoped.Group("/saml_settings"), h.AccountSamlSettings)
// Custom Attribute Definitions — CRUD for attribute schema definitions
// Reference: Chatwoot custom_attribute_definitions_controller.rb
customAttrDefs := accountScoped.Group("/custom_attribute_definitions")
{
customAttrDefs.GET("/", h.CustomAttributeDefinition.List)
customAttrDefs.POST("/", h.CustomAttributeDefinition.Create)
customAttrDefs.GET("/:id", h.CustomAttributeDefinition.Get)
customAttrDefs.PUT("/:id", h.CustomAttributeDefinition.Update)
customAttrDefs.PATCH("/:id", h.CustomAttributeDefinition.Update) // Chatwoot uses PATCH
customAttrDefs.DELETE("/:id", h.CustomAttributeDefinition.Delete)
}
// Custom Attribute Values — set/remove specific attribute values on conversations/contacts
// Reference: Chatwoot custom_attribute_definitions_controller.rb (value management)
// Conversation attribute values: POST to set, DELETE to remove a specific key
conversationAttrs := accountScoped.Group("/conversations/:conversation_id/custom_attributes")
{
conversationAttrs.POST("/", h.CustomAttributeValue.SetConversationAttribute)
conversationAttrs.DELETE("/:attribute_name", h.CustomAttributeValue.RemoveConversationAttribute)
}
// Contact attribute values: POST to set, DELETE to remove a specific key
contactAttrs := accountScoped.Group("/contacts/:contact_id/custom_attributes")
{
contactAttrs.POST("/", h.CustomAttributeValue.SetContactAttribute)
contactAttrs.DELETE("/:attribute_name", h.CustomAttributeValue.RemoveContactAttribute)
}
// Custom Filters — CRUD for saved filter queries
// Reference: Chatwoot custom_filters_controller.rb
customFilters := accountScoped.Group("/custom_filters")
{
customFilters.GET("/", h.CustomFilter.List)
customFilters.POST("/", h.CustomFilter.Create)
customFilters.GET("/:id", h.CustomFilter.Get)
customFilters.PUT("/:id", h.CustomFilter.Update)
customFilters.PATCH("/:id", h.CustomFilter.Update) // Chatwoot uses PATCH
customFilters.DELETE("/:id", h.CustomFilter.Delete)
}
}
}
}
// registerV2Routes maps Chatwoot API v2 account-scoped report routes.
// Reference: Chatwoot config/routes.rb namespace :api/:v2.
func registerV2Routes(g *gin.RouterGroup, h *Handlers) {
accounts := g.Group("/accounts")
accounts.Use(middleware.AccountScope())
{
accounts.POST("/", h.Account.Create)
accountScoped := accounts.Group("/:account_id")
{
v1.RegisterSummaryReportRoutes(accountScoped, h.SummaryReport)
reports := accountScoped.Group("/reports")
{
reports.GET("", h.Analytics.Index)
reports.GET("/summary", h.Analytics.Summary)
reports.GET("/bot_summary", h.Analytics.BotSummary)
reports.GET("/agents", h.Analytics.AgentMetrics)
reports.GET("/inboxes", h.Analytics.InboxMetrics)
reports.GET("/labels", h.Analytics.LabelMetrics)
reports.GET("/teams", h.Analytics.TeamMetrics)
reports.GET("/conversations", h.Analytics.Conversations)
reports.GET("/conversations_summary", h.Analytics.ConversationsSummary)
reports.GET("/conversation_traffic", h.Analytics.ConversationTraffic)
reports.GET("/bot_metrics", h.Analytics.BotMetrics)
reports.GET("/inbox_label_matrix", h.Analytics.InboxLabelMatrix)
reports.GET("/first_response_time_distribution", h.Analytics.FirstResponseTimeDistribution)
reports.GET("/outgoing_messages_count", h.Analytics.OutgoingMessagesCount)
}
liveReports := accountScoped.Group("/live_reports")
{
liveReports.GET("/conversation_metrics", h.LiveReport.ConversationMetrics)
liveReports.GET("/grouped_conversation_metrics", h.LiveReport.GroupedConversationMetrics)
}
accountScoped.GET("/year_in_review", h.YearInReview.Show)
}
}
}
// registerPlatformRoutes maps super-admin platform routes.
// Reference: Chatwoot namespace :platform_app (super_admin only)
func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
// PlatformApp CRUD
g.GET("/apps", h.PlatformApp.List)
g.POST("/apps", h.PlatformApp.Create)
g.GET("/apps/search", h.PlatformApp.Search)
g.GET("/apps/:id", h.PlatformApp.Get)
g.PUT("/apps/:id", h.PlatformApp.Update)
g.DELETE("/apps/:id", h.PlatformApp.Delete)
// AccessToken management (Chatwoot AccessTokenable concern)
g.POST("/apps/:id/regenerate_access_token", h.PlatformApp.RegenerateAccessToken)
g.GET("/apps/:id/access_tokens", h.PlatformApp.ListAccessTokens)
// Permissible management (Chatwoot PlatformAppPermissible)
g.GET("/apps/:id/permissibles", h.PlatformApp.ListPermissibles)
g.POST("/apps/:id/permissibles", h.PlatformApp.AddPermissible)
g.DELETE("/apps/:id/permissibles/:permissible_id", h.PlatformApp.RemovePermissible)
// Agent Bot CRUD (super-admin, global bots without account_id)
// Reference: Chatwoot namespace :agent_bots under :platform_app
g.GET("/agent_bots", h.AgentBot.List)
g.POST("/agent_bots", h.AgentBot.Create)
g.GET("/agent_bots/:id", h.AgentBot.Get)
g.PUT("/agent_bots/:id", h.AgentBot.Update)
g.DELETE("/agent_bots/:id", h.AgentBot.Delete)
g.POST("/agent_bots/:id/reset_token", h.AgentBot.ResetToken)
g.POST("/agent_bots/:id/reset_secret", h.AgentBot.ResetSecret)
g.POST("/agent_bots/:id/delete_avatar", h.AgentBot.DeleteAvatar)
g.PUT("/agent_bots/:id/avatar", h.AgentBot.PlatformUpdateAvatar)
g.POST("/agent_bots/:id/reset", h.AgentBot.PlatformResetConfig)
// InstallationConfig CRUD (super-admin, global key-value config)
// Reference: Chatwoot Platform::Api::V1::InstallationConfigsController
g.GET("/installation_configs", h.InstallationConfig.List)
g.POST("/installation_configs", h.InstallationConfig.Create)
g.GET("/installation_configs/:id", h.InstallationConfig.Get)
g.PUT("/installation_configs/:id", h.InstallationConfig.Update)
g.DELETE("/installation_configs/:id", h.InstallationConfig.Delete)
// Banner CRUD (super-admin, global announcement/alert/update banners)
// Reference: Chatwoot Platform::Api::V1::BannersController
g.GET("/banners", h.Banner.List)
g.POST("/banners", h.Banner.Create)
g.GET("/banners/:id", h.Banner.Get)
g.PUT("/banners/:id", h.Banner.Update)
g.DELETE("/banners/:id", h.Banner.Delete)
// WidgetTest read-only endpoints (platform scope, super-admin)
// Reference: Chatwoot resources :widget_tests, only: [:index] — not in production
v1.RegisterWidgetTestRoutes(g, h.WidgetTest)
}
// registerPlatformTokenRoutes maps AccessToken-authenticated Platform API routes.
// Reference: Chatwoot PlatformController — AccessTokenable concern (api_access_token header)
// These routes use PlatformApp authentication (not SuperAdmin), distinct from registerPlatformRoutes.
func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) {
// Platform Users — AccessToken authenticated
// Reference: Chatwoot Platform::Api::V1::UsersController (AccessToken auth)
g.GET("/users", h.PlatformUser.List)
g.GET("/users/:id", h.PlatformUser.Show)
g.POST("/users", h.PlatformUser.Create)
g.GET("/users/:id/login", h.PlatformUser.Login)
g.POST("/users/:id/login", h.PlatformUser.Login)
g.POST("/users/:id/token", h.PlatformUser.Token)
g.PATCH("/users/:id", h.PlatformUser.Update)
g.DELETE("/users/:id", h.PlatformUser.Destroy)
// Platform Accounts — AccessToken authenticated
// Reference: Chatwoot Platform::Api::V1::AccountsController (AccessToken auth)
g.GET("/accounts", h.PlatformAccount.List)
g.GET("/accounts/:account_id", h.PlatformAccount.Show)
g.POST("/accounts", h.PlatformAccount.Create)
g.PATCH("/accounts/:account_id", h.PlatformAccount.Update)
g.DELETE("/accounts/:account_id", h.PlatformAccount.Destroy)
// NOTE: Platform Agent Bots routes already registered in registerPlatformRoutes (super-admin scope).
// PlatformAgentBot handlers share the same /agent_bots paths but with AccessToken auth.
// Since both route sets are now merged into one group, these are NOT re-registered here.
// Platform Account Users — AccessToken authenticated
// Reference: Chatwoot Platform::Api::V1::AccountUsersController (nested under accounts)
g.GET("/accounts/:account_id/account_users", h.PlatformAccountUser.Index)
g.POST("/accounts/:account_id/account_users", h.PlatformAccountUser.Create)
g.DELETE("/accounts/:account_id/account_users/destroy", h.PlatformAccountUser.Destroy)
g.DELETE("/accounts/:account_id/account_users/:user_id", h.PlatformAccountUser.Destroy)
}
// registerChatwootWidgetRoutes maps Chatwoot's /api/v1/widget namespace.
// Widget behavior is backed by Chatwoot-compatible handlers; public inbox APIs are tracked separately.
func registerChatwootWidgetRoutes(g *gin.RouterGroup, h *Handlers) {
g.POST("/direct_uploads", h.Upload.DirectUpload)
g.PUT("/direct_uploads/:upload_uuid", h.Upload.CompleteWidgetDirectUpload)
g.POST("/config", h.Widget.Config)
g.GET("/campaigns", h.Widget.ListCampaigns)
g.POST("/events", h.Widget.CreateEvent)
g.GET("/messages", h.Widget.GetLatestMessages)
g.POST("/messages", h.Widget.SendMessage)
g.PUT("/messages/:message_id", h.Widget.UpdateMessage)
g.PATCH("/messages/:message_id", h.Widget.UpdateMessage)
g.GET("/conversations", h.Widget.GetConversations)
g.POST("/conversations", h.Widget.CreateConversation)
g.POST("/conversations/destroy_custom_attributes", h.Widget.DestroyConversationCustomAttributes)
g.POST("/conversations/set_custom_attributes", h.Widget.SetConversationCustomAttributes)
g.POST("/conversations/update_last_seen", h.Widget.UpdateLastSeen)
g.POST("/conversations/toggle_typing", h.Widget.ToggleTyping)
g.POST("/conversations/transcript", h.Widget.SendTranscript)
g.GET("/conversations/toggle_status", h.Widget.ToggleStatus)
g.GET("/contact", h.Widget.GetContact)
g.PUT("/contact", h.Widget.UpdateContact)
g.PATCH("/contact", h.Widget.UpdateContact)
g.POST("/destroy_custom_attributes", h.Widget.DestroyContactCustomAttributes)
g.POST("/contact/destroy_custom_attributes", h.Widget.DestroyContactCustomAttributes)
g.PATCH("/contact/set_user", h.Widget.SetUser)
g.GET("/inbox_members", h.Widget.ListInboxMembers)
g.POST("/labels", h.Widget.AddLabel)
g.DELETE("/labels/:label_id", h.Widget.RemoveLabel)
g.POST("/integrations/dyte/add_participant_to_meeting", h.Widget.AddDyteParticipantToMeeting)
}
// registerWidgetRoutes maps widget API routes (public, for embed).
// Reference: Chatwoot namespace :widget_api — no authentication required
func registerWidgetRoutes(g *gin.RouterGroup, h *widget.WidgetHandler) {
g.POST("/init", h.Init)
g.PATCH("/contact", h.UpdateContact)
g.GET("/conversations", h.GetConversations)
g.POST("/messages", h.SendMessage)
g.GET("/cable_token", h.GetCableToken)
g.GET("/conversations/:id/messages", h.GetMessages)
g.POST("/conversations/:id/toggle_typing", h.ToggleTyping)
// M11: Widget theme, pre-chat form, and file upload public routes
// Widget SDK fetches theme + pre-chat form before conversation starts
// These routes use /widget/ prefix to avoid Gin radix tree conflict with /:portal_id (portals)
widget := g.Group("/widget")
widget.GET("/:website_token/theme_config", h.GetThemeConfig)
widget.GET("/:website_token/pre_chat_form", h.GetPreChatForm)
// File upload staged via website_token auth; attached to conversation after init
widget.POST("/:website_token/uploads", h.StageFileUpload)
widget.GET("/:website_token/uploads/:upload_uuid", h.GetFileUploadStatus)
// M11: Offline message — visitors submit when no agents available
widget.POST("/:website_token/offline_message", h.SubmitOfflineMessage)
}
func webhookProviderUnavailable(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "webhook provider unavailable",
"message": "webhook handler is not configured",
})
}
func healthCheck(c *gin.Context) {
uptime := time.Since(startTime)
c.JSON(200, gin.H{
"status": "ok",
"service": "gochat",
"version": config.Version,
"commit": config.CommitSHA,
"buildDate": config.BuildDate,
"uptime": uptime.String(),
"uptimeSeconds": uint64(uptime.Seconds()),
})
}
func dashboardIndex(c *gin.Context) {
if dashboardWantsJSON(c) {
c.JSON(http.StatusNotAcceptable, gin.H{"error": "Please use API routes instead of dashboard routes for JSON requests"})
return
}
installationName := strings.TrimSpace(os.Getenv("INSTALLATION_NAME"))
if installationName == "" {
installationName = "GoChat"
}
frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
helpCenterURL := strings.TrimRight(os.Getenv("HELPCENTER_URL"), "/")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(dashboardHTML(installationName, frontendURL, helpCenterURL)))
}
func dashboardWantsJSON(c *gin.Context) bool {
if strings.HasSuffix(c.Request.URL.Path, ".json") {
return true
}
accept := strings.ToLower(c.GetHeader("Accept"))
return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html")
}
func dashboardHTML(installationName string, frontendURL string, helpCenterURL string) string {
name := html.EscapeString(installationName)
chatwootConfig := dashboardJSON(map[string]any{
"hostURL": frontendURL,
"helpCenterURL": helpCenterURL,
"allowedLoginMethods": []string{"email"},
"signupEnabled": "false",
"isEnterprise": "true",
"selectedLocale": "en",
})
globalConfig := dashboardJSON(map[string]any{"INSTALLATION_NAME": installationName})
return `<!DOCTYPE html>
<html>
<head>
<title>` + name + `</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=0">
<script>
window.chatwootConfig = ` + chatwootConfig + `;
window.globalConfig = ` + globalConfig + `;
</script>
</head>
<body class="text-slate-600">
<div id="app"></div>
<noscript id="noscript">This app works best with JavaScript enabled.</noscript>
</body>
</html>`
}
func dashboardJSON(value any) string {
b, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(b)
}
func androidAssetlinks(c *gin.Context) {
c.JSON(http.StatusOK, []gin.H{
{
"relation": []string{"delegate_permission/common.handle_all_urls"},
"target": gin.H{
"namespace": "android_app",
"package_name": os.Getenv("ANDROID_BUNDLE_ID"),
"sha256_cert_fingerprints": []string{os.Getenv("ANDROID_SHA256_CERT_FINGERPRINT")},
},
},
})
}
func appleAppSiteAssociation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"applinks": gin.H{
"apps": []string{},
"details": []gin.H{
{
"appID": os.Getenv("IOS_APP_ID"),
"paths": []string{"/app/accounts/*/conversations/*"},
},
},
},
})
}
func microsoftIdentityAssociation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"associatedApplications": []gin.H{
{"applicationId": os.Getenv("AZURE_APP_ID")},
},
})
}
func customDomainChallenge(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if db == nil {
c.String(http.StatusNotFound, "Domain not found")
return
}
var portal model.Portal
domain := requestHost(c.Request)
err := db.WithContext(c.Request.Context()).
Select("id, custom_domain, ssl_settings").
Where("custom_domain = ?", domain).
First(&portal).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.String(http.StatusNotFound, "Domain not found")
return
}
if err != nil {
c.String(http.StatusInternalServerError, "Internal server error")
return
}
settings := map[string]any{}
if len(portal.SSLSettings) > 0 {
_ = json.Unmarshal(portal.SSLSettings, &settings)
}
if sslSettingString(settings, "cf_verification_id") != c.Param("id") {
c.String(http.StatusNotFound, "Challenge ID not found")
return
}
c.String(http.StatusOK, sslSettingString(settings, "cf_verification_body"))
}
}
func sslSettingString(settings map[string]any, key string) string {
value, _ := settings[key].(string)
return value
}
func requestHost(req *http.Request) string {
host := strings.TrimSpace(req.Host)
if host == "" && req.URL != nil {
host = strings.TrimSpace(req.URL.Host)
}
if stripped, _, err := net.SplitHostPort(host); err == nil {
return stripped
}
return strings.Trim(host, "[]")
}
func twilioVoiceCallTwiML(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
call, err := resolveTwilioVoiceCall(c, db)
if err != nil {
c.String(http.StatusNotFound, "Not found")
return
}
conferenceSID := strings.TrimSpace(call.ConferenceSID)
if conferenceSID == "" {
conferenceSID = fmt.Sprintf("conf_account_%d_call_%d", call.AccountID, call.ID)
_ = db.WithContext(c.Request.Context()).Model(call).Update("conference_sid", conferenceSID).Error
}
participantLabel := twilioParticipantLabel(c.PostForm("From"))
phoneDigits := twilioPhoneDigits(c.Param("phone"))
xml := fmt.Sprintf(
`<?xml version="1.0" encoding="UTF-8"?><Response><Dial><Conference startConferenceOnEnter="%t" endConferenceOnExit="false" record="record-from-start" recordingStatusCallback="/twilio/voice/recording_status/%s" recordingStatusCallbackEvent="completed" recordingStatusCallbackMethod="POST" statusCallback="/twilio/voice/conference_status/%s" statusCallbackEvent="start end join leave" statusCallbackMethod="POST" participantLabel="%s">%s</Conference></Dial></Response>`,
twilioAgentLeg(c.PostForm("From")),
html.EscapeString(phoneDigits),
html.EscapeString(phoneDigits),
html.EscapeString(participantLabel),
html.EscapeString(conferenceSID),
)
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(xml))
}
}
func twilioVoiceStatus(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
call, err := resolveTwilioVoiceCallbackCall(c, db)
if err == nil {
updates := map[string]any{"status": twilioCallStatus(c.PostForm("CallStatus"))}
if duration, parseErr := strconv.Atoi(c.PostForm("CallDuration")); parseErr == nil {
updates["duration"] = duration
}
mergeCallAttributes(call, formPayload(c), "twilio_status_payload")
updates["additional_attributes"] = call.AdditionalAttributes
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
}
c.Status(http.StatusNoContent)
}
}
func twilioVoiceConferenceStatus(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
event := twilioConferenceEvent(c.PostForm("StatusCallbackEvent"))
if event == "" {
c.Status(http.StatusNoContent)
return
}
call, err := resolveTwilioVoiceCallbackCall(c, db)
if err == nil {
updates := map[string]any{"status": twilioConferenceCallStatus(event)}
attrs := formPayload(c)
if sid := strings.TrimSpace(c.PostForm("ConferenceSid")); sid != "" {
attrs["twilio_conference_sid"] = sid
}
attrs["event"] = event
mergeCallAttributes(call, attrs, "twilio_conference_payload")
updates["additional_attributes"] = call.AdditionalAttributes
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
}
c.Status(http.StatusNoContent)
}
}
func twilioVoiceRecordingStatus(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
call, err := resolveTwilioVoiceCallbackCall(c, db)
if err == nil {
updates := map[string]any{"recording_url": strings.TrimSpace(c.PostForm("RecordingUrl"))}
if duration, parseErr := strconv.Atoi(c.PostForm("RecordingDuration")); parseErr == nil {
updates["duration"] = duration
}
mergeCallAttributes(call, formPayload(c), "twilio_recording_payload")
updates["additional_attributes"] = call.AdditionalAttributes
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
}
c.Status(http.StatusNoContent)
}
}
func resolveTwilioVoiceCall(c *gin.Context, db *gorm.DB) (*model.Call, error) {
if db == nil {
return nil, gorm.ErrRecordNotFound
}
inbox, err := findTwilioVoiceInbox(c, db)
if err != nil {
return nil, err
}
from := strings.TrimSpace(c.PostForm("From"))
if twilioAgentLeg(from) {
return findTwilioVoiceCall(c, db, inbox.ID, c.PostForm("call_sid"))
}
direction := strings.TrimSpace(firstNonEmpty(c.PostForm("Direction"), c.PostForm("CallDirection")))
callSID := strings.TrimSpace(c.PostForm("CallSid"))
if direction == "outbound-dial" {
if parent := strings.TrimSpace(c.PostForm("ParentCallSid")); parent != "" {
callSID = parent
}
}
return findTwilioVoiceCall(c, db, inbox.ID, callSID)
}
func resolveTwilioVoiceCallbackCall(c *gin.Context, db *gorm.DB) (*model.Call, error) {
if db == nil {
return nil, gorm.ErrRecordNotFound
}
inbox, err := findTwilioVoiceInbox(c, db)
if err != nil {
return nil, err
}
if friendlyName := strings.TrimSpace(c.PostForm("FriendlyName")); friendlyName != "" {
if call, err := findTwilioVoiceCallByConference(c, db, inbox.ID, friendlyName); err == nil {
return call, nil
}
}
if conferenceSID := strings.TrimSpace(c.PostForm("ConferenceSid")); conferenceSID != "" {
if call, err := findTwilioVoiceCallByConference(c, db, inbox.ID, conferenceSID); err == nil {
return call, nil
}
}
return findTwilioVoiceCall(c, db, inbox.ID, c.PostForm("CallSid"))
}
func findTwilioVoiceInbox(c *gin.Context, db *gorm.DB) (*model.Inbox, error) {
phone := "+" + twilioPhoneDigits(c.Param("phone"))
var channel channelmodel.ChannelTwilioSMS
if err := db.WithContext(c.Request.Context()).Where("phone_number = ?", phone).First(&channel).Error; err != nil {
return nil, err
}
var inbox model.Inbox
if err := db.WithContext(c.Request.Context()).Where("id = ? AND channel_type IN ?", channel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil {
return nil, err
}
if !twilioVoiceEnabled(inbox.ChannelConfig) {
return nil, gorm.ErrRecordNotFound
}
return &inbox, nil
}
func findTwilioVoiceCall(c *gin.Context, db *gorm.DB, inboxID uint, callSID string) (*model.Call, error) {
var call model.Call
err := db.WithContext(c.Request.Context()).
Where("inbox_id = ? AND provider = ? AND provider_call_id = ?", inboxID, "twilio", strings.TrimSpace(callSID)).
First(&call).Error
return &call, err
}
func findTwilioVoiceCallByConference(c *gin.Context, db *gorm.DB, inboxID uint, conferenceSID string) (*model.Call, error) {
var call model.Call
err := db.WithContext(c.Request.Context()).
Where("inbox_id = ? AND provider = ? AND conference_sid = ?", inboxID, "twilio", strings.TrimSpace(conferenceSID)).
First(&call).Error
return &call, err
}
func twilioVoiceEnabled(rawConfig string) bool {
config := map[string]any{}
if rawConfig != "" {
_ = json.Unmarshal([]byte(rawConfig), &config)
}
value, ok := config["voice_enabled"]
if !ok {
return false
}
switch v := value.(type) {
case bool:
return v
case string:
return strings.EqualFold(v, "true")
default:
return false
}
}
func twilioPhoneDigits(phone string) string {
var b strings.Builder
for _, r := range phone {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
func twilioAgentLeg(from string) bool {
return strings.HasPrefix(strings.TrimSpace(from), "client:")
}
func twilioParticipantLabel(from string) string {
from = strings.TrimSpace(from)
if twilioAgentLeg(from) {
return strings.TrimPrefix(from, "client:")
}
return "contact"
}
func twilioCallStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "in-progress", "answered":
return string(model.CallStatusOngoing)
case "completed":
return string(model.CallStatusCompleted)
case "failed", "busy", "no-answer", "canceled":
return string(model.CallStatusFailed)
default:
return string(model.CallStatusRinging)
}
}
func twilioConferenceEvent(event string) string {
event = strings.ToLower(strings.TrimSpace(event))
switch {
case strings.Contains(event, "conference-start"):
return "start"
case strings.Contains(event, "participant-join"):
return "join"
case strings.Contains(event, "participant-leave"):
return "leave"
case strings.Contains(event, "conference-end"):
return "end"
default:
return ""
}
}
func twilioConferenceCallStatus(event string) string {
if event == "end" {
return string(model.CallStatusCompleted)
}
return string(model.CallStatusOngoing)
}
func formPayload(c *gin.Context) map[string]any {
_ = c.Request.ParseForm()
payload := map[string]any{}
for key, values := range c.Request.PostForm {
if len(values) > 0 {
payload[key] = values[0]
}
}
return payload
}
func mergeCallAttributes(call *model.Call, value map[string]any, key string) {
attrs := map[string]any{}
if len(call.AdditionalAttributes) > 0 {
_ = json.Unmarshal(call.AdditionalAttributes, &attrs)
}
attrs[key] = value
data, err := json.Marshal(attrs)
if err != nil {
return
}
call.AdditionalAttributes = data
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}