Files
gochat/channels/shangwutong/internal/httpapi/server.go
T
2026-08-15 19:02:52 +08:00

689 lines
29 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated"
"github.com/gochat/gochat/channels/shangwutong/internal/account"
"github.com/gochat/gochat/channels/shangwutong/internal/gochat"
"github.com/gochat/gochat/channels/shangwutong/internal/observability"
"github.com/gochat/gochat/channels/shangwutong/internal/store"
"github.com/gofiber/fiber/v3"
fiberrecover "github.com/gofiber/fiber/v3/middleware/recover"
"github.com/sirupsen/logrus"
)
const (
maxWebhookBodyBytes = 2 << 20
maxOutboundParts = 100
)
type SupervisorManager interface {
Running() int
SetTyping(int64, string, bool) error
}
type Server struct {
app *fiber.App
store *store.Store
reconciler *account.Reconciler
manager SupervisorManager
logger *logrus.Entry
metrics *observability.Metrics
now func() time.Time
ready atomic.Bool
metricMu sync.RWMutex
metricData observability.MetricSnapshot
}
func NewServer(database *store.Store, reconciler *account.Reconciler, manager SupervisorManager, logger *logrus.Entry, metricRegistries ...*observability.Metrics) (*Server, error) {
if database == nil || reconciler == nil {
return nil, errors.New("store and reconciler are required")
}
if logger == nil {
logger = logrus.NewEntry(logrus.New())
}
metrics := observability.NewMetrics()
if len(metricRegistries) > 0 && metricRegistries[0] != nil {
metrics = metricRegistries[0]
}
server := &Server{store: database, reconciler: reconciler, manager: manager, logger: logger, metrics: metrics, now: time.Now}
server.app = fiber.New(fiber.Config{
BodyLimit: maxWebhookBodyBytes + 1,
ErrorHandler: func(c fiber.Ctx, err error) error {
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return server.writeError(c, fiberErr.Code, "http_error", fiberErr.Message, fiberErr.Code >= 500)
}
server.logger.WithError(err).Error("unhandled HTTP request error")
return server.writeError(c, http.StatusInternalServerError, "internal_error", "internal server error", true)
},
})
server.registerMiddleware()
server.registerRoutes()
server.ready.Store(true)
return server, nil
}
func (s *Server) App() *fiber.App { return s.app }
func (s *Server) Listen(address string) error { return s.app.Listen(address) }
func (s *Server) Shutdown(ctx context.Context) error { return s.app.ShutdownWithContext(ctx) }
func (s *Server) SetReady(ready bool) { s.ready.Store(ready) }
func (s *Server) RefreshMetrics(ctx context.Context) error {
snapshot, err := s.store.MetricSnapshot(ctx)
if err != nil {
return err
}
s.metricMu.Lock()
s.metricData = observability.MetricSnapshot{
Accounts: snapshot.Accounts, InboundQueue: snapshot.InboundQueue,
OutboundQueue: snapshot.OutboundQueue, StatusSyncQueue: snapshot.StatusSyncQueue,
}
s.metricMu.Unlock()
return nil
}
func (s *Server) StartMetrics(ctx context.Context) {
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.RefreshMetrics(ctx); err != nil && ctx.Err() == nil {
s.logger.WithFields(logrus.Fields{
"component": "metrics", "operation": "refresh", "result": "failed",
}).WithError(err).Warn("metric snapshot refresh failed")
}
}
}
}()
}
func (s *Server) metricSnapshot() observability.MetricSnapshot {
s.metricMu.RLock()
snapshot := s.metricData
s.metricMu.RUnlock()
if s.manager != nil {
snapshot.Supervisors = s.manager.Running()
}
return snapshot
}
func (s *Server) registerMiddleware() {
s.app.Use(func(c fiber.Ctx) error {
requestID := strings.TrimSpace(c.Get("X-Request-ID"))
if requestID == "" {
requestID = newRequestID()
}
c.Locals("request_id", requestID)
c.Set("X-Request-ID", requestID)
return c.Next()
})
s.app.Use(fiberrecover.New())
s.app.Use(func(c fiber.Ctx) error {
if len(c.Body()) > maxWebhookBodyBytes {
return s.writeError(c, http.StatusRequestEntityTooLarge, "body_too_large", "request body exceeds size limit", false)
}
return c.Next()
})
s.app.Use(func(c fiber.Ctx) error {
started := time.Now()
err := c.Next()
s.logger.WithFields(logrus.Fields{
"component": "http", "operation": string(c.Method()) + " " + c.Path(),
"result": c.Response().StatusCode(), "request_id": requestID(c),
"duration_ms": time.Since(started).Milliseconds(),
}).Info("HTTP request")
return err
})
}
func (s *Server) registerRoutes() {
s.app.Get("/healthz", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
s.app.Get("/readyz", func(c fiber.Ctx) error {
if !s.ready.Load() {
return s.writeError(c, http.StatusServiceUnavailable, "shutting_down", "server is shutting down", true)
}
if err := s.store.ReadyCheck(c.Context()); err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "SQLite is not ready", true)
}
return c.JSON(fiber.Map{"status": "ready"})
})
s.app.Get("/metrics", func(c fiber.Ctx) error {
c.Set(fiber.HeaderContentType, "text/plain; version=0.0.4; charset=utf-8")
return c.Send(s.metrics.Render(s.metricSnapshot()))
})
s.app.Post("/internal/reconcile", func(c fiber.Ctx) error {
if ip := net.ParseIP(c.IP()); ip == nil || !ip.IsLoopback() {
return s.writeError(c, http.StatusForbidden, "forbidden", "loopback access required", false)
}
if err := s.reconciler.ReconcileAll(c.Context()); err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "reconcile_failed", "configuration reconcile failed", true)
}
return c.Status(http.StatusAccepted).JSON(fiber.Map{"accepted": true})
})
s.app.Post("/internal/operations/accept-transfer", s.acceptTransfer)
s.app.Post("/internal/operations/transfer-conversation", s.transferConversation)
s.app.Post("/webhooks/gochat/v1", s.handleWebhook)
}
type acceptTransferRequest struct {
InboxID int64 `json:"inbox_id"`
ConversationID int64 `json:"conversation_id"`
SID string `json:"sid"`
EventID string `json:"event_id"`
OccurredAt time.Time `json:"occurred_at"`
}
type transferConversationRequest = acceptTransferRequest
func (s *Server) transferConversation(c fiber.Ctx) error {
if ip := net.ParseIP(c.IP()); ip == nil || !ip.IsLoopback() {
return s.writeError(c, http.StatusForbidden, "forbidden", "loopback access required", false)
}
var request transferConversationRequest
if err := json.Unmarshal(c.Body(), &request); err != nil || request.InboxID <= 0 || request.ConversationID <= 0 || strings.TrimSpace(request.SID) == "" || strings.TrimSpace(request.EventID) == "" || request.OccurredAt.IsZero() {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_transfer_conversation", "inbox_id, conversation_id, sid, event_id and occurred_at are required", false)
}
request.SID, request.EventID = strings.TrimSpace(request.SID), strings.TrimSpace(request.EventID)
account, err := s.store.Reader().GetAccountByInboxID(c.Context(), request.InboxID)
if errors.Is(err, sql.ErrNoRows) {
return s.writeError(c, http.StatusNotFound, "account_not_found", "connector account was not found", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account lookup failed", true)
}
queued, duplicate, err := s.store.EnqueueTransferConversation(c.Context(), store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: request.SID, EventID: request.EventID,
Operation: "transfer_conversation", OccurredAt: request.OccurredAt,
}, request.ConversationID)
if errors.Is(err, store.ErrOutboundConflict) {
return s.writeError(c, http.StatusConflict, "idempotency_conflict", "transfer conversation operation conflicts with an existing request", false)
}
if errors.Is(err, store.ErrOutboundAccountUnavailable) {
return s.writeError(c, http.StatusConflict, "account_unavailable", "connector account is disabled or offline", false)
}
if errors.Is(err, store.ErrOutboundSessionNotFound) {
return s.writeError(c, http.StatusConflict, "session_not_found", "sid is not mapped to the requested conversation", false)
}
if errors.Is(err, store.ErrOutboundSessionState) {
return s.writeError(c, http.StatusConflict, "session_not_transferable", "conversation is not held by another operator in a transferable state", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "transfer conversation operation persistence failed", true)
}
status := http.StatusAccepted
if duplicate {
status = http.StatusOK
}
return c.Status(status).JSON(fiber.Map{
"accepted": true, "duplicate": duplicate, "event_id": request.EventID, "queue_id": queued.ID,
})
}
func (s *Server) acceptTransfer(c fiber.Ctx) error {
if ip := net.ParseIP(c.IP()); ip == nil || !ip.IsLoopback() {
return s.writeError(c, http.StatusForbidden, "forbidden", "loopback access required", false)
}
var request acceptTransferRequest
if err := json.Unmarshal(c.Body(), &request); err != nil || request.InboxID <= 0 || request.ConversationID <= 0 || strings.TrimSpace(request.SID) == "" || strings.TrimSpace(request.EventID) == "" || request.OccurredAt.IsZero() {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_accept_transfer", "inbox_id, conversation_id, sid, event_id and occurred_at are required", false)
}
request.SID, request.EventID = strings.TrimSpace(request.SID), strings.TrimSpace(request.EventID)
payload, _ := json.Marshal(request)
account, err := s.store.Reader().GetAccountByInboxID(c.Context(), request.InboxID)
if errors.Is(err, sql.ErrNoRows) {
return s.writeError(c, http.StatusNotFound, "account_not_found", "connector account was not found", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account lookup failed", true)
}
input := store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: request.SID, EventID: request.EventID,
Operation: "accept_transfer", Payload: string(payload), OccurredAt: request.OccurredAt,
}
queued, duplicate, err := s.store.EnqueueAcceptTransfer(c.Context(), input, request.ConversationID)
if errors.Is(err, store.ErrOutboundConflict) {
return s.writeError(c, http.StatusConflict, "idempotency_conflict", "accept transfer operation conflicts with an existing request", false)
}
if errors.Is(err, store.ErrOutboundAccountUnavailable) {
return s.writeError(c, http.StatusConflict, "account_unavailable", "connector account is disabled or offline", false)
}
if errors.Is(err, store.ErrOutboundSessionNotFound) {
return s.writeError(c, http.StatusConflict, "session_not_found", "sid is not mapped to the requested conversation", false)
}
if errors.Is(err, store.ErrOutboundSessionState) {
return s.writeError(c, http.StatusConflict, "session_not_transferable", "conversation is not awaiting transfer acceptance", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "accept transfer operation persistence failed", true)
}
status := http.StatusAccepted
if duplicate {
status = http.StatusOK
}
return c.Status(status).JSON(fiber.Map{
"accepted": true, "duplicate": duplicate, "event_id": request.EventID, "queue_id": queued.ID,
})
}
func (s *Server) handleWebhook(c fiber.Ctx) error {
body := append([]byte(nil), c.Body()...)
envelope, err := gochat.DecodeWebhook(body)
if err != nil {
code := "invalid_webhook"
if strings.Contains(err.Error(), "unsupported schema version") {
code = "unsupported_schema_version"
}
return s.writeError(c, http.StatusUnprocessableEntity, code, err.Error(), false)
}
timestamp, signature := c.Get("X-Chatwoot-Timestamp"), c.Get("X-Chatwoot-Signature")
deliveryID := c.Get("X-Chatwoot-Delivery")
if deliveryID == "" {
return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false)
}
local, lookupErr := s.store.Reader().GetAccountByInboxID(c.Context(), envelope.InboxID)
if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) {
return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account lookup failed", true)
}
if errors.Is(lookupErr, sql.ErrNoRows) {
local = nil
}
if local == nil {
return s.handleUnknownInbox(c, envelope, body, timestamp, signature, deliveryID)
}
if local.GochatAccountID != envelope.AccountID || gochat.VerifyWebhookSignature(local.GochatWebhookSecret, timestamp, signature, body, s.now()) != nil {
return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false)
}
return s.dispatchVerifiedWebhook(c, envelope, body, deliveryID, local)
}
func (s *Server) handleUnknownInbox(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, timestamp, signature, deliveryID string) error {
switch envelope.Event {
case "inbox_created", "inbox_updated":
if err := s.validateLifecycle(envelope); err != nil {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_lifecycle_event", err.Error(), false)
}
err := s.reconciler.BootstrapInbox(c.Context(), envelope.InboxID, func(config gochat.InboxConfig) error {
if config.AccountID != envelope.AccountID {
return errors.New("account mismatch")
}
return gochat.VerifyWebhookSignature(config.Credentials.WebhookSecret, timestamp, signature, body, s.now())
})
if err != nil {
s.logger.WithError(err).WithFields(logrus.Fields{
"component": "webhook", "operation": "bootstrap_inbox", "result": "failed", "request_id": requestID(c),
}).Warn("unknown inbox bootstrap failed")
return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false)
}
account, err := s.store.Reader().GetAccountByInboxID(c.Context(), envelope.InboxID)
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account persistence failed", true)
}
return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, account.ConfigVersion)
case "inbox_deleted":
return s.writeAck(c, http.StatusOK, envelope.EventID, deliveryID, nil, true, 0)
default:
return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false)
}
}
func (s *Server) dispatchVerifiedWebhook(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error {
switch envelope.Event {
case "inbox_created", "inbox_updated":
if err := s.validateLifecycle(envelope); err != nil {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_lifecycle_event", err.Error(), false)
}
if err := s.reconciler.ReconcileInbox(c.Context(), envelope.InboxID); err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "config_sync_failed", "configuration sync failed", true)
}
updated, err := s.store.Reader().GetAccountByInboxID(c.Context(), envelope.InboxID)
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account persistence failed", true)
}
duplicate := updated.ConfigVersion == local.ConfigVersion
status := http.StatusAccepted
if duplicate {
status = http.StatusOK
}
return s.writeAck(c, status, envelope.EventID, deliveryID, nil, duplicate, updated.ConfigVersion)
case "inbox_deleted":
if err := s.validateLifecycle(envelope); err != nil {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_lifecycle_event", err.Error(), false)
}
if err := s.reconciler.DeleteInbox(c.Context(), envelope.InboxID); err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "delete_failed", "inbox tombstone failed", true)
}
return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, 0)
case "message_created", "message_retry_requested":
return s.acceptMessage(c, envelope, body, deliveryID, local)
case "conversation_status_changed":
return s.acceptConversationStatus(c, envelope, body, deliveryID, local)
case "contact_updated":
return s.acceptContactUpdate(c, envelope, body, deliveryID, local)
case "conversation_typing_on", "conversation_typing_off":
return s.acceptTyping(c, envelope, deliveryID, local)
default:
return s.writeError(c, http.StatusUnprocessableEntity, "unsupported_event", "webhook event is not supported", false)
}
}
func (s *Server) acceptContactUpdate(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error {
var data gochat.ContactUpdatedWebhookData
if err := json.Unmarshal(envelope.Data, &data); err != nil || data.Contact.ID <= 0 || strings.TrimSpace(data.Contact.SourceID) == "" || strings.TrimSpace(data.CID) == "" || strings.TrimSpace(data.Contact.Name) == "" {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_contact_update", "contact update payload is invalid", false)
}
queued, duplicate, err := s.store.EnqueueOutboundOperation(c.Context(), store.OutboundOperationInput{
AccountID: local.ID, SWTSessionID: data.Contact.SourceID, EventID: envelope.EventID, Operation: "change_contact_name", Payload: string(body), OccurredAt: envelope.OccurredAt,
})
if errors.Is(err, store.ErrOutboundConflict) {
return s.writeError(c, http.StatusConflict, "idempotency_conflict", "contact operation conflicts with webhook", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "contact operation queue persistence failed", true)
}
status := http.StatusAccepted
if duplicate {
status = http.StatusOK
}
return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0)
}
func (s *Server) acceptConversationStatus(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error {
var data gochat.ConversationStatusWebhookData
if err := json.Unmarshal(envelope.Data, &data); err != nil {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_conversation_status", "conversation status payload is invalid", false)
}
conversation, sid := data.Conversation, data.Conversation.SWTSessionID()
if conversation.ID <= 0 || conversation.DisplayID <= 0 || sid == "" ||
(conversation.Status != "open" && conversation.Status != "resolved") ||
(conversation.PreviousStatus != "open" && conversation.PreviousStatus != "resolved") {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_conversation_status", "conversation status transition is invalid", false)
}
if _, err := s.store.Writer().UpsertConversationMap(c.Context(), dbgen.UpsertConversationMapParams{
AccountID: local.ID, SwtSid: sid, GochatContactSourceID: sid,
GochatConversationID: &conversation.ID, GochatDisplayID: &conversation.DisplayID,
}); err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "conversation mapping persistence failed", true)
}
if conversation.PreviousStatus != "open" || conversation.Status != "resolved" {
return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, 0)
}
queued, duplicate, err := s.store.EnqueueOutboundOperation(c.Context(), store.OutboundOperationInput{
AccountID: local.ID, SWTSessionID: sid, EventID: envelope.EventID,
Operation: "end_conversation", Payload: string(body), OccurredAt: envelope.OccurredAt,
})
if errors.Is(err, store.ErrOutboundConflict) {
return s.writeError(c, http.StatusConflict, "idempotency_conflict", "conversation operation conflicts with webhook", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "conversation operation queue persistence failed", true)
}
status := http.StatusAccepted
if duplicate {
status = http.StatusOK
}
return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0)
}
func (s *Server) acceptTyping(c fiber.Ctx, envelope gochat.WebhookEnvelope, deliveryID string, local *dbgen.Account) error {
var data gochat.TypingWebhookData
if err := json.Unmarshal(envelope.Data, &data); err != nil {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_typing", "typing payload is invalid", false)
}
sid := data.Conversation.SWTSessionID()
if data.Private || data.Conversation.ID <= 0 || sid == "" || s.manager == nil {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_typing", "typing payload is not eligible for Shangwutong delivery", false)
}
if err := s.manager.SetTyping(local.ID, sid, envelope.Event == "conversation_typing_on"); err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "supervisor_unavailable", "account supervisor is unavailable", true)
}
return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, 0)
}
func (s *Server) acceptMessage(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error {
var data gochat.MessageWebhookData
if err := json.Unmarshal(envelope.Data, &data); err != nil {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_message", "message payload is invalid", false)
}
message, sid := data.Message, data.Conversation.SWTSessionID()
if message.ID <= 0 || sid == "" || (message.MessageType != "outgoing" && message.MessageType != "template") || message.Private || message.External || strings.HasPrefix(message.SourceID, "swt:") {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_message", "message is not eligible for Shangwutong delivery", false)
}
retry := envelope.Event == "message_retry_requested"
if retry && data.RetryVersion <= 0 {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_retry_version", "retry_version must be positive", false)
}
content := message.Content
parts := make([]store.OutboundPartInput, 0, len(message.Attachments)+1)
contentType := strings.ToLower(strings.TrimSpace(message.ContentType))
fallbackContent := contentType == "location" || contentType == "contact"
unsupportedContent := message.MessageType == "template" || (contentType != "" && contentType != "text" && !fallbackContent)
if unsupportedContent {
parts = append(parts, store.OutboundPartInput{Type: "unsupported"})
} else if strings.TrimSpace(content) != "" {
parts = append(parts, store.OutboundPartInput{Type: "text", Content: &content})
} else if fallbackContent && len(message.Attachments) == 0 {
content = map[string]string{"location": "位置消息", "contact": "联系人消息"}[contentType]
parts = append(parts, store.OutboundPartInput{Type: "text", Content: &content})
}
for _, attachment := range message.Attachments {
if attachment.ID <= 0 {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_attachment", "attachment ID and data_url are required", false)
}
if fallback, ok := outboundAttachmentFallback(attachment); ok {
parts = append(parts, store.OutboundPartInput{Type: "text", Content: &fallback})
continue
}
if strings.TrimSpace(attachment.DataURL) == "" {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_attachment", "attachment ID and data_url are required", false)
}
partType := outboundAttachmentType(attachment.FileType, attachment.Voice())
dataURL, fileSize := attachment.DataURL, attachment.FileSize
name := strconv.FormatInt(attachment.ID, 10)
if extension := safeFileExtension(attachment.Extension); extension != "" {
name += "." + extension
}
parts = append(parts, store.OutboundPartInput{
Type: partType, AttachmentID: &attachment.ID, DataURL: &dataURL,
FileName: &name, FileSize: &fileSize, Voice: attachment.Voice(),
})
}
if len(parts) > maxOutboundParts {
return s.writeError(c, http.StatusUnprocessableEntity, "too_many_parts", "message exceeds the 100-part delivery limit", false)
}
queued, duplicate, err := s.store.EnqueueOutbound(c.Context(), store.OutboundInput{
AccountID: local.ID, SWTSessionID: sid, EventID: envelope.EventID, OccurredAt: envelope.OccurredAt, GoChatMessageID: message.ID,
RetryVersion: data.RetryVersion, MessageType: message.ContentType, Content: &content, Payload: string(body), Parts: parts,
}, retry)
if errors.Is(err, store.ErrOutboundConflict) {
return s.writeError(c, http.StatusConflict, "idempotency_conflict", "message delivery state conflicts with webhook", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "message queue persistence failed", true)
}
status := http.StatusAccepted
if duplicate {
status = http.StatusOK
}
return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0)
}
func outboundAttachmentFallback(attachment gochat.WebhookAttachment) (string, bool) {
switch strings.ToLower(strings.TrimSpace(attachment.FileType)) {
case "location":
title := metadataString(attachment.Metadata, "fallback_title", "fallbackTitle")
text := "位置消息"
if title != "" {
text = "位置:" + title
}
lat, latOK := coordinate(attachment.Metadata, -90, 90, "coordinates_lat", "coordinatesLat")
long, longOK := coordinate(attachment.Metadata, -180, 180, "coordinates_long", "coordinatesLong")
if latOK && longOK {
text += "\nhttps://maps.google.com/?q=" + lat + "," + long
}
return text, true
case "contact":
phone := metadataString(attachment.Metadata, "fallback_title", "fallbackTitle")
meta, _ := attachment.Metadata["meta"].(map[string]any)
name := strings.TrimSpace(metadataString(meta, "firstName", "first_name") + " " + metadataString(meta, "lastName", "last_name"))
text := "联系人消息"
if name != "" {
text = "联系人:" + name
}
if phone != "" {
text += "\n电话:" + phone
}
return text, true
default:
return "", false
}
}
func metadataString(metadata map[string]any, keys ...string) string {
for _, key := range keys {
value, exists := metadata[key]
if !exists {
continue
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
}
return ""
}
func coordinate(metadata map[string]any, minimum, maximum float64, keys ...string) (string, bool) {
value := metadataString(metadata, keys...)
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < minimum || parsed > maximum {
return "", false
}
return strconv.FormatFloat(parsed, 'f', -1, 64), true
}
func outboundAttachmentType(fileType string, voice bool) string {
if voice {
return "audio"
}
switch strings.ToLower(strings.TrimSpace(fileType)) {
case "image":
return "image"
case "audio":
return "audio"
case "file":
return "file"
case "video":
return "video"
default:
return "unsupported"
}
}
func safeFileExtension(value string) string {
value = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".")
if value == "" || len(value) > 16 {
return ""
}
for _, character := range value {
if (character < 'a' || character > 'z') && (character < '0' || character > '9') {
return ""
}
}
return value
}
func (s *Server) validateLifecycle(envelope gochat.WebhookEnvelope) error {
var data gochat.LifecycleData
if err := json.Unmarshal(envelope.Data, &data); err != nil {
return err
}
if data.ChannelType != "shangwutong" || data.ConfigVersion <= 0 {
return errors.New("invalid lifecycle data")
}
return nil
}
func (s *Server) writeAck(c fiber.Ctx, status int, eventID, deliveryID string, queueID *int64, duplicate bool, configVersion int64) error {
response := fiber.Map{
"accepted": true, "duplicate": duplicate, "event_id": eventID, "delivery_id": deliveryID,
}
if queueID != nil {
response["queue_id"] = *queueID
}
if configVersion > 0 {
response["applied_config_version"] = configVersion
}
return c.Status(status).JSON(response)
}
func (s *Server) writeError(c fiber.Ctx, status int, code, message string, retryable bool) error {
if strings.HasPrefix(c.Path(), "/webhooks/") {
s.metrics.ContractError("gochat_to_connector", contractMetricCode(status, code))
}
return c.Status(status).JSON(fiber.Map{"error": fiber.Map{
"code": code, "message": message, "retryable": retryable, "request_id": requestID(c),
}})
}
func contractMetricCode(status int, code string) string {
switch {
case code == "invalid_signature":
return "invalid_signature"
case code == "unsupported_schema_version":
return "unsupported_schema"
case code == "idempotency_conflict":
return "idempotency_conflict"
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return "unauthorized"
case status >= 500:
return "upstream_error"
default:
return "invalid_payload"
}
}
func requestID(c fiber.Ctx) string {
value, _ := c.Locals("request_id").(string)
return value
}
func newRequestID() string {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(value)
return fmt.Sprintf("%s-%s-%s-%s-%s", encoded[:8], encoded[8:12], encoded[12:16], encoded[16:20], encoded[20:])
}