package v1 import ( "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/service" "gorm.io/gorm" ) type ShangwutongConnectorHandler struct { db *gorm.DB messageSvc *service.MessageService } type shangwutongContactMetadataRequest struct { CID string `json:"cid"` } func (h *ShangwutongConnectorHandler) UpdateContactMetadata(c *gin.Context) { inbox, ok := h.authorizedInbox(c) if !ok { return } if strings.TrimSpace(c.Param("source_id")) == "" { h.connectorError(c, http.StatusBadRequest, "invalid_source_id", "source_id is required", false) return } var request shangwutongContactMetadataRequest if err := c.ShouldBindJSON(&request); err != nil { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_metadata", "cid is required", false) return } request.CID = strings.TrimSpace(request.CID) if request.CID == "" || len(request.CID) > 255 { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_metadata", "cid is required", false) return } var contactInbox model.ContactInbox if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ? AND source_id = ?", inbox.ID, c.Param("source_id")).First(&contactInbox).Error; err != nil { h.connectorError(c, http.StatusNotFound, "not_found", "contact source not found", false) return } metadata := map[string]any{} if len(contactInbox.ChannelMetadata) > 0 { if err := json.Unmarshal(contactInbox.ChannelMetadata, &metadata); err != nil { h.connectorError(c, http.StatusInternalServerError, "contact_metadata_invalid", "stored contact metadata is invalid", true) return } } if metadata["cid"] == request.CID { c.JSON(http.StatusOK, gin.H{"updated": false}) return } metadata["cid"] = request.CID encoded, err := json.Marshal(metadata) if err != nil { h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to encode contact metadata", true) return } if err := h.db.WithContext(c.Request.Context()).Model(&contactInbox).Update("channel_metadata", encoded).Error; err != nil { h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to update contact metadata", true) return } c.JSON(http.StatusOK, gin.H{"updated": true}) } func NewShangwutongConnectorHandler(db *gorm.DB, messageSvc *service.MessageService) *ShangwutongConnectorHandler { return &ShangwutongConnectorHandler{db: db, messageSvc: messageSvc} } type shangwutongConnectorInbox struct { SchemaVersion int64 `json:"schema_version"` AccountID uint `json:"account_id"` InboxID uint `json:"inbox_id"` InboxIdentifier string `json:"inbox_identifier"` Enabled bool `json:"enabled"` DesiredPresence string `json:"desired_presence"` ConfigVersion int64 `json:"config_version"` Credentials shangwutongConnectorCredentials `json:"credentials"` UpdatedAt time.Time `json:"updated_at"` } type shangwutongConnectorCredentials struct { SessionID string `json:"session_id"` Username string `json:"username"` Password string `json:"password"` HMACToken string `json:"hmac_token"` WebhookSecret string `json:"webhook_secret"` } func (h *ShangwutongConnectorHandler) ListInboxes(c *gin.Context) { limit := 100 if raw := c.Query("limit"); raw != "" { parsed, err := strconv.Atoi(raw) if err != nil || parsed <= 0 || parsed > 100 { h.connectorError(c, http.StatusBadRequest, "invalid_limit", "limit must be between 1 and 100", false) return } limit = parsed } cursor, err := decodeShangwutongCursor(c.Query("cursor")) if err != nil { h.connectorError(c, http.StatusBadRequest, "invalid_cursor", "cursor is invalid", false) return } accountIDs, err := h.grantedAccountIDs(c) if err != nil { h.connectorError(c, http.StatusInternalServerError, "grant_lookup_failed", "failed to load connector grants", true) return } items := make([]shangwutongConnectorInbox, 0) nextCursor := "" if len(accountIDs) > 0 { var inboxes []model.Inbox if err := h.db.WithContext(c.Request.Context()).Where( "account_id IN ? AND channel_type = ? AND id > ?", accountIDs, "shangwutong", cursor, ).Order("id ASC").Limit(limit + 1).Find(&inboxes).Error; err != nil { h.connectorError(c, http.StatusInternalServerError, "config_lookup_failed", "failed to load inbox configurations", true) return } if len(inboxes) > limit { nextCursor = encodeShangwutongCursor(inboxes[limit-1].ID) inboxes = inboxes[:limit] } for i := range inboxes { item, err := h.inboxItem(c, &inboxes[i]) if err != nil { h.connectorError(c, http.StatusInternalServerError, "config_lookup_failed", "failed to load inbox configuration", true) return } items = append(items, item) } } c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{"data": items, "next_cursor": nextCursor}) } func (h *ShangwutongConnectorHandler) GetInbox(c *gin.Context) { inbox, ok := h.authorizedInbox(c) if !ok { return } item, err := h.inboxItem(c, inbox) if err != nil { h.connectorError(c, http.StatusInternalServerError, "config_lookup_failed", "failed to load inbox configuration", true) return } c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, item) } type shangwutongStatusRequest struct { ConfigVersion int64 `json:"config_version"` ActualPresence string `json:"actual_presence"` ConnectionStatus string `json:"connection_status"` CredentialStatus string `json:"credential_status"` LastHeartbeatAt *time.Time `json:"last_heartbeat_at"` LastErrorCode *string `json:"last_error_code"` } func (h *ShangwutongConnectorHandler) UpdateInboxStatus(c *gin.Context) { inbox, ok := h.authorizedInbox(c) if !ok { return } var request shangwutongStatusRequest if err := c.ShouldBindJSON(&request); err != nil || !validShangwutongStatus(request) { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_status", "status payload is invalid", false) return } var config model.ChannelShangwutongConfig if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil { h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false) return } if request.ConfigVersion > config.ConfigVersion { h.connectorError(c, http.StatusUnprocessableEntity, "future_config_version", "config_version is newer than the inbox configuration", false) return } if request.ConfigVersion < config.ConfigVersion { c.JSON(http.StatusOK, gin.H{"updated": false, "stale": true, "config_version": config.ConfigVersion}) return } updates := map[string]any{ "actual_presence": request.ActualPresence, "connection_status": request.ConnectionStatus, "credential_status": request.CredentialStatus, "last_error_code": request.LastErrorCode, "status_updated_at": time.Now().UTC(), } if request.LastHeartbeatAt != nil { updates["last_heartbeat_at"] = request.LastHeartbeatAt.UTC() } if err := h.db.WithContext(c.Request.Context()).Model(&model.ChannelShangwutongConfig{}).Where( "inbox_id = ? AND config_version = ?", inbox.ID, request.ConfigVersion, ).Updates(updates).Error; err != nil { h.connectorError(c, http.StatusInternalServerError, "status_update_failed", "failed to update inbox status", true) return } c.JSON(http.StatusOK, gin.H{"updated": true, "config_version": config.ConfigVersion}) } type shangwutongMessageResultRequest struct { ResultVersion int64 `json:"result_version"` Status string `json:"status"` ExternalID *string `json:"external_id"` ExternalIDs []string `json:"external_ids"` ErrorCode *string `json:"error_code"` ErrorMessage *string `json:"error_message"` OccurredAt *time.Time `json:"occurred_at"` } func (h *ShangwutongConnectorHandler) UpdateMessageStatus(c *gin.Context) { inbox, ok := h.authorizedInbox(c) if !ok { return } messageID, err := strconv.ParseUint(c.Param("message_id"), 10, 64) if err != nil || messageID == 0 { h.connectorError(c, http.StatusNotFound, "not_found", "message not found", false) return } var request shangwutongMessageResultRequest if err := c.ShouldBindJSON(&request); err != nil || !validShangwutongMessageResult(request) { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_message_result", "message result payload is invalid", false) return } expectedKey := fmt.Sprintf("swt-delivery:%d:%d:%d", inbox.ID, messageID, request.ResultVersion) if c.GetHeader("Idempotency-Key") != expectedKey { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match result_version", false) return } if h.messageSvc == nil { h.connectorError(c, http.StatusServiceUnavailable, "message_service_unavailable", "message status service is unavailable", true) return } result := service.ShangwutongMessageResult{ ResultVersion: request.ResultVersion, Status: request.Status, ExternalID: request.ExternalID, ExternalIDs: request.ExternalIDs, ErrorCode: request.ErrorCode, ErrorMessage: request.ErrorMessage, OccurredAt: request.OccurredAt.UTC(), } message, applied, err := h.messageSvc.ApplyShangwutongMessageResult( c.Request.Context(), inbox.AccountID, inbox.ID, uint(messageID), result, ) if err != nil { switch { case errors.Is(err, gorm.ErrRecordNotFound): h.connectorError(c, http.StatusNotFound, "not_found", "message not found", false) case errors.Is(err, service.ErrShangwutongMessageResultConflict): h.connectorError(c, http.StatusConflict, "idempotency_conflict", err.Error(), false) case errors.Is(err, service.ErrShangwutongMessageNotEligible): h.connectorError(c, http.StatusForbidden, "message_not_eligible", err.Error(), false) default: h.connectorError(c, http.StatusInternalServerError, "message_result_update_failed", "failed to update message result", true) } return } c.JSON(http.StatusOK, gin.H{ "updated": applied, "message_id": message.ID, "status": message.Status, "result_version": request.ResultVersion, }) } func (h *ShangwutongConnectorHandler) inboxItem(c *gin.Context, inbox *model.Inbox) (shangwutongConnectorInbox, error) { var config model.ChannelShangwutongConfig if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil { return shangwutongConnectorInbox{}, err } var channelAPI channelmodel.ChannelAPI if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error; err != nil { return shangwutongConnectorInbox{}, err } return shangwutongConnectorInbox{ SchemaVersion: 1, AccountID: inbox.AccountID, InboxID: inbox.ID, InboxIdentifier: channelAPI.Identifier, Enabled: inbox.Enabled, DesiredPresence: config.DesiredPresence, ConfigVersion: config.ConfigVersion, Credentials: shangwutongConnectorCredentials{ SessionID: config.SessionID, Username: config.Username, Password: config.Password, HMACToken: channelAPI.HMACToken, WebhookSecret: channelAPI.Secret, }, UpdatedAt: config.UpdatedAt.UTC(), }, nil } func (h *ShangwutongConnectorHandler) authorizedInbox(c *gin.Context) (*model.Inbox, bool) { inboxID, err := strconv.ParseUint(c.Param("inbox_id"), 10, 64) if err != nil || inboxID == 0 { h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false) return nil, false } platformAppID := middleware.ConnectorPlatformAppID(c) var inbox model.Inbox err = h.db.WithContext(c.Request.Context()).Table("inboxes").Select("inboxes.*").Joins( "JOIN permissibles ON permissibles.permissible_id = inboxes.account_id AND permissibles.permissible_type = ?", model.PermissibleTypeAccount, ).Where( "permissibles.platform_app_id = ? AND inboxes.id = ? AND inboxes.channel_type = ?", platformAppID, uint(inboxID), "shangwutong", ).First(&inbox).Error if err != nil { h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false) return nil, false } return &inbox, true } func (h *ShangwutongConnectorHandler) grantedAccountIDs(c *gin.Context) ([]uint, error) { var accountIDs []uint err := h.db.WithContext(c.Request.Context()).Model(&model.Permissible{}).Where( "platform_app_id = ? AND permissible_type = ?", middleware.ConnectorPlatformAppID(c), model.PermissibleTypeAccount, ).Pluck("permissible_id", &accountIDs).Error return accountIDs, err } func (h *ShangwutongConnectorHandler) connectorError(c *gin.Context, status int, code, message string, retryable bool) { c.JSON(status, gin.H{"error": gin.H{ "code": code, "message": message, "retryable": retryable, "request_id": c.GetString("request_id"), }}) } func validShangwutongStatus(request shangwutongStatusRequest) bool { return request.ConfigVersion > 0 && oneOf(request.ActualPresence, "online", "busy", "away", "offline") && oneOf(request.ConnectionStatus, "pending", "logging_in", "connected", "degraded", "relogin_required", "verification_required", "auth_failed", "disabled", "offline") && oneOf(request.CredentialStatus, "pending", "verifying", "applied", "rejected", "verification_required") } func validShangwutongMessageResult(request shangwutongMessageResultRequest) bool { if request.ResultVersion <= 0 || request.OccurredAt == nil || request.OccurredAt.IsZero() || !oneOf(request.Status, "sent", "failed", "uncertain") { return false } if request.ExternalID != nil { if request.Status != "sent" || strings.TrimSpace(*request.ExternalID) == "" { return false } if _, err := strconv.ParseUint(strings.TrimSpace(*request.ExternalID), 10, 64); err != nil { return false } } if len(request.ExternalIDs) > 100 || (len(request.ExternalIDs) > 0 && request.Status != "sent") { return false } seen := make(map[string]struct{}, len(request.ExternalIDs)) for _, externalID := range request.ExternalIDs { externalID = strings.TrimSpace(externalID) if _, err := strconv.ParseUint(externalID, 10, 64); err != nil { return false } if _, duplicate := seen[externalID]; duplicate { return false } seen[externalID] = struct{}{} } return request.Status != "failed" || (request.ErrorCode != nil && strings.TrimSpace(*request.ErrorCode) != "") } func oneOf(value string, allowed ...string) bool { for _, candidate := range allowed { if value == candidate { return true } } return false } func encodeShangwutongCursor(id uint) string { return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(id), 10))) } func decodeShangwutongCursor(cursor string) (uint, error) { if strings.TrimSpace(cursor) == "" { return 0, nil } decoded, err := base64.RawURLEncoding.DecodeString(cursor) if err != nil { return 0, err } value, err := strconv.ParseUint(string(decoded), 10, 64) if err != nil || value == 0 { return 0, errors.New("invalid cursor") } return uint(value), nil }