Files
gochat/backend/internal/channel/provider/fake.go
T
rogee 05af5ebcbc fix: harden fake channel — production guard, token validation, typing events, URL validation, capability narrowing
- Guard fake channel with GOCHAT_ENV check: skip init() registration,
  bootstrap wiring, and inbox creation in production
- Reject empty-token webhooks in production (was silently skipped)
- Use PostgreSQL jsonb @> query for inbox lookup, keep SQLite fallback
- Replace isValidURL string-prefix hack with net/url.Parse
- Handle typing.start/typing.stop by returning nil (no garbage messages)
- Narrow Capabilities to only implemented features (Attachments, Replies)
- Hide fake channel from frontend channel list in production builds
2026-07-10 10:55:54 +08:00

374 lines
11 KiB
Go

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"`
}
type FakeSender struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
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
}
// Prefer contact name when available, fall back to empty.
if contact != nil {
sender.Name = contact.Name
}
payload := FakeOutboundPayload{
MessageID: message.ID,
ConversationID: message.ConversationID,
Content: message.Content,
ContentType: message.ContentType,
Sender: sender,
}
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 != ""
}