feat(realtime): complete fake channel reply flow
This commit is contained in:
@@ -223,6 +223,7 @@ type FakeOutboundPayload struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"content_type"`
|
||||
Sender FakeSender `json:"sender"`
|
||||
Recipient FakeRecipient `json:"recipient"`
|
||||
}
|
||||
|
||||
type FakeSender struct {
|
||||
@@ -231,6 +232,12 @@ type FakeSender struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type FakeRecipient struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
|
||||
func (p *FakeProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
|
||||
config := parseFakeConfig(inbox)
|
||||
webhookURL, _ := config["webhook_url"].(string)
|
||||
@@ -249,9 +256,13 @@ func (p *FakeProvider) SendMessage(ctx context.Context, inbox *model.Inbox, mess
|
||||
if message.SenderID != nil {
|
||||
sender.ID = *message.SenderID
|
||||
}
|
||||
// Prefer contact name when available, fall back to empty.
|
||||
recipient := FakeRecipient{}
|
||||
if contact != nil {
|
||||
sender.Name = contact.Name
|
||||
recipient = FakeRecipient{
|
||||
ID: contact.ID,
|
||||
Name: contact.Name,
|
||||
SourceID: contact.Identifier,
|
||||
}
|
||||
}
|
||||
|
||||
payload := FakeOutboundPayload{
|
||||
@@ -260,6 +271,7 @@ func (p *FakeProvider) SendMessage(ctx context.Context, inbox *model.Inbox, mess
|
||||
Content: message.Content,
|
||||
ContentType: message.ContentType,
|
||||
Sender: sender,
|
||||
Recipient: recipient,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
|
||||
@@ -254,7 +254,11 @@ func TestFakeProvider_SendMessage_PostsToWebhookURL(t *testing.T) {
|
||||
SenderType: "agent",
|
||||
SenderID: &senderID,
|
||||
}
|
||||
contact := &model.Contact{Name: "Agent Wang"}
|
||||
contact := &model.Contact{
|
||||
Base: model.Base{ID: 9},
|
||||
Name: "Customer Chen",
|
||||
Identifier: "customer_001",
|
||||
}
|
||||
|
||||
result, err := p.SendMessage(context.Background(), inbox, msg, contact)
|
||||
if err != nil {
|
||||
@@ -278,6 +282,16 @@ func TestFakeProvider_SendMessage_PostsToWebhookURL(t *testing.T) {
|
||||
if sender["type"] != "agent" {
|
||||
t.Fatalf("expected sender.type 'agent', got %v", sender["type"])
|
||||
}
|
||||
recipient, ok := receivedBody["recipient"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected recipient object")
|
||||
}
|
||||
if recipient["source_id"] != "customer_001" {
|
||||
t.Fatalf("expected recipient.source_id 'customer_001', got %v", recipient["source_id"])
|
||||
}
|
||||
if recipient["name"] != "Customer Chen" {
|
||||
t.Fatalf("expected recipient.name 'Customer Chen', got %v", recipient["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeProvider_GetContactProfile(t *testing.T) {
|
||||
|
||||
@@ -760,6 +760,8 @@ func parseDotEnvLine(line string) (string, string, bool) {
|
||||
val := strings.TrimSpace(parts[1])
|
||||
if len(val) >= 2 && ((val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'')) {
|
||||
val = val[1 : len(val)-1]
|
||||
} else if strings.HasPrefix(val, "#") {
|
||||
val = ""
|
||||
} else if comment := strings.Index(val, " #"); comment >= 0 {
|
||||
val = strings.TrimSpace(val[:comment])
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
@@ -1055,6 +1057,29 @@ func handleServiceError(c *gin.Context, err error) {
|
||||
return
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if errors.Is(err, llm.ErrProviderNotConfigured) {
|
||||
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrCopilotNotConfigured, errMsg)
|
||||
return
|
||||
}
|
||||
var providerErr *llm.APIError
|
||||
if errors.As(err, &providerErr) {
|
||||
switch providerErr.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderAuth, "Copilot provider authentication failed")
|
||||
case http.StatusNotFound:
|
||||
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotModelNotFound, "Copilot provider endpoint or model was not found")
|
||||
case http.StatusTooManyRequests:
|
||||
response.AbortWithStatusError(c, http.StatusTooManyRequests, response.ErrCopilotProviderRateLimited, "Copilot provider rate limit exceeded")
|
||||
default:
|
||||
response.AbortWithStatusError(c, http.StatusBadGateway, response.ErrCopilotProviderUnreachable, "Copilot provider request failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
var networkErr net.Error
|
||||
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &networkErr) && networkErr.Timeout()) {
|
||||
response.AbortWithStatusError(c, http.StatusGatewayTimeout, response.ErrCopilotProviderTimeout, "Copilot provider request timed out")
|
||||
return
|
||||
}
|
||||
lower := strings.ToLower(errMsg)
|
||||
if strings.Contains(lower, "not found") || strings.Contains(lower, "record not found") {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, errMsg)
|
||||
|
||||
@@ -694,6 +694,7 @@ func serializeContactWithContext(ctx context.Context, contact *model.Contact) ma
|
||||
"custom_attributes": jsonObject(contact.CustomAttributes),
|
||||
"last_activity_at": int64Value(contact.LastActivityAt),
|
||||
"created_at": contact.CreatedAt.Unix(),
|
||||
"type": "contact",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,6 +711,7 @@ func serializeUser(user *model.User, accountID uint) map[string]any {
|
||||
"name": user.Name,
|
||||
"role": nonEmpty(user.Role, "agent"),
|
||||
"thumbnail": user.AvatarURL,
|
||||
"type": "user",
|
||||
}
|
||||
if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 {
|
||||
payload["custom_attributes"] = attrs
|
||||
|
||||
@@ -390,9 +390,19 @@ func TestSerializeContactUsesPresenceStatus(t *testing.T) {
|
||||
payload := serializeContactWithContext(ctx, contact)
|
||||
|
||||
require.Equal(t, "online", payload["availability_status"])
|
||||
require.Equal(t, "contact", payload["type"])
|
||||
require.Equal(t, "offline", serializeContact(contact)["availability_status"])
|
||||
}
|
||||
|
||||
func TestSerializeUserIncludesSenderType(t *testing.T) {
|
||||
user := &model.User{AccountID: 7, Name: "Support Agent"}
|
||||
user.ID = 43
|
||||
|
||||
payload := serializeUser(user, user.AccountID)
|
||||
|
||||
require.Equal(t, "user", payload["type"])
|
||||
}
|
||||
|
||||
func TestSerializeCRMContactUsesPresenceStatus(t *testing.T) {
|
||||
contact := &model.Contact{AccountID: 9, Name: "CRM Online Contact"}
|
||||
contact.ID = 99
|
||||
|
||||
@@ -1759,10 +1759,19 @@ func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conve
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
event := channel.NewChannelEvent(eventType, channel.ChannelAPI, accountID, conversation.InboxID)
|
||||
channelType := channel.ChannelType(conversation.ChannelType)
|
||||
if channelType == "" {
|
||||
channelType = channel.ChannelAPI
|
||||
}
|
||||
event := channel.NewChannelEvent(eventType, channelType, accountID, conversation.InboxID)
|
||||
event.ConversationID = conversationID
|
||||
event.ContactID = conversation.ContactID
|
||||
event.UserID = userID
|
||||
event.Data["conversation"] = conversation
|
||||
var user model.User
|
||||
if err := s.repo.DB().WithContext(ctx).First(&user, userID).Error; err == nil {
|
||||
event.Data["user"] = &user
|
||||
}
|
||||
event.Data["typing_status"] = typingStatus
|
||||
event.Data["is_private"] = isPrivate
|
||||
s.dispatcher.Dispatch(ctx, event)
|
||||
|
||||
@@ -9,8 +9,11 @@ package wsevent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
wspkg "github.com/gochat/gochat/internal/ws"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
@@ -51,14 +54,14 @@ func (l *BridgeListener) OnEvent(ctx context.Context, event *channel.ChannelEven
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := event.Data
|
||||
if payload == nil {
|
||||
payload = map[string]interface{}{}
|
||||
}
|
||||
payload := wsEventPayload(event)
|
||||
payload["account_id"] = event.AccountID
|
||||
|
||||
if event.ConversationID != 0 {
|
||||
if _, exists := payload["conversation_id"]; !exists {
|
||||
payload["conversation_id"] = event.ConversationID
|
||||
}
|
||||
}
|
||||
if event.InboxID != 0 {
|
||||
payload["inbox_id"] = event.InboxID
|
||||
}
|
||||
@@ -69,6 +72,186 @@ func (l *BridgeListener) OnEvent(ctx context.Context, event *channel.ChannelEven
|
||||
return nil
|
||||
}
|
||||
|
||||
// wsEventPayload converts internal dispatcher data into the flat push payload
|
||||
// expected by the reused Chatwoot ActionCable client. Chatwoot broadcasts
|
||||
// message.push_event_data directly, not an internal {message, conversation}
|
||||
// wrapper. Keeping this normalization at the WS boundary lets other listeners
|
||||
// continue consuming the richer internal event data.
|
||||
func wsEventPayload(event *channel.ChannelEvent) map[string]interface{} {
|
||||
if event == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
switch event.Type {
|
||||
case channel.EventMessageCreated, channel.EventMessageUpdated, channel.EventMessageDeleted:
|
||||
if message, ok := eventMessage(event.Data); ok {
|
||||
return messagePushPayload(message, event.Data)
|
||||
}
|
||||
case channel.EventConversationCreated, channel.EventConversationUpdated,
|
||||
channel.EventConversationOpened, channel.EventConversationResolved,
|
||||
channel.EventConversationAssigned, channel.EventConversationUnassigned:
|
||||
if conversation, ok := eventConversation(event.Data); ok {
|
||||
return conversationPushPayload(conversation, event.Data)
|
||||
}
|
||||
case channel.EventContactCreated, channel.EventContactUpdated, channel.EventContactDeleted:
|
||||
if contact, ok := eventContact(event.Data); ok {
|
||||
return contactPushPayload(contact)
|
||||
}
|
||||
}
|
||||
return copyEventData(event.Data)
|
||||
}
|
||||
|
||||
func conversationPushPayload(conversation *model.Conversation, data map[string]interface{}) map[string]interface{} {
|
||||
payload := modelMap(conversation)
|
||||
conversationID := conversation.ID
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
conversationID = *conversation.DisplayID
|
||||
}
|
||||
payload["id"] = conversationID
|
||||
payload["created_at"] = conversation.CreatedAt.Unix()
|
||||
payload["updated_at"] = float64(conversation.UpdatedAt.UnixNano()) / 1e9
|
||||
if conversation.LastActivityAt != nil {
|
||||
payload["last_activity_at"] = *conversation.LastActivityAt
|
||||
}
|
||||
if _, exists := payload["messages"]; !exists {
|
||||
payload["messages"] = []interface{}{}
|
||||
}
|
||||
meta := map[string]interface{}{}
|
||||
if contact, ok := eventContact(data); ok {
|
||||
meta["sender"] = contactPushPayload(contact)
|
||||
}
|
||||
if inbox, ok := eventInbox(data); ok {
|
||||
meta["channel"] = inbox.ChannelType
|
||||
}
|
||||
payload["meta"] = meta
|
||||
return payload
|
||||
}
|
||||
|
||||
func messagePushPayload(message *model.Message, data map[string]interface{}) map[string]interface{} {
|
||||
payload := modelMap(message)
|
||||
payload["created_at"] = message.CreatedAt.Unix()
|
||||
payload["message_type"] = messageTypeValue(message.MessageType)
|
||||
payload["content_type"] = nonEmpty(message.ContentType, "text")
|
||||
payload["status"] = nonEmpty(message.Status, "sent")
|
||||
if message.ContentAttributes == nil {
|
||||
payload["content_attributes"] = map[string]interface{}{}
|
||||
}
|
||||
|
||||
conversationID := message.ConversationID
|
||||
conversationPayload := map[string]interface{}{
|
||||
"last_activity_at": message.CreatedAt.Unix(),
|
||||
}
|
||||
if conversation, ok := eventConversation(data); ok {
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
conversationID = *conversation.DisplayID
|
||||
}
|
||||
conversationPayload["assignee_id"] = conversation.AssigneeID
|
||||
if conversation.LastActivityAt != nil {
|
||||
conversationPayload["last_activity_at"] = *conversation.LastActivityAt
|
||||
}
|
||||
}
|
||||
if contact, ok := eventContact(data); ok {
|
||||
payload["sender"] = contactPushPayload(contact)
|
||||
conversationPayload["contact_inbox"] = map[string]interface{}{
|
||||
"source_id": contact.Identifier,
|
||||
}
|
||||
}
|
||||
payload["conversation_id"] = conversationID
|
||||
payload["conversation"] = conversationPayload
|
||||
return payload
|
||||
}
|
||||
|
||||
func contactPushPayload(contact *model.Contact) map[string]interface{} {
|
||||
payload := modelMap(contact)
|
||||
payload["created_at"] = contact.CreatedAt.Unix()
|
||||
payload["availability_status"] = "offline"
|
||||
payload["type"] = "contact"
|
||||
if contact.AdditionalAttributes == nil {
|
||||
payload["additional_attributes"] = map[string]interface{}{}
|
||||
}
|
||||
if contact.CustomAttributes == nil {
|
||||
payload["custom_attributes"] = map[string]interface{}{}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func eventMessage(data map[string]interface{}) (*model.Message, bool) {
|
||||
if message, ok := data["message"].(*model.Message); ok && message != nil {
|
||||
return message, true
|
||||
}
|
||||
if message, ok := data["message"].(model.Message); ok {
|
||||
return &message, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func eventConversation(data map[string]interface{}) (*model.Conversation, bool) {
|
||||
if conversation, ok := data["conversation"].(*model.Conversation); ok && conversation != nil {
|
||||
return conversation, true
|
||||
}
|
||||
if conversation, ok := data["conversation"].(model.Conversation); ok {
|
||||
return &conversation, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func eventContact(data map[string]interface{}) (*model.Contact, bool) {
|
||||
if contact, ok := data["contact"].(*model.Contact); ok && contact != nil {
|
||||
return contact, true
|
||||
}
|
||||
if contact, ok := data["contact"].(model.Contact); ok {
|
||||
return &contact, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func eventInbox(data map[string]interface{}) (*model.Inbox, bool) {
|
||||
if inbox, ok := data["inbox"].(*model.Inbox); ok && inbox != nil {
|
||||
return inbox, true
|
||||
}
|
||||
if inbox, ok := data["inbox"].(model.Inbox); ok {
|
||||
return &inbox, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func modelMap(value interface{}) map[string]interface{} {
|
||||
payload := map[string]interface{}{}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return payload
|
||||
}
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
func copyEventData(data map[string]interface{}) map[string]interface{} {
|
||||
payload := make(map[string]interface{}, len(data))
|
||||
for key, value := range data {
|
||||
payload[key] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func messageTypeValue(value string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "incoming":
|
||||
return 0
|
||||
case "activity":
|
||||
return 2
|
||||
case "template":
|
||||
return 3
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmpty(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// isWSEventType returns true if the event type string corresponds to a
|
||||
// WebSocket/SSE event constant defined in wspkg.
|
||||
func isWSEventType(eventType string) bool {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package wsevent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
wspkg "github.com/gochat/gochat/internal/ws"
|
||||
)
|
||||
|
||||
type captureHub struct {
|
||||
accountID uint
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (h *captureHub) SendToAccount(accountID uint, data []byte) {
|
||||
h.accountID = accountID
|
||||
h.data = append([]byte(nil), data...)
|
||||
}
|
||||
|
||||
func (h *captureHub) SendToRoom(string, []byte) {}
|
||||
|
||||
func TestBridgeListenerMessageCreatedUsesChatwootPushPayload(t *testing.T) {
|
||||
now := time.Unix(1_783_834_149, 0)
|
||||
lastActivity := now.Unix()
|
||||
displayID := uint(22)
|
||||
contactID := uint(9)
|
||||
message := &model.Message{
|
||||
Base: model.Base{ID: 12, CreatedAt: now},
|
||||
AccountID: 1,
|
||||
InboxID: 4,
|
||||
ConversationID: 2,
|
||||
SenderID: &contactID,
|
||||
SenderType: string(model.SenderTypeContact),
|
||||
Content: "hellohello",
|
||||
ContentType: "text",
|
||||
Status: "sent",
|
||||
MessageType: "incoming",
|
||||
SourceID: "fake_echo_11",
|
||||
}
|
||||
conversation := &model.Conversation{
|
||||
Base: model.Base{ID: 2, CreatedAt: now},
|
||||
AccountID: 1,
|
||||
InboxID: 4,
|
||||
ContactID: contactID,
|
||||
DisplayID: &displayID,
|
||||
LastActivityAt: &lastActivity,
|
||||
}
|
||||
contact := &model.Contact{
|
||||
Base: model.Base{ID: contactID, CreatedAt: now},
|
||||
AccountID: 1,
|
||||
Name: "Fake Customer",
|
||||
Identifier: "customer_001",
|
||||
}
|
||||
event := channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelFake, 1, 4)
|
||||
event.ConversationID = conversation.ID
|
||||
event.Data["message"] = message
|
||||
event.Data["conversation"] = conversation
|
||||
event.Data["contact"] = contact
|
||||
|
||||
hub := &captureHub{}
|
||||
listener := New(wspkg.NewEventPublisherLocal(hub, nil))
|
||||
if err := listener.OnEvent(context.Background(), event); err != nil {
|
||||
t.Fatalf("OnEvent failed: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(hub.data, &envelope); err != nil {
|
||||
t.Fatalf("decode websocket envelope: %v", err)
|
||||
}
|
||||
payload, ok := envelope["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload, got %#v", envelope["data"])
|
||||
}
|
||||
if payload["account_id"] != float64(1) {
|
||||
t.Fatalf("expected account_id=1, got %#v", payload["account_id"])
|
||||
}
|
||||
if payload["id"] != float64(12) || payload["content"] != "hellohello" {
|
||||
t.Fatalf("unexpected flattened message payload: %#v", payload)
|
||||
}
|
||||
if payload["message_type"] != float64(0) {
|
||||
t.Fatalf("expected incoming message_type=0, got %#v", payload["message_type"])
|
||||
}
|
||||
if payload["conversation_id"] != float64(displayID) {
|
||||
t.Fatalf("expected display conversation id %d, got %#v", displayID, payload["conversation_id"])
|
||||
}
|
||||
if _, nested := payload["message"]; nested {
|
||||
t.Fatalf("message.created payload must be flat: %#v", payload)
|
||||
}
|
||||
sender, ok := payload["sender"].(map[string]interface{})
|
||||
if !ok || sender["name"] != "Fake Customer" || sender["type"] != "contact" {
|
||||
t.Fatalf("expected contact sender payload, got %#v", payload["sender"])
|
||||
}
|
||||
conversationData, ok := payload["conversation"].(map[string]interface{})
|
||||
if !ok || conversationData["last_activity_at"] != float64(lastActivity) {
|
||||
t.Fatalf("expected conversation push payload, got %#v", payload["conversation"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeListenerConversationUpdatedIncludesMeta(t *testing.T) {
|
||||
now := time.Unix(1_783_834_149, 0)
|
||||
contactID := uint(9)
|
||||
conversation := &model.Conversation{
|
||||
Base: model.Base{ID: 2, CreatedAt: now, UpdatedAt: now},
|
||||
AccountID: 1,
|
||||
InboxID: 4,
|
||||
ContactID: contactID,
|
||||
Status: "open",
|
||||
}
|
||||
contact := &model.Contact{
|
||||
Base: model.Base{ID: contactID, CreatedAt: now},
|
||||
AccountID: 1,
|
||||
Name: "Fake Customer",
|
||||
Identifier: "customer_001",
|
||||
}
|
||||
inbox := &model.Inbox{Base: model.Base{ID: 4}, AccountID: 1, ChannelType: "fake"}
|
||||
event := channel.NewChannelEvent(channel.EventConversationUpdated, channel.ChannelFake, 1, 4)
|
||||
event.ConversationID = conversation.ID
|
||||
event.Data["conversation"] = conversation
|
||||
event.Data["contact"] = contact
|
||||
event.Data["inbox"] = inbox
|
||||
|
||||
hub := &captureHub{}
|
||||
listener := New(wspkg.NewEventPublisherLocal(hub, nil))
|
||||
if err := listener.OnEvent(context.Background(), event); err != nil {
|
||||
t.Fatalf("OnEvent failed: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(hub.data, &envelope); err != nil {
|
||||
t.Fatalf("decode websocket envelope: %v", err)
|
||||
}
|
||||
payload := envelope["data"].(map[string]interface{})
|
||||
meta, ok := payload["meta"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected conversation meta, got %#v", payload["meta"])
|
||||
}
|
||||
if meta["channel"] != "fake" {
|
||||
t.Fatalf("expected fake channel meta, got %#v", meta)
|
||||
}
|
||||
sender, ok := meta["sender"].(map[string]interface{})
|
||||
if !ok || sender["name"] != "Fake Customer" || sender["type"] != "contact" {
|
||||
t.Fatalf("expected sender in conversation meta, got %#v", meta)
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -8,8 +8,9 @@ FakeMessagePlatform simulates an external messaging channel. It:
|
||||
|
||||
1. Sends messages to GoChat's `/webhooks/fake/:identifier` webhook endpoint (simulating customer-initiated messages)
|
||||
2. Receives GoChat's outbound messages at `/receive` (messages sent by agents via FakeProvider.SendMessage)
|
||||
3. Tracks all sent/received messages and agent states in memory for test assertions
|
||||
4. Provides REST API for test scripts to orchestrate message flows
|
||||
3. Automatically echoes agent messages back to GoChat as the same customer, so reply flows can be tested from the dashboard
|
||||
4. Tracks all sent/received messages and agent states in memory for test assertions
|
||||
5. Provides REST API for test scripts to orchestrate message flows
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -32,6 +33,8 @@ Environment variables:
|
||||
| `PORT` | `9100` | HTTP listen port |
|
||||
| `GOCHAT_WEBHOOK_URL` | `http://127.0.0.1:3000/webhooks/fake/fake_inbox_1` | GoChat webhook URL |
|
||||
| `GOCHAT_FAKE_TOKEN` | `(empty)` | X-Fake-Token shared secret |
|
||||
| `FAKE_AUTO_REPLY` | `true` | Echo human-agent outbound messages back as customer messages |
|
||||
| `FAKE_AUTO_REPLY_DELAY_MS` | `250` | Delay before sending the automatic echo reply |
|
||||
|
||||
## REST API
|
||||
|
||||
@@ -56,8 +59,15 @@ Environment variables:
|
||||
Test script → FakeMessagePlatform → GoChat webhook → Broker → Pipeline → DB → WS push
|
||||
↑
|
||||
Agent reply → FakeProvider.SendMessage → POST /receive → FakeMessagePlatform store
|
||||
↓
|
||||
Customer echo ← GoChat fake webhook ← automatic same-content reply
|
||||
```
|
||||
|
||||
Automatic replies use the outbound message's recipient identity, so the echoed
|
||||
message is appended to the same open customer conversation. Bot/system sender
|
||||
types are ignored to prevent automation loops. Set `FAKE_AUTO_REPLY=false` to
|
||||
restore record-only behavior.
|
||||
|
||||
## Related
|
||||
|
||||
- GoChat FakeProvider: `backend/internal/channel/provider/fake.go`
|
||||
|
||||
@@ -20,6 +20,10 @@ export class GoChatClient {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
getWebhookUrl(): string {
|
||||
return this.webhookUrl;
|
||||
}
|
||||
|
||||
async sendToGoChat(payload: GoChatWebhookPayload): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// PORT — HTTP listen port (default 9100)
|
||||
// GOCHAT_WEBHOOK_URL — GoChat webhook URL (default http://127.0.0.1:3000/webhooks/fake/fake_inbox_1)
|
||||
// GOCHAT_FAKE_TOKEN — X-Fake-Token shared secret (default empty, no verification)
|
||||
// FAKE_AUTO_REPLY — echo agent messages back to GoChat (default true)
|
||||
// FAKE_AUTO_REPLY_DELAY_MS — delay before echoing (default 250ms)
|
||||
|
||||
import { createServer } from './server.js';
|
||||
|
||||
@@ -12,15 +14,27 @@ const gochatWebhookUrl =
|
||||
process.env.GOCHAT_WEBHOOK_URL ||
|
||||
'http://127.0.0.1:3000/webhooks/fake/fake_inbox_1';
|
||||
const gochatFakeToken = process.env.GOCHAT_FAKE_TOKEN || '';
|
||||
const autoReplyEnabled = !['0', 'false', 'off'].includes(
|
||||
(process.env.FAKE_AUTO_REPLY || 'true').toLowerCase()
|
||||
);
|
||||
const autoReplyDelayMs = Math.max(
|
||||
0,
|
||||
parseInt(process.env.FAKE_AUTO_REPLY_DELAY_MS || '250', 10) || 0
|
||||
);
|
||||
|
||||
const app = createServer({
|
||||
port,
|
||||
gochatWebhookUrl,
|
||||
gochatFakeToken,
|
||||
autoReplyEnabled,
|
||||
autoReplyDelayMs,
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`[FakeMessagePlatform] listening on :${port}`);
|
||||
console.log(`[FakeMessagePlatform] GoChat webhook: ${gochatWebhookUrl}`);
|
||||
console.log(`[FakeMessagePlatform] Token: ${gochatFakeToken ? '(set)' : '(none)'}`);
|
||||
console.log(
|
||||
`[FakeMessagePlatform] Auto reply: ${autoReplyEnabled ? `enabled (${autoReplyDelayMs}ms)` : 'disabled'}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ServerConfig {
|
||||
port: number;
|
||||
gochatWebhookUrl: string;
|
||||
gochatFakeToken: string;
|
||||
autoReplyEnabled?: boolean;
|
||||
autoReplyDelayMs?: number;
|
||||
}
|
||||
|
||||
export function createServer(config: ServerConfig): express.Application {
|
||||
@@ -43,13 +45,24 @@ export function createServer(config: ServerConfig): express.Application {
|
||||
config.gochatWebhookUrl,
|
||||
config.gochatFakeToken
|
||||
);
|
||||
let autoReplyEnabled = config.autoReplyEnabled ?? true;
|
||||
let autoReplyDelayMs = Math.max(0, config.autoReplyDelayMs ?? 250);
|
||||
|
||||
// Allow runtime config updates
|
||||
app.post('/api/config', (req, res) => {
|
||||
const { webhook_url, token } = req.body || {};
|
||||
const { webhook_url, token, auto_reply, auto_reply_delay_ms } = req.body || {};
|
||||
if (webhook_url) gochatClient.setWebhookUrl(webhook_url);
|
||||
if (token !== undefined) gochatClient.setToken(token);
|
||||
res.json({ status: 'ok' });
|
||||
if (auto_reply !== undefined) autoReplyEnabled = Boolean(auto_reply);
|
||||
if (auto_reply_delay_ms !== undefined) {
|
||||
const parsedDelay = Number(auto_reply_delay_ms);
|
||||
if (Number.isFinite(parsedDelay)) autoReplyDelayMs = Math.max(0, parsedDelay);
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
auto_reply: autoReplyEnabled,
|
||||
auto_reply_delay_ms: autoReplyDelayMs,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Health ---
|
||||
@@ -281,7 +294,11 @@ export function createServer(config: ServerConfig): express.Application {
|
||||
|
||||
// --- Platform status ---
|
||||
app.get('/api/status', (_req, res) => {
|
||||
res.json(store.getStatus());
|
||||
res.json({
|
||||
...store.getStatus(),
|
||||
auto_reply: autoReplyEnabled,
|
||||
auto_reply_delay_ms: autoReplyDelayMs,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reset all state ---
|
||||
@@ -298,8 +315,75 @@ export function createServer(config: ServerConfig): express.Application {
|
||||
return;
|
||||
}
|
||||
store.recordReceived(body);
|
||||
res.json({ status: 'ok', message_id: body.message_id });
|
||||
|
||||
const senderType = body.sender?.type?.toLowerCase() || '';
|
||||
const recipientSourceId = body.recipient?.source_id?.trim() || '';
|
||||
const shouldAutoReply =
|
||||
autoReplyEnabled &&
|
||||
Boolean(body.content) &&
|
||||
Boolean(recipientSourceId) &&
|
||||
(senderType === 'user' || senderType === 'agent');
|
||||
|
||||
let autoReplyMessageId: string | undefined;
|
||||
if (shouldAutoReply) {
|
||||
autoReplyMessageId = `fake_echo_${body.message_id}_${Date.now()}`;
|
||||
const payload: GoChatWebhookPayload = {
|
||||
event: 'message.incoming',
|
||||
message_id: autoReplyMessageId,
|
||||
sender_id: recipientSourceId,
|
||||
sender_name: body.recipient?.name || recipientSourceId,
|
||||
content: body.content,
|
||||
content_type: body.content_type || 'text',
|
||||
conversation_id: String(body.conversation_id),
|
||||
reply_to_id: `fake_${body.message_id}`,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const response = await gochatClient.sendToGoChat(payload);
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[FakeMessagePlatform] auto reply failed: GoChat returned HTTP ${response.status}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
store.recordSent({
|
||||
id: autoReplyMessageId!,
|
||||
direction: 'incoming',
|
||||
inbox_identifier: webhookIdentifier(gochatClient.getWebhookUrl()),
|
||||
message_id: autoReplyMessageId!,
|
||||
sender_id: recipientSourceId,
|
||||
sender_name: payload.sender_name,
|
||||
content: payload.content,
|
||||
content_type: payload.content_type,
|
||||
conversation_id: payload.conversation_id,
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[FakeMessagePlatform] auto reply failed: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}, autoReplyDelayMs);
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message_id: body.message_id,
|
||||
auto_reply: shouldAutoReply,
|
||||
auto_reply_message_id: autoReplyMessageId,
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function webhookIdentifier(webhookUrl: string): string {
|
||||
try {
|
||||
const pathname = new URL(webhookUrl).pathname;
|
||||
return pathname.split('/').filter(Boolean).pop() || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class MemoryStore {
|
||||
return [...this.receivedMessages];
|
||||
}
|
||||
|
||||
getMessageById(id: string): MessageRecord | undefined {
|
||||
getMessageById(id: string): MessageRecord | GoChatOutboundMessage | undefined {
|
||||
return this.sentMessages.find((m) => m.id === id) ||
|
||||
this.receivedMessages.find((m) => `out_${m.message_id}` === id);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface GoChatOutboundMessage {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
recipient?: {
|
||||
id: number;
|
||||
name: string;
|
||||
source_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
|
||||
@@ -40,6 +40,8 @@ function startFakeServer(webhookUrl: string): Promise<http.Server> {
|
||||
port: 0,
|
||||
gochatWebhookUrl: webhookUrl,
|
||||
gochatFakeToken: '',
|
||||
autoReplyEnabled: true,
|
||||
autoReplyDelayMs: 0,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
const srv = app.listen(0, '127.0.0.1', () => {
|
||||
@@ -59,6 +61,16 @@ async function fakeRequest(path: string, method: string = 'GET', body?: any): Pr
|
||||
return { status: res.status, data: await res.json() };
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs: number = 1000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error('condition was not met before timeout');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mockGochatServer = await startMockGochat();
|
||||
fakeApp = await startFakeServer(`http://127.0.0.1:${mockGochatPort}/webhooks/fake/fake_test_1`);
|
||||
@@ -167,21 +179,45 @@ describe('FakeMessagePlatform', () => {
|
||||
});
|
||||
|
||||
describe('/receive', () => {
|
||||
it('stores GoChat outbound messages', async () => {
|
||||
it('stores GoChat outbound messages and echoes them back as the customer', async () => {
|
||||
const res = await fakeRequest('/receive', 'POST', {
|
||||
message_id: 123,
|
||||
conversation_id: 456,
|
||||
content: 'Hello from agent',
|
||||
content_type: 'text',
|
||||
sender: { id: 1, name: 'Agent Wang', type: 'agent' },
|
||||
recipient: { id: 9, name: 'Customer Chen', source_id: 'customer_001' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.message_id).toBe(123);
|
||||
expect(res.data.auto_reply).toBe(true);
|
||||
|
||||
const msgRes = await fakeRequest('/api/messages');
|
||||
expect(msgRes.data.received).toHaveLength(1);
|
||||
expect(msgRes.data.received[0].content).toBe('Hello from agent');
|
||||
expect(msgRes.data.received[0].sender.type).toBe('agent');
|
||||
|
||||
await waitFor(() => receivedByGochat.length === 1);
|
||||
expect(receivedByGochat[0].event).toBe('message.incoming');
|
||||
expect(receivedByGochat[0].content).toBe('Hello from agent');
|
||||
expect(receivedByGochat[0].sender_id).toBe('customer_001');
|
||||
expect(receivedByGochat[0].sender_name).toBe('Customer Chen');
|
||||
expect(receivedByGochat[0].conversation_id).toBe('456');
|
||||
});
|
||||
|
||||
it('does not auto reply to bot messages', async () => {
|
||||
const res = await fakeRequest('/receive', 'POST', {
|
||||
message_id: 124,
|
||||
conversation_id: 456,
|
||||
content: 'Automated answer',
|
||||
content_type: 'text',
|
||||
sender: { id: 2, name: 'Bot', type: 'AgentBot' },
|
||||
recipient: { id: 9, name: 'Customer Chen', source_id: 'customer_001' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.auto_reply).toBe(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(receivedByGochat).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ scripts/parity_frontend_smoke.sh --enterprise-browser-smoke
|
||||
## Backend
|
||||
|
||||
- URL: http://127.0.0.1:13000
|
||||
- Command: `env GOCHAT_ENV=development GOCHAT_SERVER_HOST=127.0.0.1 GOCHAT_SERVER_PORT=13000 GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE=meilisearch GOCHAT_SEARCH_HOST=http://127.0.0.1:17700 GOCHAT_SEARCH_API_KEY=gochat_dev GOCHAT_CAPTAIN_ENABLED=false go run ./cmd/gochat serve`
|
||||
- Command: `env GOCHAT_ENV=development GOCHAT_SERVER_HOST=127.0.0.1 GOCHAT_SERVER_PORT=13000 GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE=meilisearch GOCHAT_SEARCH_HOST=http://127.0.0.1:17700 GOCHAT_SEARCH_API_KEY=gochat_dev go run ./cmd/gochat serve`
|
||||
- Search: `meilisearch` at `http://127.0.0.1:17700`
|
||||
- Log: `/home/rogee/Projects/gochat/.tmp/frontend-smoke-live/gochat.log`
|
||||
|
||||
|
||||
@@ -326,7 +326,9 @@ const menuItems = computed(() => {
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute('folder_conversations', { id: view.id }),
|
||||
to: accountScopedRoute('folder_conversations', {
|
||||
id: view.id,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
{
|
||||
@@ -338,7 +340,9 @@ const menuItems = computed(() => {
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
badgeCount: getTeamUnreadCount.value(team.id),
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
to: accountScopedRoute('team_conversations', {
|
||||
teamId: team.id,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
{
|
||||
@@ -351,7 +355,9 @@ const menuItems = computed(() => {
|
||||
label: inbox.name,
|
||||
badgeCount: getInboxUnreadCount.value(inbox.id),
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
to: accountScopedRoute('inbox_dashboard', {
|
||||
inbox_id: inbox.id,
|
||||
}),
|
||||
component: leafProps =>
|
||||
h(ChannelLeaf, {
|
||||
label: leafProps.label,
|
||||
@@ -528,7 +534,10 @@ const menuItems = computed(() => {
|
||||
{},
|
||||
{ page: 1, search: undefined }
|
||||
),
|
||||
activeOn: ['companies_dashboard_index', 'companies_dashboard_show'],
|
||||
activeOn: [
|
||||
'companies_dashboard_index',
|
||||
'companies_dashboard_show',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -688,7 +697,9 @@ const menuItems = computed(() => {
|
||||
'agent_capacity_policy_create',
|
||||
'agent_capacity_policy_edit',
|
||||
],
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
to: accountScopedRoute(
|
||||
'assignment_policy_index'
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -806,7 +817,8 @@ const menuItems = computed(() => {
|
||||
:class="[
|
||||
{
|
||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full':
|
||||
!isMobileSidebarOpen,
|
||||
'transition-transform duration-200 ease-out md:transition-[width]':
|
||||
!isResizing,
|
||||
},
|
||||
@@ -815,7 +827,9 @@ const menuItems = computed(() => {
|
||||
>
|
||||
<section
|
||||
class="grid"
|
||||
:class="isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'"
|
||||
:class="
|
||||
isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 items-center min-w-0"
|
||||
@@ -827,7 +841,9 @@ const menuItems = computed(() => {
|
||||
<template v-if="isEffectivelyCollapsed">
|
||||
<SidebarAccountSwitcher
|
||||
is-collapsed
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
@show-create-account-modal="
|
||||
emit('showCreateAccountModal')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -837,20 +853,26 @@ const menuItems = computed(() => {
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow -mx-1 min-w-0"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
@show-create-account-modal="
|
||||
emit('showCreateAccountModal')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
|
||||
:class="
|
||||
isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'
|
||||
"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="!isEffectivelyCollapsed"
|
||||
:to="{ name: 'search' }"
|
||||
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out"
|
||||
>
|
||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
|
||||
<span
|
||||
class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10"
|
||||
/>
|
||||
<span class="flex-grow text-start text-n-slate-10">
|
||||
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
|
||||
</span>
|
||||
@@ -879,7 +901,10 @@ const menuItems = computed(() => {
|
||||
isEffectivelyCollapsed
|
||||
? '!size-8 !outline-n-weak !text-n-slate-11'
|
||||
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||
{
|
||||
'!bg-n-alpha-2 dark:!bg-n-slate-9/30':
|
||||
isOpen,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
@@ -923,7 +948,11 @@ const menuItems = computed(() => {
|
||||
/>
|
||||
<div
|
||||
class="px-1 py-1.5 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
|
||||
:class="
|
||||
isEffectivelyCollapsed
|
||||
? 'justify-center'
|
||||
: 'justify-between'
|
||||
"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
:is-collapsed="isEffectivelyCollapsed"
|
||||
|
||||
@@ -549,9 +549,6 @@ function onToggleAdvanceFiltersModal() {
|
||||
}
|
||||
|
||||
function fetchConversations() {
|
||||
if (chatListLoading.value) {
|
||||
return;
|
||||
}
|
||||
store.dispatch('updateChatListFilters', conversationFilters.value);
|
||||
store.dispatch('fetchAllConversations').then(emitConversationLoaded);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ const getters = {
|
||||
const currentUserID = rootGetters.getCurrentUser?.id;
|
||||
|
||||
return _state.allConversations.filter(conversation => {
|
||||
const { assignee } = conversation.meta;
|
||||
const { assignee } = conversation.meta || {};
|
||||
const isAssignedToMe = assignee && assignee.id === currentUserID;
|
||||
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
||||
const isChatMine = isAssignedToMe && shouldFilter;
|
||||
|
||||
@@ -59,7 +59,6 @@ export const buildConversationList = (
|
||||
'conversationLabels/setBulkConversationLabels',
|
||||
conversationList
|
||||
);
|
||||
context.commit(types.CLEAR_LIST_LOADING_STATUS);
|
||||
setContacts(context.commit, conversationList);
|
||||
setPageFilter({
|
||||
dispatch: context.dispatch,
|
||||
|
||||
@@ -242,6 +242,8 @@ export const mutations = {
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION](_state, conversation) {
|
||||
if (!conversation?.id) return;
|
||||
|
||||
const { allConversations } = _state;
|
||||
const index = allConversations.findIndex(c => c.id === conversation.id);
|
||||
|
||||
|
||||
@@ -758,6 +758,22 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#UPDATE_CONVERSATION', () => {
|
||||
it('should ignore malformed realtime payloads without an id', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, status: 'open', meta: {} }],
|
||||
conversationFilters: {},
|
||||
};
|
||||
|
||||
mutations[types.UPDATE_CONVERSATION](state, {
|
||||
conversation: { id: 1 },
|
||||
message: { id: 2 },
|
||||
});
|
||||
|
||||
expect(state.allConversations).toEqual([
|
||||
{ id: 1, status: 'open', meta: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update existing conversation', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"dev": "concurrently -n backend,frontend -c blue,green \"pnpm dev:backend\" \"pnpm dev:frontend\"",
|
||||
"fake:start": "cd channels/fake && tsx src/index.ts",
|
||||
"fake:dev": "cd channels/fake && tsx watch src/index.ts",
|
||||
"fake:test": "cd channels/fake && vitest run",
|
||||
"fake:test": "pnpm --dir channels/fake test",
|
||||
"dev:all": "concurrently -n backend,frontend,fake -c blue,green,magenta \"pnpm dev:backend\" \"pnpm dev:frontend\" \"pnpm fake:start\"",
|
||||
"build:frontend": "cd frontend && pnpm build",
|
||||
"build:sdk": "cd frontend && pnpm build:sdk",
|
||||
|
||||
Reference in New Issue
Block a user