- 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
374 lines
11 KiB
Go
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 != ""
|
|
}
|