From 04b999e37790bcaf2b068dc3db6df498b68e846b Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 30 Jul 2026 09:09:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20FakeMessagePla?= =?UTF-8?q?tform=20channel=20=E5=85=A8=E9=83=A8=E9=80=BB=E8=BE=91=E3=80=81?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E3=80=81=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 channels/fake/ 整个 Node.js FakeMessagePlatform (8文件) - 删除后端 FakeProvider (fake.go + fake_test.go) + FakeWebhookHandler (fake_webhook.go) - bootstrap.go: 移除 FakeProvider 接线 + isFakeChannelEnabled() - router.go: 移除 FakeWebhook 字段 + /webhooks/fake/:identifier 路由 - provider.go: 移除 ChannelFake 常量 - inbox_service.go: 移除 fake channel 类型验证 + isFakeChannelAllowed() - 前端: 移除 Fake.vue + ChannelFactory/ChannelList/ChannelItem 引用 + i18n (en/zh_CN) - package.json: 移除 fake:start/dev/test/dev:all 脚本 - pnpm-workspace.yaml: 移除 channels/fake - .env/.env.example/quickstart: 移除 GOCHAT_ALLOW_FAKE_CHANNEL - 测试: bridge_listener_test 改用 web_widget, router_test 移除 fake 路由断言 - 保留 fake:ai LLM Provider (backend/internal/llm/fake_provider.go) — 与 channel 无关 --- .env.example | 1 - backend/internal/app/bootstrap.go | 30 -- backend/internal/channel/provider.go | 1 - backend/internal/channel/provider/fake.go | 385 ----------------- .../internal/channel/provider/fake_test.go | 390 ------------------ .../internal/handler/webhook/fake_webhook.go | 201 --------- backend/internal/router/router.go | 24 -- backend/internal/router/router_test.go | 2 - backend/internal/service/inbox_service.go | 15 - .../internal/service/inbox_service_test.go | 20 - .../internal/wsevent/bridge_listener_test.go | 10 +- channels/README.md | 4 +- channels/fake/README.md | 75 ---- channels/fake/package.json | 25 -- channels/fake/src/client/gochat-client.ts | 58 --- channels/fake/src/index.ts | 40 -- channels/fake/src/server.ts | 389 ----------------- channels/fake/src/store/memory-store.ts | 110 ----- channels/fake/src/types.ts | 104 ----- channels/fake/tests/integration.test.ts | 260 ------------ channels/fake/tsconfig.json | 15 - deploy/quickstart/.env.example | 1 - .../components/widgets/ChannelItem.vue | 1 - .../dashboard/i18n/locale/en/inboxMgmt.json | 35 +- .../i18n/locale/zh_CN/inboxMgmt.json | 35 +- .../settings/inbox/ChannelFactory.vue | 2 - .../dashboard/settings/inbox/ChannelList.vue | 10 - .../settings/inbox/channels/Fake.vue | 164 -------- package.json | 4 - pnpm-workspace.yaml | 1 - test-captain-llm-provider.sh | 133 ------ 31 files changed, 8 insertions(+), 2537 deletions(-) delete mode 100644 backend/internal/channel/provider/fake.go delete mode 100644 backend/internal/channel/provider/fake_test.go delete mode 100644 backend/internal/handler/webhook/fake_webhook.go delete mode 100644 channels/fake/README.md delete mode 100644 channels/fake/package.json delete mode 100644 channels/fake/src/client/gochat-client.ts delete mode 100644 channels/fake/src/index.ts delete mode 100644 channels/fake/src/server.ts delete mode 100644 channels/fake/src/store/memory-store.ts delete mode 100644 channels/fake/src/types.ts delete mode 100644 channels/fake/tests/integration.test.ts delete mode 100644 channels/fake/tsconfig.json delete mode 100644 frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Fake.vue delete mode 100755 test-captain-llm-provider.sh diff --git a/.env.example b/.env.example index dc970321..c2ee381d 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,6 @@ # ---- Application ---- GOCHAT_ENV=prod # Keep disabled in production. Enable only for controlled QA/UAT environments. -GOCHAT_ALLOW_FAKE_CHANNEL=false # ---- Server ---- GOCHAT_SERVER_HOST=0.0.0.0 diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 67fbebd5..ec6ff926 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -468,20 +468,6 @@ func Bootstrap(env string) (*App, error) { applogger.L().Warnf("Email provider already registered: %v", err) } - // Step 8h: Wire Fake channel provider (FakeMessagePlatform test channel) - // FakeProvider is self-contained (no service/repo/pipeline deps), so we - // only need to create the webhook handler here. The provider itself is - // registered via init() in the provider package. - // Skip in production — the fake channel is a test-only tool. - var fakeWebhookHandler *webhook.FakeWebhookHandler - if !isFakeChannelEnabled(env) { - applogger.L().Info("Fake channel provider skipped (production environment)") - } else { - fakeProvider := channelprovider.NewFakeProvider() - fakeWebhookHandler = webhook.NewFakeWebhookHandler(fakeProvider, db, channelDispatcher) - fakeWebhookHandler.WithWorkerPool(workerPool) - } - // Create Email webhook handler (Gin HTTP handler for Email webhook endpoints) emailWebhook := emailchannel.NewWebhookHandler() emailWebhookHandler := webhook.NewEmailWebhookHandler(emailWebhook, emailPipeline, db) @@ -721,9 +707,6 @@ func Bootstrap(env string) (*App, error) { tiktokWebhookHandler.WithSearchIndexer(searchIndexer) lineWebhookHandler.WithSearchIndexer(searchIndexer) twilioWebhookHandler.WithSearchIndexer(searchIndexer) - if fakeWebhookHandler != nil { - fakeWebhookHandler.WithSearchIndexer(searchIndexer) - } // Event-triggered automation/AgentBot listeners are registered after the durable // search indexer exists so action side effects keep Meilisearch current. channelDispatcher.Register(automation.NewAgentBotRuleListenerWithSearchIndexer(&dbProvider{db: db}, searchIndexer)) @@ -855,7 +838,6 @@ func Bootstrap(env string) (*App, error) { LineWebhook: lineWebhookHandler, TwilioWebhook: twilioWebhookHandler, ShopifyWebhook: shopifyWebhookHandler, - FakeWebhook: fakeWebhookHandler, Label: v1.NewLabelHandler(tagService, labelService), Campaign: v1.NewCampaignHandler(campaignService), AssignmentPolicy: v1.NewAssignmentPolicyHandler(assignmentPolicyService), @@ -1020,15 +1002,3 @@ func (a *hubTypingAdapter) SetTypingOff(ctx context.Context, accountID, conversa a.hub.SendToAccount(accountID, data) return nil } - -// isFakeChannelEnabled reports whether the fake test channel should be wired. -// It is disabled in production environments to prevent test endpoints from -// being exposed in deployed images. -func isFakeChannelEnabled(env string) bool { - switch strings.ToLower(strings.TrimSpace(env)) { - case "production", "prod": - return false - default: - return true - } -} diff --git a/backend/internal/channel/provider.go b/backend/internal/channel/provider.go index b3cf7743..33652add 100644 --- a/backend/internal/channel/provider.go +++ b/backend/internal/channel/provider.go @@ -26,7 +26,6 @@ const ( ChannelAPI ChannelType = "api" ChannelTikTok ChannelType = "tiktok" ChannelMicrosoft ChannelType = "microsoft" - ChannelFake ChannelType = "fake" ) // ChannelProvider is the core interface that all channel providers must implement. diff --git a/backend/internal/channel/provider/fake.go b/backend/internal/channel/provider/fake.go deleted file mode 100644 index 05a2b3e5..00000000 --- a/backend/internal/channel/provider/fake.go +++ /dev/null @@ -1,385 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/gochat/gochat/internal/channel" - "github.com/gochat/gochat/internal/model" -) - -// FakeProvider implements ChannelProvider for the "fake" channel — a test -// channel that integrates with the FakeMessagePlatform (channels/fake) for -// automated end-to-end integration testing. -// -// Design notes: -// - Incoming: parses a JSON payload posted by FakeMessagePlatform to the -// GoChat webhook /webhooks/fake/:identifier and converts it into an -// IncomingMessage. -// - Outgoing: POSTs the outbound message to the FakeMessagePlatform -// /receive endpoint (configured via channel config "webhook_url") so the -// test harness can assert that agents' replies reached the fake platform. -// - Auth: simple X-Fake-Token header matching the channel config "token". -// - Capabilities: all supported, so tests exercise every code path. -type FakeProvider struct{} - -func NewFakeProvider() *FakeProvider { - return &FakeProvider{} -} - -func (p *FakeProvider) Type() channel.ChannelType { - return channel.ChannelFake -} - -func (p *FakeProvider) Name() string { - return "Fake Message Platform" -} - -func (p *FakeProvider) Description() string { - return "Test channel for automated integration testing" -} - -// === Configuration === - -func (p *FakeProvider) ConfigSchema() *channel.ConfigSchemaDefinition { - return &channel.ConfigSchemaDefinition{ - Type: "object", - Properties: map[string]channel.ConfigProperty{ - "webhook_url": { - Type: "string", - Description: "FakeMessagePlatform callback URL (e.g. http://127.0.0.1:9100/receive)", - Format: "uri", - }, - "identifier": { - Type: "string", - Description: "Unique inbox identifier used in the webhook path", - }, - "token": { - Type: "string", - Description: "Shared secret for X-Fake-Token header verification", - Secret: true, - }, - }, - Required: []string{"identifier"}, - } -} - -func (p *FakeProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error { - identifier, ok := config["identifier"].(string) - if !ok || identifier == "" { - return fmt.Errorf("identifier is required") - } - if webhookURL, ok := config["webhook_url"].(string); ok && webhookURL != "" { - if !isValidURL(webhookURL) { - return fmt.Errorf("webhook_url must be a valid URL") - } - } - return nil -} - -func (p *FakeProvider) DefaultConfig() channel.ChannelConfig { - return channel.ChannelConfig{ - "webhook_url": "", - "identifier": "", - "token": "", - } -} - -// === Lifecycle === - -func (p *FakeProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) { - // No external resources to provision — the FakeMessagePlatform is a - // standalone process the operator starts separately. - return config, nil -} - -func (p *FakeProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error { - return nil -} - -// === Inbound === - -// FakeIncomingPayload is the JSON body FakeMessagePlatform posts to -// /webhooks/fake/:identifier. -type FakeIncomingPayload struct { - Event string `json:"event"` - MessageID string `json:"message_id"` - SenderID string `json:"sender_id"` - SenderName string `json:"sender_name"` - Content string `json:"content"` - ContentType string `json:"content_type"` - ConversationID string `json:"conversation_id,omitempty"` - ReplyToID string `json:"reply_to_id,omitempty"` - Timestamp int64 `json:"timestamp,omitempty"` - Attachments []FakeAttachment `json:"attachments,omitempty"` -} - -// FakeAttachment mirrors channel.Attachment for the fake payload. -type FakeAttachment struct { - URL string `json:"url"` - ContentType string `json:"content_type"` - Filename string `json:"filename,omitempty"` - FileSize int64 `json:"file_size,omitempty"` -} - -func (p *FakeProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) { - var payload FakeIncomingPayload - if err := json.Unmarshal(rawPayload, &payload); err != nil { - return nil, fmt.Errorf("failed to parse fake message payload: %w", err) - } - - contentType := channel.ContentText - if payload.ContentType != "" { - contentType = channel.ContentType(payload.ContentType) - } - - senderType := channel.SenderContact - switch payload.Event { - case "session.end": - // Session-end events are system messages. NOTE: incoming_persister - // currently hardcodes SenderType to contact (plan §7.3 限制1), so the - // sender_type here is informational only — tests identify session-end - // messages by their "[session ended]" content. - senderType = channel.SenderSystem - if payload.Content == "" { - payload.Content = "[session ended]" - } - case "typing.start", "typing.stop": - // Typing indicator events are not persisted as messages. Return nil - // so the webhook handler skips persistence and the event is not - // recorded as a conversation message. - return nil, nil - } - - incoming := &channel.IncomingMessage{ - ChannelType: channel.ChannelFake, - SourceID: payload.MessageID, - ConversationID: payload.ConversationID, - SenderID: payload.SenderID, - SenderName: payload.SenderName, - SenderType: senderType, - Content: payload.Content, - ContentType: contentType, - ReplyToID: payload.ReplyToID, - InboxID: inbox.ID, - AccountID: inbox.AccountID, - ReceivedAt: time.Now(), - Extra: channel.ChannelConfig{ - "event": payload.Event, - "timestamp": payload.Timestamp, - }, - } - - if len(payload.Attachments) > 0 { - incoming.Attachments = make([]channel.Attachment, len(payload.Attachments)) - for i, att := range payload.Attachments { - incoming.Attachments[i] = channel.Attachment{ - URL: att.URL, - ContentType: att.ContentType, - Filename: att.Filename, - FileSize: att.FileSize, - } - } - } - - return incoming, nil -} - -func (p *FakeProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error { - config := parseFakeConfig(inbox) - expectedToken, _ := config["token"].(string) - if expectedToken == "" { - // No token configured — reject in production, allow only in dev/test. - if isProductionEnv() { - return fmt.Errorf("fake webhook token is required in production") - } - return nil - } - providedToken := request.Headers["X-Fake-Token"] - if providedToken == "" { - providedToken = request.Headers["x-fake-token"] - } - if providedToken != expectedToken { - return fmt.Errorf("invalid X-Fake-Token") - } - return nil -} - -// === Outbound === - -// FakeOutboundPayload is the JSON body FakeProvider.SendMessage posts to the -// FakeMessagePlatform /receive endpoint. -type FakeOutboundPayload struct { - MessageID uint `json:"message_id"` - ConversationID uint `json:"conversation_id"` - Content string `json:"content"` - ContentType string `json:"content_type"` - Sender FakeSender `json:"sender"` - Recipient FakeRecipient `json:"recipient"` -} - -type FakeSender struct { - ID uint `json:"id"` - Name string `json:"name"` - 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) - if webhookURL == "" { - // No callback URL configured — nothing to do (tests that assert - // outbound delivery should configure webhook_url). - return &channel.SendResult{ - ExternalID: fmt.Sprintf("fake_%d", message.ID), - DeliveredAt: time.Now(), - }, nil - } - - sender := FakeSender{ - Type: message.SenderType, - } - if message.SenderID != nil { - sender.ID = *message.SenderID - } - recipient := FakeRecipient{} - if contact != nil { - recipient = FakeRecipient{ - ID: contact.ID, - Name: contact.Name, - SourceID: contact.Identifier, - } - } - - payload := FakeOutboundPayload{ - MessageID: message.ID, - ConversationID: message.ConversationID, - Content: message.Content, - ContentType: message.ContentType, - Sender: sender, - Recipient: recipient, - } - - body, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal fake outbound payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("failed to build fake outbound request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - token, _ := config["token"].(string) - if token != "" { - req.Header.Set("X-Fake-Token", token) - } - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to post to FakeMessagePlatform: %w", err) - } - defer resp.Body.Close() - io.Copy(io.Discard, resp.Body) - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("FakeMessagePlatform returned HTTP %d", resp.StatusCode) - } - - return &channel.SendResult{ - ExternalID: fmt.Sprintf("fake_%d", message.ID), - DeliveredAt: time.Now(), - }, nil -} - -// === Contact === - -func (p *FakeProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) { - return &channel.ContactProfile{ - Name: contactSource, - Extra: channel.ChannelConfig{ - "source": "fake", - }, - }, nil -} - -// === Capabilities === - -func (p *FakeProvider) Capabilities() channel.ChannelCapabilities { - // Only capabilities actually implemented by FakeProvider are - // declared. Unsupported features return false so the dispatcher does - // not wait for callbacks (e.g. delivery receipts) that never arrive. - return channel.ChannelCapabilities{ - SupportsAttachments: true, - SupportsLocation: false, - SupportsTypingIndicator: false, - SupportsDeliveryStatus: false, - SupportsReplies: true, - SupportsEmojiReactions: false, - SupportsVoiceMessages: false, - SupportsVideoCalls: false, - SupportsCustomCards: false, - SupportsTemplates: false, - SupportsEmailHeaders: false, - MaxAttachmentSize: 50 * 1024 * 1024, - MaxTextLength: 0, - } -} - -// init registers FakeProvider with the global channel registry. The provider -// package is imported by bootstrap.go, so init() runs at startup. -func init() { - if isProductionEnv() { - return - } - channel.MustRegister(NewFakeProvider()) -} - -// --- helpers --- - -// parseFakeConfig decodes the inbox ChannelConfig JSON into a ChannelConfig map. -func parseFakeConfig(inbox *model.Inbox) channel.ChannelConfig { - if inbox == nil || inbox.ChannelConfig == "" { - return channel.ChannelConfig{} - } - var cfg channel.ChannelConfig - if err := json.Unmarshal([]byte(inbox.ChannelConfig), &cfg); err != nil { - return channel.ChannelConfig{} - } - return cfg -} - -// isProductionEnv reports whether the current GOCHAT_ENV indicates a -// production deployment. The fake channel is a test-only tool and must not -// register or accept traffic in production. -func isProductionEnv() bool { - env := strings.ToLower(strings.TrimSpace(os.Getenv("GOCHAT_ENV"))) - return env == "production" || env == "prod" -} - -func isValidURL(s string) bool { - if s == "" { - return false - } - u, err := url.Parse(s) - if err != nil { - return false - } - return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" -} diff --git a/backend/internal/channel/provider/fake_test.go b/backend/internal/channel/provider/fake_test.go deleted file mode 100644 index 728c9dba..00000000 --- a/backend/internal/channel/provider/fake_test.go +++ /dev/null @@ -1,390 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gochat/gochat/internal/channel" - "github.com/gochat/gochat/internal/model" -) - -func TestFakeProvider_Type(t *testing.T) { - p := NewFakeProvider() - if p.Type() != channel.ChannelFake { - t.Fatalf("expected Type()=%s, got %s", channel.ChannelFake, p.Type()) - } - if string(p.Type()) != "fake" { - t.Fatalf("expected string value 'fake', got '%s'", string(p.Type())) - } -} - -func TestFakeProvider_Name(t *testing.T) { - p := NewFakeProvider() - if p.Name() != "Fake Message Platform" { - t.Fatalf("unexpected Name(): %s", p.Name()) - } -} - -func TestFakeProvider_Description(t *testing.T) { - p := NewFakeProvider() - if p.Description() == "" { - t.Fatal("Description() should not be empty") - } -} - -func TestFakeProvider_ValidateConfig(t *testing.T) { - p := NewFakeProvider() - ctx := context.Background() - - // Missing identifier - if err := p.ValidateConfig(ctx, channel.ChannelConfig{}); err == nil { - t.Fatal("expected error for missing identifier") - } - - // Valid config - if err := p.ValidateConfig(ctx, channel.ChannelConfig{ - "identifier": "test_1", - "webhook_url": "http://127.0.0.1:9100/receive", - }); err != nil { - t.Fatalf("expected no error for valid config, got %v", err) - } - - // Invalid webhook_url - if err := p.ValidateConfig(ctx, channel.ChannelConfig{ - "identifier": "test_1", - "webhook_url": "not-a-url", - }); err == nil { - t.Fatal("expected error for invalid webhook_url") - } -} - -func TestFakeProvider_DefaultConfig(t *testing.T) { - p := NewFakeProvider() - cfg := p.DefaultConfig() - if cfg["webhook_url"] != "" { - t.Fatalf("expected default webhook_url empty, got %v", cfg["webhook_url"]) - } - if cfg["identifier"] != "" { - t.Fatalf("expected default identifier empty, got %v", cfg["identifier"]) - } -} - -func TestFakeProvider_ConfigSchema(t *testing.T) { - p := NewFakeProvider() - schema := p.ConfigSchema() - if schema.Type != "object" { - t.Fatalf("expected schema type 'object', got '%s'", schema.Type) - } - if _, ok := schema.Properties["webhook_url"]; !ok { - t.Fatal("expected 'webhook_url' property in schema") - } - if _, ok := schema.Properties["identifier"]; !ok { - t.Fatal("expected 'identifier' property in schema") - } -} - -func TestFakeProvider_ProcessIncoming(t *testing.T) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - inbox.AccountID = 10 - - payload := `{"event":"message.incoming","message_id":"msg_001","sender_id":"cust_1","sender_name":"Test Customer","content":"Hello","content_type":"text","timestamp":1720000000}` - - msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload)) - if err != nil { - t.Fatalf("ProcessIncoming failed: %v", err) - } - if msg.SourceID != "msg_001" { - t.Fatalf("expected SourceID 'msg_001', got '%s'", msg.SourceID) - } - if msg.SenderID != "cust_1" { - t.Fatalf("expected SenderID 'cust_1', got '%s'", msg.SenderID) - } - if msg.SenderName != "Test Customer" { - t.Fatalf("expected SenderName 'Test Customer', got '%s'", msg.SenderName) - } - if msg.Content != "Hello" { - t.Fatalf("expected Content 'Hello', got '%s'", msg.Content) - } - if msg.ContentType != channel.ContentText { - t.Fatalf("expected ContentType 'text', got '%s'", msg.ContentType) - } - if msg.InboxID != 1 { - t.Fatalf("expected InboxID 1, got %d", msg.InboxID) - } - if msg.AccountID != 10 { - t.Fatalf("expected AccountID 10, got %d", msg.AccountID) - } -} - -func TestFakeProvider_ProcessIncoming_SessionEnd(t *testing.T) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - inbox.AccountID = 10 - - payload := `{"event":"session.end","message_id":"msg_end","sender_id":"cust_1","content":"","timestamp":1720000000}` - - msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload)) - if err != nil { - t.Fatalf("ProcessIncoming failed: %v", err) - } - if msg.Content != "[session ended]" { - t.Fatalf("expected content '[session ended]', got '%s'", msg.Content) - } -} - -func TestFakeProvider_ProcessIncoming_WithAttachments(t *testing.T) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - inbox.AccountID = 10 - - payload := `{"event":"message.incoming","message_id":"msg_002","sender_id":"cust_1","sender_name":"Test","content":"See this","content_type":"image","attachments":[{"url":"http://example.com/img.png","content_type":"image/png","filename":"img.png","file_size":1024}]}` - - msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload)) - if err != nil { - t.Fatalf("ProcessIncoming failed: %v", err) - } - if len(msg.Attachments) != 1 { - t.Fatalf("expected 1 attachment, got %d", len(msg.Attachments)) - } - if msg.Attachments[0].URL != "http://example.com/img.png" { - t.Fatalf("unexpected attachment URL: %s", msg.Attachments[0].URL) - } - if msg.Attachments[0].Filename != "img.png" { - t.Fatalf("unexpected filename: %s", msg.Attachments[0].Filename) - } -} - -func TestFakeProvider_ValidateWebhookRequest(t *testing.T) { - p := NewFakeProvider() - - // No token configured → always valid - inbox := &model.Inbox{} - inbox.ID = 1 - err := p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{ - Headers: map[string]string{}, - }) - if err != nil { - t.Fatalf("expected no error with no token configured, got %v", err) - } - - // Token configured, valid header - configJSON, _ := json.Marshal(map[string]string{"token": "secret123"}) - inbox.ChannelConfig = string(configJSON) - err = p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{ - Headers: map[string]string{"X-Fake-Token": "secret123"}, - }) - if err != nil { - t.Fatalf("expected no error with valid token, got %v", err) - } - - // Token configured, wrong header - err = p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{ - Headers: map[string]string{"X-Fake-Token": "wrong"}, - }) - if err == nil { - t.Fatal("expected error with wrong token") - } - - // Token configured, lowercase header - err = p.ValidateWebhookRequest(context.Background(), inbox, &channel.WebhookRequest{ - Headers: map[string]string{"x-fake-token": "secret123"}, - }) - if err != nil { - t.Fatalf("expected no error with lowercase header, got %v", err) - } -} - -func TestFakeProvider_SendMessage_NoWebhookURL(t *testing.T) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - msg := &model.Message{ - Base: model.Base{ID: 100}, - ConversationID: 50, - Content: "test", - ContentType: "text", - SenderType: "agent", - } - - result, err := p.SendMessage(context.Background(), inbox, msg, nil) - if err != nil { - t.Fatalf("SendMessage without webhook_url should not error, got %v", err) - } - if result.ExternalID == "" { - t.Fatal("expected non-empty ExternalID") - } -} - -func TestFakeProvider_SendMessage_PostsToWebhookURL(t *testing.T) { - // Start a test HTTP server to receive the outbound POST - var receivedBody map[string]interface{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Fake-Token") != "my_token" { - t.Errorf("expected X-Fake-Token 'my_token', got '%s'", r.Header.Get("X-Fake-Token")) - } - json.NewDecoder(r.Body).Decode(&receivedBody) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) - })) - defer srv.Close() - - p := NewFakeProvider() - configJSON, _ := json.Marshal(map[string]string{ - "webhook_url": srv.URL, - "token": "my_token", - }) - inbox := &model.Inbox{} - inbox.ID = 1 - inbox.ChannelConfig = string(configJSON) - - senderID := uint(5) - msg := &model.Message{ - Base: model.Base{ID: 100}, - ConversationID: 50, - Content: "Hello from agent", - ContentType: "text", - SenderType: "agent", - SenderID: &senderID, - } - 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 { - t.Fatalf("SendMessage failed: %v", err) - } - if result.ExternalID == "" { - t.Fatal("expected non-empty ExternalID") - } - - // Verify the test server received the correct payload - if receivedBody["content"] != "Hello from agent" { - t.Fatalf("expected content 'Hello from agent', got %v", receivedBody["content"]) - } - if receivedBody["message_id"] != float64(100) { - t.Fatalf("expected message_id 100, got %v", receivedBody["message_id"]) - } - sender, ok := receivedBody["sender"].(map[string]interface{}) - if !ok { - t.Fatal("expected sender object") - } - 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) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - - profile, err := p.GetContactProfile(context.Background(), inbox, "source_id_1") - if err != nil { - t.Fatalf("GetContactProfile failed: %v", err) - } - if profile.Name != "source_id_1" { - t.Fatalf("expected name 'source_id_1', got '%s'", profile.Name) - } - if profile.Extra["source"] != "fake" { - t.Fatalf("expected source 'fake', got %v", profile.Extra["source"]) - } -} - -func TestFakeProvider_Capabilities(t *testing.T) { - p := NewFakeProvider() - caps := p.Capabilities() - if !caps.SupportsAttachments { - t.Fatal("expected SupportsAttachments=true") - } - if !caps.SupportsReplies { - t.Fatal("expected SupportsReplies=true") - } - // Capabilities narrowed to only what FakeProvider actually implements. - if caps.SupportsTypingIndicator { - t.Fatal("expected SupportsTypingIndicator=false") - } - if caps.SupportsDeliveryStatus { - t.Fatal("expected SupportsDeliveryStatus=false") - } - if caps.SupportsEmojiReactions { - t.Fatal("expected SupportsEmojiReactions=false") - } - if caps.SupportsVoiceMessages { - t.Fatal("expected SupportsVoiceMessages=false") - } - if caps.SupportsCustomCards { - t.Fatal("expected SupportsCustomCards=false") - } - if caps.SupportsTemplates { - t.Fatal("expected SupportsTemplates=false") - } -} - -func TestFakeProvider_ProcessIncoming_TypingStartReturnsNil(t *testing.T) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - inbox.AccountID = 10 - - payload := `{"event":"typing.start","message_id":"msg_t1","sender_id":"cust_1","content":"","timestamp":1720000000}` - - msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload)) - if err != nil { - t.Fatalf("ProcessIncoming for typing.start should not error, got: %v", err) - } - if msg != nil { - t.Fatalf("expected nil IncomingMessage for typing.start, got: %+v", msg) - } -} - -func TestFakeProvider_ProcessIncoming_TypingStopReturnsNil(t *testing.T) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - inbox.AccountID = 10 - - payload := `{"event":"typing.stop","message_id":"msg_t2","sender_id":"cust_1","content":"","timestamp":1720000000}` - - msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload)) - if err != nil { - t.Fatalf("ProcessIncoming for typing.stop should not error, got: %v", err) - } - if msg != nil { - t.Fatalf("expected nil IncomingMessage for typing.stop, got: %+v", msg) - } -} - -func TestFakeProvider_ProcessIncoming_InvalidJSON(t *testing.T) { - p := NewFakeProvider() - inbox := &model.Inbox{} - inbox.ID = 1 - - _, err := p.ProcessIncoming(context.Background(), inbox, []byte("not json")) - if err == nil { - t.Fatal("expected error for invalid JSON") - } - if !strings.Contains(err.Error(), "failed to parse") { - t.Fatalf("expected parse error, got: %v", err) - } -} diff --git a/backend/internal/handler/webhook/fake_webhook.go b/backend/internal/handler/webhook/fake_webhook.go deleted file mode 100644 index 74690a22..00000000 --- a/backend/internal/handler/webhook/fake_webhook.go +++ /dev/null @@ -1,201 +0,0 @@ -package webhook - -// FakeWebhookHandler processes incoming FakeMessagePlatform webhook HTTP -// requests via Gin. -// -// URL pattern: /webhooks/fake/:identifier -// - GET: webhook verification (echo challenge) -// - POST: incoming message/event processing -// -// Unlike Telegram/LINE which have a dedicated channel model table, the fake -// channel stores all config (identifier, webhook_url, token) directly in the -// Inbox.ChannelConfig JSON column. lookupInbox therefore queries by -// channel_type='fake' and filters the identifier in Go (plan §7.2 错误 2). - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "github.com/gochat/gochat/internal/channel" - channelprovider "github.com/gochat/gochat/internal/channel/provider" - "github.com/gochat/gochat/internal/model" - "github.com/gochat/gochat/internal/worker" - applogger "github.com/gochat/gochat/pkg/logger" -) - -// FakeWebhookHandler processes FakeMessagePlatform webhook requests via Gin. -type FakeWebhookHandler struct { - provider *channelprovider.FakeProvider - db *gorm.DB - persister *IncomingPersister -} - -// NewFakeWebhookHandler creates a Fake webhook handler for Gin integration. -func NewFakeWebhookHandler( - provider *channelprovider.FakeProvider, - db *gorm.DB, - dispatcher ...*channel.Dispatcher, -) *FakeWebhookHandler { - return &FakeWebhookHandler{ - provider: provider, - db: db, - persister: NewIncomingPersister(db, dispatcher...), - } -} - -func (h *FakeWebhookHandler) WithWorkerPool(wp *worker.WorkerPool) *FakeWebhookHandler { - if h != nil && h.persister != nil { - h.persister.SetWorkerPool(wp) - } - return h -} - -func (h *FakeWebhookHandler) WithSearchIndexer(indexer IncomingSearchIndexer) *FakeWebhookHandler { - if h != nil && h.persister != nil { - h.persister.SetSearchIndexer(indexer) - } - return h -} - -// HandleFakeWebhookVerification echoes a challenge token for webhook URL -// verification. FakeMessagePlatform sends GET /webhooks/fake/:identifier with -// a "hub.challenge" query param (mirroring the Facebook/WhatsApp pattern). -func (h *FakeWebhookHandler) HandleFakeWebhookVerification(c *gin.Context) { - challenge := c.Query("hub.challenge") - if challenge == "" { - challenge = c.Query("challenge") - } - if challenge == "" { - c.JSON(http.StatusOK, gin.H{"status": "verified"}) - return - } - c.String(http.StatusOK, challenge) -} - -// HandleFakeWebhook processes an incoming FakeMessagePlatform webhook POST. -func (h *FakeWebhookHandler) HandleFakeWebhook(c *gin.Context) { - identifier := c.Param("identifier") - if identifier == "" { - applogger.L().Warn("Fake webhook: missing identifier in path") - c.JSON(http.StatusOK, gin.H{"status": "ignored"}) - return - } - - // Read request body - body, err := io.ReadAll(c.Request.Body) - if err != nil { - applogger.L().Errorf("Fake webhook: failed to read body: %v", err) - c.JSON(http.StatusOK, gin.H{"status": "ignored"}) - return - } - defer c.Request.Body.Close() - - applogger.L().Infof("Fake webhook received for identifier=%s", identifier) - - // Look up Inbox by channel_type='fake' and config identifier - inbox, err := h.lookupInbox(identifier) - if err != nil { - applogger.L().Errorf("Fake webhook: inbox lookup failed for identifier=%s: %v", identifier, err) - c.JSON(http.StatusOK, gin.H{"status": "ignored"}) - return - } - - // Validate webhook request (X-Fake-Token header) - headers := make(map[string]string) - for k, v := range c.Request.Header { - if len(v) > 0 { - headers[k] = v[0] - } - } - wr := &channel.WebhookRequest{ - ChannelType: channel.ChannelFake, - Identifier: identifier, - Headers: headers, - Body: body, - Method: c.Request.Method, - } - if err := h.provider.ValidateWebhookRequest(c.Request.Context(), inbox, wr); err != nil { - applogger.L().Warnf("Fake webhook: validation failed for inbox=%d: %v", inbox.ID, err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return - } - - // Process the incoming message via the provider - incomingMsg, err := h.provider.ProcessIncoming(c.Request.Context(), inbox, body) - if err != nil { - applogger.L().Errorf("Fake webhook: message processing failed for inbox=%d: %v", inbox.ID, err) - c.JSON(http.StatusOK, gin.H{"status": "ignored"}) - return - } - if incomingMsg == nil { - c.JSON(http.StatusOK, gin.H{"status": "ignored"}) - return - } - - // Persist the incoming message - if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { - applogger.L().Errorf("Fake webhook: persist message failed for inbox=%d source_id=%s: %v", - inbox.ID, incomingMsg.SourceID, persistErr) - c.JSON(http.StatusOK, gin.H{"status": "ignored"}) - return - } - - applogger.L().Infof("Fake webhook: message persisted (inbox_id=%d, source_id=%s, sender=%s)", - inbox.ID, incomingMsg.SourceID, incomingMsg.SenderID) - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// lookupInbox finds the Inbox for a fake channel by config identifier. -// On PostgreSQL, uses jsonb containment for a DB-side indexed lookup. -// On SQLite (test mode), falls back to loading all fake inboxes and -// filtering in Go — acceptable for low-volume test traffic. -func (h *FakeWebhookHandler) lookupInbox(identifier string) (*model.Inbox, error) { - if h.db == nil { - return nil, fmt.Errorf("fake webhook database is not configured") - } - - // PostgreSQL: use jsonb @> for a server-side, indexable query. - if h.db.Dialector.Name() == "postgres" { - var inbox model.Inbox - // channel_config is TEXT, so cast to jsonb before using the @> operator. - // channel_config::jsonb @> '{"identifier":""}' - query := fmt.Sprintf(`channel_type = 'fake' AND channel_config::jsonb @> '{"identifier":"%s"}'`, identifier) - if err := h.db.Where(query).First(&inbox).Error; err != nil { - return nil, fmt.Errorf("fake inbox not found for identifier=%s: %w", identifier, err) - } - return &inbox, nil - } - - // SQLite fallback: load all fake inboxes and filter in Go. - var inboxes []model.Inbox - if err := h.db.Where("channel_type = ?", "fake").Find(&inboxes).Error; err != nil { - return nil, fmt.Errorf("failed to query fake inboxes: %w", err) - } - - for i := range inboxes { - cfg := parseFakeInboxConfig(&inboxes[i]) - if id, _ := cfg["identifier"].(string); id == identifier { - return &inboxes[i], nil - } - } - return nil, fmt.Errorf("fake inbox not found for identifier=%s", identifier) -} - -// parseFakeInboxConfig decodes the Inbox.ChannelConfig JSON into a map. -func parseFakeInboxConfig(inbox *model.Inbox) map[string]interface{} { - if inbox == nil || inbox.ChannelConfig == "" { - return map[string]interface{}{} - } - var cfg map[string]interface{} - if err := json.Unmarshal([]byte(inbox.ChannelConfig), &cfg); err != nil { - applogger.L().Warnf("Fake: failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err) - return map[string]interface{}{} - } - return cfg -} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 01a592ce..a003c2b8 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -94,7 +94,6 @@ type Handlers struct { LineWebhook *webhook.LineWebhookHandler TwilioWebhook *webhook.TwilioWebhookHandler ShopifyWebhook *webhook.ShopifyWebhookHandler - FakeWebhook *webhook.FakeWebhookHandler AssignmentPolicy *v1.AssignmentPolicyHandler Label *v1.LabelHandler Search *v1.SearchHandler @@ -476,29 +475,6 @@ func RegisterRoutes( engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db)) engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db)) - // Fake webhook — FakeMessagePlatform integration test channel. - // GET: webhook URL verification (echo challenge) - // POST: incoming message/event processing (X-Fake-Token header validated) - // Reference: GoChat FakeMessagePlatform (channels/fake) posts to this - // endpoint to simulate external channel messages. - // Routes are registered unconditionally (like Telegram) so the route table - // is stable for parity tests; nil handlers respond with 503. - fakeGroup := webhookGroup.Group("/fake") - fakeGroup.GET("/:identifier", func(c *gin.Context) { - if handlers == nil || handlers.FakeWebhook == nil { - webhookProviderUnavailable(c) - return - } - handlers.FakeWebhook.HandleFakeWebhookVerification(c) - }) - fakeGroup.POST("/:identifier", func(c *gin.Context) { - if handlers == nil || handlers.FakeWebhook == nil { - webhookProviderUnavailable(c) - return - } - handlers.FakeWebhook.HandleFakeWebhook(c) - }) - // Twitter webhook — Account Activity API CRC validation + event processing // GET: CRC challenge response (crc_token query param) // POST: incoming DM/event processing diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index e5827c57..ce4f1066 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -94,8 +94,6 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "POST /webhooks/whatsapp/:phone_number", "POST /webhooks/tiktok", "POST /webhooks/shopify", - "GET /webhooks/fake/:identifier", - "POST /webhooks/fake/:identifier", "POST /twilio/callback", "POST /twilio/delivery_status", "POST /twilio/voice/call/:phone", diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index a266aedd..9acb2778 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -175,7 +175,6 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb "web_widget": true, "telegram": true, "facebook": true, "instagram": true, "whatsapp": true, "email": true, "api": true, "tiktok": true, "line": true, "twilio_sms": true, "sms": true, - "fake": isFakeChannelAllowed(), } if !validChannelTypes[req.ChannelType] { return nil, errors.New("invalid channel_type") @@ -2168,17 +2167,3 @@ func generateRandomHex(n int) (string, error) { } return hex.EncodeToString(b), nil } - -// isFakeChannelAllowed reports whether the fake test channel is allowed. -// It is disabled in production environments unless explicitly enabled for -// QA/UAT with GOCHAT_ALLOW_FAKE_CHANNEL. -func isFakeChannelAllowed() bool { - env := strings.ToLower(strings.TrimSpace(os.Getenv("GOCHAT_ENV"))) - switch env { - case "production", "prod": - value := strings.TrimSpace(os.Getenv("GOCHAT_ALLOW_FAKE_CHANNEL")) - return strings.EqualFold(value, "true") || value == "1" - default: - return true - } -} diff --git a/backend/internal/service/inbox_service_test.go b/backend/internal/service/inbox_service_test.go index 7b685e52..45edbfe7 100644 --- a/backend/internal/service/inbox_service_test.go +++ b/backend/internal/service/inbox_service_test.go @@ -683,23 +683,3 @@ func TestInboxService_SetInboundCalls_RejectsUnsupportedInbox(t *testing.T) { require.NoError(t, db.Save(inbox).Error) require.ErrorIs(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true), ErrInboxInboundCallsUnsupported) } - -func TestIsFakeChannelAllowed(t *testing.T) { - t.Run("non-production environments allow fake channels", func(t *testing.T) { - t.Setenv("GOCHAT_ENV", "development") - t.Setenv("GOCHAT_ALLOW_FAKE_CHANNEL", "") - assert.True(t, isFakeChannelAllowed()) - }) - - t.Run("production disables fake channels by default", func(t *testing.T) { - t.Setenv("GOCHAT_ENV", "prod") - t.Setenv("GOCHAT_ALLOW_FAKE_CHANNEL", "") - assert.False(t, isFakeChannelAllowed()) - }) - - t.Run("production can explicitly enable fake channels", func(t *testing.T) { - t.Setenv("GOCHAT_ENV", "production") - t.Setenv("GOCHAT_ALLOW_FAKE_CHANNEL", "true") - assert.True(t, isFakeChannelAllowed()) - }) -} diff --git a/backend/internal/wsevent/bridge_listener_test.go b/backend/internal/wsevent/bridge_listener_test.go index dc758082..9145db6e 100644 --- a/backend/internal/wsevent/bridge_listener_test.go +++ b/backend/internal/wsevent/bridge_listener_test.go @@ -55,7 +55,7 @@ func TestBridgeListenerMessageCreatedUsesChatwootPushPayload(t *testing.T) { Name: "Fake Customer", Identifier: "customer_001", } - event := channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelFake, 1, 4) + event := channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelWebWidget, 1, 4) event.ConversationID = conversation.ID event.Data["message"] = message event.Data["conversation"] = conversation @@ -116,8 +116,8 @@ func TestBridgeListenerConversationUpdatedIncludesMeta(t *testing.T) { 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) + inbox := &model.Inbox{Base: model.Base{ID: 4}, AccountID: 1, ChannelType: "web_widget"} + event := channel.NewChannelEvent(channel.EventConversationUpdated, channel.ChannelWebWidget, 1, 4) event.ConversationID = conversation.ID event.Data["conversation"] = conversation event.Data["contact"] = contact @@ -138,8 +138,8 @@ func TestBridgeListenerConversationUpdatedIncludesMeta(t *testing.T) { if !ok { t.Fatalf("expected conversation meta, got %#v", payload["meta"]) } - if meta["channel"] != "fake" { - t.Fatalf("expected fake channel meta, got %#v", meta) + if meta["channel"] != "web_widget" { + t.Fatalf("expected web_widget channel meta, got %#v", meta) } sender, ok := meta["sender"].(map[string]interface{}) if !ok || sender["name"] != "Fake Customer" || sender["type"] != "contact" { diff --git a/channels/README.md b/channels/README.md index fa3d4749..1ea4abf4 100644 --- a/channels/README.md +++ b/channels/README.md @@ -6,7 +6,6 @@ This directory houses external channel platform integrations for GoChat testing ``` channels/ -├── fake/ # FakeMessagePlatform — implemented (test channel) ├── qq/ # Reserved — QQ ├── weixin/ # Reserved — WeChat (微信公众号/小程序/企业微信) ├── shangwutong/ # Reserved — 商务通 @@ -16,9 +15,8 @@ channels/ ## Current status -- **fake** — Fully implemented. A programmable fake message platform for automated end-to-end integration testing. See `fake/README.md`. - **qq/weixin/shangwutong/douyin/xiaohongshu** — Reserved only. No code implemented. These platforms require real API credentials and per-platform provider implementations. The directories are pre-created so future extensions have a stable path convention. ## GoChat backend channel providers -Each channel's backend provider lives in `backend/internal/channel/provider/`. The fake channel's provider is at `backend/internal/channel/provider/fake.go`. +Each channel's backend provider lives in `backend/internal/channel/provider/`. diff --git a/channels/fake/README.md b/channels/fake/README.md deleted file mode 100644 index c4b4da04..00000000 --- a/channels/fake/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# FakeMessagePlatform - -A programmable fake message platform for GoChat automated integration testing. - -## What it does - -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. 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 - -```bash -# From repo root -pnpm install -pnpm fake:start - -# Health check -curl http://127.0.0.1:9100/health -# → {"status":"ok","service":"fake-message-platform"} -``` - -## Configuration - -Environment variables: - -| Variable | Default | Description | -|----------|---------|-------------| -| `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 - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/send` | Send a message to GoChat (simulate customer) | -| `POST` | `/api/reply` | Reply to a message (simulate customer reply) | -| `POST` | `/api/close` | Close a chat session (session.end event) | -| `POST` | `/api/typing` | Toggle typing indicator | -| `POST` | `/api/agent/online` | Mark an agent as online | -| `POST` | `/api/agent/offline` | Mark an agent as offline | -| `GET` | `/api/messages` | Query outbound messages received from GoChat | -| `GET` | `/api/messages/:id` | Query a single message | -| `GET` | `/api/status` | Platform status (agents, counts) | -| `POST` | `/api/reset` | Reset all in-memory state | -| `POST` | `/receive` | Receive GoChat outbound message | -| `GET` | `/health` | Health check | - -## Message flow - -``` -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` -- GoChat FakeWebhookHandler: `backend/internal/handler/webhook/fake_webhook.go` -- Test plan: `docs/qa/2026-07-09-test-plan-round5.md` diff --git a/channels/fake/package.json b/channels/fake/package.json deleted file mode 100644 index 7282d033..00000000 --- a/channels/fake/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@gochat/fake-platform", - "version": "0.1.0", - "private": true, - "description": "FakeMessagePlatform — programmable fake message platform for GoChat automated integration testing", - "type": "module", - "scripts": { - "start": "tsx src/index.ts", - "dev": "tsx watch src/index.ts", - "test": "vitest run" - }, - "dependencies": { - "express": "^4.19.2" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/node": "^20.14.0", - "tsx": "^4.15.0", - "typescript": "^5.5.0", - "vitest": "^2.0.0" - }, - "engines": { - "node": ">=20" - } -} diff --git a/channels/fake/src/client/gochat-client.ts b/channels/fake/src/client/gochat-client.ts deleted file mode 100644 index fbd86acb..00000000 --- a/channels/fake/src/client/gochat-client.ts +++ /dev/null @@ -1,58 +0,0 @@ -// GoChat webhook client — sends HTTP POST to GoChat's /webhooks/fake/:identifier -// endpoint, simulating an external channel sending messages to GoChat. - -import type { GoChatWebhookPayload } from '../types.js'; - -export class GoChatClient { - private webhookUrl: string; - private token: string; - - constructor(webhookUrl: string, token: string = '') { - this.webhookUrl = webhookUrl; - this.token = token; - } - - setWebhookUrl(url: string): void { - this.webhookUrl = url; - } - - setToken(token: string): void { - this.token = token; - } - - getWebhookUrl(): string { - return this.webhookUrl; - } - - async sendToGoChat(payload: GoChatWebhookPayload): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - }; - if (this.token) { - headers['X-Fake-Token'] = this.token; - } - - const response = await fetch(this.webhookUrl, { - method: 'POST', - headers, - body: JSON.stringify(payload), - }); - - return response; - } - - async verifyWebhook(challenge: string = 'test_challenge'): Promise { - const url = `${this.webhookUrl}?hub.challenge=${encodeURIComponent(challenge)}`; - const headers: Record = {}; - if (this.token) { - headers['X-Fake-Token'] = this.token; - } - - try { - const response = await fetch(url, { method: 'GET', headers }); - return response.ok; - } catch { - return false; - } - } -} diff --git a/channels/fake/src/index.ts b/channels/fake/src/index.ts deleted file mode 100644 index c71502b5..00000000 --- a/channels/fake/src/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -// FakeMessagePlatform — entry point. -// -// Reads configuration from environment variables: -// 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'; - -const port = parseInt(process.env.PORT || '9100', 10); -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'}` - ); -}); diff --git a/channels/fake/src/server.ts b/channels/fake/src/server.ts deleted file mode 100644 index c0ba95d0..00000000 --- a/channels/fake/src/server.ts +++ /dev/null @@ -1,389 +0,0 @@ -// FakeMessagePlatform — Express HTTP server. -// -// REST API: -// POST /api/send — send a message to GoChat webhook (simulate customer) -// POST /api/reply — reply to a message (simulate customer reply) -// POST /api/close — close a chat session (session.end event) -// POST /api/typing — toggle typing indicator -// POST /api/agent/online — mark an agent as online (observation) -// POST /api/agent/offline — mark an agent as offline -// GET /api/messages — query outbound messages received from GoChat -// GET /api/messages/:id — query a single message -// GET /api/status — platform status (agents, counts) -// POST /api/reset — reset all in-memory state -// POST /receive — receive GoChat outbound message (from FakeProvider.SendMessage) -// GET /health — health check - -import express from 'express'; -import type { - AgentOnlineRequest, - AgentOfflineRequest, - CloseSessionRequest, - GoChatOutboundMessage, - GoChatWebhookPayload, - MessageRecord, - ReplyMessageRequest, - SendMessageRequest, - TypingRequest, -} from './types.js'; -import { store } from './store/memory-store.js'; -import { GoChatClient } from './client/gochat-client.js'; - -export interface ServerConfig { - port: number; - gochatWebhookUrl: string; - gochatFakeToken: string; - autoReplyEnabled?: boolean; - autoReplyDelayMs?: number; -} - -export function createServer(config: ServerConfig): express.Application { - const app = express(); - app.use(express.json()); - - const gochatClient = new GoChatClient( - 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, auto_reply, auto_reply_delay_ms } = req.body || {}; - if (webhook_url) gochatClient.setWebhookUrl(webhook_url); - if (token !== undefined) gochatClient.setToken(token); - 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 --- - app.get('/health', (_req, res) => { - res.json({ status: 'ok', service: 'fake-message-platform' }); - }); - - // --- Send message (simulate customer sending to GoChat) --- - app.post('/api/send', async (req, res) => { - const body = req.body as SendMessageRequest; - if (!body || !body.inbox_identifier || !body.sender_id || !body.content) { - res.status(400).json({ error: 'inbox_identifier, sender_id, and content are required' }); - return; - } - - const messageId = `fake_msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const payload: GoChatWebhookPayload = { - event: 'message.incoming', - message_id: messageId, - sender_id: body.sender_id, - sender_name: body.sender_name || body.sender_id, - content: body.content, - content_type: body.content_type || 'text', - conversation_id: body.conversation_id, - reply_to_id: body.reply_to_id, - timestamp: Math.floor(Date.now() / 1000), - attachments: body.attachments, - }; - - try { - const gResponse = await gochatClient.sendToGoChat(payload); - const record: MessageRecord = { - id: messageId, - direction: 'incoming', - inbox_identifier: body.inbox_identifier, - message_id: messageId, - sender_id: body.sender_id, - sender_name: body.sender_name, - content: body.content, - content_type: payload.content_type, - conversation_id: body.conversation_id, - timestamp: payload.timestamp, - }; - store.recordSent(record); - - res.json({ - status: 'sent', - message_id: messageId, - gochat_status: gResponse.status, - }); - } catch (err) { - res.status(502).json({ - error: 'Failed to send to GoChat', - detail: (err as Error).message, - }); - } - }); - - // --- Reply message (simulate customer replying) --- - app.post('/api/reply', async (req, res) => { - const body = req.body as ReplyMessageRequest; - if (!body || !body.inbox_identifier || !body.sender_id || !body.reply_to_id) { - res.status(400).json({ error: 'inbox_identifier, sender_id, and reply_to_id are required' }); - return; - } - - const messageId = `fake_reply_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const payload: GoChatWebhookPayload = { - event: 'message.incoming', - message_id: messageId, - sender_id: body.sender_id, - sender_name: body.sender_name || body.sender_id, - content: body.content, - content_type: body.content_type || 'text', - reply_to_id: body.reply_to_id, - conversation_id: body.conversation_id, - timestamp: Math.floor(Date.now() / 1000), - attachments: body.attachments, - }; - - try { - const gResponse = await gochatClient.sendToGoChat(payload); - const record: MessageRecord = { - id: messageId, - direction: 'incoming', - inbox_identifier: body.inbox_identifier, - message_id: messageId, - sender_id: body.sender_id, - sender_name: body.sender_name, - content: body.content, - content_type: payload.content_type, - conversation_id: body.conversation_id, - timestamp: payload.timestamp, - }; - store.recordSent(record); - - res.json({ - status: 'sent', - message_id: messageId, - gochat_status: gResponse.status, - }); - } catch (err) { - res.status(502).json({ - error: 'Failed to send reply to GoChat', - detail: (err as Error).message, - }); - } - }); - - // --- Close session --- - app.post('/api/close', async (req, res) => { - const body = req.body as CloseSessionRequest; - if (!body || !body.inbox_identifier || !body.sender_id) { - res.status(400).json({ error: 'inbox_identifier and sender_id are required' }); - return; - } - - const messageId = `fake_close_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const payload: GoChatWebhookPayload = { - event: 'session.end', - message_id: messageId, - sender_id: body.sender_id, - sender_name: '', - content: '[session ended]', - content_type: 'text', - conversation_id: body.conversation_id, - timestamp: Math.floor(Date.now() / 1000), - }; - - try { - const gResponse = await gochatClient.sendToGoChat(payload); - const record: MessageRecord = { - id: messageId, - direction: 'incoming', - inbox_identifier: body.inbox_identifier, - message_id: messageId, - sender_id: body.sender_id, - content: '[session ended]', - conversation_id: body.conversation_id, - timestamp: payload.timestamp, - }; - store.recordSent(record); - - res.json({ - status: 'sent', - message_id: messageId, - gochat_status: gResponse.status, - }); - } catch (err) { - res.status(502).json({ - error: 'Failed to send session.end to GoChat', - detail: (err as Error).message, - }); - } - }); - - // --- Typing indicator --- - app.post('/api/typing', async (req, res) => { - const body = req.body as TypingRequest; - if (!body || !body.inbox_identifier || !body.sender_id) { - res.status(400).json({ error: 'inbox_identifier and sender_id are required' }); - return; - } - - const messageId = `fake_typing_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const payload: GoChatWebhookPayload = { - event: body.typing ? 'typing.start' : 'typing.stop', - message_id: messageId, - sender_id: body.sender_id, - sender_name: '', - content: '', - content_type: 'text', - timestamp: Math.floor(Date.now() / 1000), - }; - - try { - await gochatClient.sendToGoChat(payload); - res.json({ status: 'sent', typing: body.typing }); - } catch (err) { - res.status(502).json({ - error: 'Failed to send typing event to GoChat', - detail: (err as Error).message, - }); - } - }); - - // --- Agent online --- - app.post('/api/agent/online', (req, res) => { - const body = req.body as AgentOnlineRequest; - if (!body || !body.agent_id) { - res.status(400).json({ error: 'agent_id is required' }); - return; - } - store.agentOnline(body.agent_id, body.agent_name || ''); - res.json({ status: 'ok', agent_id: body.agent_id, online: true }); - }); - - // --- Agent offline --- - app.post('/api/agent/offline', (req, res) => { - const body = req.body as AgentOfflineRequest; - if (!body || !body.agent_id) { - res.status(400).json({ error: 'agent_id is required' }); - return; - } - store.agentOffline(body.agent_id); - res.json({ status: 'ok', agent_id: body.agent_id, online: false }); - }); - - // --- Query messages --- - app.get('/api/messages', (req, res) => { - const inboxIdentifier = (req.query.inbox_identifier as string) || undefined; - const received = store.getReceivedMessages(inboxIdentifier); - const sent = store.getSentMessages(inboxIdentifier); - res.json({ - received: received, - sent: sent, - }); - }); - - // --- Query single message --- - app.get('/api/messages/:id', (req, res) => { - const msg = store.getMessageById(req.params.id); - if (!msg) { - res.status(404).json({ error: 'message not found' }); - return; - } - res.json(msg); - }); - - // --- Platform status --- - app.get('/api/status', (_req, res) => { - res.json({ - ...store.getStatus(), - auto_reply: autoReplyEnabled, - auto_reply_delay_ms: autoReplyDelayMs, - }); - }); - - // --- Reset all state --- - app.post('/api/reset', (_req, res) => { - store.reset(); - res.json({ status: 'ok' }); - }); - - // --- Receive GoChat outbound message (from FakeProvider.SendMessage) --- - app.post('/receive', (req, res) => { - const body = req.body as GoChatOutboundMessage; - if (!body || !body.message_id) { - res.status(400).json({ error: 'message_id is required' }); - return; - } - store.recordReceived(body); - - 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 ''; - } -} diff --git a/channels/fake/src/store/memory-store.ts b/channels/fake/src/store/memory-store.ts deleted file mode 100644 index c47e59bb..00000000 --- a/channels/fake/src/store/memory-store.ts +++ /dev/null @@ -1,110 +0,0 @@ -// In-memory state store for FakeMessagePlatform. -// All sent/received messages, agent states, and conversation counts are -// tracked here. No persistence — the store resets on process restart. - -import type { - AgentInfo, - GoChatOutboundMessage, - MessageRecord, - PlatformStatus, -} from '../types.js'; - -export class MemoryStore { - private sentMessages: MessageRecord[] = []; - private receivedMessages: GoChatOutboundMessage[] = []; - private agents: Map = new Map(); - private sentCount = 0; - private receivedCount = 0; - - // --- Sent messages (FakeMessagePlatform → GoChat) --- - recordSent(msg: MessageRecord): void { - this.sentMessages.push(msg); - this.sentCount++; - } - - getSentMessages(inboxIdentifier?: string): MessageRecord[] { - if (!inboxIdentifier) return [...this.sentMessages]; - return this.sentMessages.filter((m) => m.inbox_identifier === inboxIdentifier); - } - - // --- Received messages (GoChat → FakeMessagePlatform, outbound) --- - recordReceived(msg: GoChatOutboundMessage): void { - this.receivedMessages.push(msg); - this.receivedCount++; - } - - getReceivedMessages(inboxIdentifier?: string): GoChatOutboundMessage[] { - if (!inboxIdentifier) return [...this.receivedMessages]; - // We don't have inbox_identifier on the outbound payload, but the test - // harness typically filters by the single active fake inbox. - return [...this.receivedMessages]; - } - - getMessageById(id: string): MessageRecord | GoChatOutboundMessage | undefined { - return this.sentMessages.find((m) => m.id === id) || - this.receivedMessages.find((m) => `out_${m.message_id}` === id); - } - - // --- Agent state --- - agentOnline(agentId: string, agentName: string): void { - const existing = this.agents.get(agentId); - this.agents.set(agentId, { - agent_id: agentId, - agent_name: agentName, - online: true, - online_at: Date.now(), - offline_at: existing?.offline_at ?? null, - }); - } - - agentOffline(agentId: string): void { - const existing = this.agents.get(agentId); - if (existing) { - existing.online = false; - existing.offline_at = Date.now(); - } else { - this.agents.set(agentId, { - agent_id: agentId, - agent_name: '', - online: false, - online_at: null, - offline_at: Date.now(), - }); - } - } - - getOnlineAgents(): AgentInfo[] { - return Array.from(this.agents.values()).filter((a) => a.online); - } - - getAllAgents(): AgentInfo[] { - return Array.from(this.agents.values()); - } - - // --- Status --- - getStatus(): PlatformStatus { - const activeConversations = new Set( - this.sentMessages - .filter((m) => m.conversation_id) - .map((m) => m.conversation_id!) - ).size; - - return { - online_agents: this.getOnlineAgents(), - total_sent: this.sentCount, - total_received: this.receivedCount, - active_conversations: activeConversations, - }; - } - - // --- Reset (for testing) --- - reset(): void { - this.sentMessages = []; - this.receivedMessages = []; - this.agents.clear(); - this.sentCount = 0; - this.receivedCount = 0; - } -} - -export const store = new MemoryStore(); diff --git a/channels/fake/src/types.ts b/channels/fake/src/types.ts deleted file mode 100644 index 68b6d0d5..00000000 --- a/channels/fake/src/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Type definitions for FakeMessagePlatform - -export interface SendMessageRequest { - inbox_identifier: string; - sender_id: string; - sender_name: string; - content: string; - content_type?: string; - reply_to_id?: string; - conversation_id?: string; - attachments?: FakeAttachment[]; -} - -export interface FakeAttachment { - url: string; - content_type: string; - filename?: string; - file_size?: number; -} - -export interface ReplyMessageRequest extends SendMessageRequest { - reply_to_id: string; -} - -export interface CloseSessionRequest { - inbox_identifier: string; - conversation_id?: string; - sender_id: string; -} - -export interface TypingRequest { - inbox_identifier: string; - sender_id: string; - typing: boolean; -} - -export interface AgentOnlineRequest { - agent_id: string; - agent_name: string; -} - -export interface AgentOfflineRequest { - agent_id: string; -} - -// The payload sent to GoChat's /webhooks/fake/:identifier endpoint -export interface GoChatWebhookPayload { - event: string; - message_id: string; - sender_id: string; - sender_name: string; - content: string; - content_type: string; - conversation_id?: string; - reply_to_id?: string; - timestamp: number; - attachments?: FakeAttachment[]; -} - -// The payload received from GoChat's FakeProvider.SendMessage (outbound) -export interface GoChatOutboundMessage { - message_id: number; - conversation_id: number; - content: string; - content_type: string; - sender: { - id: number; - name: string; - type: string; - }; - recipient?: { - id: number; - name: string; - source_id: string; - }; -} - -export interface AgentInfo { - agent_id: string; - agent_name: string; - online: boolean; - online_at: number | null; - offline_at: number | null; -} - -export interface PlatformStatus { - online_agents: AgentInfo[]; - total_sent: number; - total_received: number; - active_conversations: number; -} - -export interface MessageRecord { - id: string; - direction: 'incoming' | 'outgoing'; - inbox_identifier: string; - message_id: string; - sender_id?: string; - sender_name?: string; - content: string; - content_type?: string; - conversation_id?: string; - timestamp: number; -} diff --git a/channels/fake/tests/integration.test.ts b/channels/fake/tests/integration.test.ts deleted file mode 100644 index 24b56184..00000000 --- a/channels/fake/tests/integration.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -import http from 'node:http'; -import type { AddressInfo } from 'node:net'; -import { createServer } from '../src/server.js'; -import { store } from '../src/store/memory-store.js'; - -// Start a mock GoChat webhook server to capture the POSTs from FakeMessagePlatform. -let mockGochatPort: number; -let mockGochatServer: http.Server; -let receivedByGochat: any[] = []; - -function startMockGochat(): Promise { - return new Promise((resolve) => { - const srv = http.createServer((req, res) => { - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - if (req.method === 'POST') { - try { - receivedByGochat.push(JSON.parse(body)); - } catch { /* ignore */ } - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - }); - }); - srv.listen(0, '127.0.0.1', () => { - const addr = srv.address() as AddressInfo; - mockGochatPort = addr.port; - resolve(srv); - }); - }); -} - -let fakeApp: http.Server; -let fakePort: number; - -function startFakeServer(webhookUrl: string): Promise { - const app = createServer({ - port: 0, - gochatWebhookUrl: webhookUrl, - gochatFakeToken: '', - autoReplyEnabled: true, - autoReplyDelayMs: 0, - }); - return new Promise((resolve) => { - const srv = app.listen(0, '127.0.0.1', () => { - const addr = srv.address() as AddressInfo; - fakePort = addr.port; - resolve(srv as unknown as http.Server); - }); - }); -} - -async function fakeRequest(path: string, method: string = 'GET', body?: any): Promise { - const res = await fetch(`http://127.0.0.1:${fakePort}${path}`, { - method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - return { status: res.status, data: await res.json() }; -} - -async function waitFor(condition: () => boolean, timeoutMs: number = 1000): Promise { - 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`); -}); - -afterAll(async () => { - await new Promise((resolve) => fakeApp.close(() => resolve())); - await new Promise((resolve) => mockGochatServer.close(() => resolve())); -}); - -beforeEach(() => { - store.reset(); - receivedByGochat = []; -}); - -describe('FakeMessagePlatform', () => { - describe('/health', () => { - it('returns ok status', async () => { - const res = await fakeRequest('/health'); - expect(res.status).toBe(200); - expect(res.data.status).toBe('ok'); - expect(res.data.service).toBe('fake-message-platform'); - }); - }); - - describe('/api/send', () => { - it('sends a message to GoChat webhook', async () => { - const res = await fakeRequest('/api/send', 'POST', { - inbox_identifier: 'fake_test_1', - sender_id: 'customer_001', - sender_name: 'Test Customer', - content: 'Hello, I need help', - }); - expect(res.status).toBe(200); - expect(res.data.status).toBe('sent'); - expect(res.data.message_id).toBeDefined(); - expect(res.data.gochat_status).toBe(200); - - // Verify GoChat received the webhook - expect(receivedByGochat).toHaveLength(1); - expect(receivedByGochat[0].event).toBe('message.incoming'); - expect(receivedByGochat[0].content).toBe('Hello, I need help'); - expect(receivedByGochat[0].sender_id).toBe('customer_001'); - }); - - it('returns 400 for missing required fields', async () => { - const res = await fakeRequest('/api/send', 'POST', { - inbox_identifier: 'fake_test_1', - }); - expect(res.status).toBe(400); - expect(res.data.error).toContain('required'); - }); - }); - - describe('/api/agent/online + /api/status', () => { - it('marks an agent as online and shows in status', async () => { - const res = await fakeRequest('/api/agent/online', 'POST', { - agent_id: '1', - agent_name: 'Admin', - }); - expect(res.status).toBe(200); - expect(res.data.online).toBe(true); - - const statusRes = await fakeRequest('/api/status'); - expect(statusRes.data.online_agents).toHaveLength(1); - expect(statusRes.data.online_agents[0].agent_id).toBe('1'); - expect(statusRes.data.online_agents[0].agent_name).toBe('Admin'); - }); - - it('marks an agent as offline', async () => { - await fakeRequest('/api/agent/online', 'POST', { - agent_id: '2', - agent_name: 'Agent2', - }); - const offlineRes = await fakeRequest('/api/agent/offline', 'POST', { - agent_id: '2', - }); - expect(offlineRes.status).toBe(200); - expect(offlineRes.data.online).toBe(false); - - const statusRes = await fakeRequest('/api/status'); - expect(statusRes.data.online_agents).toHaveLength(0); - }); - }); - - describe('/api/reset', () => { - it('clears all state', async () => { - await fakeRequest('/api/agent/online', 'POST', { - agent_id: '1', - agent_name: 'Admin', - }); - await fakeRequest('/api/send', 'POST', { - inbox_identifier: 'fake_test_1', - sender_id: 'cust_1', - sender_name: 'C', - content: 'hi', - }); - - const res = await fakeRequest('/api/reset', 'POST'); - expect(res.status).toBe(200); - - const statusRes = await fakeRequest('/api/status'); - expect(statusRes.data.online_agents).toHaveLength(0); - expect(statusRes.data.total_sent).toBe(0); - }); - }); - - describe('/receive', () => { - 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); - }); - }); - - describe('/api/typing', () => { - it('sends typing event to GoChat', async () => { - const res = await fakeRequest('/api/typing', 'POST', { - inbox_identifier: 'fake_test_1', - sender_id: 'customer_001', - typing: true, - }); - expect(res.status).toBe(200); - expect(res.data.typing).toBe(true); - expect(receivedByGochat[0].event).toBe('typing.start'); - }); - - it('sends typing stop event', async () => { - const res = await fakeRequest('/api/typing', 'POST', { - inbox_identifier: 'fake_test_1', - sender_id: 'customer_001', - typing: false, - }); - expect(res.status).toBe(200); - expect(res.data.typing).toBe(false); - expect(receivedByGochat[0].event).toBe('typing.stop'); - }); - }); - - describe('/api/close', () => { - it('sends session.end event', async () => { - const res = await fakeRequest('/api/close', 'POST', { - inbox_identifier: 'fake_test_1', - conversation_id: 'ext_conv_1', - sender_id: 'customer_001', - }); - expect(res.status).toBe(200); - expect(receivedByGochat[0].event).toBe('session.end'); - expect(receivedByGochat[0].content).toBe('[session ended]'); - }); - }); -}); diff --git a/channels/fake/tsconfig.json b/channels/fake/tsconfig.json deleted file mode 100644 index 5a6c37e9..00000000 --- a/channels/fake/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src", - "types": ["node"] - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "tests"] -} diff --git a/deploy/quickstart/.env.example b/deploy/quickstart/.env.example index 99f80325..f469365a 100644 --- a/deploy/quickstart/.env.example +++ b/deploy/quickstart/.env.example @@ -19,7 +19,6 @@ REDIS_PASSWORD= # GoChat runtime GOCHAT_ENV=development -GOCHAT_ALLOW_FAKE_CHANNEL=false GOCHAT_SERVER_MODE=debug GOCHAT_JWT_SECRET=gochat_quickstart_change_me_minimum_32_chars diff --git a/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue b/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue index db0b7520..2cbad72c 100644 --- a/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -67,7 +67,6 @@ const isActive = computed(() => { 'instagram', 'tiktok', 'voice', - 'fake', ].includes(key); }); diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 373fe326..5825f31f 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -382,34 +382,6 @@ "ERROR_MESSAGE": "We were not able to save the api channel" } }, - "FAKE_CHANNEL": { - "TITLE": "Fake Test Channel", - "DESC": "Create a Fake message channel for automated integration testing.", - "CHANNEL_NAME": { - "LABEL": "Channel Name", - "PLACEHOLDER": "Please enter a channel name", - "ERROR": "This field is required" - }, - "IDENTIFIER": { - "LABEL": "Identifier", - "PLACEHOLDER": "e.g. fake_test_1", - "ERROR": "This field is required" - }, - "WEBHOOK_URL": { - "LABEL": "Webhook URL", - "SUBTITLE": "FakeMessagePlatform callback receive endpoint (e.g. http://127.0.0.1:9100/receive).", - "PLACEHOLDER": "Webhook URL" - }, - "TOKEN": { - "LABEL": "Token (optional)", - "SUBTITLE": "Shared secret for X-Fake-Token header verification.", - "PLACEHOLDER": "Token" - }, - "SUBMIT_BUTTON": "Create Fake Channel", - "API": { - "ERROR_MESSAGE": "We were not able to create the fake channel" - } - }, "EMAIL_CHANNEL": { "TITLE": "Email Channel", "DESC": "Integrate your email inbox.", @@ -517,10 +489,6 @@ "TITLE": "Instagram", "DESCRIPTION": "Connect your instagram account" }, - "FAKE": { - "TITLE": "Fake Test Platform", - "DESCRIPTION": "Create a Fake message channel for automated integration testing" - }, "TIKTOK": { "TITLE": "TikTok", "DESCRIPTION": "Connect your TikTok account" @@ -1198,8 +1166,7 @@ "API": "API Channel", "INSTAGRAM": "Instagram", "TIKTOK": "TikTok", - "VOICE": "Voice", - "FAKE": "Fake Test Channel" + "VOICE": "Voice" } } } diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json index c8f13496..3333edbb 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -382,34 +382,6 @@ "ERROR_MESSAGE": "我们无法保存 api 频道" } }, - "FAKE_CHANNEL": { - "TITLE": "Fake 测试频道", - "DESC": "创建一个 Fake 消息渠道,用于自动化集成测试。", - "CHANNEL_NAME": { - "LABEL": "频道名称", - "PLACEHOLDER": "请输入频道名称", - "ERROR": "此字段是必填项" - }, - "IDENTIFIER": { - "LABEL": "标识符", - "PLACEHOLDER": "例如 fake_test_1", - "ERROR": "此字段是必填项" - }, - "WEBHOOK_URL": { - "LABEL": "Webhook URL", - "SUBTITLE": "FakeMessagePlatform 的回调接收端点(例如 http://127.0.0.1:9100/receive)。", - "PLACEHOLDER": "Webhook URL" - }, - "TOKEN": { - "LABEL": "Token(可选)", - "SUBTITLE": "用于 X-Fake-Token 头验证的共享密钥。", - "PLACEHOLDER": "Token" - }, - "SUBMIT_BUTTON": "创建 Fake 频道", - "API": { - "ERROR_MESSAGE": "我们无法创建 fake 频道" - } - }, "EMAIL_CHANNEL": { "TITLE": "电子邮件频道", "DESC": "集成您的电子邮件收件箱。", @@ -517,10 +489,6 @@ "TITLE": "Instagram", "DESCRIPTION": "连接您的 instagram 帐户" }, - "FAKE": { - "TITLE": "Fake 测试平台", - "DESCRIPTION": "创建用于自动化测试的 Fake 消息渠道" - }, "TIKTOK": { "TITLE": "TikTok", "DESCRIPTION": "Connect your TikTok account" @@ -1198,8 +1166,7 @@ "API": "API 频道", "INSTAGRAM": "Instagram", "TIKTOK": "TikTok", - "VOICE": "语音", - "FAKE": "Fake 测试频道" + "VOICE": "语音" } } } diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index a1d12afd..7d1d5885 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -12,7 +12,6 @@ import Telegram from './channels/Telegram.vue'; import Instagram from './channels/Instagram.vue'; import Tiktok from './channels/Tiktok.vue'; import Voice from './channels/Voice.vue'; -import Fake from './channels/Fake.vue'; const channelViewList = { facebook: Facebook, @@ -27,7 +26,6 @@ const channelViewList = { instagram: Instagram, tiktok: Tiktok, voice: Voice, - fake: Fake, }; export default defineComponent({ diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index 6388e7b2..e2ebd27c 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -79,16 +79,6 @@ const channelList = computed(() => { }, ]; - // Fake channel is a test-only tool — show only in development builds. - if (import.meta.env.DEV) { - channels.push({ - key: 'fake', - title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FAKE.TITLE'), - description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FAKE.DESCRIPTION'), - icon: 'i-woot-api', - }); - } - if (hasTiktokConfigured.value) { channels.push({ key: 'tiktok', diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Fake.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Fake.vue deleted file mode 100644 index c8f5bec1..00000000 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Fake.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - diff --git a/package.json b/package.json index 9cb0e433..26f6753c 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,6 @@ "dev:backend": "cd backend && air -c .air.toml", "dev:frontend": "cd frontend && pnpm dev", "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": "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", "lint:frontend": "cd frontend && pnpm eslint", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ccc71ead..44d5ff44 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,2 @@ packages: - frontend - - channels/fake diff --git a/test-captain-llm-provider.sh b/test-captain-llm-provider.sh deleted file mode 100755 index 7b22c9b8..00000000 --- a/test-captain-llm-provider.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/bin/bash -# fake:ai LLM provider test -# Tests all Captain/Copilot API endpoints with canned data -# since there's no real AI provider configured (model is gpt-4o). -# -# CRITERIA: The API must respond without crash (no 500) for every endpoint. -# Endpoints that require an actual LLM provider return 4xx with -# "Copilot provider is not configured" — this is EXPECTED behavior. - -BASE="http://127.0.0.1:3000/api/v1/accounts/1" -AUTH="-H X-User-ID:1 -H X-Account-ID:1" -CT="-H Content-Type:application/json" -PASS=0 -FAIL=0 -EXPECTED=0 - -green() { echo -e "\033[32m✓ $1\033[0m"; PASS=$((PASS+1)); } -red() { echo -e "\033[31m✗ $1\033[0m"; FAIL=$((FAIL+1)); } -blue() { echo -e "\033[34m∼ $1 (expected — no LLM provider)\033[0m"; EXPECTED=$((EXPECTED+1)); } -check() { local label="$1" code="$2" body="$3" - if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then - green "$label (HTTP $code)" - elif echo "$body" | grep -q "Copilot provider is not configured"; then - blue "$label (HTTP $code)" - else - red "$label (HTTP $code) — $(echo "$body" | head -c 200)" - fi -} - -echo "========================================" -echo " fake:ai LLM Provider — Captain Tests" -echo "========================================" -echo "" - -# --- 1. GET captain/assistants --- -echo "--- 1. GET captain/assistants ---" -resp=$(curl -s -w "\n%{http_code}" $AUTH "$BASE/captain/assistants") -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "List assistants" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -# --- 2. POST captain/assistants (create an assistant) --- -echo "--- 2. POST captain/assistants (create assistant) ---" -resp=$(curl -s -w "\n%{http_code}" -X POST $AUTH $CT \ - "$BASE/captain/assistants" \ - -d '{"assistant":{"name":"faketest-ai","description":"test from fake:ai","config":{"model":"gpt-4o","temperature":0.7,"feature_faq":true,"feature_memory":false,"feature_contact_attributes":false,"product_name":"GoChat"}}}') -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "Create assistant" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -15 -echo "" - -# --- 3. GET captain/assistants/tools --- -echo "--- 3. GET captain/assistants/tools ---" -resp=$(curl -s -w "\n%{http_code}" $AUTH "$BASE/captain/assistants/tools") -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "List tools" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -# --- 4. POST assistant playground (simulate LLM call) --- -echo "--- 4. POST captain/assistants/1/playground (simulate LLM call) ---" -# Uses message_content (flat) as documented in the handler -resp=$(curl -s -w "\n%{http_code}" -X POST $AUTH $CT \ - "$BASE/captain/assistants/1/playground" \ - -d '{"message_content":"Hello from fake:ai test"}') -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "Playground (simulated)" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -# --- 5. POST captain/copilot_threads (create thread) --- -echo "--- 5. POST captain/copilot_threads (create thread) ---" -# Uses flat message + assistant_id as documented in the handler test -resp=$(curl -s -w "\n%{http_code}" -X POST $AUTH $CT \ - "$BASE/captain/copilot_threads" \ - -d '{"message":"Hello Copilot, please help with a customer query","assistant_id":1}') -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "Create copilot thread" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -# --- 6. POST captain/tasks/reply_suggestion (suggest reply) --- -echo "--- 6. POST captain/tasks/reply_suggestion ---" -resp=$(curl -s -w "\n%{http_code}" -X POST $AUTH $CT \ - "$BASE/captain/tasks/reply_suggestion" \ - -d '{"conversation_id":1,"assistant_id":1}') -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "Reply suggestion" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -# --- 7. POST captain/tasks/summarize --- -echo "--- 7. POST captain/tasks/summarize ---" -resp=$(curl -s -w "\n%{http_code}" -X POST $AUTH $CT \ - "$BASE/captain/tasks/summarize" \ - -d '{"conversation_id":1,"assistant_id":1}') -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "Summarize" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -# --- 8. POST captain/tasks/rewrite --- -echo "--- 8. POST captain/tasks/rewrite ---" -resp=$(curl -s -w "\n%{http_code}" -X POST $AUTH $CT \ - "$BASE/captain/tasks/rewrite" \ - -d '{"content":"This is a test message to be rewritten for clarity","assistant_id":1}') -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "Rewrite" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -# --- 9. GET captain/preferences --- -echo "--- 9. GET captain/preferences ---" -resp=$(curl -s -w "\n%{http_code}" $AUTH "$BASE/captain/preferences") -code=$(echo "$resp" | tail -1) -body=$(echo "$resp" | sed '$d') -check "Get preferences" "$code" "$body" -echo "$body" | python3 -m json.tool 2>/dev/null | head -20 -echo "" - -echo "========================================" -echo " RESULTS: $PASS passed, $FAIL failed, $EXPECTED expected (no LLM provider)" -echo "========================================" -exit $FAIL