refactor: 移除 FakeMessagePlatform channel 全部逻辑、展示、配置
- 删除 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 无关
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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":"<id>"}'
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user