diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index b62b5acff..c0778aed1 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -75,6 +75,8 @@ jobs: # The livechat widget suite makes far more than 100 widget requests a minute. LIBREDESK_RATE_LIMIT__WIDGET__REQUESTS_PER_MINUTE: "100000" LIBREDESK_RATE_LIMIT__PUBLIC__REQUESTS_PER_MINUTE: "100000" + # The WhatsApp suite drives the channel against the stand-in Graph API cypress starts. + LIBREDESK_WHATSAPP__API_URL: "http://127.0.0.1:9099" CYPRESS_SYSTEM_PASSWORD: "StrongPass!123" CYPRESS_MAILHOG_URL: "http://localhost:8025" run: | diff --git a/README.md b/README.md index eb2afc635..795bccee3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ width="250"> -
Modern, open source, self-hosted omnichannel customer support desk. Live chat, email, and more in a single binary. +
Modern, open source, self-hosted omnichannel customer support desk. Email, live chat, WhatsApp, and more in a single binary. ![image](https://libredesk.io/hero-dark.png?q=5) @@ -24,9 +24,11 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**live ## Features - **Omnichannel inbox** - Live chat and email in one inbox. Every conversation lands in the same place, whichever channel it came from. + Email, live chat, and WhatsApp in one inbox. Every conversation lands in the same place, whichever channel it came from. - **Live chat widget** Embed a real-time chat widget on your website. Replies go out from the same inbox your team already works in. +- **WhatsApp** + Connect a WhatsApp number through the Meta Cloud API. Agents reply from the same inbox, with approved templates for messages outside WhatsApp's 24-hour window. - **Help center** Publish a searchable knowledge base with collections, articles in multiple languages, and customize it however you want. - **AI assistant** diff --git a/cmd/aitools.go b/cmd/aitools.go index 0a296e882..31700dcb9 100644 --- a/cmd/aitools.go +++ b/cmd/aitools.go @@ -242,7 +242,7 @@ func (t *searchContactsTool) Execute(ctx context.Context, args string) (string, var b strings.Builder for i, r := range results { name := strings.TrimSpace(r.FirstName + " " + r.LastName) - fmt.Fprintf(&b, "%d. %s | Email: %s | Contact ID: %d\n", i+1, name, r.Email, r.ID) + fmt.Fprintf(&b, "%d. %s | Email: %s | Contact ID: %d\n", i+1, name, r.Email.String, r.ID) } return b.String(), nil } diff --git a/cmd/contacts.go b/cmd/contacts.go index 0fcfbdc40..d9060a432 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -63,6 +63,9 @@ func handleGetContact(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + if identities, err := app.user.GetChannelIdentities(id); err == nil { + c.ChannelIdentities = identities + } return r.SendEnvelope(c) } diff --git a/cmd/conversation.go b/cmd/conversation.go index 9f8712d5f..7cefc46f0 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -1,7 +1,9 @@ package main import ( + "database/sql" "encoding/json" + "errors" "fmt" "mime" "slices" @@ -13,7 +15,9 @@ import ( authzModels "github.com/abhinavxd/libredesk/internal/authz/models" "github.com/abhinavxd/libredesk/internal/automation/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" + "github.com/abhinavxd/libredesk/internal/countries" "github.com/abhinavxd/libredesk/internal/envelope" + whatsappChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/whatsapp" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" vmodels "github.com/abhinavxd/libredesk/internal/view/models" @@ -46,19 +50,24 @@ type tagsUpdateReq struct { } type createConversationRequest struct { - InboxID int `json:"inbox_id"` - AssignedAgentID int `json:"agent_id"` - AssignedTeamID int `json:"team_id"` - Email string `json:"contact_email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - ExternalUserID string `json:"external_user_id"` - ReuseContact bool `json:"reuse_contact"` - Subject string `json:"subject"` - Content string `json:"content"` - Attachments []int `json:"attachments"` - Initiator string `json:"initiator"` // "contact" | "agent" - CustomAttributes map[string]any `json:"custom_attributes"` + InboxID int `json:"inbox_id"` + AssignedAgentID int `json:"agent_id"` + AssignedTeamID int `json:"team_id"` + Email string `json:"contact_email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + ExternalUserID string `json:"external_user_id"` + ReuseContact bool `json:"reuse_contact"` + Subject string `json:"subject"` + Content string `json:"content"` + Attachments []int `json:"attachments"` + Initiator string `json:"initiator"` // "contact" | "agent" + CustomAttributes map[string]any `json:"custom_attributes"` + ContactID int `json:"contact_id"` + PhoneNumber string `json:"phone_number"` + PhoneNumberCountryCode string `json:"phone_number_country_code"` + WhatsAppTemplateID int `json:"whatsapp_template_id"` + WhatsAppTemplateParams map[string]string `json:"whatsapp_template_params"` } // handleGetAllConversations retrieves all conversations. @@ -399,14 +408,27 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } - _, err = enforceConversationAccess(app, uuid, user) + conv, err := enforceConversationAccess(app, uuid, user) if err != nil { return sendErrorEnvelope(r, err) } + var readInboxID int + var readSourceID string + if conv.InboxChannel == whatsappChannel.ChannelWhatsApp { + readInboxID, readSourceID, err = app.conversation.WhatsAppReadReceiptTarget(uuid, auser.ID) + if err != nil { + app.lo.Error("error resolving whatsapp read receipt target", "conversation_uuid", uuid, "error", err) + } + } + if err = app.conversation.UpdateUserLastSeen(uuid, auser.ID); err != nil { return sendErrorEnvelope(r, err) } + + if readSourceID != "" { + go markWhatsAppMessageRead(app, readInboxID, readSourceID) + } return r.SendEnvelope(true) } @@ -799,56 +821,98 @@ func handleCreateConversation(r *fastglue.Request) error { req.Email = strings.ToLower(strings.TrimSpace(req.Email)) - if err := validateCreateConversationRequest(req, app); err != nil { + channel, err := validateCreateConversationRequest(req, app) + if err != nil { return sendErrorEnvelope(r, err) } - email := req.Email - to := []string{email} user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } - contact := umodels.User{ - Email: null.StringFrom(email), - FirstName: req.FirstName, - LastName: req.LastName, - ExternalUserID: null.NewString(req.ExternalUserID, req.ExternalUserID != ""), - CustomAttributes: json.RawMessage(`{}`), - } - canWriteContacts, err := app.authz.Enforce(user, "contacts", "write") - if err != nil { - app.lo.Error("error checking permission", "error", err) - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) - } - policy := umodels.ContactReuse - if canWriteContacts && !req.ReuseContact { - policy = umodels.ContactSync - } - if err := app.user.ResolveContact(&contact, policy); err != nil { - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) - } - // A contact matched by external ID keeps its stored email as the recipient. - if policy == umodels.ContactReuse && contact.Email.String != "" { - to = []string{contact.Email.String} + var ( + contactID int + to = []string{req.Email} + ) + switch channel { + case whatsappChannel.ChannelWhatsApp: + if req.ContactID <= 0 { + canWriteContacts, err := app.authz.Enforce(user, "contacts", "write") + if err != nil { + app.lo.Error("error checking permission", "error", err) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + if !canWriteContacts { + return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("status.deniedPermission"), nil)) + } + } + contactID, err = resolveWhatsAppContact(app, req) + if err != nil { + return sendErrorEnvelope(r, err) + } + default: + contact := umodels.User{ + Email: null.StringFrom(req.Email), + FirstName: req.FirstName, + LastName: req.LastName, + ExternalUserID: null.NewString(req.ExternalUserID, req.ExternalUserID != ""), + CustomAttributes: json.RawMessage(`{}`), + } + canWriteContacts, err := app.authz.Enforce(user, "contacts", "write") + if err != nil { + app.lo.Error("error checking permission", "error", err) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + policy := umodels.ContactReuse + if canWriteContacts && !req.ReuseContact { + policy = umodels.ContactSync + } + if err := app.user.ResolveContact(&contact, policy); err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + // A contact matched by external ID keeps its stored email as the recipient. + if policy == umodels.ContactReuse && contact.Email.String != "" { + to = []string{contact.Email.String} + } + contactID = contact.ID } - // Create conversation first. - conversationID, conversationUUID, err := app.conversation.CreateConversation( - contact.ID, - req.InboxID, - "", /** last_message **/ - time.Now(), /** last_message_at **/ - req.Subject, - true, /** append reference number to subject? **/ - nil, - req.CustomAttributes, - 0, 0, + var ( + conversationID int + conversationUUID string + createdNew = true ) - if err != nil { - app.lo.Error("error creating conversation", "error", err) - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + + subject, appendRefNum := req.Subject, true + if channel == whatsappChannel.ChannelWhatsApp { + subject, appendRefNum = "", false + defer lockWhatsAppConversation(contactID, req.InboxID)() + // WhatsApp is one thread per contact; reuse the open conversation instead of creating a parallel one. + if id, uuid, lookupErr := app.conversation.GetLatestOpenConversationForContact(contactID, req.InboxID); lookupErr == nil { + conversationID, conversationUUID, createdNew = id, uuid, false + } else if !errors.Is(lookupErr, sql.ErrNoRows) { + app.lo.Error("error finding open whatsapp conversation", "error", lookupErr) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + } + + if createdNew { + conversationID, conversationUUID, err = app.conversation.CreateConversation( + contactID, + req.InboxID, + "", /** last_message **/ + time.Now(), /** last_message_at **/ + subject, + appendRefNum, + nil, + req.CustomAttributes, + 0, 0, + ) + if err != nil { + app.lo.Error("error creating conversation", "error", err) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } } // Get media for the attachment ids, skip any already associated with a model. @@ -857,84 +921,114 @@ func handleCreateConversation(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } - // Assign team first, it clears any assigned agent. - if req.AssignedTeamID > 0 { - app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) - } - if req.AssignedAgentID > 0 { - app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) + // WhatsApp is always an agent-initiated template; email follows the initiator. + agentInitiated := true + var sendErr error + switch { + case channel == whatsappChannel.ChannelWhatsApp: + meta := map[string]any{"whatsapp_template_id": req.WhatsAppTemplateID} + if len(req.WhatsAppTemplateParams) > 0 { + meta["whatsapp_template_params"] = req.WhatsAppTemplateParams + } + _, sendErr = app.conversation.QueueReply(media, req.InboxID, auser.ID, contactID, conversationUUID, "", nil, nil, nil, meta) + case req.Initiator == umodels.UserTypeAgent: + _, sendErr = app.conversation.QueueReply(media, req.InboxID, auser.ID, contactID, conversationUUID, req.Content, to, nil, nil, map[string]any{}) + case req.Initiator == umodels.UserTypeContact: + agentInitiated = false + _, sendErr = app.conversation.CreateContactMessage(media, contactID, conversationUUID, req.Content, cmodels.ContentTypeHTML, true) + default: + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - - // Send initial message based on the initiator of conversation. - switch req.Initiator { - case umodels.UserTypeAgent: - // Queue reply. - if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { - // Delete the conversation if msg queue fails. + if sendErr != nil { + app.lo.Error("error sending first message of new conversation", "conversation_uuid", conversationUUID, "error", sendErr) + // Roll back only a conversation we created, not a reused one. + if createdNew { if err := app.conversation.DeleteConversation(conversationUUID); err != nil { app.lo.Error("error deleting conversation", "error", err) } - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil)) } - // Trigger webhook for agent-initiated conversation, for contact intitiated the incoming message hooks handle it. + // Only envelope errors carry a message that is safe to show the agent. + if _, ok := sendErr.(envelope.Error); ok { + return sendErrorEnvelope(r, sendErr) + } + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil)) + } + + // Don't reassign a reused conversation; team first as it clears the agent. + if createdNew { + if req.AssignedTeamID > 0 { + app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) + } + if req.AssignedAgentID > 0 { + app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) + } + } + + // Contact-initiated conversations get this event from the incoming message hooks. + if agentInitiated && createdNew { if c, err := app.conversation.GetConversation(0, conversationUUID, ""); err == nil { app.webhook.TriggerEvent(wmodels.EventConversationCreated, c) } - case umodels.UserTypeContact: - // Create contact message. - if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML, true); err != nil { - // Delete the conversation if message creation fails. - if err := app.conversation.DeleteConversation(conversationUUID); err != nil { - app.lo.Error("error deleting conversation", "error", err) - } - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorSendingMessage"), nil)) - } - default: - // Guard anyway. - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } conversation, _ := app.conversation.GetConversation(conversationID, "", "") return r.SendEnvelope(conversation) } -func validateCreateConversationRequest(req createConversationRequest, app *App) error { +func validateCreateConversationRequest(req createConversationRequest, app *App) (string, error) { if req.InboxID <= 0 { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil) - } - if req.Content == "" { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil) - } - if req.Email == "" { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil) - } - if req.FirstName == "" { - return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil) - } - if !stringutil.ValidEmail(req.Email) { - return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil) - } - if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent { - return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil) } - // Check if inbox exists and is enabled. inbox, err := app.inbox.GetDBRecord(req.InboxID) if err != nil { - return err + return "", err } if !inbox.Enabled { - return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.disabled"), nil) + return "", envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.disabled"), nil) } - if inbox.Channel != "email" { - return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + + switch inbox.Channel { + case whatsappChannel.ChannelWhatsApp: + if req.WhatsAppTemplateID <= 0 { + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`whatsapp_template_id`"), nil) + } + if req.ContactID <= 0 { + if req.PhoneNumber == "" { + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`phone_number`"), nil) + } + if req.PhoneNumberCountryCode == "" { + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`phone_number_country_code`"), nil) + } + if req.FirstName == "" { + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil) + } + } + case "email": + if req.Content == "" { + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil) + } + if req.Email == "" { + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil) + } + if req.FirstName == "" { + return "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil) + } + if !stringutil.ValidEmail(req.Email) { + return "", envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil) + } + if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent { + return "", envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + } + default: + return "", envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) } // Validate custom attribute keys. Skip unknown keys. if len(req.CustomAttributes) > 0 { attrs, err := app.customAttribute.GetAll("conversation") if err != nil { - return err + return "", err } validKeys := make(map[string]struct{}, len(attrs)) for _, a := range attrs { @@ -947,5 +1041,55 @@ func validateCreateConversationRequest(req createConversationRequest, app *App) } } - return nil + return inbox.Channel, nil +} + +// resolveWhatsAppContact returns the outbound contact, creating one keyed by the wa_id when none is selected. +func resolveWhatsAppContact(app *App, req createConversationRequest) (int, error) { + if req.ContactID > 0 { + if _, err := app.user.GetContactOrVisitor(req.ContactID, ""); err != nil { + return 0, err + } + return req.ContactID, nil + } + dialCode := countries.DialCodeForISO(req.PhoneNumberCountryCode) + if dialCode == "" { + return 0, envelope.NewError(envelope.InputError, app.i18n.T("conversation.whatsapp.error.phoneCountryCodeInvalid"), nil) + } + local, err := localPhoneNumber(app, req.PhoneNumber, dialCode) + if err != nil { + return 0, err + } + waID := dialCode + local + contact := umodels.User{ + Type: umodels.UserTypeContact, + FirstName: req.FirstName, + LastName: req.LastName, + CustomAttributes: json.RawMessage(`{}`), + } + id, err := app.user.UpsertContactByChannelIdentity(whatsappChannel.ChannelWhatsApp, waID, &contact) + if err != nil { + return 0, err + } + if err := app.user.SetContactPhoneIfMissing(id, local, req.PhoneNumberCountryCode); err != nil { + app.lo.Error("error setting whatsapp contact phone", "user_id", id, "error", err) + } + return id, nil +} + +// localPhoneNumber returns the digits after the country dial code, accepting numbers typed with a leading + or 00. +func localPhoneNumber(app *App, phone, dialCode string) (string, error) { + trimmed := strings.TrimSpace(phone) + digits := stringutil.NormalizeWhatsAppPhone(trimmed) + if strings.HasPrefix(trimmed, "+") || strings.HasPrefix(digits, "00") { + digits = strings.TrimPrefix(digits, "00") + if !strings.HasPrefix(digits, dialCode) { + return "", envelope.NewError(envelope.InputError, app.i18n.T("conversation.whatsapp.error.phoneCountryMismatch"), nil) + } + digits = strings.TrimPrefix(digits, dialCode) + } + if digits == "" { + return "", envelope.NewError(envelope.InputError, app.i18n.T("conversation.whatsapp.error.phoneInvalid"), nil) + } + return digits, nil } diff --git a/cmd/handlers.go b/cmd/handlers.go index 56d5cf173..b3c411383 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -362,6 +362,17 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimit(widgetAuth(handleChatSendMessage), "widget")) g.POST("/api/v1/widget/media/upload", rateLimit(widgetAuth(handleWidgetMediaUpload), "widget")) + // WhatsApp. + g.GET("/webhooks/whatsapp/{inbox_id}", rateLimit(handleWhatsAppWebhookVerify, "public")) + g.POST("/webhooks/whatsapp/{inbox_id}", handleWhatsAppWebhookEvent) + + // WhatsApp templates. + g.GET("/api/v1/whatsapp/templates", auth(handleListWhatsAppTemplates)) + g.GET("/api/v1/whatsapp/templates/{id}", perm(handleGetWhatsAppTemplate, "inboxes:manage")) + g.POST("/api/v1/whatsapp/templates", perm(handleCreateWhatsAppTemplate, "inboxes:manage")) + g.DELETE("/api/v1/whatsapp/templates/{id}", perm(handleDeleteWhatsAppTemplate, "inboxes:manage")) + g.POST("/api/v1/whatsapp/templates/sync", perm(handleSyncWhatsAppTemplates, "inboxes:manage")) + // getAndHead registers both methods: uptime checkers and link validators probe with HEAD. getAndHead := func(path string, h fastglue.FastRequestHandler) { g.GET(path, h) @@ -565,8 +576,10 @@ func getPagination(r *fastglue.Request) (page, pageSize int) { func sendErrorEnvelope(r *fastglue.Request, err error) error { e, ok := err.(envelope.Error) if !ok { + app := r.Context.(*App) + app.lo.Error("non-envelope error reached sendErrorEnvelope", "path", string(r.RequestCtx.Path()), "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, - "Error interface conversion failed", nil, fastglue.ErrorType(envelope.GeneralError)) + app.i18n.T("globals.messages.somethingWentWrong"), nil, fastglue.ErrorType(envelope.GeneralError)) } return r.SendErrorEnvelope(e.Code, e.Error(), e.Data, fastglue.ErrorType(e.ErrorType)) } diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 462d6993a..54d028695 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -1,8 +1,10 @@ package main import ( + "context" "encoding/json" "net/mail" + "net/url" "regexp" "strconv" "strings" @@ -11,13 +13,19 @@ import ( "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/httputil" "github.com/abhinavxd/libredesk/internal/inbox" + "github.com/abhinavxd/libredesk/internal/inbox/channel/email" "github.com/abhinavxd/libredesk/internal/inbox/channel/email/oauth" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + whatsappChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/whatsapp" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" + wtmodels "github.com/abhinavxd/libredesk/internal/whatsapp_template/models" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) +// csatTemplateLocks serializes CSAT template reconciliation per inbox; EnsureReserved reads then creates. +var csatTemplateLocks = &keyedLock{entries: make(map[string]*keyedLockEntry)} + // handleGetInboxes returns all inboxes func handleGetInboxes(r *fastglue.Request) error { var app = r.Context.(*App) @@ -25,11 +33,13 @@ func handleGetInboxes(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + rootURL, _ := app.setting.GetAppRootURL() for i := range inboxes { if err := inboxes[i].ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } + setComputedInboxFieldsWithRoot(app, &inboxes[i], rootURL) } return r.SendEnvelope(inboxes) } @@ -48,9 +58,147 @@ func handleGetInbox(r *fastglue.Request) error { app.lo.Error("error clearing inbox passwords from response", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } + setComputedInboxFields(app, &inbox) return r.SendEnvelope(inbox) } +func makeInboxAuthStatusHook(app *App) email.AuthStatusCallback { + return func(inboxID int, ok bool) { + if ok { + if _, flagged := app.inboxAuthErrors.LoadAndDelete(inboxID); flagged { + app.lo.Info("inbox credentials recovered", "inbox_id", inboxID) + } + return + } + if _, flagged := app.inboxAuthErrors.LoadOrStore(inboxID, time.Now()); !flagged { + app.lo.Error("inbox credentials rejected, messages will not be sent or received until they are updated", "inbox_id", inboxID) + } + } +} + +func setComputedInboxFields(app *App, inb *imodels.Inbox) { + root, _ := app.setting.GetAppRootURL() + setComputedInboxFieldsWithRoot(app, inb, root) +} + +func setComputedInboxFieldsWithRoot(app *App, inb *imodels.Inbox, rootURL string) { + _, inb.TokenInvalid = app.inboxAuthErrors.Load(inb.ID) + if inb.Channel != whatsappChannel.ChannelWhatsApp { + return + } + url := whatsAppCallbackURLFromRoot(rootURL, inb.ID) + if url == "" { + return + } + inb.WebhookURL = url +} + +func whatsAppCallbackURLFromRoot(root string, inboxID int) string { + if root == "" { + return "" + } + return strings.TrimRight(root, "/") + "/webhooks/whatsapp/" + strconv.Itoa(inboxID) +} + +// isPublicWebhookURL reports whether root is a Meta-reachable webhook origin: an https URL with a non-loopback host. +func isPublicWebhookURL(root string) bool { + u, err := url.Parse(strings.TrimSpace(root)) + if err != nil || u.Scheme != "https" { + return false + } + switch u.Hostname() { + case "", "localhost", "127.0.0.1", "::1": + return false + } + return true +} + +// subscribeWhatsAppWebhook best-effort points the WABA's webhook at this inbox; the manual Meta dashboard setup stays as fallback. +func subscribeWhatsAppWebhook(app *App, inboxID int) { + cfg, err := whatsAppConfigForInbox(app, inboxID) + if err != nil || app.whatsappClient == nil { + return + } + root, _ := app.setting.GetAppRootURL() + if !isPublicWebhookURL(root) { + app.lo.Warn("whatsapp webhook not auto-registered: the app root URL must be a public HTTPS URL Meta can reach; set it in Settings and re-save the inbox, otherwise inbound messages will not arrive", "inbox_id", inboxID, "root_url", root) + return + } + callbackURL := whatsAppCallbackURLFromRoot(root, inboxID) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := app.whatsappClient.SubscribeWebhook(ctx, cfg.Account(), callbackURL, cfg.WebhookVerifyToken); err != nil { + app.lo.Error("whatsapp webhook auto-registration failed; configure it manually in the Meta dashboard or re-save the inbox, otherwise inbound messages will not arrive", "inbox_id", inboxID, "callback_url", callbackURL, "error", err) + return + } + app.lo.Info("whatsapp webhook subscribed automatically", "inbox_id", inboxID, "callback_url", callbackURL) +} + +func validateWhatsAppCredentials(r *fastglue.Request, app *App, inb imodels.Inbox) error { + if inb.Channel != whatsappChannel.ChannelWhatsApp || app.whatsappClient == nil { + return nil + } + var cfg whatsappChannel.Config + if err := json.Unmarshal(inb.Config, &cfg); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.T("admin.inbox.whatsapp.error.invalidConfig"), nil) + } + if err := app.whatsappClient.ValidateCredentials(r.RequestCtx, cfg.Account()); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("admin.inbox.whatsapp.error.credentialCheckFailed", "error", err.Error()), nil) + } + return nil +} + +// ensureWhatsAppCSATTemplate reconciles the inbox's reserved CSAT template on Meta; a language change creates a fresh one. Approval arrives via webhook/sync. +func ensureWhatsAppCSATTemplate(app *App, inboxID int) { + defer func() { + if r := recover(); r != nil { + app.lo.Error("recovered from panic in whatsapp csat template ensure", "inbox_id", inboxID, "panic", r) + } + }() + if app.whatsappTemplate == nil { + return + } + defer csatTemplateLocks.lock(strconv.Itoa(inboxID))() + + cfg, err := whatsAppConfigForInbox(app, inboxID) + if err != nil { + app.lo.Warn("error reading whatsapp config for csat template", "inbox_id", inboxID, "error", err) + return + } + if strings.TrimSpace(cfg.CSATTemplateBody) == "" || strings.TrimSpace(cfg.CSATTemplateLanguage) == "" || strings.TrimSpace(cfg.CSATTemplateButtonText) == "" { + return + } + root, err := app.setting.GetAppRootURL() + if err != nil || root == "" { + return + } + base := strings.TrimRight(root, "/") + buttons, err := json.Marshal([]map[string]any{{ + "type": "URL", + "text": cfg.CSATTemplateButtonText, + "url": base + "/csat/{{1}}", + "example": []string{base + "/csat/example"}, + }}) + if err != nil { + return + } + name := wtmodels.CSATTemplateName(inboxID) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := app.whatsappTemplate.EnsureReserved(ctx, wtmodels.Template{ + InboxID: inboxID, + Name: name, + Language: cfg.CSATTemplateLanguage, + Category: wtmodels.CategoryUtility, + BodyContent: cfg.CSATTemplateBody, + Buttons: buttons, + }); err != nil { + app.lo.Warn("error provisioning whatsapp csat template", "inbox_id", inboxID, "error", err) + return + } + app.lo.Info("whatsapp csat template reconciled", "inbox_id", inboxID, "name", name) +} + // handleCreateInbox creates a new inbox func handleCreateInbox(r *fastglue.Request) error { var ( @@ -66,7 +214,11 @@ func handleCreateInbox(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError) } - if err := validateInbox(app, inbox); err != nil { + if err := validateInbox(app, inbox, false); err != nil { + return sendErrorEnvelope(r, err) + } + + if err := validateWhatsAppCredentials(r, app, inbox); err != nil { return sendErrorEnvelope(r, err) } @@ -80,11 +232,16 @@ func handleCreateInbox(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } + if createdInbox.Channel == whatsappChannel.ChannelWhatsApp { + go postSaveWhatsAppTasks(app, createdInbox.ID) + } + // Clear passwords before returning. if err := createdInbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } + setComputedInboxFields(app, &createdInbox) return r.SendEnvelope(createdInbox) } @@ -110,10 +267,26 @@ func handleUpdateInbox(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError) } - if err := validateInbox(app, inbox); err != nil { + if err := validateInbox(app, inbox, true); err != nil { return sendErrorEnvelope(r, err) } + // Credentials arrive masked; the check must run on the merged config, before anything is persisted. + if inbox.Channel == whatsappChannel.ChannelWhatsApp { + previous, err := app.inbox.GetDBRecord(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + merged, err := app.inbox.MergeWhatsAppSecrets(previous.Config, inbox.Config) + if err != nil { + return sendErrorEnvelope(r, err) + } + inbox.Config = merged + if err := validateWhatsAppCredentials(r, app, inbox); err != nil { + return sendErrorEnvelope(r, err) + } + } + updatedInbox, err := app.inbox.Update(id, inbox) if err != nil { return sendErrorEnvelope(r, err) @@ -124,11 +297,16 @@ func handleUpdateInbox(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } + if updatedInbox.Channel == whatsappChannel.ChannelWhatsApp { + go postSaveWhatsAppTasks(app, id) + } + // Clear passwords before returning. if err := updatedInbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } + setComputedInboxFields(app, &updatedInbox) return r.SendEnvelope(updatedInbox) } @@ -159,6 +337,7 @@ func handleToggleInbox(r *fastglue.Request) error { app.lo.Error("error clearing inbox passwords from response", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } + setComputedInboxFields(app, &toggledInbox) return r.SendEnvelope(toggledInbox) } @@ -169,6 +348,7 @@ func handleDeleteInbox(r *fastglue.Request) error { app = r.Context.(*App) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) ) + deleted, recErr := app.inbox.GetDBRecord(id) err := app.inbox.SoftDelete(id) if err != nil { return sendErrorEnvelope(r, err) @@ -177,11 +357,65 @@ func handleDeleteInbox(r *fastglue.Request) error { app.lo.Error("error reloading inbox", "id", id, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } + if recErr == nil && deleted.Channel == whatsappChannel.ChannelWhatsApp { + go repointWhatsAppWebhookAfterDelete(app, deleted) + } return r.SendEnvelope(true) } +// repointWhatsAppWebhookAfterDelete moves a shared WABA's callback to a surviving inbox, else Meta keeps posting every WABA event to the deleted inbox's dead URL. +func repointWhatsAppWebhookAfterDelete(app *App, deleted imodels.Inbox) { + defer func() { + if r := recover(); r != nil { + app.lo.Error("recovered from panic in whatsapp webhook repoint", "inbox_id", deleted.ID, "panic", r) + } + }() + cfg, err := whatsAppConfigFromRecord(deleted) + if err != nil || cfg.WABAID == "" { + return + } + forEachEnabledWhatsAppInbox(app, func(rec imodels.Inbox, c whatsappChannel.Config) bool { + if rec.ID == deleted.ID || c.WABAID != cfg.WABAID { + return true + } + subscribeWhatsAppWebhook(app, rec.ID) + return false + }) +} + +func postSaveWhatsAppTasks(app *App, inboxID int) { + defer func() { + if r := recover(); r != nil { + app.lo.Error("recovered from panic in whatsapp post-save tasks", "inbox_id", inboxID, "panic", r) + } + }() + subscribeWhatsAppWebhook(app, inboxID) + ensureWhatsAppCSATTemplate(app, inboxID) +} + +// reconcileWhatsAppRootURL re-registers every enabled WhatsApp inbox's webhook callback and CSAT template, both of which embed the root URL. +func reconcileWhatsAppRootURL(app *App) { + defer func() { + if r := recover(); r != nil { + app.lo.Error("recovered from panic in whatsapp root url reconcile", "panic", r) + } + }() + inboxes, err := app.inbox.GetAll() + if err != nil { + app.lo.Error("error listing inboxes for whatsapp root url reconcile", "error", err) + return + } + for _, inb := range inboxes { + if inb.Channel != inbox.ChannelWhatsApp || !inb.Enabled { + continue + } + subscribeWhatsAppWebhook(app, inb.ID) + ensureWhatsAppCSATTemplate(app, inb.ID) + } +} + // validateInbox validates the inbox -func validateInbox(app *App, inbox imodels.Inbox) error { +func validateInbox(app *App, inbox imodels.Inbox, isUpdate bool) error { // Validate from address only for email channels. if inbox.Channel == "email" { if _, err := mail.ParseAddress(inbox.From); err != nil { @@ -206,6 +440,29 @@ func validateInbox(app *App, inbox imodels.Inbox) error { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil) } + // Live credential check against Meta runs in the handler where request context is available. + if inbox.Channel == whatsappChannel.ChannelWhatsApp { + var cfg whatsappChannel.Config + if err := json.Unmarshal(inbox.Config, &cfg); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.T("admin.inbox.whatsapp.error.invalidConfig"), nil) + } + if cfg.PhoneNumberID == "" || cfg.WABAID == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`phone_number_id`, `waba_id`"), nil) + } + if cfg.WebhookVerifyToken == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`webhook_verify_token`"), nil) + } + // On edit secrets arrive masked/empty and the config merge restores them. + if !isUpdate { + if cfg.AccessToken == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`access_token`"), nil) + } + if cfg.AppSecret == "" { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`app_secret`"), nil) + } + } + } + // Validate livechat-specific configuration if inbox.Channel == livechat.ChannelLiveChat { var config livechat.Config @@ -425,6 +682,24 @@ func trimInboxFields(inb *imodels.Inbox) error { } inb.Config = trimmedConfig } + + if inb.Channel == whatsappChannel.ChannelWhatsApp && len(inb.Config) > 0 { + var cfg whatsappChannel.Config + if err := json.Unmarshal(inb.Config, &cfg); err != nil { + return err + } + cfg.PhoneNumberID = strings.TrimSpace(cfg.PhoneNumberID) + cfg.WABAID = strings.TrimSpace(cfg.WABAID) + cfg.AccessToken = strings.TrimSpace(cfg.AccessToken) + cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) + cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.APIVersion = strings.TrimSpace(cfg.APIVersion) + trimmedConfig, err := json.Marshal(cfg) + if err != nil { + return err + } + inb.Config = trimmedConfig + } return nil } diff --git a/cmd/init.go b/cmd/init.go index 47c99ca41..cc9cf4b0d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -33,6 +33,7 @@ import ( "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + whatsappChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/whatsapp" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/media" @@ -54,6 +55,8 @@ import ( "github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/view" "github.com/abhinavxd/libredesk/internal/webhook" + whatsappapi "github.com/abhinavxd/libredesk/internal/whatsapp" + whatsappTemplate "github.com/abhinavxd/libredesk/internal/whatsapp_template" "github.com/abhinavxd/libredesk/internal/ws" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" @@ -694,7 +697,7 @@ func initNotifier() *notifier.Service { } // initEmailInbox loads inbox config from DB and initializes the email inbox. -func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore, mgr *inbox.Manager) (inbox.Inbox, error) { +func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore, mgr *inbox.Manager, authStatusHook email.AuthStatusCallback) (inbox.Inbox, error) { var config imodels.Config // Load JSON data into Koanf. @@ -746,6 +749,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS Config: config, Lo: initLogger("email_inbox"), TokenRefreshCallback: tokenRefreshCallback, + AuthStatusCallback: authStatusHook, }) if err != nil { @@ -787,14 +791,39 @@ func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, u return inbox, nil } +// initWhatsAppInbox initializes a WhatsApp Cloud API inbox. +func initWhatsAppInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, client *whatsappapi.Client, sourceUpdater whatsappChannel.SourceIDUpdater) (inbox.Inbox, error) { + var config whatsappChannel.Config + if err := json.Unmarshal(inboxRecord.Config, &config); err != nil { + return nil, fmt.Errorf("unmarshalling whatsapp config for inbox %q: %w", inboxRecord.Name, err) + } + + inb, err := whatsappChannel.New(msgStore, whatsappChannel.Opts{ + ID: inboxRecord.ID, + Name: inboxRecord.Name, + Config: config, + Client: client, + Lo: initLogger("whatsapp_inbox"), + SourceUpdater: sourceUpdater, + }) + if err != nil { + return nil, fmt.Errorf("initializing `%s` inbox: `%s` error: %w", inboxRecord.Channel, inboxRecord.Name, err) + } + + log.Printf("`%s` inbox successfully initialized", inboxRecord.Name) + return inb, nil +} + // makeInboxInitializer creates an inbox initializer function. -func makeInboxInitializer(mgr *inbox.Manager, signAvatarURL func(*null.String)) func(imodels.Inbox, inbox.MessageStore, inbox.UserStore) (inbox.Inbox, error) { +func makeInboxInitializer(mgr *inbox.Manager, signAvatarURL func(*null.String), waClient *whatsappapi.Client, sourceUpdater whatsappChannel.SourceIDUpdater, authStatusHook email.AuthStatusCallback) func(imodels.Inbox, inbox.MessageStore, inbox.UserStore) (inbox.Inbox, error) { return func(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { switch inboxR.Channel { case inbox.ChannelEmail: - return initEmailInbox(inboxR, msgStore, usrStore, mgr) + return initEmailInbox(inboxR, msgStore, usrStore, mgr, authStatusHook) case inbox.ChannelLiveChat: return initLiveChatInbox(inboxR, msgStore, usrStore, signAvatarURL) + case inbox.ChannelWhatsApp: + return initWhatsAppInbox(inboxR, msgStore, waClient, sourceUpdater) default: return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel) } @@ -804,15 +833,16 @@ func makeInboxInitializer(mgr *inbox.Manager, signAvatarURL func(*null.String)) // reloadInbox reloads a single inbox by ID using the signal-aware context. func reloadInbox(app *App, id int) error { app.lo.Info("reloading inbox", "id", id) - return app.inbox.ReloadInbox(app.ctx, id, makeInboxInitializer(app.inbox, app.conversation.SignAvatarURL)) + app.inboxAuthErrors.Delete(id) + return app.inbox.ReloadInbox(app.ctx, id, makeInboxInitializer(app.inbox, app.conversation.SignAvatarURL, app.whatsappClient, app.conversation, makeInboxAuthStatusHook(app))) } // startInboxes registers the active inboxes and starts receiver for each. -func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.MessageStore, usrStore inbox.UserStore, signAvatarURL func(*null.String)) { +func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.MessageStore, usrStore inbox.UserStore, signAvatarURL func(*null.String), waClient *whatsappapi.Client, sourceUpdater whatsappChannel.SourceIDUpdater, authStatusHook email.AuthStatusCallback) { mgr.SetMessageStore(msgStore) mgr.SetUserStore(usrStore) - if err := mgr.InitInboxes(makeInboxInitializer(mgr, signAvatarURL)); err != nil { + if err := mgr.InitInboxes(makeInboxInitializer(mgr, signAvatarURL, waClient, sourceUpdater, authStatusHook)); err != nil { log.Fatalf("error initializing inboxes: %v", err) } @@ -821,6 +851,52 @@ func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.Messag } } +// initWhatsAppClient constructs the shared Meta Graph API client. +func initWhatsAppClient() *whatsappapi.Client { + client := whatsappapi.New(initLogger("whatsapp_client")) + // Points the client at a stand-in Graph API. Tests set it, production leaves it empty. + if url := strings.TrimSpace(ko.String("whatsapp.api_url")); url != "" { + log.Printf("WARNING: whatsapp api_url is overridden to %s, no message will reach Meta", url) + client.SetBaseURL(url) + } + return client +} + +// inboxAccountResolver resolves per-inbox Meta credentials for the template manager. +type inboxAccountResolver struct { + inbox *inbox.Manager +} + +func (r *inboxAccountResolver) WhatsAppAccount(inboxID int) (whatsappapi.Account, error) { + rec, err := r.inbox.GetDBRecord(inboxID) + if err != nil { + return whatsappapi.Account{}, err + } + if rec.Channel != whatsappChannel.ChannelWhatsApp { + return whatsappapi.Account{}, fmt.Errorf("inbox %d is not whatsapp", inboxID) + } + var cfg whatsappChannel.Config + if err := json.Unmarshal(rec.Config, &cfg); err != nil { + return whatsappapi.Account{}, fmt.Errorf("decoding whatsapp config: %w", err) + } + return cfg.Account(), nil +} + +// initWhatsAppTemplates wires the WhatsApp template manager. +func initWhatsAppTemplates(db *sqlx.DB, i18n *i18n.I18n, client *whatsappapi.Client, inboxMgr *inbox.Manager) *whatsappTemplate.Manager { + mgr, err := whatsappTemplate.New(whatsappTemplate.Opts{ + Lo: initLogger("whatsapp_template"), + DB: db, + I18n: i18n, + Client: client, + Resolver: &inboxAccountResolver{inbox: inboxMgr}, + }) + if err != nil { + log.Fatalf("error initializing whatsapp template manager: %v", err) + } + return mgr +} + // initAuthz initializes authorization enforcer. func initAuthz(i18n *i18n.I18n) *authz.Enforcer { enforcer, err := authz.NewEnforcer(initLogger("authz"), i18n) diff --git a/cmd/main.go b/cmd/main.go index 43636c871..dadbf4038 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -53,6 +53,8 @@ import ( "github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/webhook" + whatsappapi "github.com/abhinavxd/libredesk/internal/whatsapp" + whatsappTemplate "github.com/abhinavxd/libredesk/internal/whatsapp_template" "github.com/abhinavxd/libredesk/internal/ws" "github.com/knadh/go-i18n" "github.com/knadh/koanf/v2" @@ -134,7 +136,12 @@ type App struct { redis *redis.Client fc *fastcache.FastCache importer *importer.Importer - wsHub *ws.Hub + whatsappTemplate *whatsappTemplate.Manager + whatsappClient *whatsappapi.Client + whatsappIngester *WhatsAppIngester + // Inbox IDs whose provider credentials were recently rejected, keyed to the last error time. + inboxAuthErrors sync.Map + wsHub *ws.Hub // Global state that stores data on an available app update. update *AppUpdate @@ -269,7 +276,9 @@ func main() { automation.SetSystemUserID(systemUser.ID) conversation.SetAIAgent(aiAgent) - startInboxes(ctx, inbox, conversation, user, conversation.SignAvatarURL) + waClient := initWhatsAppClient() + waTemplates := initWhatsAppTemplates(db, i18n, waClient, inbox) + conversation.SetWhatsAppTemplateStore(waTemplates) go automation.Run(ctx, automationWorkers) go autoassigner.Run(ctx, autoAssignInterval) @@ -329,11 +338,25 @@ func main() { redis: rdb, fc: initFastCache(rdb), userNotification: userNotification, + whatsappClient: waClient, + whatsappTemplate: waTemplates, wsHub: wsHub, } app.consts.Store(constants) helpCenterCacheOpts.Logger = log.New(helpCenterCacheLogWriter{lo: app.lo}, "", 0) + whatsappIngester, err := newWhatsAppIngester(app) + if err != nil { + log.Fatalf("error initializing whatsapp ingester: %v", err) + } + app.whatsappIngester = whatsappIngester + go app.whatsappIngester.Run() + waClient.SetAuthErrorHook(makeWhatsAppAuthErrorHook(app)) + + startInboxes(ctx, inbox, conversation, user, conversation.SignAvatarURL, waClient, conversation, makeInboxAuthStatusHook(app)) + + go whatsappTemplateSyncWorker(ctx, app) + g := fastglue.NewGlue() g.SetContext(app) initHandlers(g, wsHub) @@ -395,6 +418,10 @@ func main() { notifier.Close() colorlog.Red("Shutting down webhook...") webhook.Close() + if app.whatsappIngester != nil { + colorlog.Red("Shutting down whatsapp ingester...") + app.whatsappIngester.Close() + } colorlog.Red("Shutting down conversation...") conversation.Close() colorlog.Red("Shutting down SLA...") diff --git a/cmd/media.go b/cmd/media.go index c3483bd53..292fb7470 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -126,26 +126,24 @@ func handleMediaUpload(r *fastglue.Request) error { file.Seek(0, 0) thumbFile, err := image.CreateThumb(image.DefThumbSize, file) if err != nil { - app.lo.Error("error creating thumb image", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) - } - thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile) - if err != nil { - return sendErrorEnvelope(r, err) + app.lo.Warn("skipping thumbnail, unsupported image format", "error", err) + } else { + thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile) + if err != nil { + return sendErrorEnvelope(r, err) + } } - // Store image dimensions in media meta, storing dimensions for image previews in future. file.Seek(0, 0) width, height, err := image.GetDimensions(file) if err != nil { - cleanUp = true - app.lo.Error("error getting image dimensions", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorUploadingFile"), nil, envelope.GeneralError) + app.lo.Warn("skipping image dimensions, unsupported image format", "error", err) + } else { + meta, _ = json.Marshal(map[string]any{ + "width": width, + "height": height, + }) } - meta, _ = json.Marshal(map[string]interface{}{ - "width": width, - "height": height, - }) } // Reset ptr. diff --git a/cmd/messages.go b/cmd/messages.go index 147cb0818..10f945da8 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -22,6 +22,10 @@ type messageReq struct { SenderType string `json:"sender_type"` Mentions []cmodels.MentionInput `json:"mentions"` EchoID string `json:"echo_id"` + + // WhatsApp-only. Set TemplateID to send an approved template; omit for free-form. + WhatsAppTemplateID int `json:"whatsapp_template_id,omitempty"` + WhatsAppTemplateParams map[string]string `json:"whatsapp_template_params,omitempty"` } // handleGetMessages returns messages for a conversation. @@ -163,6 +167,7 @@ func handleRetryMessage(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + if msg.SenderType != cmodels.SenderTypeAgent || msg.Status != cmodels.MessageStatusFailed || msg.SenderID != user.ID || msg.ConversationUUID != cuuid { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError) } @@ -267,6 +272,12 @@ func handleSendMessage(r *fastglue.Request) error { if req.EchoID != "" { meta["echo_id"] = req.EchoID } + if req.WhatsAppTemplateID > 0 { + meta["whatsapp_template_id"] = req.WhatsAppTemplateID + } + if len(req.WhatsAppTemplateParams) > 0 { + meta["whatsapp_template_params"] = req.WhatsAppTemplateParams + } message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, meta) if err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/settings.go b/cmd/settings.go index 6cb48c8f2..d55082cd3 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -58,9 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { // Trim whitespace and trailing slash from root URL. req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/") - // Get current language before update. app.Lock() oldLang := ko.String("app.lang") + oldRootURL := ko.String("app.root_url") app.Unlock() if err := app.setting.Update(req); err != nil { @@ -86,6 +86,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { app.lo.Error("error reloading templates", "error", err) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } + + if strings.TrimRight(oldRootURL, "/") != req.RootURL { + go reconcileWhatsAppRootURL(app) + } + return r.SendEnvelope(true) } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 30d6229b6..f33f2f370 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -47,6 +47,7 @@ var migList = []migFunc{ {"v2.5.0", migrations.V2_5_0}, {"v2.6.0", migrations.V2_6_0}, {"v2.8.0", migrations.V2_8_0}, + {"v2.9.0", migrations.V2_9_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/whatsapp_ingester.go b/cmd/whatsapp_ingester.go new file mode 100644 index 000000000..fe5ad5525 --- /dev/null +++ b/cmd/whatsapp_ingester.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/abhinavxd/libredesk/internal/streamqueue" + "github.com/abhinavxd/libredesk/internal/whatsapp" +) + +const ( + whatsAppStream = "libredesk:whatsapp:inbound" + whatsAppStreamGroup = "libredesk" + whatsAppConsumer = "ingester" + + // Must exceed the worst-case media-download budget so the reclaimer never re-runs a still-in-flight delivery. + whatsAppReclaimMinIdle = 5 * time.Minute + + whatsAppEnqueueTimeout = 5 * time.Second +) + +// whatsAppJob is the durable envelope persisted to the stream; Body is the raw Meta POST body, parsed in the worker. +type whatsAppJob struct { + InboxID int `json:"inbox_id"` + Body json.RawMessage `json:"body"` +} + +// WhatsAppIngester is the durable inbound pipeline: a Redis-stream work queue plus per-sender serialization. +type WhatsAppIngester struct { + queue *streamqueue.Queue + sourceLocks *keyedLock +} + +// keyedLock serializes work per string key; entries are refcounted and dropped once the last holder releases. +type keyedLock struct { + mu sync.Mutex + entries map[string]*keyedLockEntry +} + +type keyedLockEntry struct { + mu sync.Mutex + refs int +} + +func newWhatsAppIngester(app *App) (*WhatsAppIngester, error) { + ing := &WhatsAppIngester{ + sourceLocks: &keyedLock{entries: make(map[string]*keyedLockEntry)}, + } + q, err := streamqueue.New(streamqueue.Opts{ + Redis: app.redis, + Logger: app.lo, + Stream: whatsAppStream, + Group: whatsAppStreamGroup, + Consumer: whatsAppConsumer, + Handler: ing.handle(app), + ClaimMinIdle: whatsAppReclaimMinIdle, + }) + if err != nil { + return nil, err + } + ing.queue = q + return ing, nil +} + +// Run consumes the stream until Close is called. +func (i *WhatsAppIngester) Run() { i.queue.Run() } + +// Close stops the queue and waits for in-flight work; un-acked deliveries stay durable for the next start. +func (i *WhatsAppIngester) Close() { i.queue.Close() } + +// Enqueue durably stores a raw webhook body for the inbox. +func (i *WhatsAppIngester) Enqueue(inboxID int, body []byte) error { + data, err := json.Marshal(whatsAppJob{InboxID: inboxID, Body: json.RawMessage(body)}) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), whatsAppEnqueueTimeout) + defer cancel() + return i.queue.Enqueue(ctx, data) +} + +// lockSender blocks until the per-sender-phone lock is held, returning the release func. +func (i *WhatsAppIngester) lockSender(from string) func() { + return i.sourceLocks.lock(from) +} + +// handle returns nil for an unparseable job so it is dropped rather than retried forever; a processing error keeps the entry pending for retry. +func (i *WhatsAppIngester) handle(app *App) streamqueue.Handler { + return func(ctx context.Context, payload []byte) error { + var job whatsAppJob + if err := json.Unmarshal(payload, &job); err != nil { + app.lo.Error("error decoding whatsapp stream job, dropping", "error", err) + return nil + } + parsed, err := whatsapp.ParsePayload(job.Body) + if err != nil { + app.lo.Error("error parsing whatsapp webhook payload from stream, dropping", "inbox_id", job.InboxID, "error", err) + return nil + } + return processWhatsAppPayload(ctx, app, job.InboxID, parsed) + } +} + +func (k *keyedLock) lock(key string) func() { + k.mu.Lock() + e := k.entries[key] + if e == nil { + e = &keyedLockEntry{} + k.entries[key] = e + } + e.refs++ + k.mu.Unlock() + + e.mu.Lock() + + return func() { + e.mu.Unlock() + k.mu.Lock() + e.refs-- + if e.refs == 0 { + delete(k.entries, key) + } + k.mu.Unlock() + } +} diff --git a/cmd/whatsapp_ingester_test.go b/cmd/whatsapp_ingester_test.go new file mode 100644 index 000000000..88f5a8820 --- /dev/null +++ b/cmd/whatsapp_ingester_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/abhinavxd/libredesk/internal/whatsapp" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" + "github.com/zerodha/logf" +) + +func TestEnqueueAndConsume(t *testing.T) { + app, mr := testIngesterApp(t) + ing, err := newWhatsAppIngester(app) + if err != nil { + t.Fatalf("newWhatsAppIngester: %v", err) + } + + body := []byte(`{"object":"whatsapp_business_account","entry":[]}`) + if err := ing.Enqueue(9, body); err != nil { + t.Fatalf("enqueue: %v", err) + } + if got := mr.Exists(whatsAppStream); !got { + t.Fatal("expected the delivery to be persisted to the stream") + } + + stream, err := mr.Stream(whatsAppStream) + if err != nil { + t.Fatalf("reading the stream: %v", err) + } + if len(stream) != 1 { + t.Fatalf("expected one entry, got %d", len(stream)) + } + var payload whatsAppJob + if err := json.Unmarshal([]byte(stream[0].Values[1]), &payload); err != nil { + t.Fatalf("unmarshal job: %v", err) + } + if payload.InboxID != 9 || string(payload.Body) != string(body) { + t.Fatalf("unexpected job: %+v", payload) + } +} + +func TestHandleDropsUnusableJobs(t *testing.T) { + app, _ := testIngesterApp(t) + ing, err := newWhatsAppIngester(app) + if err != nil { + t.Fatalf("newWhatsAppIngester: %v", err) + } + handler := ing.handle(app) + + if err := handler(t.Context(), []byte("not json")); err != nil { + t.Fatalf("an unparseable job must be dropped, got %v", err) + } + job, _ := json.Marshal(whatsAppJob{InboxID: 1, Body: json.RawMessage(`"not a payload"`)}) + if err := handler(t.Context(), job); err != nil { + t.Fatalf("an unparseable webhook body must be dropped, got %v", err) + } +} + +func TestIngesterRunAndClose(t *testing.T) { + app, _ := testIngesterApp(t) + ing, err := newWhatsAppIngester(app) + if err != nil { + t.Fatalf("newWhatsAppIngester: %v", err) + } + + done := make(chan struct{}) + go func() { + ing.Run() + close(done) + }() + ing.Close() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after Close") + } +} + +// Two deliveries from the same sender must not be ingested at once, or both create a conversation. +func TestLockSenderSerializesPerSender(t *testing.T) { + app, _ := testIngesterApp(t) + ing, err := newWhatsAppIngester(app) + if err != nil { + t.Fatalf("newWhatsAppIngester: %v", err) + } + + var ( + inFlight atomic.Int32 + overlaps atomic.Int32 + wg sync.WaitGroup + ) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + unlock := ing.lockSender("919876543210") + defer unlock() + if inFlight.Add(1) > 1 { + overlaps.Add(1) + } + time.Sleep(2 * time.Millisecond) + inFlight.Add(-1) + }() + } + wg.Wait() + + if overlaps.Load() != 0 { + t.Fatalf("expected no overlapping work per sender, got %d", overlaps.Load()) + } +} + +// A slow media download for one sender must not stall every other sender. +func TestLockSenderAllowsOtherSenders(t *testing.T) { + app, _ := testIngesterApp(t) + ing, err := newWhatsAppIngester(app) + if err != nil { + t.Fatalf("newWhatsAppIngester: %v", err) + } + + release := ing.lockSender("919876543210") + defer release() + + done := make(chan struct{}) + go func() { + ing.lockSender("911111111111")() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("a second sender was blocked by the first") + } +} + +func TestLockSenderReleasesEntries(t *testing.T) { + locks := &keyedLock{entries: make(map[string]*keyedLockEntry)} + for _, sender := range []string{"a", "b", "c"} { + locks.lock(sender)() + } + locks.mu.Lock() + defer locks.mu.Unlock() + if len(locks.entries) != 0 { + t.Fatalf("expected the table to be empty, got %d entries", len(locks.entries)) + } +} + +func TestBuildInboundMeta(t *testing.T) { + app, _ := testIngesterApp(t) + + if got := buildInboundMeta(app, parsedMessage("text")); got != nil { + t.Fatalf("expected no meta for an ordinary message, got %s", got) + } + got := buildInboundMeta(app, parsedMessage("unsupported")) + var out map[string]any + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("unmarshal meta: %v", err) + } + if out["wa_unsupported"] != true { + t.Fatalf("unexpected meta: %s", got) + } +} + +func TestInboxIDFromPath(t *testing.T) { + tests := []struct { + name string + value any + want int + wantErr bool + }{ + {"numeric", "15", 15, false}, + {"not a number", "abc", 0, true}, + {"empty", "", 0, true}, + {"missing", nil, 0, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := &fastglue.Request{RequestCtx: &fasthttp.RequestCtx{}} + if tc.value != nil { + r.RequestCtx.SetUserValue("inbox_id", tc.value) + } + got, err := inboxIDFromPath(r) + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error, got %d", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("expected %d, got %d", tc.want, got) + } + }) + } +} + +func testIngesterApp(t *testing.T) (*App, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + lo := logf.New(logf.Opts{Level: logf.FatalLevel}) + app := &App{lo: &lo, redis: redis.NewClient(&redis.Options{Addr: mr.Addr()})} + return app, mr +} + +func parsedMessage(typ string) whatsapp.ParsedMessage { + return whatsapp.ParsedMessage{ID: "wamid.X", From: "919876543210", Type: typ} +} diff --git a/cmd/whatsapp_template.go b/cmd/whatsapp_template.go new file mode 100644 index 000000000..64e85e442 --- /dev/null +++ b/cmd/whatsapp_template.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/abhinavxd/libredesk/internal/envelope" + whatsappChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/whatsapp" + "github.com/abhinavxd/libredesk/internal/whatsapp" + wtmodels "github.com/abhinavxd/libredesk/internal/whatsapp_template/models" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +const ( + whatsAppTemplateSyncInterval = 6 * time.Hour + whatsAppTemplateSyncTimeout = 2 * time.Minute +) + +func whatsappTemplateSyncWorker(ctx context.Context, app *App) { + initial := time.NewTimer(2 * time.Minute) + defer initial.Stop() + select { + case <-ctx.Done(): + return + case <-initial.C: + syncAllWhatsAppTemplates(ctx, app) + } + + ticker := time.NewTicker(whatsAppTemplateSyncInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + syncAllWhatsAppTemplates(ctx, app) + } + } +} + +func syncAllWhatsAppTemplates(ctx context.Context, app *App) { + if app.whatsappTemplate == nil { + return + } + inboxes, err := app.inbox.GetAll() + if err != nil { + return + } + for _, rec := range inboxes { + if rec.Channel != whatsappChannel.ChannelWhatsApp || !rec.Enabled { + continue + } + syncCtx, cancel := context.WithTimeout(ctx, whatsAppTemplateSyncTimeout) + if _, err := app.whatsappTemplate.SyncFromMeta(syncCtx, rec.ID); err != nil { + app.lo.Warn("periodic whatsapp template sync failed", "inbox_id", rec.ID, "error", err) + cancel() + continue + } + cancel() + // Must follow the sync: an edit skipped during review only applies once the status is fresh. + ensureWhatsAppCSATTemplate(app, rec.ID) + } +} + +// makeWhatsAppAuthErrorHook flags the matching inbox when Meta rejects its token. +func makeWhatsAppAuthErrorHook(app *App) func(acc whatsapp.Account) { + return func(acc whatsapp.Account) { + inboxes, err := app.inbox.GetAll() + if err != nil { + return + } + for _, rec := range inboxes { + if rec.Channel != whatsappChannel.ChannelWhatsApp { + continue + } + var cfg whatsappChannel.Config + if err := json.Unmarshal(rec.Config, &cfg); err != nil || cfg.PhoneNumberID != acc.PhoneNumberID { + continue + } + if _, flagged := app.inboxAuthErrors.LoadOrStore(rec.ID, time.Now()); !flagged { + app.lo.Error("whatsapp access token rejected by meta, sends and media downloads will fail until the token is replaced", "inbox_id", rec.ID) + } + return + } + } +} + +// handleListWhatsAppTemplates lists templates for a given inbox (?inbox_id=123). +func handleListWhatsAppTemplates(r *fastglue.Request) error { + app := r.Context.(*App) + if whatsAppTemplateUnavailable(r, app) { + return nil + } + inboxIDRaw := string(r.RequestCtx.QueryArgs().Peek("inbox_id")) + inboxID, err := strconv.Atoi(inboxIDRaw) + if err != nil || inboxID == 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id is required", nil, envelope.InputError) + } + templates, err := app.whatsappTemplate.GetByInbox(inboxID) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(templates) +} + +func handleGetWhatsAppTemplate(r *fastglue.Request) error { + app := r.Context.(*App) + if whatsAppTemplateUnavailable(r, app) { + return nil + } + id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "invalid id", nil, envelope.InputError) + } + t, err := app.whatsappTemplate.GetByID(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(t) +} + +// handleCreateWhatsAppTemplate stores a new template and submits it to Meta. +func handleCreateWhatsAppTemplate(r *fastglue.Request) error { + app := r.Context.(*App) + if whatsAppTemplateUnavailable(r, app) { + return nil + } + var t wtmodels.Template + if err := r.Decode(&t, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "invalid request", nil, envelope.InputError) + } + if t.InboxID == 0 || t.Name == "" || t.Language == "" || t.Category == "" || t.BodyContent == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id, name, language, category, body_content are required", nil, envelope.InputError) + } + ctx, cancel := context.WithTimeout(r.RequestCtx, whatsappChannel.MetaCallTimeout) + defer cancel() + created, err := app.whatsappTemplate.Create(ctx, t) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(created) +} + +// handleDeleteWhatsAppTemplate removes a template locally and on Meta. +func handleDeleteWhatsAppTemplate(r *fastglue.Request) error { + app := r.Context.(*App) + if whatsAppTemplateUnavailable(r, app) { + return nil + } + id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "invalid id", nil, envelope.InputError) + } + ctx, cancel := context.WithTimeout(r.RequestCtx, whatsappChannel.MetaCallTimeout) + defer cancel() + if err := app.whatsappTemplate.Delete(ctx, id); err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(map[string]string{"status": "deleted"}) +} + +func handleSyncWhatsAppTemplates(r *fastglue.Request) error { + app := r.Context.(*App) + if whatsAppTemplateUnavailable(r, app) { + return nil + } + inboxIDRaw := string(r.RequestCtx.QueryArgs().Peek("inbox_id")) + inboxID, err := strconv.Atoi(inboxIDRaw) + if err != nil || inboxID == 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id is required", nil, envelope.InputError) + } + ctx, cancel := context.WithTimeout(r.RequestCtx, whatsAppTemplateSyncTimeout) + defer cancel() + count, err := app.whatsappTemplate.SyncFromMeta(ctx, inboxID) + if err != nil { + app.lo.Error("error syncing whatsapp templates", "inbox_id", inboxID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadGateway, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) + } + return r.SendEnvelope(map[string]int{"synced": count}) +} + +func whatsAppTemplateUnavailable(r *fastglue.Request, app *App) bool { + if app.whatsappTemplate != nil { + return false + } + r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp not configured", nil, envelope.GeneralError) + return true +} diff --git a/cmd/whatsapp_webhook.go b/cmd/whatsapp_webhook.go new file mode 100644 index 000000000..e9ea29c0d --- /dev/null +++ b/cmd/whatsapp_webhook.go @@ -0,0 +1,596 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/abhinavxd/libredesk/internal/attachment" + "github.com/abhinavxd/libredesk/internal/conversation" + cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" + "github.com/abhinavxd/libredesk/internal/envelope" + whatsappChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/whatsapp" + imodels "github.com/abhinavxd/libredesk/internal/inbox/models" + umodels "github.com/abhinavxd/libredesk/internal/user/models" + "github.com/abhinavxd/libredesk/internal/whatsapp" + wtmodels "github.com/abhinavxd/libredesk/internal/whatsapp_template/models" + "github.com/valyala/fasthttp" + "github.com/volatiletech/null/v9" + "github.com/zerodha/fastglue" +) + +const ( + whatsAppDefaultContactName = "Contact" + // Retry window for a status whose message row is missing; older events reference a wamid that will never exist locally. + whatsAppStatusNotFoundGrace = 10 * time.Minute +) + +var ( + errNoEnabledWhatsAppInbox = errors.New("no enabled whatsapp inbox for event") + + // whatsAppConversationLocks serializes the open-conversation lookup + create per contact and inbox, across webhook ingest and agent-initiated creates. + whatsAppConversationLocks = &keyedLock{entries: make(map[string]*keyedLockEntry)} +) + +func lockWhatsAppConversation(contactID, inboxID int) func() { + return whatsAppConversationLocks.lock(strconv.Itoa(contactID) + ":" + strconv.Itoa(inboxID)) +} + +func handleWhatsAppWebhookVerify(r *fastglue.Request) error { + app := r.Context.(*App) + + inboxID, err := inboxIDFromPath(r) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "invalid inbox id", nil, envelope.InputError) + } + + mode := string(r.RequestCtx.QueryArgs().Peek("hub.mode")) + token := string(r.RequestCtx.QueryArgs().Peek("hub.verify_token")) + challenge := string(r.RequestCtx.QueryArgs().Peek("hub.challenge")) + + if mode != "subscribe" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "invalid hub.mode", nil, envelope.InputError) + } + + cfg, err := whatsAppConfigForInbox(app, inboxID) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, "inbox not found", nil, envelope.NotFoundError) + } + + if cfg.WebhookVerifyToken == "" || token != cfg.WebhookVerifyToken { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, "verify token mismatch", nil, envelope.PermissionError) + } + + r.RequestCtx.SetStatusCode(fasthttp.StatusOK) + r.RequestCtx.SetBodyString(challenge) + return nil +} + +func handleWhatsAppWebhookEvent(r *fastglue.Request) error { + app := r.Context.(*App) + + inboxID, err := inboxIDFromPath(r) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "invalid inbox id", nil, envelope.InputError) + } + + body := append([]byte(nil), r.RequestCtx.PostBody()...) + + cfg, err := whatsAppConfigForInbox(app, inboxID) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, "inbox not found", nil, envelope.NotFoundError) + } + appSecret := cfg.AppSecret + if appSecret == "" { + app.lo.Error("whatsapp webhook rejected: app secret not configured", "inbox_id", inboxID) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, "webhook app secret not configured", nil, envelope.PermissionError) + } + signature := string(r.RequestCtx.Request.Header.Peek("X-Hub-Signature-256")) + if !whatsapp.VerifySignature(body, signature, appSecret) { + app.lo.Warn("whatsapp webhook signature verification failed", "inbox_id", inboxID) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, "invalid signature", nil, envelope.PermissionError) + } + + if app.whatsappIngester == nil { + app.lo.Error("whatsapp ingester not initialized", "inbox_id", inboxID) + return r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "whatsapp ingester unavailable", nil, envelope.GeneralError) + } + if err := app.whatsappIngester.Enqueue(inboxID, body); err != nil { + app.lo.Error("error enqueuing whatsapp webhook to durable stream, asking meta to retry", "inbox_id", inboxID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusServiceUnavailable, "busy, retry shortly", nil, envelope.GeneralError) + } + + return r.SendEnvelope(map[string]string{"status": "ok"}) +} + +// processWhatsAppPayload applies every message/status/template event in one delivery; returning an error retries the whole delivery. +func processWhatsAppPayload(ctx context.Context, app *App, inboxID int, payload *whatsapp.WebhookPayload) error { + var errs []error + + for _, msg := range payload.ExtractMessages() { + if err := ingestWhatsAppMessage(ctx, app, inboxID, msg); err != nil { + app.lo.Error("error ingesting whatsapp message", "inbox_id", inboxID, "wa_message_id", msg.ID, "error", err) + errs = append(errs, err) + } + } + + for _, st := range payload.ExtractStatuses() { + if err := app.conversation.ApplyWhatsAppStatus(st.MessageID, st.Status, st.Timestamp, st.UserMsg); err != nil { + if errors.Is(err, conversation.ErrMessageNotFound) && time.Since(st.Timestamp) > whatsAppStatusNotFoundGrace { + app.lo.Warn("dropping whatsapp status for unknown message", "wa_message_id", st.MessageID, "status", st.Status, "event_at", st.Timestamp) + } else { + app.lo.Error("error applying whatsapp status update", "wa_message_id", st.MessageID, "status", st.Status, "error", err) + errs = append(errs, err) + } + } + } + + templateUpdates := payload.ExtractTemplateStatusUpdates() + if app.whatsappTemplate != nil && len(templateUpdates) > 0 { + wabaInboxes := whatsAppInboxIDsByWABA(app) + for _, ts := range templateUpdates { + ids := wabaInboxes[ts.WABAID] + if ts.WABAID == "" { + ids = []int{inboxID} + } + for _, ibID := range ids { + if err := app.whatsappTemplate.HandleStatusUpdate(ibID, ts.MetaTemplateID, ts.TemplateName, ts.Language, ts.Event, ts.Reason); err != nil { + app.lo.Error("error applying template status update", "inbox_id", ibID, "name", ts.TemplateName, "event", ts.Event, "error", err) + errs = append(errs, err) + continue + } + // An edit skipped while the template was under review can now be applied. + if ts.TemplateName == wtmodels.CSATTemplateName(ibID) { + go ensureWhatsAppCSATTemplate(app, ibID) + } + } + } + } + + return errors.Join(errs...) +} + +func ingestWhatsAppMessage(ctx context.Context, app *App, inboxID int, m whatsapp.ParsedMessage) error { + if m.ID == "" || m.From == "" { + return fmt.Errorf("missing message id or sender") + } + + // Reactions and sync/welcome events would otherwise land as placeholder rows and reset the 24h window. + switch m.Type { + case "reaction", "ephemeral", "request_welcome": + return nil + case "system": + return applyWhatsAppSystemEvent(app, m) + } + + // Meta posts all events of an app to one callback URL, so the URL's inbox ID is not authoritative. + inbRec, cfg, err := resolveWhatsAppInbox(app, inboxID, m.PhoneNumberID) + if errors.Is(err, errNoEnabledWhatsAppInbox) { + // Retrying cannot fix a disabled/unmatched inbox, so drop instead of poisoning the delivery. + app.lo.Warn("dropping whatsapp message: no enabled inbox for event", "url_inbox_id", inboxID, "phone_number_id", m.PhoneNumberID, "wa_message_id", m.ID) + return nil + } + if err != nil { + return fmt.Errorf("resolving inbox: %w", err) + } + inboxID = inbRec.ID + + app.lo.Debug("ingesting whatsapp message", "wa_message_id", m.ID, "type", m.Type, "media_id", m.MediaID, "mime", m.MediaMimeType, "context_id", m.ContextID) + + // Skip the media download up front when the message is already ingested (retries, Meta redeliveries). + if exists, err := app.conversation.MessageExists(m.ID); err != nil { + return fmt.Errorf("checking duplicate: %w", err) + } else if exists { + return nil + } + + // Download media before taking the per-sender lock; a slow CDN must not stall other senders' workers. + attachments, err := fetchWhatsAppAttachments(ctx, app, cfg, m) + if err != nil { + return fmt.Errorf("downloading whatsapp media: %w", err) + } + if ctx.Err() != nil { + return ctx.Err() + } + + // Serializing per sender keeps duplicate deliveries and concurrent messages from double-creating rows. + if app.whatsappIngester != nil { + unlock := app.whatsappIngester.lockSender(m.From) + defer unlock() + } + + if exists, err := app.conversation.MessageExists(m.ID); err != nil { + return fmt.Errorf("checking duplicate: %w", err) + } else if exists { + return nil + } + + contactID, err := upsertWhatsAppContact(app, m) + if err != nil { + return fmt.Errorf("resolving contact: %w", err) + } + + defer lockWhatsAppConversation(contactID, inboxID)() + + isNewConversation := false + conversationID, conversationUUID, err := app.conversation.GetLatestOpenConversationForContact(contactID, inboxID) + if errors.Is(err, sql.ErrNoRows) && inbRec.ReopenWindowHours > 0 { + // Reuse a recently-resolved conversation; the message insert hook reopens it. + conversationID, conversationUUID, err = app.conversation.GetReopenableConversationForContact(contactID, inboxID, inbRec.ReopenWindowHours) + } + if errors.Is(err, sql.ErrNoRows) { + conversationID, conversationUUID, err = app.conversation.CreateConversation( + contactID, + inboxID, + textPreview(m), + time.Now(), + "", + false, + nil, + nil, + 0, + 0, + ) + if err != nil { + return fmt.Errorf("creating conversation: %w", err) + } + isNewConversation = true + } else if err != nil { + return fmt.Errorf("looking up conversation: %w", err) + } + + content, contentType := textPreview(m), cmodels.ContentTypeText + + // The "[image]"-style placeholder only stays when the media download failed. + if m.Text == "" && m.Caption == "" && len(attachments) > 0 { + content = "" + } + + msg := cmodels.Message{ + Channel: whatsappChannel.ChannelWhatsApp, + ConversationID: conversationID, + ConversationUUID: conversationUUID, + SenderID: contactID, + SenderType: cmodels.SenderTypeContact, + Type: cmodels.MessageIncoming, + Status: cmodels.MessageStatusReceived, + InboxID: inboxID, + Content: content, + ContentType: contentType, + SourceID: null.StringFrom(m.ID), + Attachments: attachments, + Meta: buildInboundMeta(app, m), + } + + if _, err := app.conversation.ProcessIncomingWhatsAppMessage(msg, isNewConversation, m.Timestamp); err != nil { + return fmt.Errorf("processing whatsapp message: %w", err) + } + return nil +} + +// applyWhatsAppSystemEvent repoints a contact's wa_id when the customer moves to a new number, so their replies keep threading to the same contact. +func applyWhatsAppSystemEvent(app *App, m whatsapp.ParsedMessage) error { + if m.SystemType != "user_changed_number" || m.SystemNewWAID == "" || m.SystemNewWAID == m.From { + return nil + } + contactID, err := app.user.UpdateChannelIdentity(whatsappChannel.ChannelWhatsApp, m.From, m.SystemNewWAID) + if err != nil { + return fmt.Errorf("repointing whatsapp identity: %w", err) + } + if contactID == 0 { + app.lo.Warn("whatsapp number change not applied, new number already belongs to a contact", "old_wa_id", m.From, "new_wa_id", m.SystemNewWAID) + return nil + } + app.lo.Info("whatsapp contact changed number", "contact_id", contactID, "old_wa_id", m.From, "new_wa_id", m.SystemNewWAID) + return nil +} + +// buildInboundMeta returns nil when there's nothing to record. +func buildInboundMeta(app *App, m whatsapp.ParsedMessage) json.RawMessage { + patch := map[string]any{} + + if m.Type == "unsupported" { + patch["wa_unsupported"] = true + } + + if len(patch) == 0 { + return nil + } + raw, err := json.Marshal(patch) + if err != nil { + app.lo.Error("error marshalling whatsapp inbound meta", "wa_message_id", m.ID, "error", err) + return nil + } + return raw +} + +// fetchWhatsAppAttachments returns (nil, nil) on a permanent (4xx) failure so a placeholder is stored; any other error propagates for a queue retry. +func fetchWhatsAppAttachments(ctx context.Context, app *App, cfg whatsappChannel.Config, m whatsapp.ParsedMessage) (attachment.Attachments, error) { + if m.MediaID == "" || app.whatsappClient == nil { + return nil, nil + } + acc := cfg.Account() + + var ( + info whatsapp.MediaInfo + body []byte + err error + ) + for attempt := 1; ; attempt++ { + dlCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + info, err = app.whatsappClient.GetMediaURL(dlCtx, acc, m.MediaID) + if err == nil { + body, err = app.whatsappClient.DownloadMedia(dlCtx, acc, info.URL) + } + cancel() + if err == nil { + break + } + if ctx.Err() != nil { + return nil, nil + } + if attempt >= 3 { + if isPermanentMediaError(err) { + app.lo.Warn("whatsapp media permanently unavailable, inserting placeholder", "media_id", m.MediaID, "attempts", attempt, "error", err) + return nil, nil + } + app.lo.Warn("error downloading whatsapp media, will retry job", "media_id", m.MediaID, "attempts", attempt, "error", err) + return nil, err + } + app.lo.Warn("error downloading whatsapp media, retrying", "media_id", m.MediaID, "attempt", attempt, "error", err) + select { + case <-ctx.Done(): + return nil, nil + case <-time.After(2 * time.Second): + } + } + + if len(body) == 0 { + app.lo.Warn("whatsapp media downloaded empty, inserting placeholder", "media_id", m.MediaID, "type", m.Type) + return nil, nil + } + + contentType := info.MimeType + if contentType == "" { + contentType = m.MediaMimeType + } + filename := m.Filename + if filename == "" { + filename = defaultMediaFilename(m.Type, contentType) + } + + return attachment.Attachments{ + attachment.Attachment{ + Name: filename, + ContentType: contentType, + Content: body, + Size: len(body), + Disposition: attachment.DispositionAttachment, + }, + }, nil +} + +func isPermanentMediaError(err error) bool { + var me *whatsapp.MetaAPIError + if errors.As(err, &me) { + // 408 and 429 are 4xx but retryable; 401/403 recover once the operator replaces the token; 5xx are transient. + if me.StatusCode == http.StatusRequestTimeout || me.StatusCode == http.StatusTooManyRequests || + me.StatusCode == http.StatusUnauthorized || me.StatusCode == http.StatusForbidden { + return false + } + return me.StatusCode >= 400 && me.StatusCode < 500 + } + var netErr net.Error + if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.ErrUnexpectedEOF) { + return false + } + return true +} + +func defaultMediaFilename(messageType, mime string) string { + if i := strings.Index(mime, ";"); i >= 0 { + mime = strings.TrimSpace(mime[:i]) + } + ext := "bin" + if i := strings.LastIndex(mime, "/"); i >= 0 && i+1 < len(mime) { + ext = mime[i+1:] + } + switch messageType { + case "image": + return "image." + ext + case "video": + return "video." + ext + case "audio", "voice": + return "audio." + ext + case "document": + return "document." + ext + case "sticker": + return "sticker." + ext + } + return "attachment." + ext +} + +func upsertWhatsAppContact(app *App, m whatsapp.ParsedMessage) (int, error) { + first, last := splitName(m.ContactName) + contact := umodels.User{ + Type: umodels.UserTypeContact, + FirstName: first, + LastName: last, + } + id, err := app.user.UpsertContactByChannelIdentity(whatsappChannel.ChannelWhatsApp, m.From, &contact) + if err != nil { + return 0, err + } + if err := app.user.SetContactPhoneIfMissing(id, m.From, ""); err != nil { + app.lo.Error("error setting whatsapp contact phone", "user_id", id, "error", err) + } + // A contact created from a message without a profile name picks the real name up later. + if m.ContactName != "" { + if err := app.user.UpdateContactNameIfDefault(id, first, last, whatsAppDefaultContactName); err != nil { + app.lo.Error("error updating whatsapp contact name", "user_id", id, "error", err) + } + } + return id, nil +} + +func splitName(name string) (string, string) { + if name == "" { + return whatsAppDefaultContactName, "" + } + first, last, _ := strings.Cut(name, " ") + return first, last +} + +func textPreview(m whatsapp.ParsedMessage) string { + if m.Text != "" { + return m.Text + } + if m.Caption != "" { + return m.Caption + } + switch m.Type { + case "image": + return "[image]" + case "video": + return "[video]" + case "audio", "voice": + return "[audio]" + case "document": + return "[document]" + case "sticker": + return "[sticker]" + case "unsupported": + // Meta refuses to deliver some message types (e.g. animated stickers) to the Cloud API. + return "[unsupported message: not delivered by WhatsApp]" + } + return "[whatsapp message]" +} + +func inboxIDFromPath(r *fastglue.Request) (int, error) { + raw, ok := r.RequestCtx.UserValue("inbox_id").(string) + if !ok || raw == "" { + return 0, fmt.Errorf("missing inbox_id") + } + return strconv.Atoi(raw) +} + +// whatsAppInboxIDsByWABA maps each non-empty WABA id to its enabled WhatsApp inbox IDs. +func whatsAppInboxIDsByWABA(app *App) map[string][]int { + out := map[string][]int{} + forEachEnabledWhatsAppInbox(app, func(rec imodels.Inbox, cfg whatsappChannel.Config) bool { + if cfg.WABAID != "" { + out[cfg.WABAID] = append(out[cfg.WABAID], rec.ID) + } + return true + }) + return out +} + +func resolveWhatsAppInbox(app *App, urlInboxID int, phoneNumberID string) (imodels.Inbox, whatsappChannel.Config, error) { + rec, urlErr := app.inbox.GetDBRecord(urlInboxID) + var cfg whatsappChannel.Config + if urlErr == nil { + cfg, urlErr = whatsAppConfigFromRecord(rec) + } + if urlErr == nil && rec.Enabled && (phoneNumberID == "" || cfg.PhoneNumberID == phoneNumberID) { + return rec, cfg, nil + } + + // A bad URL inbox must not block routing by the payload's phone_number_id. + if phoneNumberID != "" { + var ( + found bool + foundRec imodels.Inbox + foundCfg whatsappChannel.Config + ) + forEachEnabledWhatsAppInbox(app, func(r imodels.Inbox, c whatsappChannel.Config) bool { + if c.PhoneNumberID != phoneNumberID { + return true + } + foundRec, foundCfg, found = r, c, true + return false + }) + if found { + if foundRec.ID != urlInboxID { + app.lo.Info("routing whatsapp message by phone_number_id", "url_inbox_id", urlInboxID, "resolved_inbox_id", foundRec.ID) + } + return foundRec, foundCfg, nil + } + } + + if urlErr != nil { + return imodels.Inbox{}, whatsappChannel.Config{}, urlErr + } + return imodels.Inbox{}, whatsappChannel.Config{}, errNoEnabledWhatsAppInbox +} + +// forEachEnabledWhatsAppInbox invokes fn with each enabled WhatsApp inbox's record and decoded config; returning false stops iteration. +func forEachEnabledWhatsAppInbox(app *App, fn func(rec imodels.Inbox, cfg whatsappChannel.Config) bool) { + inboxes, err := app.inbox.GetAll() + if err != nil { + return + } + for _, rec := range inboxes { + if rec.Channel != whatsappChannel.ChannelWhatsApp || !rec.Enabled { + continue + } + var cfg whatsappChannel.Config + if err := json.Unmarshal(rec.Config, &cfg); err != nil { + app.lo.Warn("skipping whatsapp inbox with unparseable config", "inbox_id", rec.ID, "error", err) + continue + } + if !fn(rec, cfg) { + return + } + } +} + +// whatsAppConfigForInbox prefers the running inbox's in-memory config; the DB fallback covers disabled or unregistered inboxes. +func whatsAppConfigForInbox(app *App, inboxID int) (whatsappChannel.Config, error) { + if inb, err := app.inbox.Get(inboxID); err == nil { + if wa, ok := inb.(interface{ Config() whatsappChannel.Config }); ok { + return wa.Config(), nil + } + } + rec, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + return whatsappChannel.Config{}, err + } + return whatsAppConfigFromRecord(rec) +} + +func whatsAppConfigFromRecord(rec imodels.Inbox) (whatsappChannel.Config, error) { + if rec.Channel != whatsappChannel.ChannelWhatsApp { + return whatsappChannel.Config{}, fmt.Errorf("inbox %d is not a whatsapp inbox", rec.ID) + } + var cfg whatsappChannel.Config + if err := json.Unmarshal(rec.Config, &cfg); err != nil { + return whatsappChannel.Config{}, fmt.Errorf("decoding whatsapp inbox config: %w", err) + } + return cfg, nil +} + +// markWhatsAppMessageRead sends a read receipt to Meta for an inbound message; best-effort, logs and swallows failures. +func markWhatsAppMessageRead(app *App, inboxID int, sourceID string) { + if app.whatsappClient == nil || sourceID == "" { + return + } + cfg, err := whatsAppConfigForInbox(app, inboxID) + if err != nil { + app.lo.Error("error fetching inbox config for whatsapp read receipt", "inbox_id", inboxID, "error", err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), whatsappChannel.MetaCallTimeout) + defer cancel() + if err := app.whatsappClient.MarkRead(ctx, cfg.Account(), sourceID); err != nil { + app.lo.Warn("error marking whatsapp message read", "inbox_id", inboxID, "source_id", sourceID, "error", err) + } +} diff --git a/cmd/whatsapp_webhook_test.go b/cmd/whatsapp_webhook_test.go new file mode 100644 index 000000000..21d4b0c47 --- /dev/null +++ b/cmd/whatsapp_webhook_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/abhinavxd/libredesk/internal/whatsapp" + "github.com/knadh/go-i18n" +) + +func TestTextPreview(t *testing.T) { + tests := []struct { + name string + msg whatsapp.ParsedMessage + want string + }{ + {"text wins", whatsapp.ParsedMessage{Type: "text", Text: "hello"}, "hello"}, + {"caption when there is no text", whatsapp.ParsedMessage{Type: "image", Caption: "the box"}, "the box"}, + {"image placeholder", whatsapp.ParsedMessage{Type: "image"}, "[image]"}, + {"video placeholder", whatsapp.ParsedMessage{Type: "video"}, "[video]"}, + {"audio placeholder", whatsapp.ParsedMessage{Type: "audio"}, "[audio]"}, + {"voice note placeholder", whatsapp.ParsedMessage{Type: "voice"}, "[audio]"}, + {"document placeholder", whatsapp.ParsedMessage{Type: "document"}, "[document]"}, + {"sticker placeholder", whatsapp.ParsedMessage{Type: "sticker"}, "[sticker]"}, + {"unsupported explains itself", whatsapp.ParsedMessage{Type: "unsupported"}, "[unsupported message: not delivered by WhatsApp]"}, + {"unknown type falls back", whatsapp.ParsedMessage{Type: "order"}, "[whatsapp message]"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := textPreview(tc.msg); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestDefaultMediaFilename(t *testing.T) { + tests := []struct { + messageType string + mime string + want string + }{ + {"image", "image/jpeg", "image.jpeg"}, + {"video", "video/mp4", "video.mp4"}, + {"audio", "audio/ogg; codecs=opus", "audio.ogg"}, + {"voice", "audio/ogg", "audio.ogg"}, + {"document", "application/pdf", "document.pdf"}, + {"sticker", "image/webp", "sticker.webp"}, + {"unknown", "application/pdf", "attachment.pdf"}, + {"image", "", "image.bin"}, + } + for _, tc := range tests { + t.Run(tc.messageType+"/"+tc.mime, func(t *testing.T) { + if got := defaultMediaFilename(tc.messageType, tc.mime); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestSplitName(t *testing.T) { + tests := []struct { + in string + first string + last string + }{ + {"Ravi Kumar", "Ravi", "Kumar"}, + {"Ravi", "Ravi", ""}, + {"Ravi Kumar Singh", "Ravi", "Kumar Singh"}, + {"", whatsAppDefaultContactName, ""}, + } + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + first, last := splitName(tc.in) + if first != tc.first || last != tc.last { + t.Fatalf("expected (%q, %q), got (%q, %q)", tc.first, tc.last, first, last) + } + }) + } +} + +// A permanent error stores a placeholder, anything transient is retried. +func TestIsPermanentMediaError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"404 from meta", &whatsapp.MetaAPIError{StatusCode: http.StatusNotFound}, true}, + {"400 from meta", &whatsapp.MetaAPIError{StatusCode: http.StatusBadRequest}, true}, + {"401 is retryable, a replaced token recovers the media", &whatsapp.MetaAPIError{StatusCode: http.StatusUnauthorized}, false}, + {"403 is retryable", &whatsapp.MetaAPIError{StatusCode: http.StatusForbidden}, false}, + {"408 is retryable", &whatsapp.MetaAPIError{StatusCode: http.StatusRequestTimeout}, false}, + {"429 is retryable", &whatsapp.MetaAPIError{StatusCode: http.StatusTooManyRequests}, false}, + {"500 is retryable", &whatsapp.MetaAPIError{StatusCode: http.StatusInternalServerError}, false}, + {"wrapped meta error", fmt.Errorf("downloading: %w", &whatsapp.MetaAPIError{StatusCode: http.StatusGone}), true}, + {"deadline exceeded is retryable", context.DeadlineExceeded, false}, + {"truncated body is retryable", io.ErrUnexpectedEOF, false}, + {"network error is retryable", &net.DNSError{IsTimeout: true}, false}, + {"decode failure is permanent", errors.New("decoding media info"), true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isPermanentMediaError(tc.err); got != tc.want { + t.Fatalf("expected %v, got %v", tc.want, got) + } + }) + } +} + +func TestLocalPhoneNumber(t *testing.T) { + app := testI18nApp(t) + tests := []struct { + name string + phone string + dialCode string + want string + wantErr bool + }{ + {"bare local number", "9876543210", "91", "9876543210", false}, + {"local number with spaces", "98765 43210", "91", "9876543210", false}, + {"plus prefixed", "+919876543210", "91", "9876543210", false}, + {"plus prefixed with spaces", "+91 98765 43210", "91", "9876543210", false}, + {"double zero prefixed", "00919876543210", "91", "9876543210", false}, + {"plus with the wrong country", "+15550001111", "91", "", true}, + {"empty", "", "91", "", true}, + {"punctuation only", "+ - ", "91", "", true}, + // A local number that happens to start with the dial code must not be trimmed. + {"local number starting with the dial code", "9198765432", "91", "9198765432", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := localPhoneNumber(app, tc.phone, tc.dialCode) + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestWhatsAppCallbackURLFromRoot(t *testing.T) { + tests := []struct { + root string + want string + }{ + {"https://desk.example.com", "https://desk.example.com/webhooks/whatsapp/7"}, + {"https://desk.example.com/", "https://desk.example.com/webhooks/whatsapp/7"}, + {"", ""}, + } + for _, tc := range tests { + if got := whatsAppCallbackURLFromRoot(tc.root, 7); got != tc.want { + t.Errorf("%q: expected %q, got %q", tc.root, tc.want, got) + } + } +} + +// Meta has to reach the callback URL, so anything local or plain HTTP must not be auto-registered. +func TestIsPublicWebhookURL(t *testing.T) { + tests := map[string]bool{ + "https://desk.example.com": true, + "https://desk.example.com/": true, + " https://desk.example.com": true, + "http://desk.example.com": false, + "https://localhost:9000": false, + "https://127.0.0.1:9000": false, + "https://[::1]:9000": false, + "https://": false, + "": false, + "not a url": false, + } + for root, want := range tests { + if got := isPublicWebhookURL(root); got != want { + t.Errorf("%q: expected %v, got %v", root, want, got) + } + } +} + +// testI18nApp carries the real language file, so a renamed i18n key fails the test. +func testI18nApp(t *testing.T) *App { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "i18n", "en-US.json")) + if err != nil { + t.Fatalf("reading the language file: %v", err) + } + lang, err := i18n.New(raw) + if err != nil { + t.Fatalf("loading i18n: %v", err) + } + return &App{i18n: lang} +} diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index 71d22f285..4ea25886e 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -412,6 +412,17 @@ const updateInbox = (id, data) => } }) const deleteInbox = (id) => http.delete(`/api/v1/inboxes/${id}`) + +const getWhatsAppTemplates = (inboxId) => + http.get('/api/v1/whatsapp/templates', { params: { inbox_id: inboxId } }) +const getWhatsAppTemplate = (id) => http.get(`/api/v1/whatsapp/templates/${id}`) +const createWhatsAppTemplate = (data) => + http.post('/api/v1/whatsapp/templates', data, { + headers: { 'Content-Type': 'application/json' } + }) +const deleteWhatsAppTemplate = (id) => http.delete(`/api/v1/whatsapp/templates/${id}`) +const syncWhatsAppTemplates = (inboxId) => + http.post(`/api/v1/whatsapp/templates/sync?inbox_id=${inboxId}`, {}) const saveDraft = (uuid, type, data) => http.post(`/api/v1/conversations/${uuid}/draft`, { ...data, type }, { headers: { @@ -698,6 +709,11 @@ export default { updateInbox, deleteInbox, toggleInbox, + getWhatsAppTemplates, + getWhatsAppTemplate, + createWhatsAppTemplate, + deleteWhatsAppTemplate, + syncWhatsAppTemplates, createTeam, updateTeam, getSettings, diff --git a/frontend/apps/main/src/components/icons/WhatsAppIcon.vue b/frontend/apps/main/src/components/icons/WhatsAppIcon.vue new file mode 100644 index 000000000..8bc3cc25b --- /dev/null +++ b/frontend/apps/main/src/components/icons/WhatsAppIcon.vue @@ -0,0 +1,15 @@ + diff --git a/frontend/apps/main/src/components/layout/MenuCard.vue b/frontend/apps/main/src/components/layout/MenuCard.vue index 773f9c049..93728791d 100644 --- a/frontend/apps/main/src/components/layout/MenuCard.vue +++ b/frontend/apps/main/src/components/layout/MenuCard.vue @@ -19,7 +19,7 @@ import { Card } from '@shared-ui/components/ui/card' const props = defineProps({ title: String, subTitle: String, - icon: [Function, String], + icon: [Function, String, Object], onClick: Function, badge: String }) diff --git a/frontend/apps/main/src/components/sidebar/Sidebar.vue b/frontend/apps/main/src/components/sidebar/Sidebar.vue index 922366ff4..e1e7a32d4 100644 --- a/frontend/apps/main/src/components/sidebar/Sidebar.vue +++ b/frontend/apps/main/src/components/sidebar/Sidebar.vue @@ -4,7 +4,7 @@ import { reportsNavItems, accountNavItems, contactNavItems -} from '../../constants/navigation' +} from '@main/constants/navigation' import { useRoute, useRouter } from 'vue-router' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@shared-ui/components/ui/collapsible' import { Badge } from '@shared-ui/components/ui/badge' @@ -61,6 +61,7 @@ import { Lightbulb, BookOpen } from 'lucide-vue-next' +import WhatsAppIcon from '@main/components/icons/WhatsAppIcon.vue' const navIconMap = { Settings, @@ -68,6 +69,7 @@ const navIconMap = { Timer, Inbox: InboxIcon, CircleDot, + WhatsApp: WhatsAppIcon, Tag, SlidersHorizontal, Eye, diff --git a/frontend/apps/main/src/constants/emitterEvents.js b/frontend/apps/main/src/constants/emitterEvents.js index 8cd8bcb66..69f7847ff 100644 --- a/frontend/apps/main/src/constants/emitterEvents.js +++ b/frontend/apps/main/src/constants/emitterEvents.js @@ -7,5 +7,6 @@ export const EMITTER_EVENTS = { SET_NESTED_COMMAND: 'set-nested-command', CONVERSATION_SIDEBAR_TOGGLE: 'conversation-sidebar-toggle', SCROLL_TO_MESSAGE: 'scroll-to-message', + WHATSAPP_TEMPLATE_PICKER_OPEN: 'whatsapp-template-picker-open', COPILOT_INSERT_REPLY: 'copilot-insert-reply' } \ No newline at end of file diff --git a/frontend/apps/main/src/constants/navigation.js b/frontend/apps/main/src/constants/navigation.js index b3d043a49..f77ea792e 100644 --- a/frontend/apps/main/src/constants/navigation.js +++ b/frontend/apps/main/src/constants/navigation.js @@ -96,6 +96,12 @@ export const adminNavItems = [ permission: 'inboxes:manage', isTitleKeyPlural: true, icon: 'Inbox' + }, + { + titleKey: 'admin.whatsappTemplates.title', + href: '/admin/whatsapp/templates', + permission: 'inboxes:manage', + icon: 'WhatsApp' } ] }, diff --git a/frontend/apps/main/src/features/admin/ai/ToolForm.vue b/frontend/apps/main/src/features/admin/ai/ToolForm.vue index 810bdfdc5..83e70a55d 100644 --- a/frontend/apps/main/src/features/admin/ai/ToolForm.vue +++ b/frontend/apps/main/src/features/admin/ai/ToolForm.vue @@ -59,7 +59,7 @@ - {{ t('admin.ai.tool.headers') }} + {{ t('globals.terms.header', 2) }} - {{ t('helpCenter.headerText') }} + {{ t('globals.terms.headerText') }} diff --git a/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue b/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue index def205d10..75960d85d 100644 --- a/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue +++ b/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue @@ -1,5 +1,13 @@