feat(channels): add Shangwutong connector
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
@@ -345,7 +346,11 @@ func (h *ConversationHandler) ToggleStatus(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conversation, svcErr := h.conversationSvc.ToggleStatus(c.Request.Context(), accountID, conversation.ID, req)
|
||||
if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) {
|
||||
return
|
||||
}
|
||||
requestContext := service.WithShangwutongRequestMetadata(c.Request.Context(), middleware.IsConnectorService(c), currentUserID(c))
|
||||
conversation, svcErr := h.conversationSvc.ToggleStatus(requestContext, accountID, conversation.ID, req)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
@@ -828,6 +833,9 @@ func (h *ConversationHandler) UpdateCustomAttributes(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) {
|
||||
return
|
||||
}
|
||||
conversation, svcErr := h.conversationSvc.UpdateCustomAttributes(c.Request.Context(), accountID, conversation.ID, req.CustomAttributes)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
|
||||
@@ -128,6 +128,10 @@ type chatwootMessagePayload struct {
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Private bool `json:"private"`
|
||||
SourceID string `json:"source_id"`
|
||||
External bool `json:"external"`
|
||||
ExternalSourceIDs map[string]any `json:"external_source_ids"`
|
||||
AdditionalAttrs map[string]any `json:"additional_attributes"`
|
||||
IdempotentReplay bool `json:"idempotent_replay,omitempty"`
|
||||
Sender map[string]any `json:"sender,omitempty"`
|
||||
Attachments []any `json:"attachments,omitempty"`
|
||||
Call map[string]any `json:"call,omitempty"`
|
||||
@@ -438,6 +442,10 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message,
|
||||
CreatedAt: message.CreatedAt.Unix(),
|
||||
Private: message.Private,
|
||||
SourceID: message.SourceID,
|
||||
External: message.External,
|
||||
ExternalSourceIDs: jsonObject(message.ExternalSourceIDs),
|
||||
AdditionalAttrs: jsonObject(message.AdditionalAttributes),
|
||||
IdempotentReplay: message.IdempotentReplay,
|
||||
}
|
||||
if db != nil && message.SenderID != nil && *message.SenderID != 0 {
|
||||
senderType := normalizedSenderType(message.SenderType)
|
||||
@@ -606,7 +614,7 @@ func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Att
|
||||
func serializeAttachmentPushEventData(attachment *model.Attachment) map[string]any {
|
||||
extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".")
|
||||
dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL)
|
||||
return map[string]any{
|
||||
payload := map[string]any{
|
||||
"id": attachment.ID,
|
||||
"message_id": attachment.MessageID,
|
||||
"file_type": attachment.FileType,
|
||||
@@ -618,6 +626,13 @@ func serializeAttachmentPushEventData(attachment *model.Attachment) map[string]a
|
||||
"width": attachment.Width,
|
||||
"height": attachment.Height,
|
||||
}
|
||||
if strings.TrimSpace(attachment.Metadata) != "" {
|
||||
metadata := map[string]any{}
|
||||
if json.Unmarshal([]byte(attachment.Metadata), &metadata) == nil {
|
||||
payload["metadata"] = metadata
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeAttachmentWithConversation(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any {
|
||||
|
||||
@@ -5,9 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func bindJSONWrappedOrRaw(c *gin.Context, wrapperKey string, target any) error {
|
||||
@@ -213,3 +217,27 @@ func getPageSize(c *gin.Context) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func requireConnectorShangwutongConversation(c *gin.Context, db *gorm.DB, conversation *model.Conversation) bool {
|
||||
if !middleware.IsConnectorService(c) {
|
||||
return true
|
||||
}
|
||||
if c.GetHeader("X-GoChat-Schema-Version") != "1" || db == nil || conversation == nil {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "X-GoChat-Schema-Version must be 1")
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
if err := db.WithContext(c.Request.Context()).Model(&model.Inbox{}).Where(
|
||||
"id = ? AND account_id = ? AND channel_type = ?", conversation.InboxID, conversation.AccountID, "shangwutong",
|
||||
).Count(&count).Error; err != nil || count != 1 {
|
||||
connectorRouteError(c, http.StatusForbidden, "forbidden", "connector cannot access this conversation")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func connectorRouteError(c *gin.Context, status int, code, message string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{
|
||||
"code": code, "message": message, "retryable": false, "request_id": c.GetString("request_id"),
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -71,6 +71,21 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an
|
||||
payload["webhook_url"] = inbox.WebhookURL
|
||||
payload["inbox_identifier"] = firstConfigValue(config, "inbox_identifier", "identifier")
|
||||
payload["additional_attributes"] = configValue(config, "additional_attributes")
|
||||
case "Channel::Shangwutong":
|
||||
payload["webhook_url"] = inbox.WebhookURL
|
||||
if db != nil {
|
||||
var channelConfig model.ChannelShangwutongConfig
|
||||
if err := db.Where("inbox_id = ?", inbox.ID).First(&channelConfig).Error; err == nil {
|
||||
payload["password_configured"] = channelConfig.Password != ""
|
||||
payload["config_version"] = channelConfig.ConfigVersion
|
||||
payload["desired_presence"] = channelConfig.DesiredPresence
|
||||
payload["actual_presence"] = channelConfig.ActualPresence
|
||||
payload["connection_status"] = channelConfig.ConnectionStatus
|
||||
payload["credential_status"] = channelConfig.CredentialStatus
|
||||
payload["last_heartbeat_at"] = channelConfig.LastHeartbeatAt
|
||||
payload["last_error_code"] = channelConfig.LastErrorCode
|
||||
}
|
||||
}
|
||||
case "Channel::Telegram":
|
||||
payload["bot_name"] = configValue(config, "bot_name")
|
||||
case "Channel::FacebookPage":
|
||||
@@ -189,18 +204,19 @@ func chatwootChannelType(channelType string) string {
|
||||
return channelType
|
||||
}
|
||||
aliases := map[string]string{
|
||||
"web_widget": "Channel::WebWidget",
|
||||
"facebook": "Channel::FacebookPage",
|
||||
"instagram": "Channel::Instagram",
|
||||
"twitter": "Channel::TwitterProfile",
|
||||
"twilio_sms": "Channel::TwilioSms",
|
||||
"whatsapp": "Channel::Whatsapp",
|
||||
"api": "Channel::Api",
|
||||
"email": "Channel::Email",
|
||||
"telegram": "Channel::Telegram",
|
||||
"line": "Channel::Line",
|
||||
"sms": "Channel::Sms",
|
||||
"tiktok": "Channel::Tiktok",
|
||||
"web_widget": "Channel::WebWidget",
|
||||
"facebook": "Channel::FacebookPage",
|
||||
"instagram": "Channel::Instagram",
|
||||
"twitter": "Channel::TwitterProfile",
|
||||
"twilio_sms": "Channel::TwilioSms",
|
||||
"whatsapp": "Channel::Whatsapp",
|
||||
"api": "Channel::Api",
|
||||
"shangwutong": "Channel::Shangwutong",
|
||||
"email": "Channel::Email",
|
||||
"telegram": "Channel::Telegram",
|
||||
"line": "Channel::Line",
|
||||
"sms": "Channel::Sms",
|
||||
"tiktok": "Channel::Tiktok",
|
||||
}
|
||||
if mapped, ok := aliases[channelType]; ok {
|
||||
return mapped
|
||||
|
||||
@@ -65,6 +65,7 @@ type InstagramAuthorizationRequest struct {
|
||||
RedirectURL string `json:"redirect_url" validate:"omitempty,url"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
ReturnTo string `json:"return_to"`
|
||||
}
|
||||
|
||||
// Authorization generates a Meta OAuth authorize URL for Instagram.
|
||||
@@ -118,7 +119,7 @@ func (h *InstagramChannelHandler) ChatwootAuthorization(c *gin.Context) {
|
||||
var req InstagramAuthorizationRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
redirectURL, err := buildInstagramChatwootAuthorizationURL(accountID, authorizationReturnTo(c), req.AppID, req.AppSecret)
|
||||
redirectURL, err := buildInstagramChatwootAuthorizationURL(accountID, authorizationReturnTo(c, req.ReturnTo), req.AppID, req.AppSecret)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to build Instagram authorization URL: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()})
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
@@ -99,8 +105,9 @@ func (h *MessageHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
connectorRequest := middleware.IsConnectorService(c)
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
if userID == 0 && !connectorRequest {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
@@ -122,9 +129,24 @@ func (h *MessageHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
req.ConversationID = conversation.ID
|
||||
if !requireConnectorShangwutongConversation(c, h.svc.DB(), conversation) {
|
||||
return
|
||||
}
|
||||
if connectorRequest {
|
||||
if !validateConnectorMessageImport(c, &req, conversation.InboxID) {
|
||||
return
|
||||
}
|
||||
} else if req.External {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "external messages require connector authentication")
|
||||
return
|
||||
}
|
||||
|
||||
message, svcErr := h.svc.Create(c.Request.Context(), accountID, userID, req)
|
||||
if svcErr != nil {
|
||||
if errors.Is(svcErr, service.ErrMessageIdempotencyConflict) {
|
||||
connectorRouteError(c, http.StatusConflict, "idempotency_conflict", svcErr.Error())
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
@@ -256,11 +278,18 @@ func (h *MessageHandler) Delete(c *gin.Context) {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !requireConnectorShangwutongConversation(c, h.svc.DB(), conversation) {
|
||||
return
|
||||
}
|
||||
message, svcErr := h.svc.DeleteInConversation(c.Request.Context(), accountID, conversation.ID, messageID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if middleware.IsConnectorService(c) {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation))
|
||||
}
|
||||
|
||||
@@ -393,6 +422,7 @@ func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest)
|
||||
req.SourceID = c.PostForm("source_id")
|
||||
req.EchoID = c.PostForm("echo_id")
|
||||
req.ExternalCreatedAt = c.PostForm("external_created_at")
|
||||
req.External = strings.EqualFold(c.PostForm("external"), "true") || c.PostForm("external") == "1"
|
||||
req.EmailHTMLContent = c.PostForm("email_html_content")
|
||||
req.CCEmails = c.PostForm("cc_emails")
|
||||
req.BCCEmails = c.PostForm("bcc_emails")
|
||||
@@ -403,16 +433,31 @@ func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest)
|
||||
if raw := c.PostForm("content_attributes"); raw != "" {
|
||||
req.ContentAttributes = []byte(raw)
|
||||
}
|
||||
if raw := c.PostForm("additional_attributes"); raw != "" {
|
||||
req.AdditionalAttributes = []byte(raw)
|
||||
}
|
||||
if raw := c.PostForm("external_source_ids"); raw != "" {
|
||||
req.ExternalSourceIDs = []byte(raw)
|
||||
}
|
||||
if raw := c.PostForm("template_params"); raw != "" {
|
||||
req.TemplateParams = []byte(raw)
|
||||
}
|
||||
if c.Request.MultipartForm != nil {
|
||||
for _, key := range []string{"attachments[]", "attachments"} {
|
||||
for _, file := range c.Request.MultipartForm.File[key] {
|
||||
digest := ""
|
||||
if opened, err := file.Open(); err == nil {
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, opened); err == nil {
|
||||
digest = fmt.Sprintf("%x", hash.Sum(nil))
|
||||
}
|
||||
_ = opened.Close()
|
||||
}
|
||||
req.Attachments = append(req.Attachments, service.MessageAttachmentInput{
|
||||
FileName: file.Filename,
|
||||
FileSize: int(file.Size),
|
||||
ContentType: file.Header.Get("Content-Type"),
|
||||
SHA256: digest,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -431,8 +476,54 @@ func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest)
|
||||
if value, ok := raw["content_attributes"]; ok && string(value) != "null" {
|
||||
req.ContentAttributes = datatypes.JSON(value)
|
||||
}
|
||||
if value, ok := raw["additional_attributes"]; ok && string(value) != "null" {
|
||||
req.AdditionalAttributes = datatypes.JSON(value)
|
||||
}
|
||||
if value, ok := raw["external_source_ids"]; ok && string(value) != "null" {
|
||||
req.ExternalSourceIDs = datatypes.JSON(value)
|
||||
}
|
||||
if value, ok := raw["template_params"]; ok && string(value) != "null" {
|
||||
req.TemplateParams = datatypes.JSON(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConnectorMessageImport(c *gin.Context, req *service.CreateMessageRequest, inboxID uint) bool {
|
||||
messageType := strings.ToLower(strings.TrimSpace(req.MessageType))
|
||||
if !req.External || req.Private || (messageType != "incoming" && messageType != "outgoing" && messageType != "activity") {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "invalid_message_import", "connector messages must be external, non-private incoming, outgoing, or activity messages")
|
||||
return false
|
||||
}
|
||||
wantPrefix := fmt.Sprintf("swt:%d:", inboxID)
|
||||
if !strings.HasPrefix(req.SourceID, wantPrefix) || c.GetHeader("Idempotency-Key") != req.SourceID {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "source_id and Idempotency-Key must match the shangwutong inbox namespace")
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(req.ExternalCreatedAt) == "" {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "invalid_external_created_at", "external_created_at is required")
|
||||
return false
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, req.ExternalCreatedAt); err != nil {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "invalid_external_created_at", "external_created_at must use RFC3339Nano")
|
||||
return false
|
||||
}
|
||||
if len(req.ExternalSourceIDs) > 0 && string(req.ExternalSourceIDs) != "null" {
|
||||
ids := map[string]any{}
|
||||
if json.Unmarshal(req.ExternalSourceIDs, &ids) != nil || len(ids) > 1 {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "invalid_external_source_ids", "external_source_ids is invalid")
|
||||
return false
|
||||
}
|
||||
if value, exists := ids["shangwutong"]; exists {
|
||||
text, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "invalid_external_source_ids", "shangwutong external message ID must be a decimal string")
|
||||
return false
|
||||
}
|
||||
if _, err := strconv.ParseUint(text, 10, 64); err != nil {
|
||||
connectorRouteError(c, http.StatusUnprocessableEntity, "invalid_external_source_ids", "shangwutong external message ID must be a decimal string")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"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/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestShangwutongConnectorConfigAPIUsesBearerAndAccountGrants(t *testing.T) {
|
||||
router, _, token, grantedInbox, deniedInbox := setupShangwutongConnectorAPI(t)
|
||||
unauthorized := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/connector/shangwutong/inboxes", nil))
|
||||
require.Equal(t, http.StatusUnauthorized, unauthorized.Code)
|
||||
|
||||
response := connectorRequest(t, router, token, http.MethodGet, "/api/v1/connector/shangwutong/inboxes?limit=100", nil)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
|
||||
var payload struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data, 1)
|
||||
require.EqualValues(t, grantedInbox.ID, payload.Data[0]["inbox_id"])
|
||||
require.NotEqualValues(t, deniedInbox.ID, payload.Data[0]["inbox_id"])
|
||||
credentials := payload.Data[0]["credentials"].(map[string]any)
|
||||
require.Equal(t, "password-1", credentials["password"])
|
||||
require.Equal(t, "hmac-1", credentials["hmac_token"])
|
||||
|
||||
detail := connectorRequest(t, router, token, http.MethodGet, fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d", deniedInbox.ID), nil)
|
||||
require.Equal(t, http.StatusNotFound, detail.Code)
|
||||
}
|
||||
|
||||
func TestShangwutongConnectorStatusRejectsFutureAndIgnoresStaleVersion(t *testing.T) {
|
||||
router, db, token, inbox, _ := setupShangwutongConnectorAPI(t)
|
||||
endpoint := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/status", inbox.ID)
|
||||
status := map[string]any{
|
||||
"config_version": 2, "actual_presence": "busy", "connection_status": "connected",
|
||||
"credential_status": "applied", "last_heartbeat_at": time.Now().UTC(), "last_error_code": nil,
|
||||
}
|
||||
future := connectorRequest(t, router, token, http.MethodPut, endpoint, status)
|
||||
require.Equal(t, http.StatusUnprocessableEntity, future.Code)
|
||||
status["config_version"] = 0
|
||||
invalid := connectorRequest(t, router, token, http.MethodPut, endpoint, status)
|
||||
require.Equal(t, http.StatusUnprocessableEntity, invalid.Code)
|
||||
status["config_version"] = 1
|
||||
updated := connectorRequest(t, router, token, http.MethodPut, endpoint, status)
|
||||
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
|
||||
var config model.ChannelShangwutongConfig
|
||||
require.NoError(t, db.First(&config, "inbox_id = ?", inbox.ID).Error)
|
||||
require.Equal(t, "busy", config.ActualPresence)
|
||||
require.Equal(t, "connected", config.ConnectionStatus)
|
||||
}
|
||||
|
||||
func TestShangwutongConnectorMessageResultIsVersionedAndIdempotent(t *testing.T) {
|
||||
router, db, token, inbox, _ := setupShangwutongConnectorAPI(t)
|
||||
contact := &model.Contact{AccountID: inbox.AccountID, Name: "Visitor", SourceID: "visitor"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open"}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
message := &model.Message{
|
||||
AccountID: inbox.AccountID, InboxID: inbox.ID, ConversationID: conversation.ID,
|
||||
MessageType: "outgoing", ContentType: "text", Content: "reply", Status: "progress",
|
||||
}
|
||||
require.NoError(t, db.Create(message).Error)
|
||||
endpoint := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/messages/%d/status", inbox.ID, message.ID)
|
||||
result := map[string]any{
|
||||
"result_version": 1, "status": "sent", "external_id": nil,
|
||||
"error_code": nil, "error_message": nil, "occurred_at": time.Now().UTC(),
|
||||
}
|
||||
request := func(body map[string]any, key string) *httptest.ResponseRecorder {
|
||||
encoded, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
httpRequest := httptest.NewRequest(http.MethodPut, endpoint, bytes.NewReader(encoded))
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
httpRequest.Header.Set("Idempotency-Key", key)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httpRequest)
|
||||
return response
|
||||
}
|
||||
wantKey := fmt.Sprintf("swt-delivery:%d:%d:1", inbox.ID, message.ID)
|
||||
updated := request(result, wantKey)
|
||||
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
|
||||
replayed := request(result, wantKey)
|
||||
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
|
||||
require.Contains(t, replayed.Body.String(), `"updated":false`)
|
||||
result["status"] = "uncertain"
|
||||
conflict := request(result, wantKey)
|
||||
require.Equal(t, http.StatusConflict, conflict.Code, conflict.Body.String())
|
||||
result["result_version"] = 2
|
||||
result["status"] = "sent"
|
||||
result["external_id"] = "123456"
|
||||
result["occurred_at"] = time.Now().UTC().Add(time.Second)
|
||||
updated = request(result, fmt.Sprintf("swt-delivery:%d:%d:2", inbox.ID, message.ID))
|
||||
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
|
||||
require.NoError(t, db.First(message, message.ID).Error)
|
||||
require.Equal(t, "sent", message.Status)
|
||||
require.JSONEq(t, `{"shangwutong":"123456"}`, string(message.ExternalSourceIDs))
|
||||
|
||||
result["result_version"] = 3
|
||||
result["external_id"] = nil
|
||||
result["external_ids"] = []string{"123456", "123457"}
|
||||
result["occurred_at"] = time.Now().UTC().Add(2 * time.Second)
|
||||
updated = request(result, fmt.Sprintf("swt-delivery:%d:%d:3", inbox.ID, message.ID))
|
||||
require.Equal(t, http.StatusOK, updated.Code, updated.Body.String())
|
||||
require.NoError(t, db.First(message, message.ID).Error)
|
||||
require.JSONEq(t, `{"shangwutong":["123456","123457"]}`, string(message.ExternalSourceIDs))
|
||||
}
|
||||
|
||||
func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, *model.Inbox, *model.Inbox) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{}, &model.Inbox{}, &model.ChannelShangwutongConfig{}, &channelmodel.ChannelAPI{},
|
||||
&model.PlatformApp{}, &model.AccessToken{}, &model.Permissible{}, &model.Contact{}, &model.Conversation{},
|
||||
&model.Message{}, &model.Attachment{}, &model.BackgroundJob{},
|
||||
))
|
||||
active := true
|
||||
app := &model.PlatformApp{Name: "SWT Connector", Type: "integration", Status: "active", Active: &active, Config: json.RawMessage(`{"connector":"shangwutong"}`)}
|
||||
require.NoError(t, db.Create(app).Error)
|
||||
token := "gochat_pa_connector_test_token"
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
require.NoError(t, db.Create(&model.AccessToken{
|
||||
OwnerType: model.AccessTokenOwnerTypePlatformApp, OwnerID: app.ID,
|
||||
Token: hex.EncodeToString(hash[:]), TokenPrefix: token[:8], Name: "connector",
|
||||
}).Error)
|
||||
accounts := []*model.Account{{Name: "Granted", Active: true}, {Name: "Denied", Active: true}}
|
||||
for _, account := range accounts {
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
}
|
||||
require.NoError(t, db.Create(&model.Permissible{
|
||||
PlatformAppID: app.ID, PermissibleType: model.PermissibleTypeAccount, PermissibleID: accounts[0].ID,
|
||||
}).Error)
|
||||
inboxes := make([]*model.Inbox, 0, 2)
|
||||
for index, account := range accounts {
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: fmt.Sprintf("SWT-%d", index), ChannelType: "shangwutong", Enabled: true, WebhookURL: "http://connector/hook"}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
require.NoError(t, db.Create(&model.ChannelShangwutongConfig{
|
||||
InboxID: inbox.ID, SessionID: fmt.Sprintf("BYT9991799%d", index), Username: fmt.Sprintf("agent-%d", index),
|
||||
Password: fmt.Sprintf("password-%d", index+1), DesiredPresence: "online", ConfigVersion: 1,
|
||||
ActualPresence: "offline", ConnectionStatus: "pending", CredentialStatus: "pending",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&channelmodel.ChannelAPI{
|
||||
InboxID: inbox.ID, Identifier: fmt.Sprintf("identifier-%d", index), HMACToken: fmt.Sprintf("hmac-%d", index+1), Secret: fmt.Sprintf("secret-%d", index+1),
|
||||
}).Error)
|
||||
inboxes = append(inboxes, inbox)
|
||||
}
|
||||
messageSvc := service.NewMessageService(repository.NewMessageRepo(db), channel.NewDispatcher(), nil)
|
||||
handler := NewShangwutongConnectorHandler(db, messageSvc)
|
||||
router := gin.New()
|
||||
group := router.Group("/api/v1/connector/shangwutong")
|
||||
group.Use(middleware.ConnectorServiceAuth(db))
|
||||
group.GET("/inboxes", handler.ListInboxes)
|
||||
group.GET("/inboxes/:inbox_id", handler.GetInbox)
|
||||
group.PUT("/inboxes/:inbox_id/status", handler.UpdateInboxStatus)
|
||||
group.PUT("/inboxes/:inbox_id/messages/:message_id/status", handler.UpdateMessageStatus)
|
||||
return router, db, token, inboxes[0], inboxes[1]
|
||||
}
|
||||
|
||||
func connectorRequest(t *testing.T, router http.Handler, token, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var encoded []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
encoded, err = json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(encoded))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSerializeShangwutongInboxNeverReturnsCredentials(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:shangwutong_serializer?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Inbox{}, &model.ChannelShangwutongConfig{}))
|
||||
inbox := &model.Inbox{
|
||||
AccountID: 1, Name: "商务通", ChannelType: "shangwutong", Enabled: true,
|
||||
WebhookURL: "http://connector:9100/webhooks/gochat/v1", Secret: "webhook-secret",
|
||||
}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
require.NoError(t, db.Create(&model.ChannelShangwutongConfig{
|
||||
InboxID: inbox.ID, SessionID: "BYT99917999", Username: "agent", Password: "password",
|
||||
DesiredPresence: "busy", ConfigVersion: 7, ActualPresence: "busy",
|
||||
ConnectionStatus: "connected", CredentialStatus: "applied",
|
||||
}).Error)
|
||||
payload := serializeInbox(inbox, db, true)
|
||||
require.Equal(t, "Channel::Shangwutong", payload["channel_type"])
|
||||
require.Equal(t, true, payload["password_configured"])
|
||||
require.EqualValues(t, 7, payload["config_version"])
|
||||
require.Equal(t, "busy", payload["desired_presence"])
|
||||
for _, secret := range []string{"password", "session_id", "username", "hmac_token", "secret"} {
|
||||
require.NotContains(t, payload, secret)
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,11 @@ const instagramAuthorizationScope = "instagram_business_basic,instagram_business
|
||||
|
||||
const tiktokAuthorizationScope = "user.info.basic,user.info.username,user.info.stats,user.info.profile,user.account.type,user.insights,message.list.read,message.list.send,message.list.manage"
|
||||
|
||||
func authorizationReturnTo(c *gin.Context) string {
|
||||
func authorizationReturnTo(c *gin.Context, bodyValue string) string {
|
||||
if value := strings.TrimSpace(c.Query("return_to")); value != "" {
|
||||
return value
|
||||
}
|
||||
var payload struct {
|
||||
ReturnTo string `json:"return_to" form:"return_to"`
|
||||
}
|
||||
_ = c.ShouldBind(&payload)
|
||||
return strings.TrimSpace(payload.ReturnTo)
|
||||
return strings.TrimSpace(bodyValue)
|
||||
}
|
||||
|
||||
// TikTokAuthorizationRequest is the DTO for initiating TikTok OAuth flow
|
||||
@@ -32,6 +28,7 @@ func authorizationReturnTo(c *gin.Context) string {
|
||||
type TikTokAuthorizationRequest struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
ReturnTo string `json:"return_to"`
|
||||
}
|
||||
|
||||
func buildInstagramChatwootAuthorizationURL(accountID uint, returnTo string, appID, appSecret string) (string, error) {
|
||||
|
||||
@@ -26,13 +26,11 @@ func setupSocialAuthorizationRouter() *gin.Engine {
|
||||
}
|
||||
|
||||
func TestInstagramAuthorization_ReturnsChatwootPayload(t *testing.T) {
|
||||
t.Setenv("INSTAGRAM_APP_ID", "instagram-client")
|
||||
t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret")
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
||||
|
||||
r := setupSocialAuthorizationRouter()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/instagram/authorization", strings.NewReader(`{"return_to":"onboarding"}`))
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/instagram/authorization", strings.NewReader(`{"return_to":"onboarding","app_id":"instagram-client","app_secret":"instagram-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -55,13 +53,12 @@ func TestInstagramAuthorization_ReturnsChatwootPayload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTikTokAuthorization_ReturnsChatwootPayload(t *testing.T) {
|
||||
t.Setenv("TIKTOK_APP_ID", "tiktok-client")
|
||||
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
|
||||
r := setupSocialAuthorizationRouter()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/tiktok/authorization?return_to=onboarding", nil)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/tiktok/authorization?return_to=onboarding", strings.NewReader(`{"app_id":"tiktok-client","app_secret":"tiktok-secret","return_to":"body-target"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
@@ -65,7 +65,7 @@ func (h *TikTokChannelHandler) ChatwootAuthorization(c *gin.Context) {
|
||||
var req TikTokAuthorizationRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c), req.AppID, req.AppSecret)
|
||||
redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c, req.ReturnTo), req.AppID, req.AppSecret)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to build TikTok authorization URL: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()})
|
||||
|
||||
@@ -1485,6 +1485,7 @@ func publicContactPayload(contactInbox *model.ContactInbox, contact *model.Conta
|
||||
func (h *WidgetHandler) publicConversationPayload(ctx context.Context, conversation model.Conversation, messages []model.Message) gin.H {
|
||||
payload := gin.H{
|
||||
"id": publicDisplayID(conversation),
|
||||
"internal_id": conversation.ID,
|
||||
"uuid": conversation.UUID,
|
||||
"inbox_id": conversation.InboxID,
|
||||
"contact_last_seen_at": publicUnix(conversation.ContactLastSeenAt),
|
||||
|
||||
Reference in New Issue
Block a user