feat(realtime): complete fake channel reply flow

This commit is contained in:
2026-07-13 14:57:28 +08:00
parent a712b91982
commit d16bb55d91
24 changed files with 1441 additions and 840 deletions
+14 -2
View File
@@ -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)
+15 -1
View File
@@ -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) {
+2
View File
@@ -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)
+187 -4
View File
@@ -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
View File
@@ -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',
+14
View File
@@ -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'}`
);
});
+88 -4
View File
@@ -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 '';
}
}
+1 -1
View File
@@ -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);
}
+5
View File
@@ -68,6 +68,11 @@ export interface GoChatOutboundMessage {
name: string;
type: string;
};
recipient?: {
id: number;
name: string;
source_id: string;
};
}
export interface AgentInfo {
+37 -1
View File
@@ -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);
});
});
+1 -1
View File
@@ -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
View File
@@ -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",