Files
gochat/backend/internal/handler/api/v1/shangwutong_connector_handler.go
T

853 lines
36 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"
"github.com/gochat/gochat/internal/worker"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type ShangwutongConnectorHandler struct {
db *gorm.DB
messageSvc *service.MessageService
worker *worker.WorkerPool
}
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, pools ...*worker.WorkerPool) *ShangwutongConnectorHandler {
var pool *worker.WorkerPool
if len(pools) > 0 {
pool = pools[0]
}
return &ShangwutongConnectorHandler{db: db, messageSvc: messageSvc, worker: pool}
}
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,
})
}
type shangwutongClassificationCacheResponse struct {
InboxID uint `json:"inbox_id"`
ConversationKinds json.RawMessage `json:"conversation_kinds"`
CustomerColorKinds json.RawMessage `json:"customer_color_kinds"`
SyncStatus string `json:"sync_status"`
SyncedAt *time.Time `json:"synced_at,omitempty"`
LastErrorCode *string `json:"last_error_code,omitempty"`
LastErrorMessage *string `json:"last_error_message,omitempty"`
}
type shangwutongClassificationCallbackRequest struct {
EventID string `json:"event_id"`
ConversationKinds []shangwutongConversationKind `json:"conversation_kinds"`
CustomerColorKinds []shangwutongCustomerColorKind `json:"customer_colors"`
}
type shangwutongConversationKind struct {
ID string `json:"id"`
Name string `json:"name"`
IconIndex int `json:"icon_index"`
}
type shangwutongCustomerColorKind struct {
ID string `json:"id"`
Name string `json:"name"`
}
func (h *ShangwutongConnectorHandler) ListClassifications(c *gin.Context) {
inbox, ok := h.userInbox(c)
if !ok {
return
}
var cache model.ShangwutongClassificationCache
err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, shangwutongClassificationCacheResponse{
InboxID: inbox.ID, ConversationKinds: json.RawMessage(`[]`), CustomerColorKinds: json.RawMessage(`[]`), SyncStatus: "never",
})
return
}
if err != nil {
h.connectorError(c, http.StatusInternalServerError, "classification_cache_lookup_failed", "failed to load classifications", true)
return
}
c.JSON(http.StatusOK, classificationCacheResponse(&cache))
}
func (h *ShangwutongConnectorHandler) SyncClassifications(c *gin.Context) {
inbox, ok := h.userInbox(c)
if !ok {
return
}
if h.worker == nil {
h.connectorError(c, http.StatusServiceUnavailable, "classification_sync_unavailable", "classification sync is unavailable", true)
return
}
pending := map[string]any{"sync_status": "pending", "last_error_code": nil, "last_error_message": nil}
var cache model.ShangwutongClassificationCache
err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
cache = model.ShangwutongClassificationCache{InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[]`)), SyncStatus: "pending"}
if err := h.db.WithContext(c.Request.Context()).Create(&cache).Error; err != nil {
h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to create sync state", true)
return
}
case err != nil:
h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to load sync state", true)
return
default:
if err := h.db.WithContext(c.Request.Context()).Model(&cache).Updates(pending).Error; err != nil {
h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to update sync state", true)
return
}
}
eventID, err := service.EnqueueShangwutongClassificationSync(c.Request.Context(), h.worker, inbox)
if err != nil {
code, message := "classification_sync_queue_failed", err.Error()
_ = h.db.WithContext(c.Request.Context()).Model(&cache).Updates(map[string]any{"sync_status": "failed", "last_error_code": code, "last_error_message": message})
h.connectorError(c, http.StatusServiceUnavailable, code, "failed to queue classification sync", true)
return
}
c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "sync_status": "pending"})
}
type shangwutongConversationClassificationRequest struct {
ChatKindID string `json:"chat_kind_id"`
CustomerColorID string `json:"customer_color_id"`
}
func (h *ShangwutongConnectorHandler) UpdateConversationClassification(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil || accountID == 0 {
h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false)
return
}
conversationID, err := strconv.ParseUint(c.Param("conversation_id"), 10, 64)
if err != nil || conversationID == 0 {
h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false)
return
}
var request shangwutongConversationClassificationRequest
if err := c.ShouldBindJSON(&request); err != nil {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_change", "classification change payload is invalid", false)
return
}
request.ChatKindID, request.CustomerColorID = strings.TrimSpace(request.ChatKindID), strings.TrimSpace(request.CustomerColorID)
if (request.ChatKindID == "") == (request.CustomerColorID == "") {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_change", "exactly one classification is required", false)
return
}
var conversation model.Conversation
lookup := h.db.WithContext(c.Request.Context()).Where(
"id = ? AND account_id = ? AND (display_id IS NULL OR display_id = 0)", uint(conversationID), uint(accountID),
).First(&conversation)
if errors.Is(lookup.Error, gorm.ErrRecordNotFound) {
lookup = h.db.WithContext(c.Request.Context()).Where(
"display_id = ? AND account_id = ?", uint(conversationID), uint(accountID),
).First(&conversation)
}
if lookup.Error != nil {
h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false)
return
}
var inbox model.Inbox
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ? AND channel_type = ?", conversation.InboxID, uint(accountID), "shangwutong").First(&inbox).Error; err != nil {
h.connectorError(c, http.StatusUnprocessableEntity, "not_shangwutong_conversation", "conversation is not a Shangwutong conversation", false)
return
}
var contactInbox model.ContactInbox
query := h.db.WithContext(c.Request.Context()).Where("inbox_id = ? AND contact_id = ?", inbox.ID, conversation.ContactID)
if conversation.ContactInboxID != nil && *conversation.ContactInboxID != 0 {
query = h.db.WithContext(c.Request.Context()).Where("id = ? AND inbox_id = ?", *conversation.ContactInboxID, inbox.ID)
}
if err := query.First(&contactInbox).Error; err != nil || strings.TrimSpace(contactInbox.SourceID) == "" {
h.connectorError(c, http.StatusUnprocessableEntity, "conversation_target_unavailable", "conversation has no Shangwutong session", false)
return
}
var cache model.ShangwutongClassificationCache
if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error; err != nil {
h.connectorError(c, http.StatusConflict, "classification_not_synced", "sync Shangwutong classifications first", false)
return
}
if cache.SyncStatus != "succeeded" {
h.connectorError(c, http.StatusConflict, "classification_not_synced", "sync Shangwutong classifications first", false)
return
}
var conversationKinds []shangwutongConversationKind
var customerColors []shangwutongCustomerColorKind
if err := json.Unmarshal(cache.ConversationKinds, &conversationKinds); err != nil || json.Unmarshal(cache.CustomerColorKinds, &customerColors) != nil {
h.connectorError(c, http.StatusInternalServerError, "classification_cache_invalid", "stored classifications are invalid", true)
return
}
colorName := ""
if request.ChatKindID != "" {
if !containsConversationKind(conversationKinds, request.ChatKindID) {
h.connectorError(c, http.StatusUnprocessableEntity, "classification_not_found", "conversation classification is not in the synced catalog", false)
return
}
} else {
for _, color := range customerColors {
if color.ID == request.CustomerColorID {
colorName = color.Name
break
}
}
if colorName == "" {
h.connectorError(c, http.StatusUnprocessableEntity, "classification_not_found", "customer classification is not in the synced catalog", false)
return
}
var metadata struct {
CID string `json:"cid"`
}
if err := json.Unmarshal(contactInbox.ChannelMetadata, &metadata); err != nil || strings.TrimSpace(metadata.CID) == "" {
h.connectorError(c, http.StatusUnprocessableEntity, "conversation_target_unavailable", "conversation has no customer cid", false)
return
}
request.CustomerColorID = strings.TrimSpace(request.CustomerColorID)
if h.worker == nil {
h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_unavailable", "classification queue is unavailable", true)
return
}
eventID, err := service.EnqueueShangwutongClassificationChange(c.Request.Context(), h.worker, &inbox, conversation.ID, contactInbox.SourceID, metadata.CID, "", request.CustomerColorID, colorName)
if err != nil {
h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_failed", "failed to queue classification change", true)
return
}
c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "status": "pending", "customer_color_id": request.CustomerColorID})
return
}
if h.worker == nil {
h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_unavailable", "classification queue is unavailable", true)
return
}
eventID, err := service.EnqueueShangwutongClassificationChange(c.Request.Context(), h.worker, &inbox, conversation.ID, contactInbox.SourceID, "", request.ChatKindID, "", "")
if err != nil {
h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_failed", "failed to queue classification change", true)
return
}
c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "status": "pending", "chat_kind_id": request.ChatKindID})
}
func containsConversationKind(kinds []shangwutongConversationKind, id string) bool {
for _, kind := range kinds {
if kind.ID == id {
return true
}
}
return false
}
func (h *ShangwutongConnectorHandler) UpdateClassificationCatalog(c *gin.Context) {
inbox, ok := h.authorizedInbox(c)
if !ok {
return
}
var request shangwutongClassificationCallbackRequest
if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || len(request.EventID) > 128 {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false)
return
}
expectedKey := fmt.Sprintf("swt-classification-sync:%d:%s", inbox.ID, request.EventID)
if c.GetHeader("Idempotency-Key") != expectedKey {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false)
return
}
if !validClassificationCatalog(request) {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false)
return
}
conversationKinds, err := json.Marshal(request.ConversationKinds)
if err != nil {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false)
return
}
customerColors, err := json.Marshal(request.CustomerColorKinds)
if err != nil {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false)
return
}
var cache model.ShangwutongClassificationCache
err = h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
cache = model.ShangwutongClassificationCache{InboxID: inbox.ID}
} else if err != nil {
h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to load classification cache", true)
return
} else if cache.LastSyncEventID == request.EventID {
c.JSON(http.StatusOK, gin.H{"updated": false, "sync_status": cache.SyncStatus})
return
}
now := time.Now().UTC()
cache.ConversationKinds = conversationKinds
cache.CustomerColorKinds = customerColors
cache.SyncStatus = "succeeded"
cache.SyncedAt = &now
cache.LastErrorCode = nil
cache.LastErrorMessage = nil
cache.LastSyncEventID = request.EventID
if err := h.db.WithContext(c.Request.Context()).Save(&cache).Error; err != nil {
h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to save classifications", true)
return
}
c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": cache.SyncStatus, "synced_at": cache.SyncedAt})
}
type shangwutongClassificationSyncStatusRequest struct {
EventID string `json:"event_id"`
Status string `json:"status"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
}
func (h *ShangwutongConnectorHandler) UpdateClassificationSyncStatus(c *gin.Context) {
inbox, ok := h.authorizedInbox(c)
if !ok {
return
}
var request shangwutongClassificationSyncStatusRequest
if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || request.Status != "failed" {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_sync_status", "classification sync status is invalid", false)
return
}
expectedKey := fmt.Sprintf("swt-classification-sync:%d:%s", inbox.ID, request.EventID)
if c.GetHeader("Idempotency-Key") != expectedKey {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false)
return
}
var cache model.ShangwutongClassificationCache
if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
cache = model.ShangwutongClassificationCache{InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[]`))}
} else {
h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to load classification cache", true)
return
}
}
cache.SyncStatus = request.Status
cache.LastErrorCode = stringPointer(request.ErrorCode)
cache.LastErrorMessage = stringPointer(request.ErrorMessage)
if err := h.db.WithContext(c.Request.Context()).Save(&cache).Error; err != nil {
h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to save classification sync status", true)
return
}
c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": cache.SyncStatus})
}
func stringPointer(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
type shangwutongClassificationStatusRequest struct {
EventID string `json:"event_id"`
Operation string `json:"operation"`
Status string `json:"status"`
ChatKindID string `json:"chat_kind_id"`
CustomerColorID string `json:"customer_color_id"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
}
func (h *ShangwutongConnectorHandler) UpdateClassificationStatus(c *gin.Context) {
inbox, ok := h.authorizedInbox(c)
if !ok {
return
}
conversationID, err := strconv.ParseUint(c.Params.ByName("conversation_id"), 10, 64)
if err != nil || conversationID == 0 {
h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false)
return
}
var request shangwutongClassificationStatusRequest
if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || request.Operation == "" || (request.Status != "succeeded" && request.Status != "failed" && request.Status != "uncertain") {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_status", "classification status is invalid", false)
return
}
expectedKey := fmt.Sprintf("swt-classification-operation:%d:%s", inbox.ID, request.EventID)
if c.GetHeader("Idempotency-Key") != expectedKey {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false)
return
}
request.Operation = strings.TrimSpace(request.Operation)
request.ChatKindID, request.CustomerColorID = strings.TrimSpace(request.ChatKindID), strings.TrimSpace(request.CustomerColorID)
if request.Operation != "set_chat_kind" && request.Operation != "set_customer_color" || (request.ChatKindID == "") == (request.CustomerColorID == "") {
h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_status", "classification operation and target are invalid", false)
return
}
var conversation model.Conversation
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND inbox_id = ?", uint(conversationID), inbox.ID).First(&conversation).Error; err != nil {
h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false)
return
}
attributes := map[string]any{}
if len(conversation.AdditionalAttributes) > 0 {
if err := json.Unmarshal(conversation.AdditionalAttributes, &attributes); err != nil {
h.connectorError(c, http.StatusInternalServerError, "conversation_attributes_invalid", "conversation attributes are invalid", true)
return
}
}
if request.Status == "succeeded" {
if request.ChatKindID != "" {
attributes["swt_chat_kind"] = request.ChatKindID
} else {
attributes["swt_label_color"] = request.CustomerColorID
}
}
attributes["swt_classification_status"] = request.Status
attributes["swt_classification_event_id"] = request.EventID
if request.ErrorCode != "" {
attributes["swt_classification_error_code"] = request.ErrorCode
} else {
delete(attributes, "swt_classification_error_code")
}
if request.ErrorMessage != "" {
attributes["swt_classification_error"] = request.ErrorMessage
} else {
delete(attributes, "swt_classification_error")
}
updatedAttributes, err := json.Marshal(attributes)
if err != nil {
h.connectorError(c, http.StatusInternalServerError, "conversation_attributes_invalid", "conversation attributes are invalid", true)
return
}
if err := h.db.WithContext(c.Request.Context()).Model(&conversation).Update("additional_attributes", updatedAttributes).Error; err != nil {
h.connectorError(c, http.StatusInternalServerError, "conversation_update_failed", "failed to update conversation classification", true)
return
}
c.JSON(http.StatusOK, gin.H{"updated": true, "status": request.Status})
}
func (h *ShangwutongConnectorHandler) userInbox(c *gin.Context) (*model.Inbox, bool) {
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
if err != nil || accountID == 0 {
h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false)
return nil, false
}
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
}
var inbox model.Inbox
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ? AND channel_type = ?", uint(inboxID), uint(accountID), "shangwutong").First(&inbox).Error; err != nil {
h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false)
return nil, false
}
return &inbox, true
}
func classificationCacheResponse(cache *model.ShangwutongClassificationCache) shangwutongClassificationCacheResponse {
conversationKinds := json.RawMessage(cache.ConversationKinds)
if len(conversationKinds) == 0 {
conversationKinds = json.RawMessage(`[]`)
}
customerColors := json.RawMessage(cache.CustomerColorKinds)
if len(customerColors) == 0 {
customerColors = json.RawMessage(`[]`)
}
return shangwutongClassificationCacheResponse{
InboxID: cache.InboxID, ConversationKinds: conversationKinds, CustomerColorKinds: customerColors,
SyncStatus: cache.SyncStatus, SyncedAt: cache.SyncedAt, LastErrorCode: cache.LastErrorCode, LastErrorMessage: cache.LastErrorMessage,
}
}
func validClassificationCatalog(request shangwutongClassificationCallbackRequest) bool {
if len(request.ConversationKinds) > 256 || len(request.CustomerColorKinds) > 256 {
return false
}
seen := make(map[string]struct{}, len(request.ConversationKinds)+len(request.CustomerColorKinds))
for _, kind := range request.ConversationKinds {
if strings.TrimSpace(kind.ID) == "" || strings.TrimSpace(kind.Name) == "" || len(kind.ID) > 128 || len(kind.Name) > 255 {
return false
}
key := "conversation:" + kind.ID
if _, ok := seen[key]; ok {
return false
}
seen[key] = struct{}{}
}
for _, color := range request.CustomerColorKinds {
if strings.TrimSpace(color.ID) == "" || strings.TrimSpace(color.Name) == "" || len(color.ID) > 128 || len(color.Name) > 255 {
return false
}
key := "color:" + color.ID
if _, ok := seen[key]; ok {
return false
}
seen[key] = struct{}{}
}
return true
}
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
}