feat(channels): add Shangwutong connector

This commit is contained in:
2026-08-03 10:13:40 +08:00
parent 96f5c796a6
commit 897b4e018f
126 changed files with 20207 additions and 272 deletions
@@ -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),