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
@@ -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
}