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:]) }