feat(public): implement chatwoot public inbox flow
This commit is contained in:
@@ -20,6 +20,7 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
|
||||
- Route dump succeeds with `TOTAL: 791`.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
- Tracked frontend-critical route audit covers 251 Chatwoot routes: 251 exact, 0 method-compatible, 0 parameter-compatible, 0 missing.
|
||||
- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers.
|
||||
- Handler test stability fixes are committed into the baseline before feature parity work continues.
|
||||
- `.codegraph/` is generated indexing output and is not part of tracked product code.
|
||||
|
||||
@@ -30,10 +31,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
|
||||
| Phase 0 | Test and route baseline | Done | none |
|
||||
| Phase 1 | Meilisearch search engine | Review | live Meilisearch integration and document-shape parity still need reference verification |
|
||||
| Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback |
|
||||
| Phase 3 | Data and serializer parity | Planned | needs route-gap priorities from Phase 2 |
|
||||
| Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based |
|
||||
| Phase 4 | Enterprise feature completion | Planned | excluded SSO family must stay out of scope; all other enterprise features remain included |
|
||||
| Phase 5 | Background jobs and integrations | Planned | durable worker choice and job parity are open |
|
||||
| Phase 6 | Core placeholder burn-down | Planned | placeholders must be converted into real Chatwoot-compatible behavior |
|
||||
| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad |
|
||||
| Phase 7 | Verification harness | Planned | route/JSON/frontend smoke harness not complete |
|
||||
|
||||
## Tracking Artifacts
|
||||
@@ -401,9 +402,20 @@ Tracking table:
|
||||
| P6.3 | Conversation APIs | `docs/ROUTE_GAP_ANALYSIS.md`, conversation handlers/services | Implement frontend-critical filters, assignment, status, snooze, merge, bulk actions. | Todo |
|
||||
| P6.4 | Message APIs | `docs/ROUTE_GAP_ANALYSIS.md`, message handlers/services | Implement create/list/delete, private notes, attachments, source attribution, events. | Todo |
|
||||
| P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. | Todo |
|
||||
| P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Implement init/config, campaigns, events, contact, conversations, messages, cable token, direct uploads, public inbox flows, and public CSAT with Chatwoot contracts. | Doing |
|
||||
| P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Finish direct uploads and deeper public CSAT parity; public inbox/contact/conversation/message core flow is now handler-backed. | Doing |
|
||||
| P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Todo |
|
||||
|
||||
Widget/public subtracking:
|
||||
|
||||
| ID | Slice | Reference behavior | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| P6.6a | `/api/v1/widget` config, message send/list, contact, conversations, cable token | `reference/chatwoot/app/controllers/api/v1/widget/*` and widget SDK clients | Done |
|
||||
| P6.6b | `/api/v1/widget` campaigns, events, inbox members, labels | Chatwoot widget campaigns/events/labels controllers and serializers | Done |
|
||||
| P6.6c | `/api/v1/widget` message update, transcript, `contact/set_user`, Dyte participant | Chatwoot widget message/contact/transcript/integration behavior | Done |
|
||||
| P6.6d | `/public/api/v1/inboxes` contact/conversation/message core flow | `reference/chatwoot/app/controllers/public/api/v1/inboxes/*` and matching jbuilder views | Done |
|
||||
| P6.6e | Widget direct uploads and attachments | Chatwoot active storage/direct upload and attachment payloads | Todo |
|
||||
| P6.6f | Public CSAT deep behavior | Chatwoot CSAT survey controller/listener and message locking rules | Todo |
|
||||
|
||||
## Phase 7: Verification Harness
|
||||
|
||||
Status: planned.
|
||||
@@ -465,3 +477,4 @@ Verification milestone gates:
|
||||
- 2026-06-04: Started Phase 3/6 widget behavior parity for the reused Chatwoot frontend. `/api/v1/widget/config` now returns Chatwoot-style `website_channel_config`, contact pubsub token, and global config; `/api/v1/widget/messages` accepts `X-Auth-Token` and nested `message.content`, returns Chatwoot message shape, and exposes latest messages as `{payload, meta}`; `/api/v1/widget/contact` and core conversation actions now route to real handlers instead of parity stubs. Legacy `/widget/*` response compatibility is preserved. Focused widget/service/router tests pass.
|
||||
- 2026-06-04: Continued Phase 3/6 widget behavior parity. Replaced more `/api/v1/widget` stubs with handlers for `campaigns`, `events`, `inbox_members`, `labels`, and label removal. Inbox member payload now follows Chatwoot `{payload: [...]}` shape; campaigns return enabled inbox campaigns with trigger rules; events validate website/contact token context and return `204`; labels mutate the latest widget conversation only when the label exists in the account. Added focused widget handler coverage for available agents, events, and label add/remove. Remaining widget stubs: message update, transcript, `contact/set_user`, and Dyte participant integration; public inbox/contact/conversation/message routes are still placeholder-backed.
|
||||
- 2026-06-04: Completed the remaining `/api/v1/widget` stub burn-down. Message update now persists submitted email/form values and identifies the contact; `contact/set_user` validates identifier HMAC, supports verified contact identification, and returns `widget_auth_token` when the contact context changes; conversation transcript returns Chatwoot-compatible status behavior around missing conversations; Dyte participant endpoint validates integration messages and returns a meeting token payload. Added model/repository support for contact inbox HMAC verification and identifier lookup. Public inbox/contact/conversation/message routes remain the next P6.6 placeholder group.
|
||||
- 2026-06-04: Replaced `/public/api/v1/inboxes` contact/conversation/message placeholders with real Chatwoot public API handlers. API inboxes now resolve through `Channel::Api` identifiers, public contacts create/update by `source_id` with optional identifier HMAC verification, public conversations enforce the same verified-contact visibility split, and public messages support create/list/update submitted values. Added focused public API handler flow coverage. Verified `go test ./...`, regenerated `docs/parity/gochat_routes.txt` (`TOTAL: 791`), and regenerated `docs/parity/route_parity.md` (`251 exact, 0 missing`). Remaining P6.6 work is direct uploads/attachments and deeper public CSAT behavior.
|
||||
|
||||
@@ -735,6 +735,202 @@ func (h *WidgetHandler) AddDyteParticipantToMeeting(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicInboxShow(c *gin.Context) {
|
||||
inbox, identityValidation, err := h.widgetService.PublicGetInbox(c.Request.Context(), c.Param("inbox_id"))
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
payload := publicInboxPayload(inbox)
|
||||
payload["identifier"] = c.Param("inbox_id")
|
||||
payload["identity_validation_enabled"] = identityValidation
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicCreateContact(c *gin.Context) {
|
||||
req, err := bindPublicContactRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicGetContact(c *gin.Context) {
|
||||
resp, err := h.widgetService.PublicGetContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"))
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) {
|
||||
req, err := bindPublicContactRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicListConversations(c *gin.Context) {
|
||||
conversations, err := h.widgetService.PublicListConversations(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"))
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
payload := make([]gin.H, 0, len(conversations))
|
||||
for _, conversation := range conversations {
|
||||
payload = append(payload, publicConversationPayload(conversation, nil))
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicCreateConversation(c *gin.Context) {
|
||||
var req struct {
|
||||
CustomAttributes map[string]any `json:"custom_attributes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength != 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
conversation, err := h.widgetService.PublicCreateConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), service.PublicConversationRequest{CustomAttributes: req.CustomAttributes})
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicConversationPayload(*conversation, nil))
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicGetConversation(c *gin.Context) {
|
||||
conversationID, ok := publicUintParam(c, "conversation_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conversation, err := h.widgetService.PublicGetConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
messages, _, _, _ := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, 0, 100)
|
||||
c.JSON(http.StatusOK, publicConversationPayload(*conversation, messages))
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicToggleStatus(c *gin.Context) {
|
||||
conversationID, ok := publicUintParam(c, "conversation_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conversation, err := h.widgetService.PublicToggleStatus(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicConversationPayload(*conversation, nil))
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicToggleTyping(c *gin.Context) {
|
||||
conversationID, ok := publicUintParam(c, "conversation_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TypingStatus string `json:"typing_status"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.TypingStatus != "on" && req.TypingStatus != "off" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if err := h.widgetService.PublicToggleTyping(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req.TypingStatus != "off"); err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicUpdateLastSeen(c *gin.Context) {
|
||||
conversationID, ok := publicUintParam(c, "conversation_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := h.widgetService.PublicUpdateLastSeen(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID); err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicListMessages(c *gin.Context) {
|
||||
conversationID, ok := publicUintParam(c, "conversation_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "25"))
|
||||
messages, _, conversation, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, offset, limit)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
payload := make([]gin.H, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
payload = append(payload, publicMessagePayload(message, *conversation))
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicCreateMessage(c *gin.Context) {
|
||||
conversationID, ok := publicUintParam(c, "conversation_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := bindPublicMessageRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
message, conversation, err := h.widgetService.PublicCreateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicMessagePayload(*message, *conversation))
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) PublicUpdateMessage(c *gin.Context) {
|
||||
conversationID, ok := publicUintParam(c, "conversation_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
messageID, ok := publicUintParam(c, "message_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := bindPublicMessageRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
message, conversation, err := h.widgetService.PublicUpdateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, messageID, req)
|
||||
if err != nil {
|
||||
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, publicMessagePayload(*message, *conversation))
|
||||
}
|
||||
|
||||
// SubmitOfflineMessage handles a visitor submitting a message when agents are offline.
|
||||
// POST /widget/offline_message
|
||||
// Reference: Chatwoot widget SDK — when business_hours are disabled and no agents online,
|
||||
@@ -863,6 +1059,137 @@ func widgetContactFullPayload(contact *model.Contact) gin.H {
|
||||
}
|
||||
}
|
||||
|
||||
func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, error) {
|
||||
var body struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Identifier string `json:"identifier"`
|
||||
IdentifierHash string `json:"identifier_hash"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
CustomAttributes map[string]any `json:"custom_attributes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 {
|
||||
return service.PublicContactRequest{}, err
|
||||
}
|
||||
if body.SourceID == "" {
|
||||
body.SourceID = c.Query("source_id")
|
||||
}
|
||||
if body.Identifier == "" {
|
||||
body.Identifier = c.Query("identifier")
|
||||
}
|
||||
if body.IdentifierHash == "" {
|
||||
body.IdentifierHash = c.Query("identifier_hash")
|
||||
}
|
||||
return service.PublicContactRequest{
|
||||
SourceID: body.SourceID,
|
||||
Identifier: body.Identifier,
|
||||
IdentifierHash: body.IdentifierHash,
|
||||
Email: body.Email,
|
||||
Name: body.Name,
|
||||
AvatarURL: body.AvatarURL,
|
||||
PhoneNumber: body.PhoneNumber,
|
||||
CustomAttributes: body.CustomAttributes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bindPublicMessageRequest(c *gin.Context) (service.PublicMessageRequest, error) {
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
EchoID string `json:"echo_id"`
|
||||
SubmittedValues []map[string]any `json:"submitted_values"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
return service.PublicMessageRequest{}, err
|
||||
}
|
||||
return service.PublicMessageRequest{
|
||||
Content: body.Content,
|
||||
EchoID: body.EchoID,
|
||||
SubmittedValues: body.SubmittedValues,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func publicInboxPayload(inbox *model.Inbox) gin.H {
|
||||
workingHours := map[string]any{}
|
||||
return gin.H{
|
||||
"name": inbox.Name,
|
||||
"timezone": inbox.Timezone,
|
||||
"working_hours": workingHours,
|
||||
"working_hours_enabled": inbox.WorkingHoursEnabled,
|
||||
"csat_survey_enabled": inbox.CsatSurveyEnabled,
|
||||
"greeting_enabled": inbox.GreetingEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func publicContactPayload(contactInbox *model.ContactInbox, contact *model.Contact) gin.H {
|
||||
return gin.H{
|
||||
"source_id": contactInbox.SourceID,
|
||||
"pubsub_token": contactInbox.PubsubToken,
|
||||
"id": contact.ID,
|
||||
"name": contact.Name,
|
||||
"email": contact.Email,
|
||||
"phone_number": contact.PhoneNumber,
|
||||
}
|
||||
}
|
||||
|
||||
func publicConversationPayload(conversation model.Conversation, messages []model.Message) gin.H {
|
||||
payload := gin.H{
|
||||
"id": publicDisplayID(conversation),
|
||||
"uuid": conversation.UUID,
|
||||
"inbox_id": conversation.InboxID,
|
||||
"contact_last_seen_at": publicUnix(conversation.ContactLastSeenAt),
|
||||
"status": conversation.Status,
|
||||
"agent_last_seen_at": publicUnix(conversation.AgentLastSeenAt),
|
||||
"contact": gin.H{"id": conversation.ContactID},
|
||||
}
|
||||
messagePayloads := make([]gin.H, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
messagePayloads = append(messagePayloads, publicMessagePayload(message, conversation))
|
||||
}
|
||||
payload["messages"] = messagePayloads
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicMessagePayload(message model.Message, conversation model.Conversation) gin.H {
|
||||
payload := gin.H{
|
||||
"id": message.ID,
|
||||
"content": message.Content,
|
||||
"message_type": message.MessageType,
|
||||
"content_type": message.ContentType,
|
||||
"content_attributes": message.ContentAttributes,
|
||||
"created_at": message.CreatedAt.Unix(),
|
||||
"conversation_id": publicDisplayID(conversation),
|
||||
}
|
||||
if message.SenderID != nil {
|
||||
payload["sender"] = gin.H{"id": *message.SenderID, "type": message.SenderType}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func publicUintParam(c *gin.Context, name string) (uint, bool) {
|
||||
value, err := strconv.ParseUint(c.Param(name), 10, 64)
|
||||
if err != nil || value == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": name + " must be a positive integer"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(value), true
|
||||
}
|
||||
|
||||
func publicDisplayID(conversation model.Conversation) uint {
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
return *conversation.DisplayID
|
||||
}
|
||||
return conversation.ID
|
||||
}
|
||||
|
||||
func publicUnix(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func widgetErrorStatus(err error) int {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "HMAC failed") {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/campaign"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
ws "github.com/gochat/gochat/internal/ws"
|
||||
@@ -62,6 +63,7 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler
|
||||
&model.AccountUser{},
|
||||
&model.InboxMember{},
|
||||
&model.Tag{},
|
||||
&channelmodel.ChannelAPI{},
|
||||
&campaign.Campaign{},
|
||||
))
|
||||
|
||||
@@ -122,6 +124,34 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler
|
||||
chatwootWidgetGroup.POST("/integrations/dyte/add_participant_to_meeting", handler.AddDyteParticipantToMeeting)
|
||||
}
|
||||
|
||||
publicInboxes := router.Group("/public/api/v1/inboxes")
|
||||
{
|
||||
publicInboxes.GET("/:inbox_id", handler.PublicInboxShow)
|
||||
contacts := publicInboxes.Group("/:inbox_id/contacts")
|
||||
{
|
||||
contacts.POST("", handler.PublicCreateContact)
|
||||
contacts.GET("/:contact_id", handler.PublicGetContact)
|
||||
contacts.PUT("/:contact_id", handler.PublicUpdateContact)
|
||||
contacts.PATCH("/:contact_id", handler.PublicUpdateContact)
|
||||
conversations := contacts.Group("/:contact_id/conversations")
|
||||
{
|
||||
conversations.GET("", handler.PublicListConversations)
|
||||
conversations.POST("", handler.PublicCreateConversation)
|
||||
conversations.GET("/:conversation_id", handler.PublicGetConversation)
|
||||
conversations.POST("/:conversation_id/toggle_status", handler.PublicToggleStatus)
|
||||
conversations.POST("/:conversation_id/toggle_typing", handler.PublicToggleTyping)
|
||||
conversations.POST("/:conversation_id/update_last_seen", handler.PublicUpdateLastSeen)
|
||||
messages := conversations.Group("/:conversation_id/messages")
|
||||
{
|
||||
messages.GET("", handler.PublicListMessages)
|
||||
messages.POST("", handler.PublicCreateMessage)
|
||||
messages.PUT("/:message_id", handler.PublicUpdateMessage)
|
||||
messages.PATCH("/:message_id", handler.PublicUpdateMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return db, router, handler
|
||||
}
|
||||
|
||||
@@ -152,6 +182,32 @@ func seedWidgetHandlerData(t *testing.T, db *gorm.DB) (*model.Account, *model.In
|
||||
return account, inbox
|
||||
}
|
||||
|
||||
func seedPublicAPIInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox, *channelmodel.ChannelAPI) {
|
||||
t.Helper()
|
||||
|
||||
account := &model.Account{Name: "PublicAPIOrg", Locale: "en", Status: "active"}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
|
||||
inbox := &model.Inbox{
|
||||
AccountID: account.ID,
|
||||
Name: "Public API Inbox",
|
||||
ChannelType: "api",
|
||||
ChannelID: 1,
|
||||
Enabled: true,
|
||||
Timezone: "UTC",
|
||||
GreetingEnabled: true,
|
||||
CsatSurveyEnabled: true,
|
||||
WorkingHoursEnabled: false,
|
||||
AllowMessagesAfterResolved: true,
|
||||
}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
|
||||
channelAPI := &channelmodel.ChannelAPI{InboxID: inbox.ID, Identifier: "public-api-inbox", HMACToken: "public_hmac_secret"}
|
||||
require.NoError(t, db.Create(channelAPI).Error)
|
||||
|
||||
return account, inbox, channelAPI
|
||||
}
|
||||
|
||||
// ========== Init Handler Tests ==========
|
||||
|
||||
func TestWidgetHandler_Init_Success(t *testing.T) {
|
||||
@@ -1053,3 +1109,105 @@ func TestWidgetHandler_Init_HMACInvalid(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.NotEmpty(t, resp["widget_token"])
|
||||
}
|
||||
|
||||
func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T) {
|
||||
db, router, _ := setupWidgetHandlerTest(t)
|
||||
_, inbox, _ := seedPublicAPIInbox(t, db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var inboxResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &inboxResp))
|
||||
assert.Equal(t, "public-api-inbox", inboxResp["identifier"])
|
||||
assert.Equal(t, inbox.Name, inboxResp["name"])
|
||||
assert.Equal(t, true, inboxResp["identity_validation_enabled"])
|
||||
|
||||
contactBody := map[string]any{
|
||||
"source_id": "public-source-1",
|
||||
"name": "Public Visitor",
|
||||
"email": "Visitor@Public.test",
|
||||
"phone_number": "+15550001111",
|
||||
"custom_attributes": map[string]any{
|
||||
"plan": "trial",
|
||||
},
|
||||
}
|
||||
contactJSON, _ := json.Marshal(contactBody)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader(contactJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var contactResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &contactResp))
|
||||
assert.Equal(t, "public-source-1", contactResp["source_id"])
|
||||
assert.Equal(t, "Public Visitor", contactResp["name"])
|
||||
assert.Equal(t, "visitor@public.test", contactResp["email"])
|
||||
assert.NotEmpty(t, contactResp["pubsub_token"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations", bytes.NewReader([]byte(`{"custom_attributes":{"topic":"sales"}}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var conversationResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp))
|
||||
conversationID := strconv.FormatUint(uint64(conversationResp["id"].(float64)), 10)
|
||||
assert.Equal(t, "open", conversationResp["status"])
|
||||
|
||||
messageBody := map[string]any{"content": "Hello from public API", "echo_id": "echo-1"}
|
||||
messageJSON, _ := json.Marshal(messageBody)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", bytes.NewReader(messageJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var messageResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp))
|
||||
messageID := strconv.FormatUint(uint64(messageResp["id"].(float64)), 10)
|
||||
assert.Equal(t, "Hello from public API", messageResp["content"])
|
||||
assert.Equal(t, "incoming", messageResp["message_type"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var messagesResp []map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messagesResp))
|
||||
require.Len(t, messagesResp, 1)
|
||||
assert.Equal(t, "Hello from public API", messagesResp[0]["content"])
|
||||
|
||||
updateBody := map[string]any{"submitted_values": []map[string]any{{"name": "email", "value": "visitor@public.test"}}}
|
||||
updateJSON, _ := json.Marshal(updateBody)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PATCH", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages/"+messageID, bytes.NewReader(updateJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp))
|
||||
attrs := messageResp["content_attributes"].(map[string]any)
|
||||
assert.NotEmpty(t, attrs["submitted_values"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/update_last_seen", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_typing", bytes.NewReader([]byte(`{"typing_status":"on"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
)
|
||||
|
||||
// InboxRepo implements GORM repository for Inbox.
|
||||
@@ -145,6 +146,19 @@ func (r *InboxRepo) FindByWebsiteToken(ctx context.Context, websiteToken string)
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// FindAPIInboxByIdentifier resolves a public API inbox via Channel::Api identifier.
|
||||
func (r *InboxRepo) FindAPIInboxByIdentifier(ctx context.Context, identifier string) (*model.Inbox, *channelmodel.ChannelAPI, error) {
|
||||
var channelAPI channelmodel.ChannelAPI
|
||||
if err := r.db.WithContext(ctx).Where("identifier = ?", identifier).First(&channelAPI).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
inbox, err := r.FindByID(ctx, channelAPI.InboxID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return inbox, &channelAPI, nil
|
||||
}
|
||||
|
||||
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
|
||||
func parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
|
||||
if inbox.ChannelConfig == "" {
|
||||
@@ -176,4 +190,4 @@ func (r *InboxRepo) UpdateSecret(ctx context.Context, id uint, secret string) er
|
||||
func (r *InboxRepo) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error {
|
||||
return r.db.WithContext(ctx).Model(&model.Inbox{}).Where("id = ?", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
}
|
||||
|
||||
+15
-15
@@ -248,27 +248,27 @@ func RegisterRoutes(
|
||||
// Public API compatibility routes used by Chatwoot public inbox/contact flows.
|
||||
publicInboxes := publicAPI.Group("/inboxes")
|
||||
{
|
||||
publicInboxes.GET("/:inbox_id", chatwootParityStub)
|
||||
publicInboxes.GET("/:inbox_id", handlers.Widget.PublicInboxShow)
|
||||
contacts := publicInboxes.Group("/:inbox_id/contacts")
|
||||
{
|
||||
contacts.POST("", chatwootParityStub)
|
||||
contacts.GET("/:contact_id", chatwootParityStub)
|
||||
contacts.PUT("/:contact_id", chatwootParityStub)
|
||||
contacts.PATCH("/:contact_id", chatwootParityStub)
|
||||
contacts.POST("", handlers.Widget.PublicCreateContact)
|
||||
contacts.GET("/:contact_id", handlers.Widget.PublicGetContact)
|
||||
contacts.PUT("/:contact_id", handlers.Widget.PublicUpdateContact)
|
||||
contacts.PATCH("/:contact_id", handlers.Widget.PublicUpdateContact)
|
||||
conversations := contacts.Group("/:contact_id/conversations")
|
||||
{
|
||||
conversations.GET("", chatwootParityStub)
|
||||
conversations.POST("", chatwootParityStub)
|
||||
conversations.GET("/:conversation_id", chatwootParityStub)
|
||||
conversations.POST("/:conversation_id/toggle_status", chatwootParityStub)
|
||||
conversations.POST("/:conversation_id/toggle_typing", chatwootParityStub)
|
||||
conversations.POST("/:conversation_id/update_last_seen", chatwootParityStub)
|
||||
conversations.GET("", handlers.Widget.PublicListConversations)
|
||||
conversations.POST("", handlers.Widget.PublicCreateConversation)
|
||||
conversations.GET("/:conversation_id", handlers.Widget.PublicGetConversation)
|
||||
conversations.POST("/:conversation_id/toggle_status", handlers.Widget.PublicToggleStatus)
|
||||
conversations.POST("/:conversation_id/toggle_typing", handlers.Widget.PublicToggleTyping)
|
||||
conversations.POST("/:conversation_id/update_last_seen", handlers.Widget.PublicUpdateLastSeen)
|
||||
messages := conversations.Group("/:conversation_id/messages")
|
||||
{
|
||||
messages.GET("", chatwootParityStub)
|
||||
messages.POST("", chatwootParityStub)
|
||||
messages.PUT("/:message_id", chatwootParityStub)
|
||||
messages.PATCH("/:message_id", chatwootParityStub)
|
||||
messages.GET("", handlers.Widget.PublicListMessages)
|
||||
messages.POST("", handlers.Widget.PublicCreateMessage)
|
||||
messages.PUT("/:message_id", handlers.Widget.PublicUpdateMessage)
|
||||
messages.PATCH("/:message_id", handlers.Widget.PublicUpdateMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
ws "github.com/gochat/gochat/internal/ws"
|
||||
@@ -162,6 +163,32 @@ type WidgetMessageUpdate struct {
|
||||
SubmittedValues []map[string]any
|
||||
}
|
||||
|
||||
type PublicContactRequest struct {
|
||||
SourceID string
|
||||
Identifier string
|
||||
IdentifierHash string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
PhoneNumber string
|
||||
CustomAttributes map[string]any
|
||||
}
|
||||
|
||||
type PublicContactResponse struct {
|
||||
ContactInbox *model.ContactInbox
|
||||
Contact *model.Contact
|
||||
}
|
||||
|
||||
type PublicConversationRequest struct {
|
||||
CustomAttributes map[string]any
|
||||
}
|
||||
|
||||
type PublicMessageRequest struct {
|
||||
Content string
|
||||
EchoID string
|
||||
SubmittedValues []map[string]any
|
||||
}
|
||||
|
||||
type WidgetInboxMember struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -547,6 +574,240 @@ func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicGetInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, bool, error) {
|
||||
inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return inbox, channelAPI.HMACToken != "", nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier string, req PublicContactRequest) (*PublicContactResponse, error) {
|
||||
inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.SourceID == "" {
|
||||
req.SourceID, err = generateToken(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if existingInbox, err := s.contactInboxRepo.FindBySourceID(ctx, inbox.ID, req.SourceID); err == nil {
|
||||
contact, err := s.contactRepo.FindByID(ctx, existingInbox.ContactID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingInbox.Contact = *contact
|
||||
return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil
|
||||
}
|
||||
contact, err := s.findPublicContact(ctx, inbox.AccountID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pubsubToken, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hmacToken, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contactInbox := &model.ContactInbox{
|
||||
ContactID: contact.ID,
|
||||
InboxID: inbox.ID,
|
||||
SourceID: req.SourceID,
|
||||
PubsubToken: pubsubToken,
|
||||
HMACToken: hmacToken,
|
||||
HMACVerified: req.IdentifierHash != "",
|
||||
}
|
||||
if err := s.contactInboxRepo.Create(ctx, contactInbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicGetContact(ctx context.Context, inboxIdentifier, sourceID string) (*PublicContactResponse, error) {
|
||||
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PublicContactResponse{ContactInbox: contactInbox, Contact: &contactInbox.Contact}, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier, sourceID string, req PublicContactRequest) (*PublicContactResponse, error) {
|
||||
channelAPI, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePublicHMAC(channelAPI.HMACToken, req.Identifier, req.IdentifierHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contact, err := s.updateContactFields(ctx, &contactInbox.Contact, WidgetContactUpdate{
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
Identifier: req.Identifier,
|
||||
AvatarURL: req.AvatarURL,
|
||||
CustomAttributes: req.CustomAttributes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.IdentifierHash != "" && !contactInbox.HMACVerified {
|
||||
contactInbox.HMACVerified = true
|
||||
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicListConversations(ctx context.Context, inboxIdentifier, sourceID string) ([]model.Conversation, error) {
|
||||
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversations, _, err := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterPublicConversations(conversations, contactInbox), nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicCreateConversation(ctx context.Context, inboxIdentifier, sourceID string, req PublicConversationRequest) (*model.Conversation, error) {
|
||||
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversation, err := s.createWidgetConversation(ctx, contactInbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.CustomAttributes) > 0 {
|
||||
conversation.CustomAttributes = mustJSON(req.CustomAttributes)
|
||||
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicGetConversation(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
|
||||
_, contactInbox, err := s.resolvePublicContactInbox(ctx, inboxIdentifier, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversations, err := s.PublicListConversations(ctx, inboxIdentifier, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range conversations {
|
||||
if publicConversationID(conversations[i]) == displayID && conversations[i].InboxID == contactInbox.InboxID {
|
||||
return &conversations[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("conversation not found")
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicToggleStatus(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
|
||||
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversation.Status = string(model.ConversationStatusResolved)
|
||||
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicUpdateLastSeen(ctx context.Context, inboxIdentifier, sourceID string, displayID uint) (*model.Conversation, error) {
|
||||
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
conversation.ContactLastSeenAt = &now
|
||||
if err := s.conversationRepo.Update(ctx, conversation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicToggleTyping(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, typing bool) error {
|
||||
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.typingIndicator == nil {
|
||||
return nil
|
||||
}
|
||||
performer := &ws.Performer{ID: conversation.ContactID, Type: "contact"}
|
||||
if typing {
|
||||
return s.typingIndicator.SetTypingOn(ctx, conversation.AccountID, conversation.ID, performer)
|
||||
}
|
||||
return s.typingIndicator.SetTypingOff(ctx, conversation.AccountID, conversation.ID, performer)
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicListMessages(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, offset, limit int) ([]model.Message, int64, *model.Conversation, error) {
|
||||
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
messages, total, err := s.messageRepo.FindByConversation(ctx, conversation.ID, offset, limit)
|
||||
return messages, total, conversation, err
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) {
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
return nil, nil, errors.New("content is required")
|
||||
}
|
||||
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
message := &model.Message{
|
||||
ConversationID: conversation.ID,
|
||||
AccountID: conversation.AccountID,
|
||||
InboxID: conversation.InboxID,
|
||||
SenderID: &conversation.ContactID,
|
||||
SenderType: "Contact",
|
||||
Content: req.Content,
|
||||
ContentType: "text",
|
||||
MessageType: "incoming",
|
||||
Status: "sent",
|
||||
SourceID: req.EchoID,
|
||||
}
|
||||
if err := s.messageRepo.Create(ctx, message); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return message, conversation, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicUpdateMessage(ctx context.Context, inboxIdentifier, sourceID string, displayID, messageID uint, req PublicMessageRequest) (*model.Message, *model.Conversation, error) {
|
||||
conversation, err := s.PublicGetConversation(ctx, inboxIdentifier, sourceID, displayID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
message, err := s.messageRepo.FindByConversationAndID(ctx, conversation.ID, messageID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
attrs := jsonMap(message.ContentAttributes)
|
||||
if req.SubmittedValues != nil {
|
||||
attrs["submitted_values"] = req.SubmittedValues
|
||||
message.ContentAttributes = mustJSON(attrs)
|
||||
}
|
||||
if err := s.messageRepo.Update(ctx, message); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return message, conversation, nil
|
||||
}
|
||||
|
||||
// GetMessages returns messages for a conversation belonging to a widget contact.
|
||||
func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
|
||||
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
|
||||
@@ -1241,3 +1502,91 @@ func splitWidgetLabels(raw string) []string {
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, req PublicContactRequest) (*model.Contact, error) {
|
||||
if req.Identifier != "" {
|
||||
if contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, req.Identifier); err == nil {
|
||||
return contact, nil
|
||||
}
|
||||
}
|
||||
if req.Email != "" {
|
||||
if contact, err := s.contactRepo.FindByEmail(ctx, accountID, strings.ToLower(req.Email)); err == nil {
|
||||
return contact, nil
|
||||
}
|
||||
}
|
||||
contact := &model.Contact{
|
||||
AccountID: accountID,
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
AvatarURL: req.AvatarURL,
|
||||
Identifier: req.Identifier,
|
||||
ContactType: "visitor",
|
||||
CustomAttributes: mustJSON(req.CustomAttributes),
|
||||
}
|
||||
if err := s.contactRepo.Create(ctx, contact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) resolvePublicInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, *channelmodel.ChannelAPI, error) {
|
||||
if inboxIdentifier == "" {
|
||||
return nil, nil, errors.New("inbox identifier is required")
|
||||
}
|
||||
inbox, channelAPI, err := s.inboxRepo.FindAPIInboxByIdentifier(ctx, inboxIdentifier)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return inbox, channelAPI, nil
|
||||
}
|
||||
|
||||
func (s *WidgetService) resolvePublicContactInbox(ctx context.Context, inboxIdentifier, sourceID string) (*channelmodel.ChannelAPI, *model.ContactInbox, error) {
|
||||
if sourceID == "" {
|
||||
return nil, nil, errors.New("contact source_id is required")
|
||||
}
|
||||
_, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
contactInbox, err := s.contactInboxRepo.FindBySourceID(ctx, channelAPI.InboxID, sourceID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
contact, err := s.contactRepo.FindByID(ctx, contactInbox.ContactID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
contactInbox.Contact = *contact
|
||||
return channelAPI, contactInbox, nil
|
||||
}
|
||||
|
||||
func validatePublicHMAC(hmacToken, identifier, signature string) error {
|
||||
if signature == "" {
|
||||
return nil
|
||||
}
|
||||
if !VerifyHMAC(hmacToken, identifier, signature) {
|
||||
return errors.New("HMAC failed: Invalid Identifier Hash Provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterPublicConversations(conversations []model.Conversation, contactInbox *model.ContactInbox) []model.Conversation {
|
||||
if contactInbox.HMACVerified {
|
||||
return conversations
|
||||
}
|
||||
filtered := make([]model.Conversation, 0, len(conversations))
|
||||
for _, conversation := range conversations {
|
||||
if conversation.ContactInboxID != nil && *conversation.ContactInboxID == contactInbox.ID {
|
||||
filtered = append(filtered, conversation)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func publicConversationID(conversation model.Conversation) uint {
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
return *conversation.DisplayID
|
||||
}
|
||||
return conversation.ID
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user