587 lines
23 KiB
Go
587 lines
23 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("/webhooks/gochat/v1", s.handleWebhook)
|
|
}
|
|
|
|
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:])
|
|
}
|