* fix(shangwutong): close contact sync review gaps * fix(shangwutong): harden CID sync boundaries --------- Co-authored-by: Rogee <rogee@ipao.vip>
390 lines
15 KiB
Go
390 lines
15 KiB
Go
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
|
|
}
|