diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e5a98a0..77262dc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,10 +99,29 @@ jobs: with: files: backend/coverage.out + shangwutong: + name: Shangwutong Connector + runs-on: ubuntu-latest + defaults: + run: + working-directory: channels/shangwutong + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.4' + cache-dependency-path: channels/shangwutong/go.sum + - name: Verify sqlc generation + run: go tool sqlc generate && git diff --exit-code -- db/generated + - name: Test with race detector + run: go test -race ./... + - name: Vet and build + run: go vet ./... && go build -o /tmp/shangwutong-build-check ./cmd/shangwutong + # ---- Stage 2: Security Scan ---- security: name: Security Scan - needs: test + needs: [test, shangwutong] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -135,7 +154,7 @@ jobs: # ---- Stage 3: Build Docker Image ---- build: name: Build Docker Image - needs: [test, security] + needs: [test, shangwutong, security] runs-on: ubuntu-latest if: github.event_name == 'push' # Only build on push, not PRs permissions: @@ -196,4 +215,4 @@ jobs: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} format: 'table' exit-code: '1' - severity: 'CRITICAL,HIGH' \ No newline at end of file + severity: 'CRITICAL,HIGH' diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index b33d5659..607b387f 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -254,6 +254,7 @@ func autoMigrate(db *gorm.DB) error { &model.CaptainMessageReport{}, // S6: WorkingHour — out-of-office / business hours per inbox &model.WorkingHour{}, + &model.ChannelShangwutongConfig{}, // Notification settings — per-user per-account notification preferences &model.NotificationSetting{}, } diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index f43cbe0c..fa427368 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -375,6 +375,7 @@ func Bootstrap(env string) (*App, error) { channelDispatcher.Register(fbListener) channelDispatcher.Register(igListener) channelDispatcher.Register(channel.NewWebhookListenerWithDB(db)) + channelDispatcher.Register(service.NewShangwutongTypingWebhookListener(db)) // Create Facebook webhook handler (Gin HTTP handler for FB/IG webhook endpoints) facebookWebhookHandler := webhook.NewFacebookWebhookHandler(fbProvider, igProvider, db, channelDispatcher) facebookWebhookHandler.WithWorkerPool(workerPool) @@ -488,6 +489,7 @@ func Bootstrap(env string) (*App, error) { inboxService := service.NewInboxService(inboxRepo, agentBotInboxRepo, agentBotRepo, campaignRepo, webhookSubRepo, waService, waRepo) inboxService.SetWorkerPool(workerPool) service.RegisterInboxTemplateSyncJobs(workerPool, inboxService) + service.RegisterShangwutongWebhookDeliveryJobs(workerPool, db, channelDispatcher) igRepo := repository.NewChannelInstagramRepo(db) igService := service.NewChannelInstagramService(igRepo, igProvider) fbChannelRepo := repository.NewChannelFacebookRepo(db) @@ -855,7 +857,7 @@ func Bootstrap(env string) (*App, error) { Search: v1.NewSearchHandler(searchService, db), Widget: widgetHandler, // M13: SSO enterprise authentication handlers - SSOSession: v1.NewSSOSessionHandler(ssoSessionStore), + SSOSession: v1.NewSSOSessionHandler(ssoSessionStore), // M13: OIDC enterprise authentication handlers OIDC: v1.NewOIDCHandler(oidcService, ssoMiddleware, jwtService, refreshStore, &cfg.OIDC), SSOMiddleware: ssoMiddleware, @@ -909,7 +911,8 @@ func Bootstrap(env string) (*App, error) { // EmailChannelMigration handler (account-scoped create-only) EmailChannelMigration: v1.NewEmailChannelMigrationHandler(emailChannelMigrationService), // SummaryReport handler (read-only reporting resource — agent/team/inbox/label summaries) - SummaryReport: v1.NewSummaryReportHandler(summaryReportService), + SummaryReport: v1.NewSummaryReportHandler(summaryReportService), + ShangwutongConnector: v1.NewShangwutongConnectorHandler(db, messageService), } // Step 10: Setup Gin router + middleware chain // (ref: Chatwoot Rails middleware stack in config/application.rb) @@ -920,7 +923,7 @@ func Bootstrap(env string) (*App, error) { corsMiddleware := middleware.CORS(middleware.CORSConfigFromAppConfig(cfg)) engine.Use(middleware.Recovery()) // panic recovery engine.Use(middleware.RequestLogger()) // structured request logging - engine.Use(middleware.RateLimit(rdb)) // rate limiting (ref: Chatwoot rack-attack) + engine.Use(middleware.RateLimit(rdb)) // rate limiting (ref: Chatwoot rack-attack) engine.Use(corsMiddleware) // CORS with configurable whitelist engine.Use(middleware.SecurityHeaders(middleware.DefaultSecurityHeadersConfig())) // security headers (ref: P14 deliverable #11) engine.StaticFS("/uploads", gin.Dir(cfg.Storage.LocalPath, false)) diff --git a/backend/internal/channel/listener.go b/backend/internal/channel/listener.go index b9246754..182e5483 100644 --- a/backend/internal/channel/listener.go +++ b/backend/internal/channel/listener.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/gochat/gochat/internal/model" @@ -377,6 +378,10 @@ func (w *WebhookListener) deliverAPIInboxWebhook(ctx context.Context, event *Cha if w == nil || w.db == nil || event == nil || event.InboxID == 0 || !isAPIInboxWebhookEvent(event) { return nil } + var inbox model.Inbox + if err := w.db.WithContext(ctx).Select("id", "channel_type").First(&inbox, event.InboxID).Error; err == nil && strings.EqualFold(inbox.ChannelType, "shangwutong") { + return nil + } var channelAPI channelmodel.ChannelAPI if err := w.db.WithContext(ctx).Where("inbox_id = ?", event.InboxID).First(&channelAPI).Error; err != nil { if err == gorm.ErrRecordNotFound { diff --git a/backend/internal/channel/provider.go b/backend/internal/channel/provider.go index 33652add..f271b8de 100644 --- a/backend/internal/channel/provider.go +++ b/backend/internal/channel/provider.go @@ -2,6 +2,7 @@ package channel import ( "context" + "strings" "time" "github.com/gochat/gochat/internal/model" @@ -13,21 +14,31 @@ import ( type ChannelType string const ( - ChannelWebWidget ChannelType = "web_widget" - ChannelTelegram ChannelType = "telegram" - ChannelFacebook ChannelType = "facebook" - ChannelInstagram ChannelType = "instagram" - ChannelWhatsApp ChannelType = "whatsapp" - ChannelEmail ChannelType = "email" - ChannelTwilioSMS ChannelType = "twilio_sms" - ChannelTwilioWA ChannelType = "twilio_whatsapp" - ChannelLine ChannelType = "line" - ChannelSlack ChannelType = "slack" - ChannelAPI ChannelType = "api" - ChannelTikTok ChannelType = "tiktok" - ChannelMicrosoft ChannelType = "microsoft" + ChannelWebWidget ChannelType = "web_widget" + ChannelTelegram ChannelType = "telegram" + ChannelFacebook ChannelType = "facebook" + ChannelInstagram ChannelType = "instagram" + ChannelWhatsApp ChannelType = "whatsapp" + ChannelEmail ChannelType = "email" + ChannelTwilioSMS ChannelType = "twilio_sms" + ChannelTwilioWA ChannelType = "twilio_whatsapp" + ChannelLine ChannelType = "line" + ChannelSlack ChannelType = "slack" + ChannelAPI ChannelType = "api" + ChannelShangwutong ChannelType = "shangwutong" + ChannelTikTok ChannelType = "tiktok" + ChannelMicrosoft ChannelType = "microsoft" ) +func IsAPIInboxLike(channelType string) bool { + switch strings.ToLower(strings.TrimSpace(channelType)) { + case "api", "channel::api", "shangwutong", "channel::shangwutong": + return true + default: + return false + } +} + // ChannelProvider is the core interface that all channel providers must implement. // Reference: P2D §3.2 — Chatwoot's Channelable concern + polymorphic channel association // @@ -158,41 +169,41 @@ type ChannelConfig map[string]interface{} // ConfigSchemaDefinition defines the JSON Schema for channel configuration validation. // Reference: P2D §7 — replaces Chatwoot's EDITABLE_ATTRS constants type ConfigSchemaDefinition struct { - Type string `json:"type"` // "object" - Properties map[string]ConfigProperty `json:"properties"` // field definitions - Required []string `json:"required"` // mandatory fields + Type string `json:"type"` // "object" + Properties map[string]ConfigProperty `json:"properties"` // field definitions + Required []string `json:"required"` // mandatory fields } // ConfigProperty defines a single configuration field. type ConfigProperty struct { - Type string `json:"type"` // "string", "number", "boolean", "array" - Description string `json:"description"` // human-readable field description - Default interface{} `json:"default,omitempty"` // default value - Enum []string `json:"enum,omitempty"` // allowed values (for enum fields) - Pattern string `json:"pattern,omitempty"` // regex pattern for validation - Format string `json:"format,omitempty"` // "url", "email", "uri" etc. - Secret bool `json:"secret,omitempty"` // true for sensitive fields (bot_token etc.) - Required bool `json:"required,omitempty"` // whether this property is required + Type string `json:"type"` // "string", "number", "boolean", "array" + Description string `json:"description"` // human-readable field description + Default interface{} `json:"default,omitempty"` // default value + Enum []string `json:"enum,omitempty"` // allowed values (for enum fields) + Pattern string `json:"pattern,omitempty"` // regex pattern for validation + Format string `json:"format,omitempty"` // "url", "email", "uri" etc. + Secret bool `json:"secret,omitempty"` // true for sensitive fields (bot_token etc.) + Required bool `json:"required,omitempty"` // whether this property is required } // OAuthConfigDefinition defines OAuth requirements for a channel. // Reference: P2D §3.3 type OAuthConfigDefinition struct { - Provider string `json:"provider"` // "facebook", "google", "slack" etc. - Scopes []string `json:"scopes"` // required OAuth scopes - AuthorizeURL string `json:"authorize_url"` // OAuth authorize endpoint - TokenURL string `json:"token_url"` // OAuth token exchange endpoint - RefreshURL string `json:"refresh_url"` // OAuth token refresh endpoint - RequiresRefresh bool `json:"requires_refresh"` // whether token needs periodic refresh - TokenExpiry int `json:"token_expiry"` // token expiry in seconds (0 = no expiry) + Provider string `json:"provider"` // "facebook", "google", "slack" etc. + Scopes []string `json:"scopes"` // required OAuth scopes + AuthorizeURL string `json:"authorize_url"` // OAuth authorize endpoint + TokenURL string `json:"token_url"` // OAuth token exchange endpoint + RefreshURL string `json:"refresh_url"` // OAuth token refresh endpoint + RequiresRefresh bool `json:"requires_refresh"` // whether token needs periodic refresh + TokenExpiry int `json:"token_expiry"` // token expiry in seconds (0 = no expiry) } // OAuthTokenResult contains the result of OAuth token exchange/refresh. type OAuthTokenResult struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - Scope string `json:"scope,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + Scope string `json:"scope,omitempty"` Extra ChannelConfig `json:"extra,omitempty"` // provider-specific extra data } @@ -202,26 +213,26 @@ type OAuthTokenResult struct { type IncomingMessage struct { // Source identification ChannelType ChannelType `json:"channel_type"` - SourceID string `json:"source_id"` // external message ID (TG message_id, FB mid etc.) - ConversationID string `json:"conversation_id"` // external conversation/thread ID + SourceID string `json:"source_id"` // external message ID (TG message_id, FB mid etc.) + ConversationID string `json:"conversation_id"` // external conversation/thread ID // Sender identification - SenderID string `json:"sender_id"` // external sender ID (TG user_id, FB sender_id etc.) - SenderName string `json:"sender_name"` // sender display name - SenderType SenderType `json:"sender_type"` // contact / agent / system + SenderID string `json:"sender_id"` // external sender ID (TG user_id, FB sender_id etc.) + SenderName string `json:"sender_name"` // sender display name + SenderType SenderType `json:"sender_type"` // contact / agent / system // Message content - Content string `json:"content"` // text content - ContentType ContentType `json:"content_type"` // text / image / file / audio / video / location / email - Attachments []Attachment `json:"attachments"` // media attachments - ReplyToID string `json:"reply_to_id,omitempty"` // replied-to message source ID + Content string `json:"content"` // text content + ContentType ContentType `json:"content_type"` // text / image / file / audio / video / location / email + Attachments []Attachment `json:"attachments"` // media attachments + ReplyToID string `json:"reply_to_id,omitempty"` // replied-to message source ID // Metadata - InboxID uint `json:"inbox_id"` - AccountID uint `json:"account_id"` - ReceivedAt time.Time `json:"received_at"` - Extra ChannelConfig `json:"extra,omitempty"` // channel-specific metadata - SenderExtra ChannelConfig `json:"sender_extra,omitempty"` // sender-specific metadata (e.g., Telegram user details) + InboxID uint `json:"inbox_id"` + AccountID uint `json:"account_id"` + ReceivedAt time.Time `json:"received_at"` + Extra ChannelConfig `json:"extra,omitempty"` // channel-specific metadata + SenderExtra ChannelConfig `json:"sender_extra,omitempty"` // sender-specific metadata (e.g., Telegram user details) ConversationExtra ChannelConfig `json:"conversation_extra,omitempty"` // conversation-specific metadata (e.g., group info) } @@ -251,53 +262,53 @@ const ( // Attachment represents a media attachment in a message. type Attachment struct { - URL string `json:"url"` // download URL - ContentType string `json:"content_type"` // MIME type - Filename string `json:"filename,omitempty"` // original filename - FileSize int64 `json:"file_size,omitempty"` // file size in bytes - ThumbnailURL string `json:"thumbnail_url,omitempty"` // thumbnail URL (for images/videos) - Extra ChannelConfig `json:"extra,omitempty"` // channel-specific metadata + URL string `json:"url"` // download URL + ContentType string `json:"content_type"` // MIME type + Filename string `json:"filename,omitempty"` // original filename + FileSize int64 `json:"file_size,omitempty"` // file size in bytes + ThumbnailURL string `json:"thumbnail_url,omitempty"` // thumbnail URL (for images/videos) + Extra ChannelConfig `json:"extra,omitempty"` // channel-specific metadata } // SendResult contains the result of an outbound message send operation. type SendResult struct { - ExternalID string `json:"external_id"` // external channel message ID - DeliveredAt time.Time `json:"delivered_at"` // delivery timestamp - Extra ChannelConfig `json:"extra,omitempty"` // channel-specific response data + ExternalID string `json:"external_id"` // external channel message ID + DeliveredAt time.Time `json:"delivered_at"` // delivery timestamp + Extra ChannelConfig `json:"extra,omitempty"` // channel-specific response data } // WebhookRequest wraps an incoming webhook HTTP request. // Reference: P2D §6 — unified webhook entry point type WebhookRequest struct { - ChannelType ChannelType `json:"channel_type"` // identifies which provider handles this - Identifier string `json:"identifier"` // inbox identifier (TG bot token, FB page ID etc.) - Headers map[string]string `json:"headers"` // HTTP headers for signature verification - Body []byte `json:"body"` // raw request body - QueryParams map[string]string `json:"query_params"` // URL query parameters - Method string `json:"method"` // HTTP method (GET/POST) + ChannelType ChannelType `json:"channel_type"` // identifies which provider handles this + Identifier string `json:"identifier"` // inbox identifier (TG bot token, FB page ID etc.) + Headers map[string]string `json:"headers"` // HTTP headers for signature verification + Body []byte `json:"body"` // raw request body + QueryParams map[string]string `json:"query_params"` // URL query parameters + Method string `json:"method"` // HTTP method (GET/POST) } // ContactProfile contains contact profile info fetched from external channel. type ContactProfile struct { - Name string `json:"name"` - AvatarURL string `json:"avatar_url,omitempty"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url,omitempty"` Extra ChannelConfig `json:"extra,omitempty"` // channel-specific profile data } // ChannelCapabilities declares what features a channel supports. // Reference: P2D §3.2 — replaces Chatwoot's per-channel feature flags type ChannelCapabilities struct { - SupportsAttachments bool `json:"supports_attachments"` - SupportsLocation bool `json:"supports_location"` - SupportsTypingIndicator bool `json:"supports_typing_indicator"` - SupportsDeliveryStatus bool `json:"supports_delivery_status"` - SupportsReplies bool `json:"supports_replies"` // reply-to specific messages - SupportsEmojiReactions bool `json:"supports_emoji_reactions"` - SupportsVoiceMessages bool `json:"supports_voice_messages"` - SupportsVideoCalls bool `json:"supports_video_calls"` - SupportsCustomCards bool `json:"supports_custom_cards"` // rich message cards - SupportsTemplates bool `json:"supports_templates"` // WhatsApp message templates - SupportsEmailHeaders bool `json:"supports_email_headers"` // Email subject/cc/bcc - MaxAttachmentSize int64 `json:"max_attachment_size"` // max attachment size in bytes - MaxTextLength int `json:"max_text_length"` // max text message length -} \ No newline at end of file + SupportsAttachments bool `json:"supports_attachments"` + SupportsLocation bool `json:"supports_location"` + SupportsTypingIndicator bool `json:"supports_typing_indicator"` + SupportsDeliveryStatus bool `json:"supports_delivery_status"` + SupportsReplies bool `json:"supports_replies"` // reply-to specific messages + SupportsEmojiReactions bool `json:"supports_emoji_reactions"` + SupportsVoiceMessages bool `json:"supports_voice_messages"` + SupportsVideoCalls bool `json:"supports_video_calls"` + SupportsCustomCards bool `json:"supports_custom_cards"` // rich message cards + SupportsTemplates bool `json:"supports_templates"` // WhatsApp message templates + SupportsEmailHeaders bool `json:"supports_email_headers"` // Email subject/cc/bcc + MaxAttachmentSize int64 `json:"max_attachment_size"` // max attachment size in bytes + MaxTextLength int `json:"max_text_length"` // max text message length +} diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index 76ee3667..cef878ae 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -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) diff --git a/backend/internal/handler/api/v1/conversation_serializer.go b/backend/internal/handler/api/v1/conversation_serializer.go index 79845467..7d4b56a8 100644 --- a/backend/internal/handler/api/v1/conversation_serializer.go +++ b/backend/internal/handler/api/v1/conversation_serializer.go @@ -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 { diff --git a/backend/internal/handler/api/v1/helpers.go b/backend/internal/handler/api/v1/helpers.go index 9f37db45..510d9018 100644 --- a/backend/internal/handler/api/v1/helpers.go +++ b/backend/internal/handler/api/v1/helpers.go @@ -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"), + }}) +} diff --git a/backend/internal/handler/api/v1/inbox_serializer.go b/backend/internal/handler/api/v1/inbox_serializer.go index e2b63214..8c4b4359 100644 --- a/backend/internal/handler/api/v1/inbox_serializer.go +++ b/backend/internal/handler/api/v1/inbox_serializer.go @@ -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 diff --git a/backend/internal/handler/api/v1/instagram_channel_handler.go b/backend/internal/handler/api/v1/instagram_channel_handler.go index ceb687a4..146d2033 100644 --- a/backend/internal/handler/api/v1/instagram_channel_handler.go +++ b/backend/internal/handler/api/v1/instagram_channel_handler.go @@ -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()}) diff --git a/backend/internal/handler/api/v1/message_handler.go b/backend/internal/handler/api/v1/message_handler.go index 69394364..bc94cc20 100644 --- a/backend/internal/handler/api/v1/message_handler.go +++ b/backend/internal/handler/api/v1/message_handler.go @@ -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 +} diff --git a/backend/internal/handler/api/v1/shangwutong_connector_handler.go b/backend/internal/handler/api/v1/shangwutong_connector_handler.go new file mode 100644 index 00000000..fdd3c829 --- /dev/null +++ b/backend/internal/handler/api/v1/shangwutong_connector_handler.go @@ -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 +} diff --git a/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go b/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go new file mode 100644 index 00000000..9c7560cc --- /dev/null +++ b/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go @@ -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 +} diff --git a/backend/internal/handler/api/v1/shangwutong_inbox_serializer_test.go b/backend/internal/handler/api/v1/shangwutong_inbox_serializer_test.go new file mode 100644 index 00000000..d3e6c225 --- /dev/null +++ b/backend/internal/handler/api/v1/shangwutong_inbox_serializer_test.go @@ -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) + } +} diff --git a/backend/internal/handler/api/v1/social_authorization.go b/backend/internal/handler/api/v1/social_authorization.go index e86b0efc..2d036ac8 100644 --- a/backend/internal/handler/api/v1/social_authorization.go +++ b/backend/internal/handler/api/v1/social_authorization.go @@ -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) { diff --git a/backend/internal/handler/api/v1/social_authorization_test.go b/backend/internal/handler/api/v1/social_authorization_test.go index 4d23d467..73d61db8 100644 --- a/backend/internal/handler/api/v1/social_authorization_test.go +++ b/backend/internal/handler/api/v1/social_authorization_test.go @@ -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) diff --git a/backend/internal/handler/api/v1/tiktok_channel_handler.go b/backend/internal/handler/api/v1/tiktok_channel_handler.go index 9d2a5cdd..bac674c3 100644 --- a/backend/internal/handler/api/v1/tiktok_channel_handler.go +++ b/backend/internal/handler/api/v1/tiktok_channel_handler.go @@ -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()}) diff --git a/backend/internal/handler/widget/widget_handler.go b/backend/internal/handler/widget/widget_handler.go index 264a8fdd..6cd06eb5 100644 --- a/backend/internal/handler/widget/widget_handler.go +++ b/backend/internal/handler/widget/widget_handler.go @@ -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), diff --git a/backend/internal/middleware/account_scope.go b/backend/internal/middleware/account_scope.go index 26b5b113..be4aea4a 100644 --- a/backend/internal/middleware/account_scope.go +++ b/backend/internal/middleware/account_scope.go @@ -31,6 +31,14 @@ import ( // router.Use(AuthRequired(jwtSvc), AccountScope()) func AccountScope() gin.HandlerFunc { return func(c *gin.Context) { + if IsConnectorService(c) { + if authorized, _ := c.Get("connector_account_authorized"); authorized != true { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Connector does not have access to this account") + return + } + c.Next() + return + } // Step 1: Get user_id from JWT claims (set by AuthRequired) userID, exists := c.Get("user_id") if !exists { @@ -111,6 +119,14 @@ func AccountScope() gin.HandlerFunc { // router.Use(AuthRequired(jwtSvc), AccountScopeWithService(rbacSvc)) func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { return func(c *gin.Context) { + if IsConnectorService(c) { + if authorized, _ := c.Get("connector_account_authorized"); authorized != true { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Connector does not have access to this account") + return + } + c.Next() + return + } userID, exists := c.Get("user_id") if !exists { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, diff --git a/backend/internal/middleware/connector_service_auth.go b/backend/internal/middleware/connector_service_auth.go new file mode 100644 index 00000000..5ed03363 --- /dev/null +++ b/backend/internal/middleware/connector_service_auth.go @@ -0,0 +1,144 @@ +package middleware + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" + "github.com/gochat/gochat/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ConnectorPlatformAppIDKey = "connector_platform_app_id" + +func ConnectorServiceAuth(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + setConnectorRequestID(c) + app, accessToken, ok := authenticateShangwutongConnector(c, db) + if !ok { + connectorAuthError(c) + return + } + setConnectorPrincipal(c, db, app, accessToken) + c.Next() + } +} + +func AuthMiddlewareWithConnectorAllowlist(jwtService *auth.JWTService, db *gorm.DB) gin.HandlerFunc { + userAuth := AuthMiddlewareWithServiceAndDB(jwtService, db) + return func(c *gin.Context) { + if connectorApplicationRoute(c) { + setConnectorRequestID(c) + if app, accessToken, ok := authenticateShangwutongConnector(c, db); ok { + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) + if err != nil || accountID == 0 { + connectorPermissionError(c) + return + } + var count int64 + if err := db.WithContext(c.Request.Context()).Model(&model.Permissible{}).Where( + "platform_app_id = ? AND permissible_type = ? AND permissible_id = ?", app.ID, model.PermissibleTypeAccount, uint(accountID), + ).Count(&count).Error; err != nil || count == 0 { + connectorPermissionError(c) + return + } + setConnectorPrincipal(c, db, app, accessToken) + c.Set("connector_account_authorized", true) + c.Set("account_id", uint(accountID)) + c.Next() + return + } + } + userAuth(c) + } +} + +func connectorApplicationRoute(c *gin.Context) bool { + switch c.FullPath() { + case "/api/v1/accounts/:account_id/conversations/:conversation_id/messages": + return c.Request.Method == http.MethodPost + case "/api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes", + "/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_status": + return c.Request.Method == http.MethodPost + case "/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id": + return c.Request.Method == http.MethodDelete + default: + return false + } +} + +func authenticateShangwutongConnector(c *gin.Context, db *gorm.DB) (*model.PlatformApp, *model.AccessToken, bool) { + parts := strings.Fields(c.GetHeader("Authorization")) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" { + return nil, nil, false + } + hash := sha256.Sum256([]byte(parts[1])) + var accessToken model.AccessToken + if err := db.WithContext(c.Request.Context()).Where( + "token = ? AND owner_type = ?", hex.EncodeToString(hash[:]), model.AccessTokenOwnerTypePlatformApp, + ).First(&accessToken).Error; err != nil { + return nil, nil, false + } + var app model.PlatformApp + if err := db.WithContext(c.Request.Context()).First(&app, accessToken.OwnerID).Error; err != nil || !app.IsActive() || !isShangwutongConnectorApp(&app) { + return nil, nil, false + } + return &app, &accessToken, true +} + +func setConnectorPrincipal(c *gin.Context, db *gorm.DB, app *model.PlatformApp, accessToken *model.AccessToken) { + c.Set(ConnectorPlatformAppIDKey, app.ID) + c.Set("connector_service_principal", *app) + _ = db.WithContext(c.Request.Context()).Model(&model.AccessToken{}).Where("id = ?", accessToken.ID).Update("last_used_at", gorm.Expr("CURRENT_TIMESTAMP")).Error +} + +func setConnectorRequestID(c *gin.Context) { + requestID := strings.TrimSpace(c.GetHeader("X-Request-ID")) + if requestID == "" { + requestID = uuid.NewString() + } + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) +} + +func ConnectorPlatformAppID(c *gin.Context) uint { + value, _ := c.Get(ConnectorPlatformAppIDKey) + id, _ := value.(uint) + return id +} + +func IsConnectorService(c *gin.Context) bool { + return ConnectorPlatformAppID(c) != 0 +} + +func isShangwutongConnectorApp(app *model.PlatformApp) bool { + if app == nil || app.Type != "integration" { + return false + } + config := map[string]any{} + if len(app.Config) == 0 || json.Unmarshal(app.Config, &config) != nil { + return false + } + connector, _ := config["connector"].(string) + return connector == "shangwutong" +} + +func connectorAuthError(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{ + "code": "unauthorized", "message": "connector authentication failed", "retryable": false, + "request_id": c.GetString("request_id"), + }}) +} + +func connectorPermissionError(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": gin.H{ + "code": "forbidden", "message": "connector account access denied", "retryable": false, + "request_id": c.GetString("request_id"), + }}) +} diff --git a/backend/internal/middleware/connector_service_auth_test.go b/backend/internal/middleware/connector_service_auth_test.go new file mode 100644 index 00000000..a1988ea0 --- /dev/null +++ b/backend/internal/middleware/connector_service_auth_test.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" + "github.com/gochat/gochat/internal/model" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestConnectorServiceTokenIsLimitedToApplicationAllowlistAndAccountGrant(t *testing.T) { + 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.PlatformApp{}, &model.AccessToken{}, &model.Permissible{})) + active := true + app := &model.PlatformApp{Name: "SWT", Type: "integration", Status: "active", Active: &active, Config: json.RawMessage(`{"connector":"shangwutong"}`)} + require.NoError(t, db.Create(app).Error) + token := "gochat_pa_connector_allowlist" + digest := sha256.Sum256([]byte(token)) + require.NoError(t, db.Create(&model.AccessToken{ + OwnerType: model.AccessTokenOwnerTypePlatformApp, OwnerID: app.ID, + Token: hex.EncodeToString(digest[:]), TokenPrefix: token[:8], Name: "connector", + }).Error) + require.NoError(t, db.Create(&model.Permissible{ + PlatformAppID: app.ID, PermissibleType: model.PermissibleTypeAccount, PermissibleID: 1, + }).Error) + + router := gin.New() + api := router.Group("/api/v1") + api.Use(AuthMiddlewareWithConnectorAllowlist(auth.NewJWTService(makeJWTConfig()), db)) + accounts := api.Group("/accounts") + accounts.Use(AccountScope()) + accounts.POST("/:account_id/conversations/:conversation_id/messages", func(c *gin.Context) { c.Status(http.StatusOK) }) + accounts.GET("/:account_id/conversations/:conversation_id/messages", func(c *gin.Context) { c.Status(http.StatusOK) }) + accounts.POST("/:account_id/inboxes", func(c *gin.Context) { c.Status(http.StatusOK) }) + + request := func(method, path string) int { + response := httptest.NewRecorder() + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(response, req) + return response.Code + } + require.Equal(t, http.StatusOK, request(http.MethodPost, "/api/v1/accounts/1/conversations/2/messages")) + require.Equal(t, http.StatusForbidden, request(http.MethodPost, "/api/v1/accounts/2/conversations/2/messages")) + require.Equal(t, http.StatusUnauthorized, request(http.MethodGet, "/api/v1/accounts/1/conversations/2/messages")) + require.Equal(t, http.StatusUnauthorized, request(http.MethodPost, "/api/v1/accounts/1/inboxes")) +} diff --git a/backend/internal/model/channel_shangwutong_config.go b/backend/internal/model/channel_shangwutong_config.go new file mode 100644 index 00000000..6059c6e4 --- /dev/null +++ b/backend/internal/model/channel_shangwutong_config.go @@ -0,0 +1,27 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type ChannelShangwutongConfig struct { + InboxID uint `gorm:"primaryKey" json:"inbox_id"` + SessionID string `gorm:"size:64;not null;uniqueIndex:idx_shangwutong_identity,where:deleted_at IS NULL" json:"session_id"` + Username string `gorm:"size:255;not null;uniqueIndex:idx_shangwutong_identity,where:deleted_at IS NULL" json:"username"` + Password string `gorm:"type:text;not null" json:"-"` + DesiredPresence string `gorm:"size:20;not null;default:online" json:"desired_presence"` + ConfigVersion int64 `gorm:"not null;default:1" json:"config_version"` + ActualPresence string `gorm:"size:20;not null;default:offline" json:"actual_presence"` + ConnectionStatus string `gorm:"size:40;not null;default:pending" json:"connection_status"` + CredentialStatus string `gorm:"size:40;not null;default:pending" json:"credential_status"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"` + LastErrorCode *string `gorm:"size:255" json:"last_error_code,omitempty"` + StatusUpdatedAt *time.Time `json:"status_updated_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (ChannelShangwutongConfig) TableName() string { return "channel_shangwutong_configs" } diff --git a/backend/internal/model/enums.go b/backend/internal/model/enums.go index 3af90b46..7c07bcb9 100644 --- a/backend/internal/model/enums.go +++ b/backend/internal/model/enums.go @@ -51,11 +51,11 @@ const ( type MessageType string const ( - MessageTypeIncoming MessageType = "incoming" - MessageTypeOutgoing MessageType = "outgoing" - MessageTypeActivity MessageType = "activity" - MessageTypeTemplate MessageType = "template" - MessageTypePrivate MessageType = "private" + MessageTypeIncoming MessageType = "incoming" + MessageTypeOutgoing MessageType = "outgoing" + MessageTypeActivity MessageType = "activity" + MessageTypeTemplate MessageType = "template" + MessageTypePrivate MessageType = "private" ) type MessageContentType string @@ -86,25 +86,26 @@ const ( type MessageStatus string const ( - MessageStatusSent MessageStatus = "sent" + MessageStatusProgress MessageStatus = "progress" + MessageStatusSent MessageStatus = "sent" MessageStatusDelivered MessageStatus = "delivered" - MessageStatusRead MessageStatus = "read" - MessageStatusFailed MessageStatus = "failed" + MessageStatusRead MessageStatus = "read" + MessageStatusFailed MessageStatus = "failed" ) // ConversationEventType defines domain-specific events for conversation lifecycle. type ConversationEventType string const ( - ConversationEventCreated ConversationEventType = "created" - ConversationEventUpdated ConversationEventType = "updated" - ConversationEventStatusChanged ConversationEventType = "status_changed" - ConversationEventAssigned ConversationEventType = "assigned" - ConversationEventUnassigned ConversationEventType = "unassigned" - ConversationEventDeleted ConversationEventType = "deleted" - ConversationEventMuted ConversationEventType = "muted" - ConversationEventUnmuted ConversationEventType = "unmuted" - ConversationEventLabelsUpdated ConversationEventType = "labels_updated" + ConversationEventCreated ConversationEventType = "created" + ConversationEventUpdated ConversationEventType = "updated" + ConversationEventStatusChanged ConversationEventType = "status_changed" + ConversationEventAssigned ConversationEventType = "assigned" + ConversationEventUnassigned ConversationEventType = "unassigned" + ConversationEventDeleted ConversationEventType = "deleted" + ConversationEventMuted ConversationEventType = "muted" + ConversationEventUnmuted ConversationEventType = "unmuted" + ConversationEventLabelsUpdated ConversationEventType = "labels_updated" ConversationEventPriorityUpdated ConversationEventType = "priority_updated" ) @@ -123,20 +124,21 @@ const ( type InboxChannelType string const ( - InboxChannelTypeWebWidget InboxChannelType = "Channel::WebWidget" - InboxChannelTypeTelegram InboxChannelType = "Channel::Telegram" - InboxChannelTypeFacebook InboxChannelType = "Channel::FacebookPage" - InboxChannelTypeWhatsApp InboxChannelType = "Channel::Whatsapp" - InboxChannelTypeEmail InboxChannelType = "Channel::Email" - InboxChannelTypeTwilioSMS InboxChannelType = "Channel::TwilioSms" - InboxChannelTypeLine InboxChannelType = "Channel::Line" - InboxChannelTypeSMS InboxChannelType = "Channel::Sms" - InboxChannelTypeInstagram InboxChannelType = "Channel::Instagram" - InboxChannelTypeTikTok InboxChannelType = "Channel::TikTok" - InboxChannelTypeTwitter InboxChannelType = "Channel::Twitter" - InboxChannelTypeMicrosoft InboxChannelType = "Channel::Microsoft" - InboxChannelTypeGoogle InboxChannelType = "Channel::Google" - InboxChannelTypeAPI InboxChannelType = "Channel::Api" + InboxChannelTypeWebWidget InboxChannelType = "Channel::WebWidget" + InboxChannelTypeTelegram InboxChannelType = "Channel::Telegram" + InboxChannelTypeFacebook InboxChannelType = "Channel::FacebookPage" + InboxChannelTypeWhatsApp InboxChannelType = "Channel::Whatsapp" + InboxChannelTypeEmail InboxChannelType = "Channel::Email" + InboxChannelTypeTwilioSMS InboxChannelType = "Channel::TwilioSms" + InboxChannelTypeLine InboxChannelType = "Channel::Line" + InboxChannelTypeSMS InboxChannelType = "Channel::Sms" + InboxChannelTypeInstagram InboxChannelType = "Channel::Instagram" + InboxChannelTypeTikTok InboxChannelType = "Channel::TikTok" + InboxChannelTypeTwitter InboxChannelType = "Channel::Twitter" + InboxChannelTypeMicrosoft InboxChannelType = "Channel::Microsoft" + InboxChannelTypeGoogle InboxChannelType = "Channel::Google" + InboxChannelTypeAPI InboxChannelType = "Channel::Api" + InboxChannelTypeShangwutong InboxChannelType = "Channel::Shangwutong" ) type AssignmentLogic string @@ -159,7 +161,7 @@ const ( type ContactSource string const ( - ContactSourceChatwoot ContactSource = "chatwoot" + ContactSourceChatwoot ContactSource = "chatwoot" ContactSourceFacebook ContactSource = "facebook" ContactSourceWhatsApp ContactSource = "whatsapp" ContactSourceTelegram ContactSource = "telegram" @@ -197,12 +199,12 @@ const ( type NotificationEventType string const ( - NotificationEventConversationCreated NotificationEventType = "conversation_created" - NotificationEventConversationAssigned NotificationEventType = "conversation_assigned" - NotificationEventMessageCreated NotificationEventType = "message_created" + NotificationEventConversationCreated NotificationEventType = "conversation_created" + NotificationEventConversationAssigned NotificationEventType = "conversation_assigned" + NotificationEventMessageCreated NotificationEventType = "message_created" NotificationEventConversationStatusChanged NotificationEventType = "conversation_status_changed" - NotificationEventAssigneeChanged NotificationEventType = "assignee_changed" - NotificationEventTeamChanged NotificationEventType = "team_changed" + NotificationEventAssigneeChanged NotificationEventType = "assignee_changed" + NotificationEventTeamChanged NotificationEventType = "team_changed" ) type SubscriptionType string @@ -309,7 +311,7 @@ type AttributeModelType string const ( AttributeModelConversation AttributeModelType = "conversation" - AttributeModelContact AttributeModelType = "contact" + AttributeModelContact AttributeModelType = "contact" ) type AttributeValueType string @@ -328,9 +330,9 @@ type FilterType string const ( FilterTypeConversation FilterType = "conversation" - FilterTypeContact FilterType = "contact" - FilterTypeInbox FilterType = "inbox" - FilterTypeMessage FilterType = "message" + FilterTypeContact FilterType = "contact" + FilterTypeInbox FilterType = "inbox" + FilterTypeMessage FilterType = "message" ) // --- Reporting Enums (M7) --- @@ -359,11 +361,11 @@ const ( HookTypeInboxOutgoing HookType = "inbox_outgoing" HookTypeAccountOutgoing HookType = "account_outgoing" // M11: Integration hook types (webhook/slack/shopify/linear/notion) - HookTypeWebhook HookType = "webhook" - HookTypeSlack HookType = "slack" - HookTypeShopify HookType = "shopify" - HookTypeLinear HookType = "linear" - HookTypeNotion HookType = "notion" + HookTypeWebhook HookType = "webhook" + HookTypeSlack HookType = "slack" + HookTypeShopify HookType = "shopify" + HookTypeLinear HookType = "linear" + HookTypeNotion HookType = "notion" ) type HookStatus string @@ -390,4 +392,4 @@ type TwilioMedium string const ( TwilioMediumSMS TwilioMedium = "sms" TwilioMediumWhatsApp TwilioMedium = "whatsapp" -) \ No newline at end of file +) diff --git a/backend/internal/model/message.go b/backend/internal/model/message.go index c3382dc6..d341e807 100644 --- a/backend/internal/model/message.go +++ b/backend/internal/model/message.go @@ -9,20 +9,22 @@ type Message struct { Base ConversationID uint `gorm:"index;not null" json:"conversation_id"` AccountID uint `gorm:"index;not null" json:"account_id"` - InboxID uint `gorm:"index;not null" json:"inbox_id"` + InboxID uint `gorm:"index;not null;uniqueIndex:idx_messages_swt_source,priority:1,where:source_id LIKE 'swt:%'" json:"inbox_id"` SenderID *uint `gorm:"index" json:"sender_id,omitempty"` SenderType string `gorm:"size:50" json:"sender_type"` // contact, agent, bot Content string `gorm:"type:text" json:"content"` ContentType string `gorm:"size:50;default:text" json:"content_type"` // text, input, input_csat, file, image, etc - Status string `gorm:"size:50;default:sent" json:"status"` // sent, delivered, read, failed + Status string `gorm:"size:50;default:sent" json:"status"` // progress, sent, delivered, read, failed Private bool `gorm:"default:false" json:"private"` EchoID string `gorm:"size:255" json:"echo_id,omitempty"` - SourceID string `gorm:"size:255" json:"source_id,omitempty"` + SourceID string `gorm:"size:255;uniqueIndex:idx_messages_swt_source,priority:2,where:source_id LIKE 'swt:%'" json:"source_id,omitempty"` MessageType string `gorm:"size:50;default:incoming" json:"message_type"` // incoming, outgoing, activity, template External bool `gorm:"default:false" json:"external"` ContentAttributes datatypes.JSON `gorm:"type:jsonb" json:"content_attributes,omitempty"` // attachments, mentions, etc AdditionalAttributes datatypes.JSON `gorm:"type:jsonb" json:"additional_attributes,omitempty"` ExternalSourceIDs datatypes.JSON `gorm:"type:jsonb" json:"external_source_ids,omitempty"` // external platform IDs + ExternalRequestHash string `gorm:"size:64" json:"-"` + IdempotentReplay bool `gorm:"-" json:"idempotent_replay,omitempty"` Conversation *Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"` Attachments []Attachment `gorm:"foreignKey:MessageID" json:"attachments,omitempty"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d7613252..c37b853e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -102,8 +102,8 @@ type Handlers struct { // M13: SSO enterprise authentication handlers SSOSession *v1.SSOSessionHandler // M13: OIDC enterprise authentication handler - OIDC *v1.OIDCHandler - SSOMiddleware *auth.SSOMiddleware + OIDC *v1.OIDCHandler + SSOMiddleware *auth.SSOMiddleware InstagramChannel *v1.InstagramChannelHandler FacebookChannel *v1.FacebookChannelHandler TwitterChannel *v1.TwitterChannelHandler @@ -180,7 +180,8 @@ type Handlers struct { // EmailChannelMigration handler (account-scoped create-only) EmailChannelMigration *v1.EmailChannelMigrationHandler // SummaryReport handler (read-only reporting resource — agent/team/inbox/label summaries) - SummaryReport *v1.SummaryReportHandler + SummaryReport *v1.SummaryReportHandler + ShangwutongConnector *v1.ShangwutongConnectorHandler } // RegisterRoutes sets up all HTTP routes on the Gin engine. @@ -234,9 +235,20 @@ func RegisterRoutes( // OIDC routes — mixed: public auth flow + admin config routes (OIDC flow is external) v1.RegisterOIDCRoutes(engine.Group("/api/v1"), handlers.OIDC, middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) + // Long-lived Connector service principal routes are intentionally outside + // the user JWT group and accept only a dedicated PlatformApp Bearer token. + if handlers.ShangwutongConnector != nil { + connector := engine.Group("/api/v1/connector/shangwutong") + connector.Use(middleware.ConnectorServiceAuth(db)) + connector.GET("/inboxes", handlers.ShangwutongConnector.ListInboxes) + connector.GET("/inboxes/:inbox_id", handlers.ShangwutongConnector.GetInbox) + connector.PUT("/inboxes/:inbox_id/status", handlers.ShangwutongConnector.UpdateInboxStatus) + connector.PUT("/inboxes/:inbox_id/messages/:message_id/status", handlers.ShangwutongConnector.UpdateMessageStatus) + } + // API v1 routes — authenticated, account-scoped apiV1 := engine.Group("/api/v1") - apiV1.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) + apiV1.Use(middleware.AuthMiddlewareWithConnectorAllowlist(jwtService, db)) registerV1Routes(apiV1, handlers) // Enterprise API routes consumed by the reused Chatwoot dashboard. diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index 62249a37..aefcf3df 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -37,6 +37,17 @@ type ConversationService struct { worker *worker.WorkerPool } +type shangwutongRequestMetadata struct { + ConnectorOrigin bool + ActorID uint +} + +type shangwutongRequestMetadataKey struct{} + +func WithShangwutongRequestMetadata(ctx context.Context, connectorOrigin bool, actorID uint) context.Context { + return context.WithValue(ctx, shangwutongRequestMetadataKey{}, shangwutongRequestMetadata{ConnectorOrigin: connectorOrigin, ActorID: actorID}) +} + // NewConversationService creates a new Conversation service. func NewConversationService(repo *repository.ConversationRepo, msgRepo *repository.MessageRepo, dispatcher *channel.Dispatcher, inboxMemberSvc *InboxMemberService, accountUserRepo *repository.AccountUserRepo, teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo) *ConversationService { return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer()} @@ -553,16 +564,19 @@ func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id ui newStatus = model.ConversationStatusOpen } } + if strings.TrimSpace(req.Status) != "" && oldStatus == newStatus { + return conversation, nil + } // Reference: Chatwoot conversations_controller#toggle_status // 1. pending_to_open_by_bot: AgentBot moves pending→open triggers bot_handoff! if oldStatus == model.ConversationStatusPending && newStatus == model.ConversationStatusOpen && req.IsBot { // Bot handoff: transition from pending to open via agent bot // Chatwoot: @conversation.bot_handoff! sets status to open and fires handoff event - if err := s.repo.ToggleStatus(ctx, conversation.ID, model.ConversationStatusOpen); err != nil { + conversation.Status = string(model.ConversationStatusOpen) + if err := s.persistShangwutongConversationStatus(ctx, conversation, string(oldStatus)); err != nil { return nil, err } - conversation.Status = string(model.ConversationStatusOpen) // Fire bot handoff event (Chatwoot dispatches conversation.bot_handoff!) changes := changedAttributes(map[string][2]interface{}{"status": {string(oldStatus), conversation.Status}}) s.dispatchConversationEventWithData(ctx, channel.EventConversationOpened, conversation, eventDataWithChanges(changes)) @@ -577,10 +591,6 @@ func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id ui conversation.AssigneeID = req.UserID } - if err := s.repo.ToggleStatus(ctx, conversation.ID, newStatus); err != nil { - return nil, err - } - // Chatwoot: on reopen, auto-assign to previous agent if no assignee specified // Reference: Chatwoot Conversations::StatusChangeService auto-assigns on reopen if newStatus == model.ConversationStatusOpen && conversation.Status == string(model.ConversationStatusResolved) { @@ -611,7 +621,7 @@ func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id ui } // Persist timestamp changes - if err := s.repo.Update(ctx, conversation); err != nil { + if err := s.persistShangwutongConversationStatus(ctx, conversation, string(oldStatus)); err != nil { return nil, err } @@ -632,6 +642,40 @@ func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id ui return conversation, nil } +func (s *ConversationService) persistShangwutongConversationStatus(ctx context.Context, conversation *model.Conversation, previousStatus string) error { + var inbox model.Inbox + if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").First(&inbox, conversation.InboxID).Error; err != nil { + return err + } + metadata, _ := ctx.Value(shangwutongRequestMetadataKey{}).(shangwutongRequestMetadata) + queue := strings.EqualFold(inbox.ChannelType, "shangwutong") && !metadata.ConnectorOrigin && previousStatus != conversation.Status && + (conversation.Status == "open" || conversation.Status == "resolved") && (previousStatus == "open" || previousStatus == "resolved") + var job *model.BackgroundJob + var created bool + err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(conversation).Error; err != nil { + return err + } + if !queue || s.worker == nil { + return nil + } + payload := newShangwutongConversationStatusJob(conversation, previousStatus, metadata.ActorID) + var err error + job, created, err = s.worker.EnqueueInTransaction( + ctx, tx, TaskTypeShangwutongWebhookDelivery, payload, worker.WithMaxAttempts(10), + worker.WithIdempotencyKey(fmt.Sprintf("api-inbox-conversation:%d:status:%d", conversation.ID, payload.ConversationVersion)), + ) + return err + }) + if err != nil { + return err + } + if created { + s.worker.Publish(ctx, job) + } + return nil +} + // UpdateLabelsRequest is the DTO for updating conversation labels. type UpdateLabelsRequest struct { Labels []string `json:"labels" validate:"required"` @@ -783,7 +827,7 @@ type FilterParams struct { TeamID *uint `json:"team_id,omitempty" form:"team_id"` Labels string `json:"labels,omitempty" form:"labels"` LabelsArr []string `json:"labels_arr,omitempty" form:"labels[]"` // Chatwoot frontend sends labels[] array params - Tags string `json:"tags,omitempty" form:"tags"` // custom tags (Chatwoot: same as labels via ActsAsTaggableOn) + Tags string `json:"tags,omitempty" form:"tags"` // custom tags (Chatwoot: same as labels via ActsAsTaggableOn) ConversationType string `json:"conversation_type,omitempty" form:"conversation_type" validate:"omitempty,oneof=mention participating unattended"` SortBy string `json:"sort_by,omitempty" form:"sort_by" validate:"omitempty,oneof=last_activity_at_asc last_activity_at_desc created_at_asc created_at_desc priority_asc priority_desc waiting_since_asc waiting_since_desc priority_desc_created_at_asc latest sort_on_created_at sort_on_priority sort_on_waiting_since"` UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index 2462b36a..74f1d65c 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -7,11 +7,14 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" + "regexp" "strconv" "strings" "github.com/gochat/gochat/internal/campaign" + "github.com/gochat/gochat/internal/channel" whatsapp "github.com/gochat/gochat/internal/channel/whatsapp" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" @@ -175,6 +178,7 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb "web_widget": true, "telegram": true, "facebook": true, "instagram": true, "whatsapp": true, "email": true, "api": true, "tiktok": true, "line": true, "twilio_sms": true, "sms": true, + "shangwutong": true, } if !validChannelTypes[req.ChannelType] { return nil, errors.New("invalid channel_type") @@ -185,6 +189,9 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb if len(req.Name) < 2 { return nil, errors.New("name is too short") } + if req.ChannelType == "shangwutong" { + return s.createShangwutongInbox(ctx, accountID, req) + } channelConfig := buildInitialInboxChannelConfig(req.ChannelType, req.Channel) channelConfigJSON, err := json.Marshal(channelConfig) @@ -274,6 +281,9 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat if err != nil { return nil, err } + if inbox.ChannelType == "shangwutong" { + return s.updateShangwutongInbox(ctx, inbox, req) + } if req.Name != "" { inbox.Name = req.Name @@ -315,7 +325,7 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat } func (s *InboxService) syncAPIChannel(ctx context.Context, inbox *model.Inbox) error { - if s == nil || s.repo == nil || s.repo.DB() == nil || inbox == nil || inbox.ChannelType != "api" { + if s == nil || s.repo == nil || s.repo.DB() == nil || inbox == nil || !channel.IsAPIInboxLike(inbox.ChannelType) { return nil } config := parseChannelConfigMap(inbox.ChannelConfig) @@ -345,6 +355,260 @@ func (s *InboxService) syncAPIChannel(ctx context.Context, inbox *model.Inbox) e return nil } +var shangwutongSessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{11}$`) + +func (s *InboxService) createShangwutongInbox(ctx context.Context, accountID uint, req CreateInboxRequest) (*model.Inbox, error) { + if err := validateShangwutongChannelKeys(req.Channel); err != nil { + return nil, err + } + sessionID := mapString(req.Channel, "session_id") + username := strings.TrimSpace(mapString(req.Channel, "username")) + password := mapString(req.Channel, "password") + desiredPresence := firstNonEmpty(mapString(req.Channel, "desired_presence"), "online") + webhookURL := mapString(req.Channel, "webhook_url") + if err := validateShangwutongConfig(sessionID, username, password, desiredPresence, webhookURL); err != nil { + return nil, err + } + if err := validateShangwutongWebhookConsistency(ctx, s.repo.DB(), 0, webhookURL, true, desiredPresence, ""); err != nil { + return nil, err + } + + inbox := &model.Inbox{ + AccountID: accountID, Name: req.Name, ChannelType: "shangwutong", Enabled: true, + EnableAutoAssignment: req.EnableAutoAssignment, EnableEmailCollect: true, + AllowMessagesAfterResolved: true, SenderNameType: "friendly", Timezone: "UTC", + ChannelConfig: "{}", WebhookURL: webhookURL, Secret: generateInboxSecret(), + } + applyCreateInboxSettings(inbox, req) + channelAPI := &channelmodel.ChannelAPI{ + WebhookURL: webhookURL, Secret: inbox.Secret, Identifier: generateInboxSecret(), + HMACToken: generateInboxSecret(), HMACMandatory: true, AdditionalAttributes: []byte(`{}`), + } + config := &model.ChannelShangwutongConfig{ + SessionID: sessionID, Username: username, Password: password, DesiredPresence: desiredPresence, + ConfigVersion: 1, ActualPresence: "offline", ConnectionStatus: "pending", CredentialStatus: "pending", + } + var lifecycleJob *model.BackgroundJob + var lifecycleCreated bool + err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(inbox).Error; err != nil { + return err + } + channelAPI.InboxID = inbox.ID + config.InboxID = inbox.ID + if err := tx.Create(channelAPI).Error; err != nil { + return err + } + if err := tx.Create(config).Error; err != nil { + return err + } + inbox.ChannelID = channelAPI.ID + if err := tx.Omit("WorkingHours").Save(inbox).Error; err != nil { + return err + } + if tx.Migrator().HasTable(&model.WorkingHour{}) { + hours := repository.NewWorkingHourRepo(tx) + if err := hours.CreateDefaultWorkingHours(ctx, inbox.ID, accountID); err != nil { + return err + } + if len(req.WorkingHours) > 0 { + if err := hours.UpdateWorkingHours(ctx, inbox.ID, req.WorkingHours); err != nil { + return err + } + } + } + if s.worker != nil { + var err error + lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction( + ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongLifecycleJob("inbox_created", inbox, config.ConfigVersion), + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, config.ConfigVersion)), + ) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + if lifecycleCreated { + s.worker.Publish(ctx, lifecycleJob) + } + _ = s.loadInboxWorkingHours(ctx, inbox) + return inbox, nil +} + +func (s *InboxService) updateShangwutongInbox(ctx context.Context, inbox *model.Inbox, req UpdateInboxRequest) (*model.Inbox, error) { + if err := validateShangwutongChannelKeys(req.Channel); err != nil { + return nil, err + } + var config model.ChannelShangwutongConfig + if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil { + return nil, err + } + if value, exists := req.Channel["session_id"]; exists && strings.TrimSpace(fmt.Sprint(value)) != config.SessionID { + return nil, errors.New("session_id cannot be changed in place") + } + if value, exists := req.Channel["username"]; exists && strings.TrimSpace(fmt.Sprint(value)) != config.Username { + return nil, errors.New("username cannot be changed in place") + } + + password, passwordChanged := config.Password, false + if value, exists := req.Channel["password"]; exists { + text, ok := value.(string) + if !ok || text == "" { + return nil, errors.New("password cannot be empty") + } + password, passwordChanged = text, text != config.Password + } + desiredPresence := config.DesiredPresence + if value, exists := req.Channel["desired_presence"]; exists { + desiredPresence = strings.TrimSpace(fmt.Sprint(value)) + } + webhookURL := inbox.WebhookURL + if value, exists := req.Channel["webhook_url"]; exists { + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return nil, errors.New("webhook_url cannot be empty") + } + webhookURL = text + } + enabled := inbox.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + if err := validateShangwutongConfig(config.SessionID, config.Username, password, desiredPresence, webhookURL); err != nil { + return nil, err + } + if err := validateShangwutongWebhookConsistency(ctx, s.repo.DB(), inbox.ID, webhookURL, enabled, desiredPresence, inbox.WebhookURL); err != nil { + return nil, err + } + + connectorChanged := passwordChanged || desiredPresence != config.DesiredPresence || enabled != inbox.Enabled || normalizeShangwutongURL(webhookURL) != normalizeShangwutongURL(inbox.WebhookURL) + if req.Name != "" { + inbox.Name = req.Name + } + if req.EnableAutoAssignment != nil { + inbox.EnableAutoAssignment = *req.EnableAutoAssignment + } + inbox.Enabled, inbox.WebhookURL = enabled, webhookURL + applyUpdateInboxSettings(inbox, req) + config.Password, config.DesiredPresence = password, desiredPresence + if connectorChanged { + config.ConfigVersion++ + if passwordChanged { + config.CredentialStatus = "pending" + } + } + + var lifecycleJob *model.BackgroundJob + var lifecycleCreated bool + err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Omit("WorkingHours").Save(inbox).Error; err != nil { + return err + } + if err := tx.Save(&config).Error; err != nil { + return err + } + if err := tx.Model(&channelmodel.ChannelAPI{}).Where("inbox_id = ?", inbox.ID).Update("webhook_url", webhookURL).Error; err != nil { + return err + } + if len(req.WorkingHours) > 0 { + if err := repository.NewWorkingHourRepo(tx).UpdateWorkingHours(ctx, inbox.ID, req.WorkingHours); err != nil { + return err + } + } + if connectorChanged && s.worker != nil { + var err error + lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction( + ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongLifecycleJob("inbox_updated", inbox, config.ConfigVersion), + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, config.ConfigVersion)), + ) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + if lifecycleCreated { + s.worker.Publish(ctx, lifecycleJob) + } + _ = s.loadInboxWorkingHours(ctx, inbox) + return inbox, nil +} + +func validateShangwutongChannelKeys(channelConfig map[string]any) error { + allowed := map[string]bool{ + "type": true, "session_id": true, "username": true, "password": true, + "desired_presence": true, "webhook_url": true, + } + for key := range channelConfig { + if !allowed[normalizeInboxConfigKey(key)] { + return fmt.Errorf("unsupported shangwutong channel field %q", key) + } + } + return nil +} + +func validateShangwutongConfig(sessionID, username, password, desiredPresence, webhookURL string) error { + if !shangwutongSessionIDPattern.MatchString(sessionID) { + return errors.New("session_id must contain exactly 11 ASCII letters or digits") + } + if username == "" || len(username) > 255 { + return errors.New("username is required and must not exceed 255 characters") + } + if password == "" || len(password) > 4096 { + return errors.New("password is required and must not exceed 4096 characters") + } + switch desiredPresence { + case "online", "busy", "away", "offline": + default: + return errors.New("desired_presence must be online, busy, away, or offline") + } + parsed, err := url.Parse(webhookURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return errors.New("webhook_url must be an absolute HTTP(S) URL without userinfo or fragment") + } + return nil +} + +func validateShangwutongWebhookConsistency(ctx context.Context, db *gorm.DB, inboxID uint, webhookURL string, enabled bool, desiredPresence, currentURL string) error { + normalized := normalizeShangwutongURL(webhookURL) + var others []model.Inbox + query := db.WithContext(ctx).Where("channel_type = ? AND enabled = ?", "shangwutong", true) + if inboxID != 0 { + query = query.Where("id <> ?", inboxID) + } + if err := query.Find(&others).Error; err != nil { + return err + } + for i := range others { + if normalizeShangwutongURL(others[i].WebhookURL) != normalized { + return errors.New("all enabled shangwutong inboxes must use the same webhook_url") + } + } + if currentURL != "" && normalizeShangwutongURL(currentURL) != normalized && (enabled || desiredPresence != "offline" || len(others) > 0) { + return errors.New("webhook_url can only change while the inbox is disabled and offline with no other enabled shangwutong inbox") + } + return nil +} + +func normalizeShangwutongURL(value string) string { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil { + return strings.TrimSpace(value) + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawPath = "" + return parsed.String() +} + func validateChannelAPIAdditionalAttributes(value any) error { attrs, ok := value.(map[string]any) if !ok || attrs == nil { @@ -491,6 +755,9 @@ func normalizeInboxSenderNameType(value string) string { } func buildInitialInboxChannelConfig(channelType string, channel map[string]any) map[string]interface{} { + if channelType == "shangwutong" { + return map[string]interface{}{} + } config := map[string]interface{}{} mergeInboxChannelConfig(config, channel) switch channelType { @@ -607,6 +874,7 @@ func normalizeInboxChannelType(channelType string) string { aliases := map[string]string{ "Channel::WebWidget": "web_widget", "Channel::Api": "api", + "Channel::Shangwutong": "shangwutong", "Channel::Email": "email", "Channel::Line": "line", "Channel::Telegram": "telegram", @@ -631,17 +899,18 @@ func defaultInboxName(channelType string, channel map[string]any) string { } } names := map[string]string{ - "web_widget": "Website", - "telegram": "Telegram", - "facebook": "Facebook", - "instagram": "Instagram", - "whatsapp": "WhatsApp", - "email": "Email", - "api": "API", - "line": "LINE", - "sms": "SMS", - "twilio_sms": "Twilio SMS", - "tiktok": "TikTok", + "web_widget": "Website", + "telegram": "Telegram", + "facebook": "Facebook", + "instagram": "Instagram", + "whatsapp": "WhatsApp", + "email": "Email", + "api": "API", + "shangwutong": "商务通", + "line": "LINE", + "sms": "SMS", + "twilio_sms": "Twilio SMS", + "tiktok": "TikTok", } if name, ok := names[channelType]; ok { return name @@ -822,9 +1091,57 @@ func (s *InboxService) DeleteByAccount(ctx context.Context, accountID, id uint) if err != nil { return err } + if inbox.ChannelType == "shangwutong" { + return s.deleteShangwutongInbox(ctx, inbox) + } return s.repo.Delete(ctx, inbox.ID) } +func (s *InboxService) deleteShangwutongInbox(ctx context.Context, inbox *model.Inbox) error { + var config model.ChannelShangwutongConfig + if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil { + return err + } + var channelAPI channelmodel.ChannelAPI + if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error; err != nil { + return err + } + tombstoneVersion := config.ConfigVersion + 1 + jobPayload := newShangwutongLifecycleJob("inbox_deleted", inbox, tombstoneVersion) + jobPayload.Tombstone = true + jobPayload.WebhookURL = channelAPI.WebhookURL + jobPayload.SigningSecret = channelAPI.Secret + var lifecycleJob *model.BackgroundJob + var lifecycleCreated bool + err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + config.ConfigVersion = tombstoneVersion + if err := tx.Save(&config).Error; err != nil { + return err + } + if s.worker != nil { + var err error + lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction( + ctx, tx, TaskTypeShangwutongWebhookDelivery, jobPayload, + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, tombstoneVersion)), + ) + if err != nil { + return err + } + } + if err := tx.Delete(&config).Error; err != nil { + return err + } + return tx.Delete(&model.Inbox{}, inbox.ID).Error + }) + if err != nil { + return err + } + if lifecycleCreated { + s.worker.Publish(ctx, lifecycleJob) + } + return nil +} + // --- WebWidget-specific methods --- // Reference: Chatwoot app/models/channel/web_widget.rb + web_widget_config_controller.rb @@ -1792,6 +2109,18 @@ func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (map return nil, fmt.Errorf("inbox not found: %w", err) } + if inbox.ChannelType == "shangwutong" { + var config model.ChannelShangwutongConfig + if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil { + return nil, err + } + return map[string]interface{}{ + "config_version": config.ConfigVersion, "desired_presence": config.DesiredPresence, + "actual_presence": config.ActualPresence, "connection_status": config.ConnectionStatus, + "credential_status": config.CredentialStatus, "last_heartbeat_at": config.LastHeartbeatAt, + "last_error_code": config.LastErrorCode, "status_updated_at": config.StatusUpdatedAt, + }, nil + } if inbox.ChannelType != "whatsapp" { return nil, ErrInboxHealthWhatsAppCloudOnly } @@ -1934,8 +2263,12 @@ func (s *InboxService) SetInboundCalls(ctx context.Context, accountID, inboxID u if err := tx.Where("account_id = ? AND inbox_id = ?", accountID, inboxID).First(&channel).Error; err == nil { providerConfig := parseJSONMap(channel.ProviderConfig) providerConfig["inbound_calls_enabled"] = enabled - if err := tx.Model(&channel).Update("provider_config", marshalInboxJSON(providerConfig)).Error; err != nil { return err } - } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } + if err := tx.Model(&channel).Update("provider_config", marshalInboxJSON(providerConfig)).Error; err != nil { + return err + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } } return tx.Save(inbox).Error }) @@ -2139,6 +2472,9 @@ func (s *InboxService) ResetSecret(ctx context.Context, accountID, inboxID uint) return nil, fmt.Errorf("inbox not found: %w", err) } + if inbox.ChannelType == "shangwutong" { + return s.resetShangwutongSecret(ctx, inbox) + } // Chatwoot: returns 404 for non-API inboxes if inbox.ChannelType != "api" && inbox.ChannelType != string(model.InboxChannelTypeAPI) { return nil, fmt.Errorf("inbox not found: only API inboxes support reset_secret") @@ -2167,6 +2503,55 @@ func (s *InboxService) ResetSecret(ctx context.Context, accountID, inboxID uint) return s.GetByAccountAndID(ctx, accountID, inboxID) } +func (s *InboxService) resetShangwutongSecret(ctx context.Context, inbox *model.Inbox) (*model.Inbox, error) { + var channelAPI channelmodel.ChannelAPI + if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error; err != nil { + return nil, err + } + var config model.ChannelShangwutongConfig + if err := s.repo.DB().WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil { + return nil, err + } + newSecret, err := generateRandomHex(32) + if err != nil { + return nil, err + } + oldSecret := channelAPI.Secret + channelAPI.Secret, inbox.Secret = newSecret, newSecret + config.ConfigVersion++ + jobPayload := newShangwutongLifecycleJob("inbox_updated", inbox, config.ConfigVersion) + jobPayload.SigningSecret = oldSecret + var lifecycleJob *model.BackgroundJob + var lifecycleCreated bool + err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(&channelAPI).Error; err != nil { + return err + } + if err := tx.Omit("WorkingHours").Save(inbox).Error; err != nil { + return err + } + if err := tx.Save(&config).Error; err != nil { + return err + } + if s.worker != nil { + var err error + lifecycleJob, lifecycleCreated, err = s.worker.EnqueueInTransaction( + ctx, tx, TaskTypeShangwutongWebhookDelivery, jobPayload, + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(shangwutongLifecycleIdempotencyKey(inbox.ID, config.ConfigVersion)), + ) + return err + } + return nil + }) + if err != nil { + return nil, err + } + if lifecycleCreated { + s.worker.Publish(ctx, lifecycleJob) + } + return s.GetByAccountAndID(ctx, inbox.AccountID, inbox.ID) +} + // generateRandomHex generates a cryptographically random hex string of n bytes. func generateRandomHex(n int) (string, error) { b := make([]byte, n) diff --git a/backend/internal/service/inbox_service_test.go b/backend/internal/service/inbox_service_test.go index 45edbfe7..a22e8cce 100644 --- a/backend/internal/service/inbox_service_test.go +++ b/backend/internal/service/inbox_service_test.go @@ -39,6 +39,7 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) { &model.AgentBotInbox{}, &model.WebhookSubscription{}, &model.BackgroundJob{}, + &model.ChannelShangwutongConfig{}, &channelmodel.ChannelAPI{}, &channelmodel.ChannelWhatsApp{}, ), "failed to auto-migrate") @@ -302,6 +303,146 @@ func TestInboxService_APIInboxRejectsInvalidAgentReplyTimeWindow(t *testing.T) { assert.Contains(t, err.Error(), "agent_reply_time_window must be greater than 0") } +func TestInboxService_CreateShangwutongPersistsSecretsOutsideInboxConfig(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "SWT Account", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "商务通站点", ChannelType: "shangwutong", + Channel: map[string]any{ + "session_id": "BYT99917999", "username": "agent", "password": "plain-secret", + "desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1", + }, + }) + require.NoError(t, err) + assert.Equal(t, "{}", inbox.ChannelConfig) + assert.Equal(t, "shangwutong", inbox.ChannelType) + var channelAPI channelmodel.ChannelAPI + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error) + assert.True(t, channelAPI.HMACMandatory) + assert.NotEmpty(t, channelAPI.HMACToken) + assert.NotEmpty(t, channelAPI.Secret) + var config model.ChannelShangwutongConfig + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&config).Error) + assert.Equal(t, "plain-secret", config.Password) + assert.Equal(t, int64(1), config.ConfigVersion) +} + +func TestInboxService_ShangwutongLifecycleJobsAreTransactionalAndKeepRotationDeleteSnapshots(t *testing.T) { + svc, db := setupInboxServiceTest(t) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + account := &model.Account{Name: "Account", Active: true} + require.NoError(t, db.Create(account).Error) + inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "商务通", ChannelType: "shangwutong", Channel: map[string]any{ + "session_id": "LZA69557093", "username": "operator", "password": "secret-password", + "desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1", + }, + }) + require.NoError(t, err) + var channelAPI channelmodel.ChannelAPI + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error) + oldSecret := channelAPI.Secret + _, err = svc.ResetSecret(context.Background(), account.ID, inbox.ID) + require.NoError(t, err) + require.NoError(t, svc.DeleteByAccount(context.Background(), account.ID, inbox.ID)) + var jobs []model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Order("id ASC").Find(&jobs).Error) + require.Len(t, jobs, 3) + var created, rotated, deleted shangwutongWebhookDeliveryJob + require.NoError(t, json.Unmarshal(jobs[0].Payload, &created)) + require.NoError(t, json.Unmarshal(jobs[1].Payload, &rotated)) + require.NoError(t, json.Unmarshal(jobs[2].Payload, &deleted)) + require.Equal(t, "inbox_created", created.Event) + require.Empty(t, created.SigningSecret) + require.Equal(t, "inbox_updated", rotated.Event) + require.Equal(t, oldSecret, rotated.SigningSecret) + require.Equal(t, "inbox_deleted", deleted.Event) + require.True(t, deleted.Tombstone) + require.Equal(t, "http://connector:9100/webhooks/gochat/v1", deleted.WebhookURL) + require.NotEmpty(t, deleted.SigningSecret) + var activeConfigCount int64 + require.NoError(t, db.Model(&model.ChannelShangwutongConfig{}).Where("inbox_id = ?", inbox.ID).Count(&activeConfigCount).Error) + require.Zero(t, activeConfigCount) + replacement, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "商务通替换", ChannelType: "shangwutong", Channel: map[string]any{ + "session_id": "LZA69557093", "username": "operator", "password": "replacement-password", + "desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1", + }, + }) + require.NoError(t, err) + require.NotEqual(t, inbox.ID, replacement.ID) + for _, job := range jobs { + require.NotContains(t, string(job.Payload), "secret-password") + require.NotContains(t, string(job.Payload), "operator") + require.NotContains(t, string(job.Payload), "LZA69557093") + } +} + +func TestInboxService_UpdateShangwutongVersionsConnectorFieldsAndKeepsOmittedPassword(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "SWT Update", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{ + Name: "商务通站点", ChannelType: "shangwutong", + Channel: map[string]any{ + "session_id": "BYT99917999", "username": "agent", "password": "plain-secret", + "desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1", + }, + }) + require.NoError(t, err) + updated, err := svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{ + Name: "只改名称", Channel: map[string]any{"desired_presence": "busy"}, + }) + require.NoError(t, err) + assert.Equal(t, "只改名称", updated.Name) + var config model.ChannelShangwutongConfig + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&config).Error) + assert.Equal(t, "plain-secret", config.Password) + assert.Equal(t, "busy", config.DesiredPresence) + assert.Equal(t, int64(2), config.ConfigVersion) + _, err = svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{ + Channel: map[string]any{"username": "other"}, + }) + require.ErrorContains(t, err, "cannot be changed in place") +} + +func TestInboxService_SharedShangwutongConnectorURLIsEnforced(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "SWT URLs", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + base := CreateInboxRequest{Name: "商务通一", ChannelType: "shangwutong", Channel: map[string]any{ + "session_id": "BYT99917999", "username": "agent1", "password": "secret", + "desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1", + }} + _, err := svc.Create(context.Background(), account.ID, base) + require.NoError(t, err) + base.Name = "商务通二" + base.Channel["session_id"] = "BYT99917998" + base.Channel["username"] = "agent2" + base.Channel["webhook_url"] = "http://other:9100/webhooks/gochat/v1" + _, err = svc.Create(context.Background(), account.ID, base) + require.ErrorContains(t, err, "same webhook_url") +} + +func TestInboxService_DeletedShangwutongIdentityCanBeRecreated(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account := &model.Account{Name: "SWT identity reuse", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + request := CreateInboxRequest{Name: "商务通旧收件箱", Channel: map[string]any{ + "type": "shangwutong", "session_id": "BYT99917999", "username": "agent", "password": "secret", + "desired_presence": "offline", "webhook_url": "http://connector:9100/webhooks/gochat/v1", + }} + first, err := svc.Create(context.Background(), account.ID, request) + require.NoError(t, err) + require.NoError(t, svc.DeleteByAccount(context.Background(), account.ID, first.ID)) + request.Name = "商务通新收件箱" + second, err := svc.Create(context.Background(), account.ID, request) + require.NoError(t, err) + assert.NotEqual(t, first.ID, second.ID) +} + func TestInboxService_ResetSecretRegeneratesAPIWebhookSecret(t *testing.T) { svc, db := setupInboxServiceTest(t) account := &model.Account{Name: "API Secret Reset", Locale: "en", Active: true, InboxLimit: 0} @@ -457,6 +598,27 @@ func TestInboxService_Health_NonWhatsAppInboxRejected(t *testing.T) { assert.Nil(t, result) } +func TestInboxService_Health_ShangwutongReturnsOnlyRuntimeState(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account, inbox := createInboxTestPrereqs(t, db, "shangwutong") + rejected := "auth_failed" + require.NoError(t, db.Create(&model.ChannelShangwutongConfig{ + InboxID: inbox.ID, SessionID: "LZA69557093", Username: "operator", Password: "plain-secret", + DesiredPresence: "busy", ConfigVersion: 7, ActualPresence: "online", + ConnectionStatus: "connected", CredentialStatus: "rejected", LastErrorCode: &rejected, + }).Error) + + result, err := svc.Health(context.Background(), account.ID, inbox.ID) + require.NoError(t, err) + assert.Equal(t, int64(7), result["config_version"]) + assert.Equal(t, "busy", result["desired_presence"]) + assert.Equal(t, "connected", result["connection_status"]) + assert.Equal(t, "rejected", result["credential_status"]) + assert.NotContains(t, result, "password") + assert.NotContains(t, result, "session_id") + assert.NotContains(t, result, "username") +} + func TestInboxService_Health_WhatsAppCloudReturnsPayload(t *testing.T) { svc, db := setupInboxServiceTest(t) account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud") @@ -668,7 +830,9 @@ func TestInboxService_SetInboundCalls_PersistsVoiceInboxSetting(t *testing.T) { var updated model.Inbox require.NoError(t, db.First(&updated, inbox.ID).Error) assert.Equal(t, false, parseJSONMap(updated.ChannelConfig)["inbound_calls_enabled"]) - var updatedChannel channelmodel.ChannelWhatsApp; require.NoError(t, db.First(&updatedChannel, channel.ID).Error); assert.Equal(t, false, parseJSONMap(updatedChannel.ProviderConfig)["inbound_calls_enabled"]) + var updatedChannel channelmodel.ChannelWhatsApp + require.NoError(t, db.First(&updatedChannel, channel.ID).Error) + assert.Equal(t, false, parseJSONMap(updatedChannel.ProviderConfig)["inbound_calls_enabled"]) require.NoError(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true)) require.NoError(t, db.First(&updated, inbox.ID).Error) assert.Equal(t, true, parseJSONMap(updated.ChannelConfig)["inbound_calls_enabled"]) diff --git a/backend/internal/service/message_service.go b/backend/internal/service/message_service.go index e7632821..480a8124 100644 --- a/backend/internal/service/message_service.go +++ b/backend/internal/service/message_service.go @@ -2,10 +2,13 @@ package service import ( "context" + "crypto/sha256" "encoding/json" + "errors" "fmt" "strconv" "strings" + "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/llm" @@ -18,6 +21,7 @@ import ( "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // MessageService implements business logic for Message operations. @@ -123,33 +127,39 @@ func (s *MessageService) Search(ctx context.Context, accountID uint, query strin // CreateMessageRequest is the DTO for creating a message. // Reference: Chatwoot app/controllers/api/v1/accounts/conversations/messages_controller.rb #create type CreateMessageRequest struct { - ConversationID uint `json:"conversation_id" validate:"required"` - Content string `json:"content"` - MessageType string `json:"message_type,omitempty"` - ContentType string `json:"content_type,omitempty"` - Private bool `json:"private,omitempty"` - SenderID uint `json:"sender_id,omitempty"` - SenderType string `json:"sender_type,omitempty"` - SourceID string `json:"source_id,omitempty"` - EchoID string `json:"echo_id,omitempty"` - ExternalCreatedAt string `json:"external_created_at,omitempty"` - ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"` - EmailHTMLContent string `json:"email_html_content,omitempty"` - CCEmails string `json:"cc_emails,omitempty"` - BCCEmails string `json:"bcc_emails,omitempty"` - ToEmails string `json:"to_emails,omitempty"` - CampaignID any `json:"campaign_id,omitempty"` - TemplateParams datatypes.JSON `json:"template_params,omitempty"` - IsVoiceMessage bool `json:"is_voice_message,omitempty"` - Attachments []MessageAttachmentInput `json:"-"` + ConversationID uint `json:"conversation_id" validate:"required"` + Content string `json:"content"` + MessageType string `json:"message_type,omitempty"` + ContentType string `json:"content_type,omitempty"` + Private bool `json:"private,omitempty"` + SenderID uint `json:"sender_id,omitempty"` + SenderType string `json:"sender_type,omitempty"` + SourceID string `json:"source_id,omitempty"` + EchoID string `json:"echo_id,omitempty"` + ExternalCreatedAt string `json:"external_created_at,omitempty"` + External bool `json:"external,omitempty"` + ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"` + AdditionalAttributes datatypes.JSON `json:"additional_attributes,omitempty"` + ExternalSourceIDs datatypes.JSON `json:"external_source_ids,omitempty"` + EmailHTMLContent string `json:"email_html_content,omitempty"` + CCEmails string `json:"cc_emails,omitempty"` + BCCEmails string `json:"bcc_emails,omitempty"` + ToEmails string `json:"to_emails,omitempty"` + CampaignID any `json:"campaign_id,omitempty"` + TemplateParams datatypes.JSON `json:"template_params,omitempty"` + IsVoiceMessage bool `json:"is_voice_message,omitempty"` + Attachments []MessageAttachmentInput `json:"-"` } type MessageAttachmentInput struct { FileName string FileSize int ContentType string + SHA256 string } +var ErrMessageIdempotencyConflict = errors.New("idempotency key is already associated with a different message payload") + // Create creates a new message. func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint, req CreateMessageRequest) (*model.Message, error) { req.MessageType = normalizeMessageType(req.MessageType) @@ -195,6 +205,19 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint return nil, fmt.Errorf("Incoming messages are only allowed in Api inboxes") } } + loadedInbox := loadInbox() + requestHash, err := shangwutongMessageRequestHash(req) + if err != nil { + return nil, err + } + if requestHash != "" { + if existing, found, err := s.findShangwutongMessageReplay(ctx, conversation.InboxID, req.SourceID, requestHash); err != nil { + return nil, err + } else if found { + return existing, nil + } + } + contentAttributes := messageContentAttributes(req.ContentAttributes) contentAttributes = mergeMessageContentAttributes(contentAttributes, map[string]any{ "external_created_at": strings.TrimSpace(req.ExternalCreatedAt), @@ -210,36 +233,57 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint contentAttributes = mergeEmailContentAttributes(contentAttributes, req.Content, req.EmailHTMLContent) } } - senderID := userID - senderType := "user" - if strings.TrimSpace(req.SenderType) == string(model.SenderTypeAgentBot) && req.SenderID != 0 { + var senderID *uint + senderType := "" + if req.External { + if req.MessageType == "incoming" && conversation.ContactID != 0 { + contactID := conversation.ContactID + senderID, senderType = &contactID, string(model.SenderTypeContact) + } + } else { + id := userID + senderID, senderType = &id, "user" + } + if !req.External && strings.TrimSpace(req.SenderType) == string(model.SenderTypeAgentBot) && req.SenderID != 0 { var bot model.AgentBot if err := s.repo.DB().WithContext(ctx). Where("id = ? AND (account_id IS NULL OR account_id = ?)", req.SenderID, accountID). First(&bot).Error; err == nil { - senderID = bot.ID + id := bot.ID + senderID = &id senderType = string(model.SenderTypeAgentBot) } } + externalSourceIDs := messageContentAttributes(req.ExternalSourceIDs) + additionalAttributes := messageContentAttributes(req.AdditionalAttributes) + additionalAttributes = mergeMessageContentAttributes(additionalAttributes, map[string]any{ + "campaign_id": req.CampaignID, "template_params": req.TemplateParams, + }) + queueShangwutong := loadedInbox != nil && loadedInbox.ChannelType == "shangwutong" && !req.External && + (req.MessageType == "outgoing" || req.MessageType == "template") && !req.Private + status := string(model.MessageStatusSent) + if queueShangwutong { + status = string(model.MessageStatusProgress) + } message := &model.Message{ - AccountID: accountID, - ConversationID: req.ConversationID, - InboxID: conversation.InboxID, - Content: req.Content, - MessageType: req.MessageType, - ContentType: req.ContentType, - SenderID: &senderID, - SenderType: senderType, - Private: req.Private, - SourceID: req.SourceID, - EchoID: req.EchoID, - Status: "sent", - ContentAttributes: contentAttributes, - AdditionalAttributes: messageAdditionalAttributes(map[string]any{ - "campaign_id": req.CampaignID, - "template_params": req.TemplateParams, - }), + AccountID: accountID, + ConversationID: req.ConversationID, + InboxID: conversation.InboxID, + Content: req.Content, + MessageType: req.MessageType, + ContentType: req.ContentType, + SenderID: senderID, + SenderType: senderType, + Private: req.Private, + SourceID: req.SourceID, + EchoID: req.EchoID, + Status: status, + External: req.External, + ContentAttributes: contentAttributes, + AdditionalAttributes: additionalAttributes, + ExternalSourceIDs: externalSourceIDs, + ExternalRequestHash: requestHash, } // Chatwoot: when message_type is "private_note", force Private=true and ContentType="private_note" @@ -250,6 +294,8 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint } } + var deliveryJob *model.BackgroundJob + var deliveryCreated bool if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(message).Error; err != nil { return err @@ -273,11 +319,29 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint return err } } + if queueShangwutong && s.worker != nil { + var err error + deliveryJob, deliveryCreated, err = s.worker.EnqueueInTransaction( + ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongMessageJob("message_created", message, 0), + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(fmt.Sprintf("api-inbox-message:%d:created", message.ID)), + ) + if err != nil { + return err + } + } return nil }); err != nil { + if requestHash != "" { + if existing, found, replayErr := s.findShangwutongMessageReplay(ctx, conversation.InboxID, req.SourceID, requestHash); replayErr == nil && found { + return existing, nil + } + } applogger.L().Errorf("Failed to create message: %v", err) return nil, err } + if deliveryCreated { + s.worker.Publish(ctx, deliveryJob) + } // Dispatch EventMessageCreated s.dispatchMessageEvent(ctx, channel.EventMessageCreated, message) @@ -291,8 +355,10 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint return message, err } } - } else if req.MessageType == "outgoing" { - if s.worker != nil { + } else if req.MessageType == "outgoing" || req.MessageType == "template" { + if req.External || queueShangwutong { + // Imported messages and Shangwutong messages use their dedicated external flow. + } else if s.worker != nil { if _, err := EnqueueSendReply(ctx, s.worker, message.ID); err != nil { return message, err } @@ -305,12 +371,7 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint } func messageCreateAllowedIncoming(channelType string) bool { - switch strings.TrimSpace(channelType) { - case "api", string(model.InboxChannelTypeAPI): - return true - default: - return false - } + return channel.IsAPIInboxLike(channelType) } func messageCreateEmailInbox(channelType string) bool { @@ -524,6 +585,95 @@ func messageAdditionalAttributes(values map[string]any) datatypes.JSON { return datatypes.JSON(encoded) } +func shangwutongMessageRequestHash(req CreateMessageRequest) (string, error) { + if !strings.HasPrefix(strings.TrimSpace(req.SourceID), "swt:") { + return "", nil + } + contentAttributes, err := canonicalJSONValue(req.ContentAttributes) + if err != nil { + return "", fmt.Errorf("invalid content_attributes: %w", err) + } + additionalAttributes, err := canonicalJSONValue(req.AdditionalAttributes) + if err != nil { + return "", fmt.Errorf("invalid additional_attributes: %w", err) + } + externalSourceIDs, err := canonicalJSONValue(req.ExternalSourceIDs) + if err != nil { + return "", fmt.Errorf("invalid external_source_ids: %w", err) + } + attachments := make([]map[string]any, 0, len(req.Attachments)) + for _, attachment := range req.Attachments { + attachments = append(attachments, map[string]any{ + "content_type": strings.TrimSpace(attachment.ContentType), + "file_name": strings.TrimSpace(attachment.FileName), + "file_size": attachment.FileSize, + "sha256": strings.ToLower(strings.TrimSpace(attachment.SHA256)), + }) + } + payload := struct { + MessageType string `json:"message_type"` + ContentType string `json:"content_type"` + Content string `json:"content"` + Private bool `json:"private"` + SourceID string `json:"source_id"` + External bool `json:"external"` + ExternalCreatedAt string `json:"external_created_at"` + ExternalSourceIDs any `json:"external_source_ids"` + ContentAttributes any `json:"content_attributes"` + AdditionalAttributes any `json:"additional_attributes"` + Attachments []map[string]any `json:"attachments"` + }{ + MessageType: req.MessageType, ContentType: req.ContentType, Content: req.Content, + Private: req.Private, SourceID: strings.TrimSpace(req.SourceID), External: req.External, + ExternalCreatedAt: strings.TrimSpace(req.ExternalCreatedAt), ExternalSourceIDs: externalSourceIDs, + ContentAttributes: contentAttributes, AdditionalAttributes: additionalAttributes, Attachments: attachments, + } + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + hash := sha256.Sum256(encoded) + return fmt.Sprintf("%x", hash[:]), nil +} + +func canonicalJSONValue(raw datatypes.JSON) (any, error) { + if len(raw) == 0 || string(raw) == "null" || strings.TrimSpace(string(raw)) == "" { + return nil, nil + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, err + } + if encoded, ok := value.(string); ok { + if err := json.Unmarshal([]byte(encoded), &value); err != nil { + return nil, err + } + } + if value == nil { + return nil, nil + } + if _, ok := value.(map[string]any); !ok { + return nil, errors.New("must be a JSON object") + } + return value, nil +} + +func (s *MessageService) findShangwutongMessageReplay(ctx context.Context, inboxID uint, sourceID, requestHash string) (*model.Message, bool, error) { + var existing model.Message + err := s.repo.DB().WithContext(ctx).Where("inbox_id = ? AND source_id = ?", inboxID, sourceID).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if existing.ExternalRequestHash == "" || existing.ExternalRequestHash != requestHash { + return nil, false, ErrMessageIdempotencyConflict + } + existing.IdempotentReplay = true + return &existing, true, nil +} + func normalizeMessageType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "1", "outgoing": @@ -619,12 +769,7 @@ func (s *MessageService) messageInboxIsAPI(ctx context.Context, inboxID uint) bo if err := s.repo.DB().WithContext(ctx).First(&inbox, inboxID).Error; err != nil { return false } - switch strings.ToLower(inbox.ChannelType) { - case "api", "channel::api": - return true - default: - return false - } + return channel.IsAPIInboxLike(inbox.ChannelType) } // Delete marks a message deleted using Chatwoot's visible tombstone payload. @@ -675,7 +820,195 @@ func (s *MessageService) UpdateStatus(ctx context.Context, id uint, status strin return nil, err } - // Dispatch EventMessageStatusUpdated + s.dispatchMessageStatus(ctx, message, status) + s.indexMessage(ctx, message) + + return message, nil +} + +type ShangwutongMessageResult struct { + ResultVersion int64 + Status string + ExternalID *string + ExternalIDs []string + ErrorCode *string + ErrorMessage *string + OccurredAt time.Time +} + +var ( + ErrShangwutongMessageResultConflict = errors.New("shangwutong message result conflicts with the stored result") + ErrShangwutongMessageNotEligible = errors.New("message is not eligible for shangwutong result updates") +) + +func (s *MessageService) ApplyShangwutongMessageResult(ctx context.Context, accountID, inboxID, messageID uint, result ShangwutongMessageResult) (*model.Message, bool, error) { + hash, err := shangwutongResultHash(result) + if err != nil { + return nil, false, err + } + var message model.Message + applied := false + err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where( + "id = ? AND account_id = ? AND inbox_id = ?", messageID, accountID, inboxID, + ).First(&message).Error; err != nil { + return err + } + if message.External || message.Private || (message.MessageType != "outgoing" && message.MessageType != "template") { + return ErrShangwutongMessageNotEligible + } + attrs := webhookJSONObject(message.ContentAttributes) + delivery, _ := attrs["swt_delivery"].(map[string]any) + if delivery == nil { + delivery = map[string]any{} + } + currentVersion := int64ValueFromJSON(delivery["result_version"]) + if result.ResultVersion < currentVersion { + return nil + } + if result.ResultVersion == currentVersion && currentVersion > 0 { + if storedHash, _ := delivery["request_hash"].(string); storedHash != hash { + return ErrShangwutongMessageResultConflict + } + return nil + } + previousCode, _ := delivery["error_code"].(string) + if message.Status == string(model.MessageStatusSent) && result.Status != string(model.MessageStatusSent) { + return ErrShangwutongMessageResultConflict + } + if message.Status == string(model.MessageStatusFailed) && result.Status == string(model.MessageStatusSent) && previousCode != "uncertain_timeout" { + return ErrShangwutongMessageResultConflict + } + + delivery = map[string]any{ + "result_version": result.ResultVersion, "request_hash": hash, "status": result.Status, + "occurred_at": result.OccurredAt.UTC().Format(time.RFC3339Nano), + } + if result.ErrorCode != nil { + delivery["error_code"] = strings.TrimSpace(*result.ErrorCode) + } + if result.ErrorMessage != nil { + delivery["error_message"] = strings.TrimSpace(*result.ErrorMessage) + } + attrs["swt_delivery"] = delivery + switch result.Status { + case string(model.MessageStatusSent): + if message.Status != string(model.MessageStatusDelivered) && message.Status != string(model.MessageStatusRead) { + message.Status = string(model.MessageStatusSent) + } + delete(attrs, "external_error") + delete(attrs, "external_delivery_state") + case string(model.MessageStatusFailed): + message.Status = string(model.MessageStatusFailed) + errorText := "shangwutong delivery failed" + if result.ErrorMessage != nil && strings.TrimSpace(*result.ErrorMessage) != "" { + errorText = strings.TrimSpace(*result.ErrorMessage) + } else if result.ErrorCode != nil && strings.TrimSpace(*result.ErrorCode) != "" { + errorText = strings.TrimSpace(*result.ErrorCode) + } + attrs["external_error"] = errorText + delete(attrs, "external_delivery_state") + case "uncertain": + message.Status = string(model.MessageStatusProgress) + attrs["external_delivery_state"] = "uncertain" + delete(attrs, "external_error") + default: + return errors.New("invalid shangwutong result status") + } + if result.ExternalID != nil { + externalIDs := webhookJSONObject(message.ExternalSourceIDs) + externalIDs["shangwutong"] = mergeShangwutongExternalID( + externalIDs["shangwutong"], strings.TrimSpace(*result.ExternalID), + ) + message.ExternalSourceIDs, _ = json.Marshal(externalIDs) + } + if len(result.ExternalIDs) > 0 { + externalIDs := webhookJSONObject(message.ExternalSourceIDs) + for _, externalID := range result.ExternalIDs { + externalIDs["shangwutong"] = mergeShangwutongExternalID( + externalIDs["shangwutong"], strings.TrimSpace(externalID), + ) + } + message.ExternalSourceIDs, _ = json.Marshal(externalIDs) + } + message.ContentAttributes, _ = json.Marshal(attrs) + if err := tx.Save(&message).Error; err != nil { + return err + } + applied = true + return nil + }) + if err != nil { + return nil, false, err + } + if applied { + s.dispatchMessageStatus(ctx, &message, message.Status) + s.indexMessage(ctx, &message) + } + return &message, applied, nil +} + +func mergeShangwutongExternalID(current any, externalID string) any { + if externalID == "" { + return current + } + values := make([]string, 0, 2) + appendUnique := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + for _, existing := range values { + if existing == value { + return + } + } + values = append(values, value) + } + switch typed := current.(type) { + case string: + appendUnique(typed) + case []any: + for _, value := range typed { + if text, ok := value.(string); ok { + appendUnique(text) + } + } + case []string: + for _, value := range typed { + appendUnique(value) + } + } + appendUnique(externalID) + if len(values) == 1 { + return values[0] + } + return values +} + +func shangwutongResultHash(result ShangwutongMessageResult) (string, error) { + payload := struct { + ResultVersion int64 `json:"result_version"` + Status string `json:"status"` + ExternalID *string `json:"external_id"` + ExternalIDs []string `json:"external_ids,omitempty"` + ErrorCode *string `json:"error_code"` + ErrorMessage *string `json:"error_message"` + OccurredAt string `json:"occurred_at"` + }{ + ResultVersion: result.ResultVersion, Status: result.Status, ExternalID: result.ExternalID, + ExternalIDs: result.ExternalIDs, + ErrorCode: result.ErrorCode, ErrorMessage: result.ErrorMessage, OccurredAt: result.OccurredAt.UTC().Format(time.RFC3339Nano), + } + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + hash := sha256.Sum256(encoded) + return fmt.Sprintf("%x", hash[:]), nil +} + +func (s *MessageService) dispatchMessageStatus(ctx context.Context, message *model.Message, status string) { event := channel.NewChannelEvent(channel.EventMessageStatusUpdated, channel.ChannelAPI, message.AccountID, message.InboxID) event.ConversationID = message.ConversationID if message.SenderID != nil { @@ -687,9 +1020,6 @@ func (s *MessageService) UpdateStatus(ctx context.Context, id uint, status strin if err := s.dispatcher.Dispatch(ctx, event); err != nil { applogger.L().Errorf("failed to dispatch event %s for message %d: %v", channel.EventMessageStatusUpdated, message.ID, err) } - s.indexMessage(ctx, message) - - return message, nil } func (s *MessageService) ListByConversationFinder(ctx context.Context, conversationID uint, after, before uint, filterInternal bool) ([]model.Message, int64, error) { @@ -698,7 +1028,7 @@ func (s *MessageService) ListByConversationFinder(ctx context.Context, conversat func validMessageStatus(value string) bool { switch value { - case "sent", "delivered", "read", "failed": + case "progress", "sent", "delivered", "read", "failed": return true default: return false @@ -761,6 +1091,13 @@ func (s *MessageService) RetryInConversation(ctx context.Context, accountID, con if err != nil { return nil, err } + var inbox model.Inbox + if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").First(&inbox, message.InboxID).Error; err != nil { + return nil, err + } + if strings.EqualFold(inbox.ChannelType, "shangwutong") { + return s.retryShangwutongMessage(ctx, message) + } message.Status = "sent" message.ContentAttributes = datatypes.JSON([]byte(`{}`)) @@ -790,6 +1127,62 @@ func (s *MessageService) RetryInConversation(ctx context.Context, accountID, con return message, nil } +func (s *MessageService) retryShangwutongMessage(ctx context.Context, message *model.Message) (*model.Message, error) { + if message.Status != string(model.MessageStatusFailed) || message.External || message.Private || + (message.MessageType != "outgoing" && message.MessageType != "template") { + return nil, errors.New("only failed non-external shangwutong outgoing messages can be retried") + } + attrs := webhookJSONObject(message.ContentAttributes) + retryVersion := int64ValueFromJSON(attrs["external_retry_version"]) + 1 + attrs["external_retry_version"] = retryVersion + delete(attrs, "external_error") + delete(attrs, "external_delivery_state") + encoded, _ := json.Marshal(attrs) + message.Status = string(model.MessageStatusProgress) + message.ContentAttributes = datatypes.JSON(encoded) + var job *model.BackgroundJob + var created bool + err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(message).Error; err != nil { + return err + } + if s.worker == nil { + return errors.New("shangwutong webhook worker is unavailable") + } + var err error + job, created, err = s.worker.EnqueueInTransaction( + ctx, tx, TaskTypeShangwutongWebhookDelivery, newShangwutongMessageJob("message_retry_requested", message, retryVersion), + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(fmt.Sprintf("api-inbox-message:%d:retry:%d", message.ID, retryVersion)), + ) + return err + }) + if err != nil { + return nil, err + } + if created { + s.worker.Publish(ctx, job) + } + s.dispatchMessageStatus(ctx, message, message.Status) + s.indexMessage(ctx, message) + return message, nil +} + +func int64ValueFromJSON(value any) int64 { + switch typed := value.(type) { + case float64: + return int64(typed) + case int64: + return typed + case int: + return int64(typed) + case json.Number: + result, _ := typed.Int64() + return result + default: + return 0 + } +} + func (s *MessageService) findMessageForConversationRoute(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { if conversationID == 0 { return s.repo.FindByAccountAndID(ctx, accountID, id) diff --git a/backend/internal/service/message_service_test.go b/backend/internal/service/message_service_test.go index 26a39e63..04a5e8d1 100644 --- a/backend/internal/service/message_service_test.go +++ b/backend/internal/service/message_service_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -482,7 +483,7 @@ func TestMessageService_Create(t *testing.T) { require.NoError(t, db.Where("message_id = ?", createdVoiceMessage.ID).Order("id ASC").Find(&voiceAttachments).Error) require.Len(t, voiceAttachments, 2) assert.Equal(t, `{"is_voice_message":true}`, voiceAttachments[0].Metadata) - assert.Empty(t, voiceAttachments[1].Metadata) + assert.JSONEq(t, `{}`, voiceAttachments[1].Metadata) // 正常路径:创建私密消息 reqPrivate := CreateMessageRequest{ @@ -532,6 +533,111 @@ func TestMessageService_Create(t *testing.T) { assert.Error(t, err7) } +func TestMessageService_ShangwutongImportIsIdempotentAndDoesNotQueueOutbound(t *testing.T) { + db, _, _, svc := setupMessageServiceWithDefaultLLM(t) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + account := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, "shangwutong") + contact := createTestContact(t, db, account.ID) + conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + req := CreateMessageRequest{ + ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "您好", + SourceID: fmt.Sprintf("swt:%d:visitor:2:98765:0", inbox.ID), External: true, + ExternalCreatedAt: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), + ExternalSourceIDs: datatypes.JSON([]byte(`{"shangwutong":"98765"}`)), + ContentAttributes: datatypes.JSON([]byte(`{"swt":{"kind":2,"seq_id":98765}}`)), + AdditionalAttributes: datatypes.JSON([]byte(`{"senderName":"商务通访客"}`)), + } + created, err := svc.Create(context.Background(), account.ID, 0, req) + require.NoError(t, err) + replayed, err := svc.Create(context.Background(), account.ID, 0, req) + require.NoError(t, err) + require.Equal(t, created.ID, replayed.ID) + require.True(t, replayed.IdempotentReplay) + require.True(t, created.External) + require.Equal(t, string(model.SenderTypeContact), created.SenderType) + require.NotNil(t, created.SenderID) + require.Equal(t, contact.ID, *created.SenderID) + var count int64 + require.NoError(t, db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, req.SourceID).Count(&count).Error) + require.EqualValues(t, 1, count) + require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Count(&count).Error) + require.Zero(t, count) + req.Content = "不同正文" + _, err = svc.Create(context.Background(), account.ID, 0, req) + require.ErrorIs(t, err, ErrMessageIdempotencyConflict) +} + +func TestMessageService_ShangwutongOutboundResultAndRetryStayDurable(t *testing.T) { + db, _, _, svc := setupMessageServiceWithDefaultLLM(t) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + account := createTestAccount(t, db) + user := createTestUser(t, db, account.ID) + inbox := createTestInbox(t, db, account.ID, "shangwutong") + contact := createTestContact(t, db, account.ID) + conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + message, err := svc.Create(context.Background(), account.ID, user.ID, CreateMessageRequest{ + ConversationID: conversation.ID, MessageType: "outgoing", ContentType: "text", Content: "回复", + ContentAttributes: datatypes.JSON([]byte(`{"business":"keep"}`)), + }) + require.NoError(t, err) + require.Equal(t, string(model.MessageStatusProgress), message.Status) + var jobs []model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Order("id ASC").Find(&jobs).Error) + require.Len(t, jobs, 1) + require.Equal(t, fmt.Sprintf("api-inbox-message:%d:created", message.ID), jobs[0].IdempotencyKey) + + uncertain := ShangwutongMessageResult{ResultVersion: 1, Status: "uncertain", OccurredAt: time.Date(2026, 8, 1, 9, 1, 0, 0, time.UTC)} + updated, applied, err := svc.ApplyShangwutongMessageResult(context.Background(), account.ID, inbox.ID, message.ID, uncertain) + require.NoError(t, err) + require.True(t, applied) + require.Equal(t, string(model.MessageStatusProgress), updated.Status) + require.Contains(t, string(updated.ContentAttributes), `"external_delivery_state":"uncertain"`) + _, applied, err = svc.ApplyShangwutongMessageResult(context.Background(), account.ID, inbox.ID, message.ID, uncertain) + require.NoError(t, err) + require.False(t, applied) + conflict := uncertain + conflict.Status = "failed" + errorCode := "uncertain_timeout" + conflict.ErrorCode = &errorCode + _, _, err = svc.ApplyShangwutongMessageResult(context.Background(), account.ID, inbox.ID, message.ID, conflict) + require.ErrorIs(t, err, ErrShangwutongMessageResultConflict) + + failed := ShangwutongMessageResult{ResultVersion: 2, Status: "failed", ErrorCode: &errorCode, OccurredAt: time.Date(2026, 8, 1, 9, 2, 0, 0, time.UTC)} + updated, applied, err = svc.ApplyShangwutongMessageResult(context.Background(), account.ID, inbox.ID, message.ID, failed) + require.NoError(t, err) + require.True(t, applied) + require.Equal(t, string(model.MessageStatusFailed), updated.Status) + + retried, err := svc.RetryInConversation(context.Background(), account.ID, conversation.ID, message.ID) + require.NoError(t, err) + require.Equal(t, string(model.MessageStatusProgress), retried.Status) + attrs := map[string]any{} + require.NoError(t, json.Unmarshal(retried.ContentAttributes, &attrs)) + require.Equal(t, "keep", attrs["business"]) + require.EqualValues(t, 1, attrs["external_retry_version"]) + require.NotContains(t, attrs, "external_error") + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Order("id ASC").Find(&jobs).Error) + require.Len(t, jobs, 2) + require.Equal(t, fmt.Sprintf("api-inbox-message:%d:retry:1", message.ID), jobs[1].IdempotencyKey) + + externalID := "98766" + sent := ShangwutongMessageResult{ResultVersion: 3, Status: "sent", ExternalID: &externalID, OccurredAt: time.Date(2026, 8, 1, 9, 3, 0, 0, time.UTC)} + updated, applied, err = svc.ApplyShangwutongMessageResult(context.Background(), account.ID, inbox.ID, message.ID, sent) + require.NoError(t, err) + require.True(t, applied) + require.Equal(t, string(model.MessageStatusSent), updated.Status) + require.JSONEq(t, `{"shangwutong":"98766"}`, string(updated.ExternalSourceIDs)) + + secondSent := ShangwutongMessageResult{ResultVersion: 4, Status: "sent", ExternalIDs: []string{"98766", "98767"}, OccurredAt: time.Date(2026, 8, 1, 9, 4, 0, 0, time.UTC)} + updated, applied, err = svc.ApplyShangwutongMessageResult(context.Background(), account.ID, inbox.ID, message.ID, secondSent) + require.NoError(t, err) + require.True(t, applied) + require.JSONEq(t, `{"shangwutong":["98766","98767"]}`, string(updated.ExternalSourceIDs)) +} + func TestMessageService_ConversationScopedMessageActions(t *testing.T) { db, _, _, svc := setupMessageServiceWithDefaultLLM(t) ctx := context.Background() diff --git a/backend/internal/service/shangwutong_webhook_delivery.go b/backend/internal/service/shangwutong_webhook_delivery.go new file mode 100644 index 00000000..6286424e --- /dev/null +++ b/backend/internal/service/shangwutong_webhook_delivery.go @@ -0,0 +1,422 @@ +package service + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/webhookutil" + "github.com/gochat/gochat/internal/worker" + "github.com/google/uuid" + "gorm.io/gorm" +) + +const TaskTypeShangwutongWebhookDelivery = "api_inbox:webhook_delivery:v1" + +type shangwutongWebhookDeliveryJob struct { + Event string `json:"event"` + EventID string `json:"event_id"` + OccurredAt time.Time `json:"occurred_at"` + AccountID uint `json:"account_id"` + InboxID uint `json:"inbox_id"` + ConfigVersion int64 `json:"config_version"` + MessageID uint `json:"message_id,omitempty"` + RetryVersion int64 `json:"retry_version,omitempty"` + ConversationID uint `json:"conversation_id,omitempty"` + ConversationVersion int64 `json:"conversation_version,omitempty"` + PreviousStatus string `json:"previous_status,omitempty"` + ActorID uint `json:"actor_id,omitempty"` + Private bool `json:"private,omitempty"` + WebhookURL string `json:"webhook_url,omitempty"` + SigningSecret string `json:"signing_secret,omitempty"` + Tombstone bool `json:"tombstone,omitempty"` +} + +var shangwutongWebhookRegistrations sync.Map + +func RegisterShangwutongWebhookDeliveryJobs(pool *worker.WorkerPool, db *gorm.DB, dispatchers ...*channel.Dispatcher) { + if pool == nil || db == nil { + return + } + if _, loaded := shangwutongWebhookRegistrations.LoadOrStore(pool, struct{}{}); loaded { + return + } + runner := &shangwutongWebhookDeliveryRunner{ + db: db, client: &http.Client{Timeout: webhookutil.Timeout(context.Background(), db)}, now: time.Now, + } + if len(dispatchers) > 0 { + runner.dispatcher = dispatchers[0] + } + pool.Register(TaskTypeShangwutongWebhookDelivery, runner.perform) +} + +type shangwutongWebhookDeliveryRunner struct { + db *gorm.DB + client *http.Client + now func() time.Time + dispatcher *channel.Dispatcher +} + +func (r *shangwutongWebhookDeliveryRunner) perform(ctx context.Context, backgroundJob *model.BackgroundJob) error { + var job shangwutongWebhookDeliveryJob + if err := json.Unmarshal(backgroundJob.Payload, &job); err != nil { + return fmt.Errorf("decode shangwutong webhook job: %w", err) + } + webhookURL, secret := job.WebhookURL, job.SigningSecret + if !job.Tombstone { + var inbox model.Inbox + if err := r.db.WithContext(ctx).Where("id = ? AND account_id = ? AND channel_type = ?", job.InboxID, job.AccountID, "shangwutong").First(&inbox).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) && (job.Event == "inbox_created" || job.Event == "inbox_updated") { + return nil + } + return err + } + var channelAPI channelmodel.ChannelAPI + if err := r.db.WithContext(ctx).Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error; err != nil { + return err + } + webhookURL = channelAPI.WebhookURL + if secret == "" { + secret = channelAPI.Secret + } + } + if webhookURL == "" || secret == "" { + return fmt.Errorf("shangwutong webhook target is incomplete") + } + payload, err := r.payload(ctx, job) + if err != nil { + return err + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + timestamp := strconv.FormatInt(r.now().Unix(), 10) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-GoChat-Webhook-Version", "1") + request.Header.Set("X-Chatwoot-Timestamp", timestamp) + request.Header.Set("X-Chatwoot-Signature", signShangwutongWebhook(body, secret, timestamp)) + request.Header.Set("X-Chatwoot-Delivery", uuid.NewString()) + response, err := r.client.Do(request) + if err != nil { + if isShangwutongMessageDelivery(job) && backgroundJob.Attempts >= backgroundJob.MaxAttempts { + r.markMessageDeliveryFailed(ctx, job, "connector_delivery_exhausted") + } + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + deliveryErr := fmt.Errorf("shangwutong webhook returned %d: %s", response.StatusCode, string(payload)) + permanent := response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusTooManyRequests + if isShangwutongMessageDelivery(job) && (permanent || backgroundJob.Attempts >= backgroundJob.MaxAttempts) { + code := "connector_delivery_exhausted" + if permanent { + code = shangwutongWebhookErrorCode(payload, "connector_delivery_rejected") + } + r.markMessageDeliveryFailed(ctx, job, code) + } + if permanent { + return worker.Permanent(deliveryErr) + } + return deliveryErr + } + return nil +} + +func isShangwutongMessageDelivery(job shangwutongWebhookDeliveryJob) bool { + return job.MessageID != 0 && (job.Event == "message_created" || job.Event == "message_retry_requested") +} + +func shangwutongWebhookErrorCode(payload []byte, fallback string) string { + var envelope struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if json.Unmarshal(payload, &envelope) == nil && strings.TrimSpace(envelope.Error.Code) != "" { + return strings.TrimSpace(envelope.Error.Code) + } + return fallback +} + +func (r *shangwutongWebhookDeliveryRunner) markMessageDeliveryFailed(ctx context.Context, job shangwutongWebhookDeliveryJob, code string) { + var message model.Message + if r.db.WithContext(ctx).Where( + "id = ? AND account_id = ? AND inbox_id = ? AND status = ?", job.MessageID, job.AccountID, job.InboxID, model.MessageStatusProgress, + ).First(&message).Error != nil { + return + } + message.Status = string(model.MessageStatusFailed) + message.ContentAttributes = setMessageExternalError(message.ContentAttributes, message.Status, code) + if r.db.WithContext(ctx).Save(&message).Error != nil { + return + } + if r.dispatcher != nil { + event := channel.NewChannelEvent(channel.EventMessageStatusUpdated, channel.ChannelAPI, message.AccountID, message.InboxID) + event.ConversationID = message.ConversationID + event.Data["message_id"], event.Data["status"] = message.ID, message.Status + _ = r.dispatcher.Dispatch(ctx, event) + } +} + +func (r *shangwutongWebhookDeliveryRunner) payload(ctx context.Context, job shangwutongWebhookDeliveryJob) (map[string]any, error) { + data := map[string]any{} + switch job.Event { + case "inbox_created", "inbox_updated", "inbox_deleted": + data = map[string]any{"channel_type": "shangwutong", "config_version": job.ConfigVersion} + case "message_created", "message_retry_requested": + messageData, err := r.messageData(ctx, job) + if err != nil { + return nil, err + } + data = messageData + case "conversation_status_changed": + conversationData, err := r.conversationStatusData(ctx, job) + if err != nil { + return nil, err + } + data = conversationData + case "conversation_typing_on", "conversation_typing_off": + typingData, err := r.typingData(ctx, job) + if err != nil { + return nil, err + } + data = typingData + default: + return nil, fmt.Errorf("unsupported shangwutong webhook event %q", job.Event) + } + return map[string]any{ + "schema_version": 1, "event": job.Event, "event_id": job.EventID, + "occurred_at": job.OccurredAt.UTC(), "account_id": job.AccountID, "inbox_id": job.InboxID, "data": data, + }, nil +} + +func (r *shangwutongWebhookDeliveryRunner) typingData(ctx context.Context, job shangwutongWebhookDeliveryJob) (map[string]any, error) { + var conversation model.Conversation + if err := r.db.WithContext(ctx).Where( + "id = ? AND account_id = ? AND inbox_id = ?", job.ConversationID, job.AccountID, job.InboxID, + ).First(&conversation).Error; err != nil { + return nil, err + } + displayID := conversation.ID + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + displayID = *conversation.DisplayID + } + return map[string]any{ + "conversation": map[string]any{ + "id": conversation.ID, "display_id": displayID, + "custom_attributes": webhookJSONObject(conversation.CustomAttributes), + }, + "actor": map[string]any{"id": job.ActorID, "type": "user"}, "private": job.Private, + }, nil +} + +func (r *shangwutongWebhookDeliveryRunner) conversationStatusData(ctx context.Context, job shangwutongWebhookDeliveryJob) (map[string]any, error) { + var conversation model.Conversation + if err := r.db.WithContext(ctx).Where( + "id = ? AND account_id = ? AND inbox_id = ?", job.ConversationID, job.AccountID, job.InboxID, + ).First(&conversation).Error; err != nil { + return nil, err + } + displayID := conversation.ID + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + displayID = *conversation.DisplayID + } + actor := map[string]any{} + if job.ActorID != 0 { + var user model.User + if r.db.WithContext(ctx).Where("id = ? AND account_id = ?", job.ActorID, job.AccountID).First(&user).Error == nil { + actor = map[string]any{"id": user.ID, "type": "user", "name": user.Name} + } + } + return map[string]any{ + "conversation": map[string]any{ + "id": conversation.ID, "display_id": displayID, "status": conversation.Status, + "previous_status": job.PreviousStatus, "custom_attributes": webhookJSONObject(conversation.CustomAttributes), + }, + "actor": actor, + }, nil +} + +func (r *shangwutongWebhookDeliveryRunner) messageData(ctx context.Context, job shangwutongWebhookDeliveryJob) (map[string]any, error) { + var message model.Message + if err := r.db.WithContext(ctx).Where("id = ? AND account_id = ? AND inbox_id = ?", job.MessageID, job.AccountID, job.InboxID).First(&message).Error; err != nil { + return nil, err + } + var conversation model.Conversation + if err := r.db.WithContext(ctx).Where("id = ? AND account_id = ? AND inbox_id = ?", message.ConversationID, job.AccountID, job.InboxID).First(&conversation).Error; err != nil { + return nil, err + } + var contact model.Contact + if err := r.db.WithContext(ctx).Where("id = ? AND account_id = ?", conversation.ContactID, job.AccountID).First(&contact).Error; err != nil { + return nil, err + } + var attachments []model.Attachment + if err := r.db.WithContext(ctx).Where("message_id = ?", message.ID).Order("id ASC").Find(&attachments).Error; err != nil { + return nil, err + } + attachmentPayload := make([]map[string]any, 0, len(attachments)) + for i := range attachments { + item := map[string]any{ + "id": attachments[i].ID, "file_type": attachments[i].FileType, + "data_url": firstNonEmpty(attachments[i].FileURL, attachments[i].ExternalURL), + "file_size": attachments[i].FileSize, + "extension": strings.TrimPrefix(filepath.Ext(attachments[i].FileName), "."), + } + if metadata := webhookJSONObject([]byte(attachments[i].Metadata)); len(metadata) > 0 { + item["metadata"] = metadata + } + attachmentPayload = append(attachmentPayload, item) + } + displayID := conversation.ID + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + displayID = *conversation.DisplayID + } + sender := r.messageSender(ctx, &message) + data := map[string]any{ + "message": map[string]any{ + "id": message.ID, "message_type": message.MessageType, "content_type": message.ContentType, + "content": message.Content, "private": message.Private, "external": message.External, + "source_id": message.SourceID, "status": message.Status, + "content_attributes": webhookJSONObject(message.ContentAttributes), + "additional_attributes": webhookJSONObject(message.AdditionalAttributes), "attachments": attachmentPayload, + }, + "conversation": map[string]any{ + "id": message.ConversationID, "display_id": displayID, "status": conversation.Status, + "custom_attributes": webhookJSONObject(conversation.CustomAttributes), + }, + "contact": map[string]any{"id": contact.ID, "source_id": contact.SourceID, "name": contact.Name}, + "sender": sender, + } + if job.Event == "message_retry_requested" { + data["retry_version"] = job.RetryVersion + } + return data, nil +} + +func (r *shangwutongWebhookDeliveryRunner) messageSender(ctx context.Context, message *model.Message) map[string]any { + if message == nil || message.SenderID == nil || *message.SenderID == 0 { + return map[string]any{} + } + if strings.EqualFold(message.SenderType, string(model.SenderTypeAgentBot)) { + var bot model.AgentBot + if r.db.WithContext(ctx).First(&bot, *message.SenderID).Error == nil { + return map[string]any{"id": bot.ID, "type": "agent_bot", "name": bot.Name} + } + } + var user model.User + if r.db.WithContext(ctx).First(&user, *message.SenderID).Error == nil { + return map[string]any{"id": user.ID, "type": "user", "name": user.Name} + } + return map[string]any{} +} + +func webhookJSONObject(raw []byte) map[string]any { + result := map[string]any{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &result) + } + return result +} + +func signShangwutongWebhook(body []byte, secret, timestamp string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func newShangwutongLifecycleJob(event string, inbox *model.Inbox, configVersion int64) shangwutongWebhookDeliveryJob { + return shangwutongWebhookDeliveryJob{ + Event: event, EventID: fmt.Sprintf("inbox:%d:config:%d", inbox.ID, configVersion), + OccurredAt: time.Now().UTC(), AccountID: inbox.AccountID, InboxID: inbox.ID, ConfigVersion: configVersion, + } +} + +func newShangwutongMessageJob(event string, message *model.Message, retryVersion int64) shangwutongWebhookDeliveryJob { + eventID := fmt.Sprintf("message:%d:created", message.ID) + if event == "message_retry_requested" { + eventID = fmt.Sprintf("message:%d:retry:%d", message.ID, retryVersion) + } + return shangwutongWebhookDeliveryJob{ + Event: event, EventID: eventID, OccurredAt: time.Now().UTC(), AccountID: message.AccountID, + InboxID: message.InboxID, MessageID: message.ID, RetryVersion: retryVersion, + } +} + +func newShangwutongConversationStatusJob(conversation *model.Conversation, previousStatus string, actorID uint) shangwutongWebhookDeliveryJob { + version := conversation.UpdatedAt.UTC().UnixNano() + if version <= 0 { + version = time.Now().UTC().UnixNano() + } + return shangwutongWebhookDeliveryJob{ + Event: "conversation_status_changed", EventID: fmt.Sprintf("conversation:%d:status:%d", conversation.ID, version), + OccurredAt: conversation.UpdatedAt.UTC(), AccountID: conversation.AccountID, InboxID: conversation.InboxID, + ConversationID: conversation.ID, ConversationVersion: version, PreviousStatus: previousStatus, ActorID: actorID, + } +} + +type ShangwutongTypingWebhookListener struct { + runner *shangwutongWebhookDeliveryRunner +} + +func NewShangwutongTypingWebhookListener(db *gorm.DB) *ShangwutongTypingWebhookListener { + return &ShangwutongTypingWebhookListener{runner: &shangwutongWebhookDeliveryRunner{ + db: db, client: &http.Client{Timeout: 3 * time.Second}, now: time.Now, + }} +} + +func (l *ShangwutongTypingWebhookListener) Name() string { return "shangwutong-typing-webhook" } + +func (l *ShangwutongTypingWebhookListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { + if l == nil || l.runner == nil || event == nil || event.UserID == 0 || + (event.Type != channel.EventConversationTypingOn && event.Type != channel.EventConversationTypingOff) { + return nil + } + if private, _ := event.Data["is_private"].(bool); private { + return nil + } + var inbox model.Inbox + if err := l.runner.db.WithContext(ctx).Select("id", "channel_type").Where( + "id = ? AND account_id = ? AND channel_type = ?", event.InboxID, event.AccountID, "shangwutong", + ).First(&inbox).Error; err != nil { + return nil + } + eventName := "conversation_typing_on" + if event.Type == channel.EventConversationTypingOff { + eventName = "conversation_typing_off" + } + now := time.Now().UTC() + job := shangwutongWebhookDeliveryJob{ + Event: eventName, EventID: fmt.Sprintf("typing:%d:%d:%d", event.ConversationID, event.UserID, now.UnixMilli()), + OccurredAt: now, AccountID: event.AccountID, InboxID: event.InboxID, + ConversationID: event.ConversationID, ActorID: event.UserID, + } + payload, _ := json.Marshal(job) + _ = l.runner.perform(ctx, &model.BackgroundJob{Payload: payload}) + return nil +} + +func shangwutongLifecycleIdempotencyKey(inboxID uint, configVersion int64) string { + return fmt.Sprintf("swt-inbox:%d:config:%d", inboxID, configVersion) +} diff --git a/backend/internal/service/shangwutong_webhook_delivery_test.go b/backend/internal/service/shangwutong_webhook_delivery_test.go new file mode 100644 index 00000000..c2101511 --- /dev/null +++ b/backend/internal/service/shangwutong_webhook_delivery_test.go @@ -0,0 +1,257 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gochat/gochat/internal/channel" + "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/worker" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestShangwutongWebhookDeliveryUsesVersionedSecretFreeEnvelope(t *testing.T) { + var body []byte + var headers http.Header + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + body, _ = io.ReadAll(request.Body) + headers = request.Header.Clone() + response.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "current-secret") + now := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) + runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: func() time.Time { return now }} + payload := newShangwutongLifecycleJob("inbox_updated", inbox, 7) + payload.OccurredAt = now + encoded, _ := json.Marshal(payload) + require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded})) + require.Equal(t, "1", headers.Get("X-GoChat-Webhook-Version")) + require.Equal(t, "1785578400", headers.Get("X-Chatwoot-Timestamp")) + require.NotEmpty(t, headers.Get("X-Chatwoot-Delivery")) + require.Equal(t, testWebhookSignature(body, "current-secret", "1785578400"), headers.Get("X-Chatwoot-Signature")) + require.JSONEq(t, `{"schema_version":1,"event":"inbox_updated","event_id":"inbox:1:config:7","occurred_at":"2026-08-01T10:00:00Z","account_id":1,"inbox_id":1,"data":{"channel_type":"shangwutong","config_version":7}}`, string(body)) + for _, secret := range []string{"password", "username", "session_id", "hmac_token", "webhook_secret", "current-secret"} { + require.NotContains(t, string(body), secret) + } +} + +func TestShangwutongWebhookDeliveryUsesOldSecretAndDeleteSnapshot(t *testing.T) { + var signatures []string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + payload, _ := io.ReadAll(request.Body) + timestamp := request.Header.Get("X-Chatwoot-Timestamp") + signatures = append(signatures, request.Header.Get("X-Chatwoot-Signature")+"|"+string(payload)+"|"+timestamp) + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "new-secret") + runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) }} + rotation := newShangwutongLifecycleJob("inbox_updated", inbox, 8) + rotation.SigningSecret = "old-secret" + encoded, _ := json.Marshal(rotation) + require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded})) + parts := strings.SplitN(signatures[0], "|", 3) + require.Equal(t, testWebhookSignature([]byte(parts[1]), "old-secret", parts[2]), parts[0]) + + tombstone := newShangwutongLifecycleJob("inbox_deleted", inbox, 9) + tombstone.Tombstone, tombstone.WebhookURL, tombstone.SigningSecret = true, server.URL, "new-secret" + require.NoError(t, db.Delete(&model.Inbox{}, inbox.ID).Error) + encoded, _ = json.Marshal(tombstone) + require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded})) + require.Len(t, signatures, 2) +} + +func TestShangwutongMessageWebhookReloadsCompleteContract(t *testing.T) { + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + body, _ = io.ReadAll(request.Body) + response.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret") + contact := &model.Contact{AccountID: inbox.AccountID, Name: "访客", SourceID: "visitor-session"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(88) + conversation := &model.Conversation{ + AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", + CustomAttributes: []byte(`{"swt_sid":"visitor-session"}`), + } + require.NoError(t, db.Create(conversation).Error) + user := &model.User{AccountID: inbox.AccountID, Name: "坐席", Email: "agent@example.com"} + require.NoError(t, db.Create(user).Error) + message := &model.Message{ + AccountID: inbox.AccountID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &user.ID, SenderType: "user", + MessageType: "outgoing", ContentType: "text", Content: "您好", Status: "progress", + ContentAttributes: []byte(`{"business":"keep"}`), AdditionalAttributes: []byte(`{"audit":"yes"}`), + } + require.NoError(t, db.Create(message).Error) + require.NoError(t, db.Create(&model.Attachment{ + AccountID: inbox.AccountID, MessageID: message.ID, FileType: "file", FileURL: "https://gochat/files/1", + FileName: "manual.pdf", FileSize: 12, Metadata: `{}`, + }).Error) + runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: time.Now} + job := newShangwutongMessageJob("message_created", message, 0) + encoded, _ := json.Marshal(job) + require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded})) + var envelope map[string]any + require.NoError(t, json.Unmarshal(body, &envelope)) + require.EqualValues(t, 1, envelope["schema_version"]) + require.Equal(t, "message_created", envelope["event"]) + data := envelope["data"].(map[string]any) + messagePayload := data["message"].(map[string]any) + require.Equal(t, "progress", messagePayload["status"]) + require.Equal(t, false, messagePayload["external"]) + require.Len(t, messagePayload["attachments"], 1) + require.EqualValues(t, conversation.ID, data["conversation"].(map[string]any)["id"]) + require.EqualValues(t, displayID, data["conversation"].(map[string]any)["display_id"]) + require.Equal(t, "visitor-session", data["contact"].(map[string]any)["source_id"]) + require.Equal(t, "坐席", data["sender"].(map[string]any)["name"]) +} + +func TestShangwutongMessageWebhookPermanentFailureMarksMessageFailed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusUnprocessableEntity) + _, _ = response.Write([]byte(`{"error":{"code":"unsupported_outbound_content","message":"unsupported","retryable":false}}`)) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret") + contact := &model.Contact{AccountID: inbox.AccountID, Name: "访客"} + 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: "unsupported", Status: "progress", + } + require.NoError(t, db.Create(message).Error) + runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: time.Now} + payload, _ := json.Marshal(newShangwutongMessageJob("message_created", message, 0)) + err := runner.perform(context.Background(), &model.BackgroundJob{Payload: payload, Attempts: 1, MaxAttempts: 10}) + require.Error(t, err) + require.NoError(t, db.First(message, message.ID).Error) + require.Equal(t, "failed", message.Status) + require.Contains(t, string(message.ContentAttributes), "unsupported_outbound_content") +} + +func TestShangwutongStaleLifecycleJobIsObsoleteAfterInboxDeletion(t *testing.T) { + db := newShangwutongDeliveryTestDB(t) + runner := &shangwutongWebhookDeliveryRunner{db: db, client: http.DefaultClient, now: time.Now} + payload, err := json.Marshal(shangwutongWebhookDeliveryJob{ + Event: "inbox_created", EventID: "inbox:99:config:1", AccountID: 1, InboxID: 99, ConfigVersion: 1, + }) + require.NoError(t, err) + require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: payload})) +} + +func TestShangwutongConversationStatusQueuesOnceAndSkipsConnectorOrigin(t *testing.T) { + db := setupServiceTestDB(t) + wp := worker.NewWorkerPool(db) + svc := NewConversationService(repository.NewConversationRepo(db), repository.NewMessageRepo(db), channel.NewDispatcher(), nil, nil, nil, nil) + svc.SetWorkerPool(wp) + account := createTestAccount(t, db) + user := createTestUser(t, db, account.ID) + inbox := createTestInbox(t, db, account.ID, "shangwutong") + contact := createTestContact(t, db, account.ID) + conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + conversation.Status = "open" + require.NoError(t, db.Save(conversation).Error) + ctx := WithShangwutongRequestMetadata(context.Background(), false, user.ID) + updated, err := svc.ToggleStatus(ctx, account.ID, conversation.ID, ToggleStatusRequest{Status: "resolved"}) + require.NoError(t, err) + require.Equal(t, "resolved", updated.Status) + var jobs []model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error) + require.Len(t, jobs, 1) + var payload shangwutongWebhookDeliveryJob + require.NoError(t, json.Unmarshal(jobs[0].Payload, &payload)) + require.Equal(t, "conversation_status_changed", payload.Event) + require.Equal(t, "open", payload.PreviousStatus) + require.Equal(t, user.ID, payload.ActorID) + _, err = svc.ToggleStatus(ctx, account.ID, conversation.ID, ToggleStatusRequest{Status: "resolved"}) + require.NoError(t, err) + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error) + require.Len(t, jobs, 1) + connectorContext := WithShangwutongRequestMetadata(context.Background(), true, 0) + _, err = svc.ToggleStatus(connectorContext, account.ID, conversation.ID, ToggleStatusRequest{Status: "open"}) + require.NoError(t, err) + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error) + require.Len(t, jobs, 1) +} + +func TestShangwutongTypingWebhookIsBestEffortAndSkipsPrivateOrVisitorEvents(t *testing.T) { + calls := 0 + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + calls++ + body, _ = io.ReadAll(request.Body) + response.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret") + contact := &model.Contact{AccountID: inbox.AccountID, Name: "访客"} + require.NoError(t, db.Create(contact).Error) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", CustomAttributes: []byte(`{"swt_sid":"visitor"}`)} + require.NoError(t, db.Create(conversation).Error) + listener := NewShangwutongTypingWebhookListener(db) + listener.runner.client = server.Client() + event := channel.NewChannelEvent(channel.EventConversationTypingOn, channel.ChannelShangwutong, inbox.AccountID, inbox.ID) + event.ConversationID, event.UserID = conversation.ID, 9 + event.Data["is_private"] = false + require.NoError(t, listener.OnEvent(context.Background(), event)) + require.Equal(t, 1, calls) + var envelope map[string]any + require.NoError(t, json.Unmarshal(body, &envelope)) + require.Equal(t, "conversation_typing_on", envelope["event"]) + event.Data["is_private"] = true + require.NoError(t, listener.OnEvent(context.Background(), event)) + event.Data["is_private"], event.UserID = false, 0 + require.NoError(t, listener.OnEvent(context.Background(), event)) + require.Equal(t, 1, calls) +} + +func newShangwutongDeliveryTestDB(t *testing.T) *gorm.DB { + t.Helper() + 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{}, &channelmodel.ChannelAPI{}, &model.Contact{}, &model.Conversation{}, + &model.User{}, &model.Message{}, &model.Attachment{}, &model.AgentBot{}, + )) + return db +} + +func seedShangwutongDeliveryInbox(t *testing.T, db *gorm.DB, webhookURL, secret string) *model.Inbox { + t.Helper() + account := &model.Account{Name: "Account", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "SWT", ChannelType: "shangwutong", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + require.NoError(t, db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, WebhookURL: webhookURL, Secret: secret, Identifier: "identifier"}).Error) + return inbox +} + +func testWebhookSignature(body []byte, secret, timestamp string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} diff --git a/backend/internal/worker/worker.go b/backend/internal/worker/worker.go index 45dcd189..f2666fec 100644 --- a/backend/internal/worker/worker.go +++ b/backend/internal/worker/worker.go @@ -20,6 +20,18 @@ import ( var ErrWorkerDatabaseRequired = errors.New("worker database is required") +type permanentError struct{ err error } + +func (e *permanentError) Error() string { return e.err.Error() } +func (e *permanentError) Unwrap() error { return e.err } + +func Permanent(err error) error { + if err == nil { + return nil + } + return &permanentError{err: err} +} + // JobHandler performs one durable background job. type JobHandler func(context.Context, *model.BackgroundJob) error @@ -233,13 +245,33 @@ func (wp *WorkerPool) Enqueue(ctx context.Context, jobType string, payload any, if wp.db == nil { return nil, ErrWorkerDatabaseRequired } + job, created, err := wp.persistJob(ctx, wp.db, jobType, payload, opts...) + if err != nil { + return nil, err + } + if created { + wp.Publish(ctx, job) + } + return job, nil +} + +// EnqueueInTransaction persists a job on the caller's transaction. Call +// Publish only after the transaction commits successfully. +func (wp *WorkerPool) EnqueueInTransaction(ctx context.Context, tx *gorm.DB, jobType string, payload any, opts ...EnqueueOption) (*model.BackgroundJob, bool, error) { + if tx == nil { + return nil, false, ErrWorkerDatabaseRequired + } + return wp.persistJob(ctx, tx, jobType, payload, opts...) +} + +func (wp *WorkerPool) persistJob(ctx context.Context, db *gorm.DB, jobType string, payload any, opts ...EnqueueOption) (*model.BackgroundJob, bool, error) { if jobType == "" { - return nil, errors.New("job type is required") + return nil, false, errors.New("job type is required") } payloadBytes, err := marshalPayload(payload) if err != nil { - return nil, err + return nil, false, err } job := &model.BackgroundJob{ @@ -262,25 +294,31 @@ func (wp *WorkerPool) Enqueue(ctx context.Context, jobType string, payload any, if job.IdempotencyKey != "" { var existing model.BackgroundJob - err := wp.db.WithContext(ctx).Where("idempotency_key = ?", job.IdempotencyKey).First(&existing).Error + err := db.WithContext(ctx).Where("idempotency_key = ?", job.IdempotencyKey).First(&existing).Error if err == nil { - return &existing, nil + return &existing, false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err + return nil, false, err } } - if err := wp.db.WithContext(ctx).Create(job).Error; err != nil { + if err := db.WithContext(ctx).Create(job).Error; err != nil { if job.IdempotencyKey != "" { var existing model.BackgroundJob - if findErr := wp.db.WithContext(ctx).Where("idempotency_key = ?", job.IdempotencyKey).First(&existing).Error; findErr == nil { - return &existing, nil + if findErr := db.WithContext(ctx).Where("idempotency_key = ?", job.IdempotencyKey).First(&existing).Error; findErr == nil { + return &existing, false, nil } } - return nil, err + return nil, false, err } + return job, true, nil +} +func (wp *WorkerPool) Publish(ctx context.Context, job *model.BackgroundJob) { + if job == nil { + return + } // Push to Redis Stream for immediate dispatch if the job is due. // Scheduled (future) jobs are picked up by the sweep goroutine when they mature. if wp.rdb != nil && !job.ScheduledAt.After(wp.now()) { @@ -293,7 +331,6 @@ func (wp *WorkerPool) Enqueue(ctx context.Context, jobType string, payload any, ) } } - return job, nil } func (wp *WorkerPool) Start() error { @@ -676,7 +713,8 @@ func (wp *WorkerPool) fail(ctx context.Context, job *model.BackgroundJob, err er "locked_by": "", "last_error": err.Error(), } - if job.Attempts >= job.MaxAttempts { + var permanent *permanentError + if job.Attempts >= job.MaxAttempts || errors.As(err, &permanent) { updates["status"] = model.BackgroundJobStatusDead updates["failed_at"] = &now } else { diff --git a/backend/internal/worker/worker_test.go b/backend/internal/worker/worker_test.go index e1f14e68..5765e1af 100644 --- a/backend/internal/worker/worker_test.go +++ b/backend/internal/worker/worker_test.go @@ -136,6 +136,26 @@ func TestWorkerPoolRetriesThenDeadLettersFailures(t *testing.T) { } } +func TestWorkerPoolPermanentErrorDeadLettersImmediately(t *testing.T) { + db := newWorkerTestDB(t) + wp := NewWorkerPoolWithOptions(db, WithBackoff(func(attempt int) time.Duration { return 0 })) + wp.Register("permanent", func(context.Context, *model.BackgroundJob) error { + return Permanent(errors.New("invalid webhook payload")) + }) + job, err := wp.Enqueue(context.Background(), "permanent", nil, WithMaxAttempts(10)) + if err != nil { + t.Fatal(err) + } + processed, err := wp.ProcessOne(context.Background()) + if !processed || err == nil { + t.Fatalf("processed=%v err=%v", processed, err) + } + reloaded := loadJob(t, db, job.ID) + if reloaded.Status != model.BackgroundJobStatusDead || reloaded.Attempts != 1 { + t.Fatalf("expected immediate dead letter, got %+v", reloaded) + } +} + func TestWorkerPoolRespectsScheduleAndQueues(t *testing.T) { db := newWorkerTestDB(t) now := time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC) diff --git a/backend/migrations/000060_add_shangwutong_channel.down.sql b/backend/migrations/000060_add_shangwutong_channel.down.sql new file mode 100644 index 00000000..4a328b37 --- /dev/null +++ b/backend/migrations/000060_add_shangwutong_channel.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_channel_shangwutong_config_version; +DROP INDEX IF EXISTS idx_channel_shangwutong_deleted_at; +DROP INDEX IF EXISTS uq_channel_shangwutong_active_identity; +DROP INDEX IF EXISTS idx_messages_swt_source; +ALTER TABLE messages DROP COLUMN IF EXISTS external_request_hash; +DROP TABLE IF EXISTS channel_shangwutong_configs; diff --git a/backend/migrations/000060_add_shangwutong_channel.up.sql b/backend/migrations/000060_add_shangwutong_channel.up.sql new file mode 100644 index 00000000..9aab1fee --- /dev/null +++ b/backend/migrations/000060_add_shangwutong_channel.up.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS channel_shangwutong_configs ( + inbox_id BIGINT PRIMARY KEY REFERENCES inboxes(id) ON DELETE CASCADE, + session_id VARCHAR(64) NOT NULL, + username VARCHAR(255) NOT NULL, + password TEXT NOT NULL, + desired_presence VARCHAR(20) NOT NULL DEFAULT 'online', + config_version BIGINT NOT NULL DEFAULT 1, + actual_presence VARCHAR(20) NOT NULL DEFAULT 'offline', + connection_status VARCHAR(40) NOT NULL DEFAULT 'pending', + credential_status VARCHAR(40) NOT NULL DEFAULT 'pending', + last_heartbeat_at TIMESTAMPTZ, + last_error_code VARCHAR(255), + status_updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_channel_shangwutong_desired_presence CHECK (desired_presence IN ('online', 'busy', 'away', 'offline')), + CONSTRAINT ck_channel_shangwutong_actual_presence CHECK (actual_presence IN ('online', 'busy', 'away', 'offline')), + CONSTRAINT ck_channel_shangwutong_connection_status CHECK (connection_status IN ('pending', 'logging_in', 'connected', 'degraded', 'relogin_required', 'verification_required', 'auth_failed', 'disabled', 'offline')), + CONSTRAINT ck_channel_shangwutong_credential_status CHECK (credential_status IN ('pending', 'verifying', 'applied', 'rejected', 'verification_required')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_shangwutong_active_identity + ON channel_shangwutong_configs(session_id, username) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_channel_shangwutong_deleted_at + ON channel_shangwutong_configs(deleted_at); + +CREATE INDEX IF NOT EXISTS idx_channel_shangwutong_config_version + ON channel_shangwutong_configs(config_version, inbox_id); + +ALTER TABLE messages + ADD COLUMN IF NOT EXISTS external_request_hash VARCHAR(64); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_swt_source + ON messages(inbox_id, source_id) + WHERE source_id LIKE 'swt:%'; diff --git a/channels/shangwutong/Dockerfile b/channels/shangwutong/Dockerfile new file mode 100644 index 00000000..d6aa97d5 --- /dev/null +++ b/channels/shangwutong/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.26.4-alpine AS builder + +WORKDIR /src/channels/shangwutong +RUN apk add --no-cache ca-certificates git + +COPY channels/shangwutong/go.mod channels/shangwutong/go.sum ./ +RUN go mod download + +COPY channels/shangwutong/ ./ +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/shangwutong ./cmd/shangwutong + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S -g 10001 connector \ + && adduser -S -D -H -u 10001 -G connector connector \ + && mkdir -p /data /backup \ + && chown connector:connector /data /backup \ + && chmod 0700 /data /backup + +COPY --from=builder /out/shangwutong /usr/local/bin/shangwutong + +USER connector:connector +VOLUME ["/data", "/backup"] +EXPOSE 9100 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -T 3 -O /dev/null http://127.0.0.1:9100/healthz || exit 1 + +ENTRYPOINT ["/usr/local/bin/shangwutong"] +CMD ["serve"] diff --git a/channels/shangwutong/README.md b/channels/shangwutong/README.md index 11037796..c26a0fb5 100644 --- a/channels/shangwutong/README.md +++ b/channels/shangwutong/README.md @@ -1,10 +1,101 @@ -# 商务通渠道(预留) +# GoChat 商务通 Connector -此目录预留给商务通渠道对接。当前未实现。 +该服务把多个商务通客服账号接入 GoChat 的 `Channel::Shangwutong` 收件箱。一个 Connector 进程统一管理全部账号,每个 Inbox 对应一个独立 supervisor;账号、session、cursor、入站队列和出站队列持久化在同一个 SQLite 文件中。 -商务通是常用的在线客服 SaaS 平台,对接需要: -- 客服上下线状态同步 -- 客户对话消息桥接 -- 正在输入/停止输入状态传递 +GoChat 商务通 Inbox 是唯一配置入口。Connector 不提供账号 CRUD 后台,也不接受启动参数中的账号密码。Inbox 创建或更新后,GoChat 发送签名 lifecycle webhook,Connector 再使用 account-scoped service token 拉取收件箱级配置。 -GoChat 后端的商务通 provider 实现将位于 `backend/internal/channel/provider/shangwutong.go`。 +完整协议、Schema、消息映射和验收边界见 [开发计划](../../docs/plans/2026-07-31-shangwutong-connector-development-plan.md),生产操作见 [运行手册](../../docs/runbooks/shangwutong-connector.md)。 + +## 本地构建与测试 + +需要 Go 1.26.x。所有命令从本目录执行: + +```bash +go tool sqlc generate +go test ./... +go vet ./... +go build -o /tmp/shangwutong-build-check ./cmd/shangwutong + +# 500 账号等价协议长时间验证(非常规 CI) +SWT_SOAK_DURATION=1h go test ./internal/account \ + -run TestManagerMaintainsOneSupervisorPer500Accounts -count=1 -timeout 70m +``` + +sqlc 生成代码提交在 `db/generated/`。修改 `db/queries/*.sql` 或 migration 后必须重新生成并确认工作区没有生成漂移。长时间测试使用 fake SWT 协议客户端验证 supervisor、心跳调度、SQLite 和退出清理;不代替真实商务通的单 IP/域名限流证据。 + +## 启动配置 + +```text +SWT_CONNECTOR_LISTEN=:9100 +SWT_CONNECTOR_DB_PATH=/data/connector.db +GOCHAT_BASE_URL=http://gochat:3000 +GOCHAT_CONNECTOR_SERVICE_TOKEN= +SWT_MAX_INFLIGHT_HEARTBEATS=64 +SWT_INBOUND_WORKERS=8 +SWT_OUTBOUND_WORKERS=8 +SWT_SHUTDOWN_TIMEOUT=30s +``` + +只有前四项中的数据库路径、GoChat 地址和 service token 必填,监听地址有默认值。`GOCHAT_CONNECTOR_SERVICE_TOKEN` 只授予要接入的 GoChat Account,并仅允许 Connector 路由。不要增加 `GOCHAT_SWT_*` 全局配置;账号登录字段、presence 和 webhook URL 都属于 Inbox 配置。 + +SQLite 父目录必须已存在并且对运行用户可写。Connector 会把数据库、WAL/SHM 和在线备份权限收紧为 `0600`,镜像内 `/data` 和 `/backup` 为 `0700`;宿主机挂载目录仍需由部署层限制为 Connector 运行用户可访问。密码按内部系统约定明文保存在 GoChat 专用配置表和 Connector SQLite 中。 + +```bash +export SWT_CONNECTOR_DB_PATH="$PWD/tmp/connector.db" +export GOCHAT_BASE_URL="http://127.0.0.1:3000" +export GOCHAT_CONNECTOR_SERVICE_TOKEN="..." +mkdir -p "$(dirname "$SWT_CONNECTOR_DB_PATH")" +go run ./cmd/shangwutong serve +``` + +## 容器 + +镜像构建上下文必须是仓库根目录: + +```bash +docker build -f channels/shangwutong/Dockerfile -t gochat/shangwutong:dev . +``` + +生产 Compose 已包含 `shangwutong` 服务、SQLite 数据卷和独立 `/backup` 备份卷。Quickstart 中该服务位于 `shangwutong` profile,避免未配置 service token 时影响普通 GoChat 启动: + +```bash +cd deploy/quickstart +GOCHAT_CONNECTOR_SERVICE_TOKEN='...' docker compose --profile shangwutong up -d --build +``` + +在 GoChat 创建 Inbox 时 webhook URL 填写容器网络地址: + +```text +http://shangwutong:9100/webhooks/gochat/v1 +``` + +## 运维端点与命令 + +```text +GET /healthz +GET /readyz +GET /metrics +POST /internal/reconcile # 仅 loopback +``` + +```bash +shangwutong migrate up +shangwutong migrate status +shangwutong reconcile +shangwutong backup --output /backup/connector-$(date +%F).db +shangwutong doctor +``` + +`healthz` 只表示进程存活;`readyz` 使用轻量 SQLite quick check 并检查写锁。`doctor` 检查配置、migration、完整的 SQLite integrity check 和 GoChat 配置 API,但不会登录商务通。`backup` 使用 SQLite `VACUUM INTO`,不要直接复制活动中的 DB/WAL 文件。 + +## 可靠性边界 + +- GoChat webhook 的 2xx 只表示 Connector 已持久化,不表示商务通已发送。 +- 商务通明确成功后回写 `sent`;永久失败回写 `failed`;请求已经写出但结果未知时回写 `uncertain`。 +- uncertain 默认观察 5 分钟。同账号后续发送在此期间被顺序屏障阻塞;超时转 failed 后释放。晚到且唯一匹配的 kind=3 回显仍可纠正为 sent。 +- `oc/send.aspx` 不返回消息 ID。kind=2/3 心跳中的原始 `seq_id` 才是商务通消息 ID;组合 source ID 只用于幂等,不能冒充外部消息 ID。 +- kind=52 没有真实版本 fixture 前保留 raw-only,不伪造 child 消息 ID。 +- 启动时先从 SQLite 恢复 supervisor,再异步拉取 GoChat 全量配置;首次完整快照失败会从 1 秒指数退避到 5 分钟持续重试,不依赖 GoChat health 才启动进程。 +- SIGTERM 会先停止 readiness 和 HTTP 接收,再取消 worker、等待 supervisor、checkpoint WAL;升级不会主动批量 logout。 + +自动化测试通过不等于真实商务通生产可用。本地 500 账号一小时 fake protocol soak 只证明调度、SQLite 和事件持久化;登录、收发、presence、密码更新、负 kind cursor、kind=52、真实媒体 URL、验证码流程和真实商务通限流必须按运行手册留存证据。 diff --git a/channels/shangwutong/cmd/shangwutong/main.go b/channels/shangwutong/cmd/shangwutong/main.go new file mode 100644 index 00000000..dcd6c756 --- /dev/null +++ b/channels/shangwutong/cmd/shangwutong/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/gochat/gochat/channels/shangwutong/internal/command" +) + +func main() { + if err := command.NewRootCommand().Execute(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/channels/shangwutong/db/generated/accounts.sql.go b/channels/shangwutong/db/generated/accounts.sql.go new file mode 100644 index 00000000..a0ac8eee --- /dev/null +++ b/channels/shangwutong/db/generated/accounts.sql.go @@ -0,0 +1,666 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: accounts.sql + +package dbgen + +import ( + "context" +) + +const applyAccountSession = `-- name: ApplyAccountSession :one +UPDATE accounts SET + password = COALESCE(pending_password, password), + pending_password = NULL, + credential_state = 'applied', + applied_config_version = config_version, + config_sync_status = 'applied', + base_url = ?, + site_id = ?, + login_name = ?, + ma_token = ?, + actual_presence = ?, + connection_status = 'connected', + failure_count = 0, + last_error_code = NULL, + last_error_message = NULL, + last_login_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING id, gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, config_version, applied_config_version, config_sync_status, session_id, username, password, pending_password, credential_state, enabled, desired_presence, actual_presence, connection_status, base_url, site_id, login_name, ma_token, maxwordid, maxotick, maxtmpid, gochat_hmac_token, gochat_webhook_secret, failure_count, last_error_code, last_error_message, last_login_at, last_heartbeat_at, last_config_sync_at, deleted_at, created_at, updated_at +` + +type ApplyAccountSessionParams struct { + BaseUrl *string `json:"base_url"` + SiteID *string `json:"site_id"` + LoginName *string `json:"login_name"` + MaToken *string `json:"ma_token"` + ActualPresence string `json:"actual_presence"` + ID int64 `json:"id"` +} + +func (q *Queries) ApplyAccountSession(ctx context.Context, arg ApplyAccountSessionParams) (*Account, error) { + row := q.db.QueryRowContext(ctx, applyAccountSession, + arg.BaseUrl, + arg.SiteID, + arg.LoginName, + arg.MaToken, + arg.ActualPresence, + arg.ID, + ) + var i Account + err := row.Scan( + &i.ID, + &i.GochatAccountID, + &i.GochatInboxID, + &i.GochatInboxIdentifier, + &i.ConfigVersion, + &i.AppliedConfigVersion, + &i.ConfigSyncStatus, + &i.SessionID, + &i.Username, + &i.Password, + &i.PendingPassword, + &i.CredentialState, + &i.Enabled, + &i.DesiredPresence, + &i.ActualPresence, + &i.ConnectionStatus, + &i.BaseUrl, + &i.SiteID, + &i.LoginName, + &i.MaToken, + &i.Maxwordid, + &i.Maxotick, + &i.Maxtmpid, + &i.GochatHmacToken, + &i.GochatWebhookSecret, + &i.FailureCount, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.LastLoginAt, + &i.LastHeartbeatAt, + &i.LastConfigSyncAt, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const clearAccountSession = `-- name: ClearAccountSession :exec +UPDATE accounts SET + ma_token = NULL, + actual_presence = 'offline', + connection_status = 'relogin_required', + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +func (q *Queries) ClearAccountSession(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, clearAccountSession, id) + return err +} + +const createAccount = `-- name: CreateAccount :one +INSERT INTO accounts ( + gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, + config_version, session_id, username, password, enabled, + desired_presence, gochat_hmac_token, gochat_webhook_secret, + last_config_sync_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +RETURNING id, gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, config_version, applied_config_version, config_sync_status, session_id, username, password, pending_password, credential_state, enabled, desired_presence, actual_presence, connection_status, base_url, site_id, login_name, ma_token, maxwordid, maxotick, maxtmpid, gochat_hmac_token, gochat_webhook_secret, failure_count, last_error_code, last_error_message, last_login_at, last_heartbeat_at, last_config_sync_at, deleted_at, created_at, updated_at +` + +type CreateAccountParams struct { + GochatAccountID int64 `json:"gochat_account_id"` + GochatInboxID int64 `json:"gochat_inbox_id"` + GochatInboxIdentifier string `json:"gochat_inbox_identifier"` + ConfigVersion int64 `json:"config_version"` + SessionID string `json:"session_id"` + Username string `json:"username"` + Password string `json:"password"` + Enabled int64 `json:"enabled"` + DesiredPresence string `json:"desired_presence"` + GochatHmacToken string `json:"gochat_hmac_token"` + GochatWebhookSecret string `json:"gochat_webhook_secret"` +} + +func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (*Account, error) { + row := q.db.QueryRowContext(ctx, createAccount, + arg.GochatAccountID, + arg.GochatInboxID, + arg.GochatInboxIdentifier, + arg.ConfigVersion, + arg.SessionID, + arg.Username, + arg.Password, + arg.Enabled, + arg.DesiredPresence, + arg.GochatHmacToken, + arg.GochatWebhookSecret, + ) + var i Account + err := row.Scan( + &i.ID, + &i.GochatAccountID, + &i.GochatInboxID, + &i.GochatInboxIdentifier, + &i.ConfigVersion, + &i.AppliedConfigVersion, + &i.ConfigSyncStatus, + &i.SessionID, + &i.Username, + &i.Password, + &i.PendingPassword, + &i.CredentialState, + &i.Enabled, + &i.DesiredPresence, + &i.ActualPresence, + &i.ConnectionStatus, + &i.BaseUrl, + &i.SiteID, + &i.LoginName, + &i.MaToken, + &i.Maxwordid, + &i.Maxotick, + &i.Maxtmpid, + &i.GochatHmacToken, + &i.GochatWebhookSecret, + &i.FailureCount, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.LastLoginAt, + &i.LastHeartbeatAt, + &i.LastConfigSyncAt, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const getAccountByID = `-- name: GetAccountByID :one +SELECT id, gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, config_version, applied_config_version, config_sync_status, session_id, username, password, pending_password, credential_state, enabled, desired_presence, actual_presence, connection_status, base_url, site_id, login_name, ma_token, maxwordid, maxotick, maxtmpid, gochat_hmac_token, gochat_webhook_secret, failure_count, last_error_code, last_error_message, last_login_at, last_heartbeat_at, last_config_sync_at, deleted_at, created_at, updated_at FROM accounts WHERE id = ? LIMIT 1 +` + +func (q *Queries) GetAccountByID(ctx context.Context, id int64) (*Account, error) { + row := q.db.QueryRowContext(ctx, getAccountByID, id) + var i Account + err := row.Scan( + &i.ID, + &i.GochatAccountID, + &i.GochatInboxID, + &i.GochatInboxIdentifier, + &i.ConfigVersion, + &i.AppliedConfigVersion, + &i.ConfigSyncStatus, + &i.SessionID, + &i.Username, + &i.Password, + &i.PendingPassword, + &i.CredentialState, + &i.Enabled, + &i.DesiredPresence, + &i.ActualPresence, + &i.ConnectionStatus, + &i.BaseUrl, + &i.SiteID, + &i.LoginName, + &i.MaToken, + &i.Maxwordid, + &i.Maxotick, + &i.Maxtmpid, + &i.GochatHmacToken, + &i.GochatWebhookSecret, + &i.FailureCount, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.LastLoginAt, + &i.LastHeartbeatAt, + &i.LastConfigSyncAt, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const getAccountByInboxID = `-- name: GetAccountByInboxID :one +SELECT id, gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, config_version, applied_config_version, config_sync_status, session_id, username, password, pending_password, credential_state, enabled, desired_presence, actual_presence, connection_status, base_url, site_id, login_name, ma_token, maxwordid, maxotick, maxtmpid, gochat_hmac_token, gochat_webhook_secret, failure_count, last_error_code, last_error_message, last_login_at, last_heartbeat_at, last_config_sync_at, deleted_at, created_at, updated_at FROM accounts WHERE gochat_inbox_id = ? LIMIT 1 +` + +func (q *Queries) GetAccountByInboxID(ctx context.Context, gochatInboxID int64) (*Account, error) { + row := q.db.QueryRowContext(ctx, getAccountByInboxID, gochatInboxID) + var i Account + err := row.Scan( + &i.ID, + &i.GochatAccountID, + &i.GochatInboxID, + &i.GochatInboxIdentifier, + &i.ConfigVersion, + &i.AppliedConfigVersion, + &i.ConfigSyncStatus, + &i.SessionID, + &i.Username, + &i.Password, + &i.PendingPassword, + &i.CredentialState, + &i.Enabled, + &i.DesiredPresence, + &i.ActualPresence, + &i.ConnectionStatus, + &i.BaseUrl, + &i.SiteID, + &i.LoginName, + &i.MaToken, + &i.Maxwordid, + &i.Maxotick, + &i.Maxtmpid, + &i.GochatHmacToken, + &i.GochatWebhookSecret, + &i.FailureCount, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.LastLoginAt, + &i.LastHeartbeatAt, + &i.LastConfigSyncAt, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const listAccounts = `-- name: ListAccounts :many +SELECT id, gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, config_version, applied_config_version, config_sync_status, session_id, username, password, pending_password, credential_state, enabled, desired_presence, actual_presence, connection_status, base_url, site_id, login_name, ma_token, maxwordid, maxotick, maxtmpid, gochat_hmac_token, gochat_webhook_secret, failure_count, last_error_code, last_error_message, last_login_at, last_heartbeat_at, last_config_sync_at, deleted_at, created_at, updated_at FROM accounts ORDER BY id +` + +func (q *Queries) ListAccounts(ctx context.Context) ([]*Account, error) { + rows, err := q.db.QueryContext(ctx, listAccounts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Account{} + for rows.Next() { + var i Account + if err := rows.Scan( + &i.ID, + &i.GochatAccountID, + &i.GochatInboxID, + &i.GochatInboxIdentifier, + &i.ConfigVersion, + &i.AppliedConfigVersion, + &i.ConfigSyncStatus, + &i.SessionID, + &i.Username, + &i.Password, + &i.PendingPassword, + &i.CredentialState, + &i.Enabled, + &i.DesiredPresence, + &i.ActualPresence, + &i.ConnectionStatus, + &i.BaseUrl, + &i.SiteID, + &i.LoginName, + &i.MaToken, + &i.Maxwordid, + &i.Maxotick, + &i.Maxtmpid, + &i.GochatHmacToken, + &i.GochatWebhookSecret, + &i.FailureCount, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.LastLoginAt, + &i.LastHeartbeatAt, + &i.LastConfigSyncAt, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRunnableAccounts = `-- name: ListRunnableAccounts :many +SELECT id, gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, config_version, applied_config_version, config_sync_status, session_id, username, password, pending_password, credential_state, enabled, desired_presence, actual_presence, connection_status, base_url, site_id, login_name, ma_token, maxwordid, maxotick, maxtmpid, gochat_hmac_token, gochat_webhook_secret, failure_count, last_error_code, last_error_message, last_login_at, last_heartbeat_at, last_config_sync_at, deleted_at, created_at, updated_at FROM accounts +WHERE enabled = 1 + AND desired_presence <> 'offline' + AND deleted_at IS NULL +ORDER BY id +` + +func (q *Queries) ListRunnableAccounts(ctx context.Context) ([]*Account, error) { + rows, err := q.db.QueryContext(ctx, listRunnableAccounts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Account{} + for rows.Next() { + var i Account + if err := rows.Scan( + &i.ID, + &i.GochatAccountID, + &i.GochatInboxID, + &i.GochatInboxIdentifier, + &i.ConfigVersion, + &i.AppliedConfigVersion, + &i.ConfigSyncStatus, + &i.SessionID, + &i.Username, + &i.Password, + &i.PendingPassword, + &i.CredentialState, + &i.Enabled, + &i.DesiredPresence, + &i.ActualPresence, + &i.ConnectionStatus, + &i.BaseUrl, + &i.SiteID, + &i.LoginName, + &i.MaToken, + &i.Maxwordid, + &i.Maxotick, + &i.Maxtmpid, + &i.GochatHmacToken, + &i.GochatWebhookSecret, + &i.FailureCount, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.LastLoginAt, + &i.LastHeartbeatAt, + &i.LastConfigSyncAt, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markAccountConfigApplied = `-- name: MarkAccountConfigApplied :exec +UPDATE accounts SET + applied_config_version = config_version, + config_sync_status = 'applied', + credential_state = CASE + WHEN pending_password IS NULL THEN 'applied' + ELSE credential_state + END, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +func (q *Queries) MarkAccountConfigApplied(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, markAccountConfigApplied, id) + return err +} + +const markAccountDeleted = `-- name: MarkAccountDeleted :exec +UPDATE accounts SET + enabled = 0, + desired_presence = 'offline', + actual_presence = 'offline', + connection_status = 'disabled', + config_sync_status = 'deleted', + deleted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE gochat_inbox_id = ? AND deleted_at IS NULL +` + +func (q *Queries) MarkAccountDeleted(ctx context.Context, gochatInboxID int64) error { + _, err := q.db.ExecContext(ctx, markAccountDeleted, gochatInboxID) + return err +} + +const rejectPendingCredential = `-- name: RejectPendingCredential :exec +UPDATE accounts SET + credential_state = ?, + config_sync_status = 'rejected', + connection_status = ?, + failure_count = failure_count + 1, + last_error_code = ?, + last_error_message = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND pending_password IS NOT NULL +` + +type RejectPendingCredentialParams struct { + CredentialState string `json:"credential_state"` + ConnectionStatus string `json:"connection_status"` + LastErrorCode *string `json:"last_error_code"` + LastErrorMessage *string `json:"last_error_message"` + ID int64 `json:"id"` +} + +func (q *Queries) RejectPendingCredential(ctx context.Context, arg RejectPendingCredentialParams) error { + _, err := q.db.ExecContext(ctx, rejectPendingCredential, + arg.CredentialState, + arg.ConnectionStatus, + arg.LastErrorCode, + arg.LastErrorMessage, + arg.ID, + ) + return err +} + +const stopAccountRuntime = `-- name: StopAccountRuntime :exec +UPDATE accounts SET + actual_presence = 'offline', + connection_status = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +type StopAccountRuntimeParams struct { + ConnectionStatus string `json:"connection_status"` + ID int64 `json:"id"` +} + +func (q *Queries) StopAccountRuntime(ctx context.Context, arg StopAccountRuntimeParams) error { + _, err := q.db.ExecContext(ctx, stopAccountRuntime, arg.ConnectionStatus, arg.ID) + return err +} + +const touchAccountHeartbeat = `-- name: TouchAccountHeartbeat :exec +UPDATE accounts SET + last_heartbeat_at = CURRENT_TIMESTAMP, + connection_status = 'connected', + failure_count = 0, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +func (q *Queries) TouchAccountHeartbeat(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, touchAccountHeartbeat, id) + return err +} + +const updateAccountConfig = `-- name: UpdateAccountConfig :one +UPDATE accounts SET + gochat_account_id = ?1, + gochat_inbox_identifier = ?2, + config_version = ?3, + pending_password = ?4, + credential_state = ?5, + enabled = ?6, + desired_presence = ?7, + gochat_hmac_token = ?8, + gochat_webhook_secret = ?9, + config_sync_status = 'pending', + last_config_sync_at = CURRENT_TIMESTAMP, + deleted_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE gochat_inbox_id = ?10 + AND config_version < ?3 +RETURNING id, gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, config_version, applied_config_version, config_sync_status, session_id, username, password, pending_password, credential_state, enabled, desired_presence, actual_presence, connection_status, base_url, site_id, login_name, ma_token, maxwordid, maxotick, maxtmpid, gochat_hmac_token, gochat_webhook_secret, failure_count, last_error_code, last_error_message, last_login_at, last_heartbeat_at, last_config_sync_at, deleted_at, created_at, updated_at +` + +type UpdateAccountConfigParams struct { + GochatAccountID int64 `json:"gochat_account_id"` + GochatInboxIdentifier string `json:"gochat_inbox_identifier"` + ConfigVersion int64 `json:"config_version"` + PendingPassword *string `json:"pending_password"` + CredentialState string `json:"credential_state"` + Enabled int64 `json:"enabled"` + DesiredPresence string `json:"desired_presence"` + GochatHmacToken string `json:"gochat_hmac_token"` + GochatWebhookSecret string `json:"gochat_webhook_secret"` + GochatInboxID int64 `json:"gochat_inbox_id"` +} + +func (q *Queries) UpdateAccountConfig(ctx context.Context, arg UpdateAccountConfigParams) (*Account, error) { + row := q.db.QueryRowContext(ctx, updateAccountConfig, + arg.GochatAccountID, + arg.GochatInboxIdentifier, + arg.ConfigVersion, + arg.PendingPassword, + arg.CredentialState, + arg.Enabled, + arg.DesiredPresence, + arg.GochatHmacToken, + arg.GochatWebhookSecret, + arg.GochatInboxID, + ) + var i Account + err := row.Scan( + &i.ID, + &i.GochatAccountID, + &i.GochatInboxID, + &i.GochatInboxIdentifier, + &i.ConfigVersion, + &i.AppliedConfigVersion, + &i.ConfigSyncStatus, + &i.SessionID, + &i.Username, + &i.Password, + &i.PendingPassword, + &i.CredentialState, + &i.Enabled, + &i.DesiredPresence, + &i.ActualPresence, + &i.ConnectionStatus, + &i.BaseUrl, + &i.SiteID, + &i.LoginName, + &i.MaToken, + &i.Maxwordid, + &i.Maxotick, + &i.Maxtmpid, + &i.GochatHmacToken, + &i.GochatWebhookSecret, + &i.FailureCount, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.LastLoginAt, + &i.LastHeartbeatAt, + &i.LastConfigSyncAt, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const updateAccountCursor = `-- name: UpdateAccountCursor :exec +UPDATE accounts SET + maxwordid = ?, + maxotick = ?, + maxtmpid = ?, + last_heartbeat_at = CURRENT_TIMESTAMP, + connection_status = 'connected', + failure_count = 0, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +type UpdateAccountCursorParams struct { + Maxwordid int64 `json:"maxwordid"` + Maxotick int64 `json:"maxotick"` + Maxtmpid int64 `json:"maxtmpid"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAccountCursor(ctx context.Context, arg UpdateAccountCursorParams) error { + _, err := q.db.ExecContext(ctx, updateAccountCursor, + arg.Maxwordid, + arg.Maxotick, + arg.Maxtmpid, + arg.ID, + ) + return err +} + +const updateAccountPresence = `-- name: UpdateAccountPresence :exec +UPDATE accounts SET + actual_presence = ?, + connection_status = ?, + failure_count = 0, + last_error_code = NULL, + last_error_message = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +type UpdateAccountPresenceParams struct { + ActualPresence string `json:"actual_presence"` + ConnectionStatus string `json:"connection_status"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAccountPresence(ctx context.Context, arg UpdateAccountPresenceParams) error { + _, err := q.db.ExecContext(ctx, updateAccountPresence, arg.ActualPresence, arg.ConnectionStatus, arg.ID) + return err +} + +const updateAccountRuntimeError = `-- name: UpdateAccountRuntimeError :exec +UPDATE accounts SET + connection_status = ?, + credential_state = ?, + failure_count = failure_count + 1, + last_error_code = ?, + last_error_message = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +type UpdateAccountRuntimeErrorParams struct { + ConnectionStatus string `json:"connection_status"` + CredentialState string `json:"credential_state"` + LastErrorCode *string `json:"last_error_code"` + LastErrorMessage *string `json:"last_error_message"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAccountRuntimeError(ctx context.Context, arg UpdateAccountRuntimeErrorParams) error { + _, err := q.db.ExecContext(ctx, updateAccountRuntimeError, + arg.ConnectionStatus, + arg.CredentialState, + arg.LastErrorCode, + arg.LastErrorMessage, + arg.ID, + ) + return err +} diff --git a/channels/shangwutong/db/generated/db.go b/channels/shangwutong/db/generated/db.go new file mode 100644 index 00000000..1d56ad87 --- /dev/null +++ b/channels/shangwutong/db/generated/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package dbgen + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/channels/shangwutong/db/generated/inbound.sql.go b/channels/shangwutong/db/generated/inbound.sql.go new file mode 100644 index 00000000..8d3865b1 --- /dev/null +++ b/channels/shangwutong/db/generated/inbound.sql.go @@ -0,0 +1,441 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: inbound.sql + +package dbgen + +import ( + "context" + "time" +) + +const claimInboundEvent = `-- name: ClaimInboundEvent :one +UPDATE inbound_events SET + delivery_status = 'delivering', + attempts = attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM inbound_events AS candidate + WHERE candidate.delivery_status = 'pending' + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + AND NOT EXISTS ( + SELECT 1 FROM inbound_events AS earlier + WHERE earlier.account_id = candidate.account_id + AND earlier.swt_sid = candidate.swt_sid + AND earlier.id < candidate.id + AND earlier.delivery_status IN ('pending', 'delivering') + ) + ORDER BY candidate.id + LIMIT 1 +) +AND delivery_status = 'pending' +RETURNING id, account_id, swt_sid, seq_id, kind, swt_event_key, event_subtype, op_name, text, swt_timestamp, raw_line, normalized_payload, mapping_strategy, delivery_status, attempts, next_attempt_at, gochat_message_id, last_error, created_at, updated_at +` + +func (q *Queries) ClaimInboundEvent(ctx context.Context) (*InboundEvent, error) { + row := q.db.QueryRowContext(ctx, claimInboundEvent) + var i InboundEvent + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.SeqID, + &i.Kind, + &i.SwtEventKey, + &i.EventSubtype, + &i.OpName, + &i.Text, + &i.SwtTimestamp, + &i.RawLine, + &i.NormalizedPayload, + &i.MappingStrategy, + &i.DeliveryStatus, + &i.Attempts, + &i.NextAttemptAt, + &i.GochatMessageID, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const completeInboundEvent = `-- name: CompleteInboundEvent :exec +UPDATE inbound_events SET + delivery_status = 'delivered', + gochat_message_id = ?, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +type CompleteInboundEventParams struct { + GochatMessageID *int64 `json:"gochat_message_id"` + ID int64 `json:"id"` +} + +func (q *Queries) CompleteInboundEvent(ctx context.Context, arg CompleteInboundEventParams) error { + _, err := q.db.ExecContext(ctx, completeInboundEvent, arg.GochatMessageID, arg.ID) + return err +} + +const completeInboundEventWithMapping = `-- name: CompleteInboundEventWithMapping :exec +UPDATE inbound_events SET + delivery_status = 'delivered', + gochat_message_id = ?1, + event_subtype = ?2, + normalized_payload = ?3, + mapping_strategy = ?4, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?5 AND delivery_status = 'delivering' +` + +type CompleteInboundEventWithMappingParams struct { + GochatMessageID *int64 `json:"gochat_message_id"` + EventSubtype *string `json:"event_subtype"` + NormalizedPayload *string `json:"normalized_payload"` + MappingStrategy *string `json:"mapping_strategy"` + ID int64 `json:"id"` +} + +func (q *Queries) CompleteInboundEventWithMapping(ctx context.Context, arg CompleteInboundEventWithMappingParams) error { + _, err := q.db.ExecContext(ctx, completeInboundEventWithMapping, + arg.GochatMessageID, + arg.EventSubtype, + arg.NormalizedPayload, + arg.MappingStrategy, + arg.ID, + ) + return err +} + +const failInboundEvent = `-- name: FailInboundEvent :exec +UPDATE inbound_events SET + delivery_status = 'failed', + mapping_strategy = ?1, + last_error = ?2, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?3 AND delivery_status = 'delivering' +` + +type FailInboundEventParams struct { + MappingStrategy *string `json:"mapping_strategy"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) FailInboundEvent(ctx context.Context, arg FailInboundEventParams) error { + _, err := q.db.ExecContext(ctx, failInboundEvent, arg.MappingStrategy, arg.LastError, arg.ID) + return err +} + +const getConversationMap = `-- name: GetConversationMap :one +SELECT account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at FROM conversation_maps +WHERE account_id = ? AND swt_sid = ? +LIMIT 1 +` + +type GetConversationMapParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` +} + +func (q *Queries) GetConversationMap(ctx context.Context, arg GetConversationMapParams) (*ConversationMap, error) { + row := q.db.QueryRowContext(ctx, getConversationMap, arg.AccountID, arg.SwtSid) + var i ConversationMap + err := row.Scan( + &i.AccountID, + &i.SwtSid, + &i.GochatContactSourceID, + &i.GochatContactID, + &i.GochatConversationID, + &i.GochatDisplayID, + &i.SwtAssigneeName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const getInboundEventByKey = `-- name: GetInboundEventByKey :one +SELECT id, account_id, swt_sid, seq_id, kind, swt_event_key, event_subtype, op_name, text, swt_timestamp, raw_line, normalized_payload, mapping_strategy, delivery_status, attempts, next_attempt_at, gochat_message_id, last_error, created_at, updated_at FROM inbound_events +WHERE swt_event_key = ? +LIMIT 1 +` + +func (q *Queries) GetInboundEventByKey(ctx context.Context, swtEventKey string) (*InboundEvent, error) { + row := q.db.QueryRowContext(ctx, getInboundEventByKey, swtEventKey) + var i InboundEvent + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.SeqID, + &i.Kind, + &i.SwtEventKey, + &i.EventSubtype, + &i.OpName, + &i.Text, + &i.SwtTimestamp, + &i.RawLine, + &i.NormalizedPayload, + &i.MappingStrategy, + &i.DeliveryStatus, + &i.Attempts, + &i.NextAttemptAt, + &i.GochatMessageID, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const getMessageMapBySWTMessageID = `-- name: GetMessageMapBySWTMessageID :one +SELECT account_id, swt_sid, swt_message_id, swt_seq_id, kind, child_index, direction, gochat_message_id, gochat_source_id, content_fingerprint, retracted_at, created_at, updated_at FROM message_maps +WHERE account_id = ? AND swt_sid = ? AND swt_message_id = ? +LIMIT 1 +` + +type GetMessageMapBySWTMessageIDParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + SwtMessageID *string `json:"swt_message_id"` +} + +func (q *Queries) GetMessageMapBySWTMessageID(ctx context.Context, arg GetMessageMapBySWTMessageIDParams) (*MessageMap, error) { + row := q.db.QueryRowContext(ctx, getMessageMapBySWTMessageID, arg.AccountID, arg.SwtSid, arg.SwtMessageID) + var i MessageMap + err := row.Scan( + &i.AccountID, + &i.SwtSid, + &i.SwtMessageID, + &i.SwtSeqID, + &i.Kind, + &i.ChildIndex, + &i.Direction, + &i.GochatMessageID, + &i.GochatSourceID, + &i.ContentFingerprint, + &i.RetractedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const insertInboundEvent = `-- name: InsertInboundEvent :one +INSERT INTO inbound_events ( + account_id, swt_sid, seq_id, kind, swt_event_key, + event_subtype, op_name, text, swt_timestamp, raw_line, + normalized_payload, mapping_strategy, delivery_status +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(swt_event_key) DO NOTHING +RETURNING id, account_id, swt_sid, seq_id, kind, swt_event_key, event_subtype, op_name, text, swt_timestamp, raw_line, normalized_payload, mapping_strategy, delivery_status, attempts, next_attempt_at, gochat_message_id, last_error, created_at, updated_at +` + +type InsertInboundEventParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + SeqID int64 `json:"seq_id"` + Kind int64 `json:"kind"` + SwtEventKey string `json:"swt_event_key"` + EventSubtype *string `json:"event_subtype"` + OpName *string `json:"op_name"` + Text *string `json:"text"` + SwtTimestamp *string `json:"swt_timestamp"` + RawLine string `json:"raw_line"` + NormalizedPayload *string `json:"normalized_payload"` + MappingStrategy *string `json:"mapping_strategy"` + DeliveryStatus string `json:"delivery_status"` +} + +func (q *Queries) InsertInboundEvent(ctx context.Context, arg InsertInboundEventParams) (*InboundEvent, error) { + row := q.db.QueryRowContext(ctx, insertInboundEvent, + arg.AccountID, + arg.SwtSid, + arg.SeqID, + arg.Kind, + arg.SwtEventKey, + arg.EventSubtype, + arg.OpName, + arg.Text, + arg.SwtTimestamp, + arg.RawLine, + arg.NormalizedPayload, + arg.MappingStrategy, + arg.DeliveryStatus, + ) + var i InboundEvent + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.SeqID, + &i.Kind, + &i.SwtEventKey, + &i.EventSubtype, + &i.OpName, + &i.Text, + &i.SwtTimestamp, + &i.RawLine, + &i.NormalizedPayload, + &i.MappingStrategy, + &i.DeliveryStatus, + &i.Attempts, + &i.NextAttemptAt, + &i.GochatMessageID, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const insertMessageMap = `-- name: InsertMessageMap :exec +INSERT INTO message_maps ( + account_id, swt_sid, swt_message_id, swt_seq_id, kind, child_index, + direction, gochat_message_id, gochat_source_id, content_fingerprint +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(account_id, swt_sid, kind, swt_seq_id, child_index) DO UPDATE SET + swt_message_id = COALESCE(excluded.swt_message_id, message_maps.swt_message_id), + gochat_message_id = excluded.gochat_message_id, + gochat_source_id = COALESCE(excluded.gochat_source_id, message_maps.gochat_source_id), + content_fingerprint = COALESCE(excluded.content_fingerprint, message_maps.content_fingerprint), + updated_at = CURRENT_TIMESTAMP +` + +type InsertMessageMapParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + SwtMessageID *string `json:"swt_message_id"` + SwtSeqID int64 `json:"swt_seq_id"` + Kind int64 `json:"kind"` + ChildIndex int64 `json:"child_index"` + Direction string `json:"direction"` + GochatMessageID int64 `json:"gochat_message_id"` + GochatSourceID *string `json:"gochat_source_id"` + ContentFingerprint *string `json:"content_fingerprint"` +} + +func (q *Queries) InsertMessageMap(ctx context.Context, arg InsertMessageMapParams) error { + _, err := q.db.ExecContext(ctx, insertMessageMap, + arg.AccountID, + arg.SwtSid, + arg.SwtMessageID, + arg.SwtSeqID, + arg.Kind, + arg.ChildIndex, + arg.Direction, + arg.GochatMessageID, + arg.GochatSourceID, + arg.ContentFingerprint, + ) + return err +} + +const markMessageMapRetracted = `-- name: MarkMessageMapRetracted :exec +UPDATE message_maps SET + retracted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? AND swt_sid = ? AND swt_message_id = ? +` + +type MarkMessageMapRetractedParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + SwtMessageID *string `json:"swt_message_id"` +} + +func (q *Queries) MarkMessageMapRetracted(ctx context.Context, arg MarkMessageMapRetractedParams) error { + _, err := q.db.ExecContext(ctx, markMessageMapRetracted, arg.AccountID, arg.SwtSid, arg.SwtMessageID) + return err +} + +const recoverInboundDeliveries = `-- name: RecoverInboundDeliveries :execrows +UPDATE inbound_events SET + delivery_status = 'pending', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'delivering' +` + +func (q *Queries) RecoverInboundDeliveries(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, recoverInboundDeliveries) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const retryInboundEvent = `-- name: RetryInboundEvent :exec +UPDATE inbound_events SET + delivery_status = 'pending', + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +type RetryInboundEventParams struct { + NextAttemptAt *time.Time `json:"next_attempt_at"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) RetryInboundEvent(ctx context.Context, arg RetryInboundEventParams) error { + _, err := q.db.ExecContext(ctx, retryInboundEvent, arg.NextAttemptAt, arg.LastError, arg.ID) + return err +} + +const upsertConversationMap = `-- name: UpsertConversationMap :one +INSERT INTO conversation_maps ( + account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, + gochat_conversation_id, gochat_display_id, swt_assignee_name +) VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(account_id, swt_sid) DO UPDATE SET + gochat_contact_source_id = excluded.gochat_contact_source_id, + gochat_contact_id = COALESCE(excluded.gochat_contact_id, conversation_maps.gochat_contact_id), + gochat_conversation_id = COALESCE(excluded.gochat_conversation_id, conversation_maps.gochat_conversation_id), + gochat_display_id = COALESCE(excluded.gochat_display_id, conversation_maps.gochat_display_id), + swt_assignee_name = COALESCE(excluded.swt_assignee_name, conversation_maps.swt_assignee_name), + updated_at = CURRENT_TIMESTAMP +RETURNING account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, gochat_conversation_id, gochat_display_id, swt_assignee_name, created_at, updated_at +` + +type UpsertConversationMapParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + GochatContactSourceID string `json:"gochat_contact_source_id"` + GochatContactID *int64 `json:"gochat_contact_id"` + GochatConversationID *int64 `json:"gochat_conversation_id"` + GochatDisplayID *int64 `json:"gochat_display_id"` + SwtAssigneeName *string `json:"swt_assignee_name"` +} + +func (q *Queries) UpsertConversationMap(ctx context.Context, arg UpsertConversationMapParams) (*ConversationMap, error) { + row := q.db.QueryRowContext(ctx, upsertConversationMap, + arg.AccountID, + arg.SwtSid, + arg.GochatContactSourceID, + arg.GochatContactID, + arg.GochatConversationID, + arg.GochatDisplayID, + arg.SwtAssigneeName, + ) + var i ConversationMap + err := row.Scan( + &i.AccountID, + &i.SwtSid, + &i.GochatContactSourceID, + &i.GochatContactID, + &i.GochatConversationID, + &i.GochatDisplayID, + &i.SwtAssigneeName, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/channels/shangwutong/db/generated/metrics.sql.go b/channels/shangwutong/db/generated/metrics.sql.go new file mode 100644 index 00000000..b82a4b9b --- /dev/null +++ b/channels/shangwutong/db/generated/metrics.sql.go @@ -0,0 +1,130 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: metrics.sql + +package dbgen + +import ( + "context" +) + +const countStatusSyncQueue = `-- name: CountStatusSyncQueue :one +SELECT COUNT(*) +FROM outbound_messages +WHERE status_sync_status IN ('pending', 'syncing') +` + +func (q *Queries) CountStatusSyncQueue(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countStatusSyncQueue) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listAccountMetricCounts = `-- name: ListAccountMetricCounts :many +SELECT connection_status, actual_presence, COUNT(*) AS count +FROM accounts +WHERE deleted_at IS NULL +GROUP BY connection_status, actual_presence +ORDER BY connection_status, actual_presence +` + +type ListAccountMetricCountsRow struct { + ConnectionStatus string `json:"connection_status"` + ActualPresence string `json:"actual_presence"` + Count int64 `json:"count"` +} + +func (q *Queries) ListAccountMetricCounts(ctx context.Context) ([]*ListAccountMetricCountsRow, error) { + rows, err := q.db.QueryContext(ctx, listAccountMetricCounts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListAccountMetricCountsRow{} + for rows.Next() { + var i ListAccountMetricCountsRow + if err := rows.Scan(&i.ConnectionStatus, &i.ActualPresence, &i.Count); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listInboundQueueMetricCounts = `-- name: ListInboundQueueMetricCounts :many +SELECT delivery_status, COUNT(*) AS count +FROM inbound_events +GROUP BY delivery_status +ORDER BY delivery_status +` + +type ListInboundQueueMetricCountsRow struct { + DeliveryStatus string `json:"delivery_status"` + Count int64 `json:"count"` +} + +func (q *Queries) ListInboundQueueMetricCounts(ctx context.Context) ([]*ListInboundQueueMetricCountsRow, error) { + rows, err := q.db.QueryContext(ctx, listInboundQueueMetricCounts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListInboundQueueMetricCountsRow{} + for rows.Next() { + var i ListInboundQueueMetricCountsRow + if err := rows.Scan(&i.DeliveryStatus, &i.Count); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listOutboundQueueMetricCounts = `-- name: ListOutboundQueueMetricCounts :many +SELECT delivery_status, COUNT(*) AS count +FROM outbound_messages +GROUP BY delivery_status +ORDER BY delivery_status +` + +type ListOutboundQueueMetricCountsRow struct { + DeliveryStatus string `json:"delivery_status"` + Count int64 `json:"count"` +} + +func (q *Queries) ListOutboundQueueMetricCounts(ctx context.Context) ([]*ListOutboundQueueMetricCountsRow, error) { + rows, err := q.db.QueryContext(ctx, listOutboundQueueMetricCounts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListOutboundQueueMetricCountsRow{} + for rows.Next() { + var i ListOutboundQueueMetricCountsRow + if err := rows.Scan(&i.DeliveryStatus, &i.Count); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/channels/shangwutong/db/generated/models.go b/channels/shangwutong/db/generated/models.go new file mode 100644 index 00000000..4e1940a2 --- /dev/null +++ b/channels/shangwutong/db/generated/models.go @@ -0,0 +1,159 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package dbgen + +import ( + "time" +) + +type Account struct { + ID int64 `json:"id"` + GochatAccountID int64 `json:"gochat_account_id"` + GochatInboxID int64 `json:"gochat_inbox_id"` + GochatInboxIdentifier string `json:"gochat_inbox_identifier"` + ConfigVersion int64 `json:"config_version"` + AppliedConfigVersion int64 `json:"applied_config_version"` + ConfigSyncStatus string `json:"config_sync_status"` + SessionID string `json:"session_id"` + Username string `json:"username"` + Password string `json:"password"` + PendingPassword *string `json:"pending_password"` + CredentialState string `json:"credential_state"` + Enabled int64 `json:"enabled"` + DesiredPresence string `json:"desired_presence"` + ActualPresence string `json:"actual_presence"` + ConnectionStatus string `json:"connection_status"` + BaseUrl *string `json:"base_url"` + SiteID *string `json:"site_id"` + LoginName *string `json:"login_name"` + MaToken *string `json:"ma_token"` + Maxwordid int64 `json:"maxwordid"` + Maxotick int64 `json:"maxotick"` + Maxtmpid int64 `json:"maxtmpid"` + GochatHmacToken string `json:"gochat_hmac_token"` + GochatWebhookSecret string `json:"gochat_webhook_secret"` + FailureCount int64 `json:"failure_count"` + LastErrorCode *string `json:"last_error_code"` + LastErrorMessage *string `json:"last_error_message"` + LastLoginAt *time.Time `json:"last_login_at"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at"` + LastConfigSyncAt *time.Time `json:"last_config_sync_at"` + DeletedAt *time.Time `json:"deleted_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ConversationMap struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + GochatContactSourceID string `json:"gochat_contact_source_id"` + GochatContactID *int64 `json:"gochat_contact_id"` + GochatConversationID *int64 `json:"gochat_conversation_id"` + GochatDisplayID *int64 `json:"gochat_display_id"` + SwtAssigneeName *string `json:"swt_assignee_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type InboundEvent struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + SeqID int64 `json:"seq_id"` + Kind int64 `json:"kind"` + SwtEventKey string `json:"swt_event_key"` + EventSubtype *string `json:"event_subtype"` + OpName *string `json:"op_name"` + Text *string `json:"text"` + SwtTimestamp *string `json:"swt_timestamp"` + RawLine string `json:"raw_line"` + NormalizedPayload *string `json:"normalized_payload"` + MappingStrategy *string `json:"mapping_strategy"` + DeliveryStatus string `json:"delivery_status"` + Attempts int64 `json:"attempts"` + NextAttemptAt *time.Time `json:"next_attempt_at"` + GochatMessageID *int64 `json:"gochat_message_id"` + LastError *string `json:"last_error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type MessageMap struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + SwtMessageID *string `json:"swt_message_id"` + SwtSeqID int64 `json:"swt_seq_id"` + Kind int64 `json:"kind"` + ChildIndex int64 `json:"child_index"` + Direction string `json:"direction"` + GochatMessageID int64 `json:"gochat_message_id"` + GochatSourceID *string `json:"gochat_source_id"` + ContentFingerprint *string `json:"content_fingerprint"` + RetractedAt *time.Time `json:"retracted_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OutboundMessage struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + EventID string `json:"event_id"` + OccurredAt time.Time `json:"occurred_at"` + GochatMessageID int64 `json:"gochat_message_id"` + RetryVersion int64 `json:"retry_version"` + MessageType string `json:"message_type"` + Content *string `json:"content"` + Payload string `json:"payload"` + DeliveryStatus string `json:"delivery_status"` + ResultVersion int64 `json:"result_version"` + ExternalID *string `json:"external_id"` + ExternalErrorCode *string `json:"external_error_code"` + ClaimedAt *time.Time `json:"claimed_at"` + Attempts int64 `json:"attempts"` + NextAttemptAt *time.Time `json:"next_attempt_at"` + StatusSyncStatus string `json:"status_sync_status"` + StatusSyncAttempts int64 `json:"status_sync_attempts"` + StatusSyncNextAt *time.Time `json:"status_sync_next_at"` + StatusReportedAt *time.Time `json:"status_reported_at"` + LastError *string `json:"last_error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OutboundOperation struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + EventID string `json:"event_id"` + Operation string `json:"operation"` + Payload string `json:"payload"` + OccurredAt time.Time `json:"occurred_at"` + DeliveryStatus string `json:"delivery_status"` + ClaimedAt *time.Time `json:"claimed_at"` + Attempts int64 `json:"attempts"` + NextAttemptAt *time.Time `json:"next_attempt_at"` + LastError *string `json:"last_error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OutboundPart struct { + ID int64 `json:"id"` + OutboundMessageID int64 `json:"outbound_message_id"` + PartIndex int64 `json:"part_index"` + PartType string `json:"part_type"` + AttachmentID *int64 `json:"attachment_id"` + Content *string `json:"content"` + DataUrl *string `json:"data_url"` + FileName *string `json:"file_name"` + FileSize *int64 `json:"file_size"` + Voice int64 `json:"voice"` + DeliveryStatus string `json:"delivery_status"` + ExternalID *string `json:"external_id"` + LastError *string `json:"last_error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/channels/shangwutong/db/generated/operations.sql.go b/channels/shangwutong/db/generated/operations.sql.go new file mode 100644 index 00000000..b0dfbcc6 --- /dev/null +++ b/channels/shangwutong/db/generated/operations.sql.go @@ -0,0 +1,257 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: operations.sql + +package dbgen + +import ( + "context" + "time" +) + +const claimOutboundOperation = `-- name: ClaimOutboundOperation :one +UPDATE outbound_operations SET + delivery_status = 'delivering', + claimed_at = CURRENT_TIMESTAMP, + attempts = attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM outbound_operations AS candidate + JOIN accounts AS account ON account.id = candidate.account_id + WHERE candidate.delivery_status = 'pending' + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + AND NOT EXISTS ( + SELECT 1 FROM outbound_operations AS earlier + WHERE earlier.account_id = candidate.account_id + AND earlier.id < candidate.id + AND earlier.delivery_status IN ('pending', 'delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS active_message + WHERE active_message.account_id = candidate.account_id + AND active_message.delivery_status IN ('delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS earlier_message + WHERE earlier_message.account_id = candidate.account_id + AND earlier_message.delivery_status = 'pending' + AND ( + julianday(earlier_message.occurred_at) < julianday(candidate.occurred_at) OR + (julianday(earlier_message.occurred_at) = julianday(candidate.occurred_at) AND earlier_message.event_id < candidate.event_id) + ) + ) + ORDER BY candidate.id + LIMIT 1 +) +AND delivery_status = 'pending' +RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, created_at, updated_at +` + +func (q *Queries) ClaimOutboundOperation(ctx context.Context) (*OutboundOperation, error) { + row := q.db.QueryRowContext(ctx, claimOutboundOperation) + var i OutboundOperation + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.Operation, + &i.Payload, + &i.OccurredAt, + &i.DeliveryStatus, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const completeOutboundOperation = `-- name: CompleteOutboundOperation :exec +UPDATE outbound_operations SET + delivery_status = 'delivered', + claimed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +func (q *Queries) CompleteOutboundOperation(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, completeOutboundOperation, id) + return err +} + +const failExpiredUncertainOperations = `-- name: FailExpiredUncertainOperations :execrows +UPDATE outbound_operations SET + delivery_status = 'failed', + claimed_at = NULL, + last_error = 'operation result remained uncertain beyond the observation window', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'uncertain' + AND julianday(updated_at) <= julianday(?) +` + +func (q *Queries) FailExpiredUncertainOperations(ctx context.Context, julianday interface{}) (int64, error) { + result, err := q.db.ExecContext(ctx, failExpiredUncertainOperations, julianday) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const failOutboundOperation = `-- name: FailOutboundOperation :exec +UPDATE outbound_operations SET + delivery_status = 'failed', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status IN ('delivering', 'uncertain') +` + +type FailOutboundOperationParams struct { + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) FailOutboundOperation(ctx context.Context, arg FailOutboundOperationParams) error { + _, err := q.db.ExecContext(ctx, failOutboundOperation, arg.LastError, arg.ID) + return err +} + +const getOutboundOperationByEventID = `-- name: GetOutboundOperationByEventID :one +SELECT id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, created_at, updated_at FROM outbound_operations +WHERE event_id = ? +LIMIT 1 +` + +func (q *Queries) GetOutboundOperationByEventID(ctx context.Context, eventID string) (*OutboundOperation, error) { + row := q.db.QueryRowContext(ctx, getOutboundOperationByEventID, eventID) + var i OutboundOperation + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.Operation, + &i.Payload, + &i.OccurredAt, + &i.DeliveryStatus, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const insertOutboundOperation = `-- name: InsertOutboundOperation :one +INSERT INTO outbound_operations ( + account_id, swt_sid, event_id, operation, payload, occurred_at +) VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT(event_id) DO NOTHING +RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, created_at, updated_at +` + +type InsertOutboundOperationParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + EventID string `json:"event_id"` + Operation string `json:"operation"` + Payload string `json:"payload"` + OccurredAt time.Time `json:"occurred_at"` +} + +func (q *Queries) InsertOutboundOperation(ctx context.Context, arg InsertOutboundOperationParams) (*OutboundOperation, error) { + row := q.db.QueryRowContext(ctx, insertOutboundOperation, + arg.AccountID, + arg.SwtSid, + arg.EventID, + arg.Operation, + arg.Payload, + arg.OccurredAt, + ) + var i OutboundOperation + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.Operation, + &i.Payload, + &i.OccurredAt, + &i.DeliveryStatus, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const markOutboundOperationUncertain = `-- name: MarkOutboundOperationUncertain :exec +UPDATE outbound_operations SET + delivery_status = 'uncertain', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +type MarkOutboundOperationUncertainParams struct { + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) MarkOutboundOperationUncertain(ctx context.Context, arg MarkOutboundOperationUncertainParams) error { + _, err := q.db.ExecContext(ctx, markOutboundOperationUncertain, arg.LastError, arg.ID) + return err +} + +const recoverOutboundOperationsAsUncertain = `-- name: RecoverOutboundOperationsAsUncertain :execrows +UPDATE outbound_operations SET + delivery_status = 'uncertain', + claimed_at = NULL, + last_error = 'connector restarted while operation was delivering', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'delivering' +` + +func (q *Queries) RecoverOutboundOperationsAsUncertain(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, recoverOutboundOperationsAsUncertain) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const retryOutboundOperation = `-- name: RetryOutboundOperation :exec +UPDATE outbound_operations SET + delivery_status = 'pending', + claimed_at = NULL, + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +type RetryOutboundOperationParams struct { + NextAttemptAt *time.Time `json:"next_attempt_at"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) RetryOutboundOperation(ctx context.Context, arg RetryOutboundOperationParams) error { + _, err := q.db.ExecContext(ctx, retryOutboundOperation, arg.NextAttemptAt, arg.LastError, arg.ID) + return err +} diff --git a/channels/shangwutong/db/generated/outbound.sql.go b/channels/shangwutong/db/generated/outbound.sql.go new file mode 100644 index 00000000..f4bf11e8 --- /dev/null +++ b/channels/shangwutong/db/generated/outbound.sql.go @@ -0,0 +1,793 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: outbound.sql + +package dbgen + +import ( + "context" + "time" +) + +const claimOutboundMessage = `-- name: ClaimOutboundMessage :one +UPDATE outbound_messages SET + delivery_status = 'delivering', + claimed_at = CURRENT_TIMESTAMP, + attempts = attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM outbound_messages AS candidate + JOIN accounts AS account ON account.id = candidate.account_id + WHERE candidate.delivery_status = 'pending' + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS earlier + WHERE earlier.account_id = candidate.account_id + AND earlier.id < candidate.id + AND earlier.delivery_status IN ('pending', 'delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_operations AS operation + WHERE operation.account_id = candidate.account_id + AND operation.delivery_status IN ('delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_operations AS earlier_operation + WHERE earlier_operation.account_id = candidate.account_id + AND earlier_operation.delivery_status = 'pending' + AND ( + julianday(earlier_operation.occurred_at) < julianday(candidate.occurred_at) OR + (julianday(earlier_operation.occurred_at) = julianday(candidate.occurred_at) AND earlier_operation.event_id < candidate.event_id) + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS active + WHERE active.account_id = candidate.account_id + AND active.delivery_status = 'delivering' + ) + ORDER BY candidate.id + LIMIT 1 +) +AND delivery_status = 'pending' +RETURNING id, account_id, swt_sid, event_id, occurred_at, gochat_message_id, retry_version, message_type, content, payload, delivery_status, result_version, external_id, external_error_code, claimed_at, attempts, next_attempt_at, status_sync_status, status_sync_attempts, status_sync_next_at, status_reported_at, last_error, created_at, updated_at +` + +func (q *Queries) ClaimOutboundMessage(ctx context.Context) (*OutboundMessage, error) { + row := q.db.QueryRowContext(ctx, claimOutboundMessage) + var i OutboundMessage + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.OccurredAt, + &i.GochatMessageID, + &i.RetryVersion, + &i.MessageType, + &i.Content, + &i.Payload, + &i.DeliveryStatus, + &i.ResultVersion, + &i.ExternalID, + &i.ExternalErrorCode, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.StatusSyncStatus, + &i.StatusSyncAttempts, + &i.StatusSyncNextAt, + &i.StatusReportedAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const claimStatusSync = `-- name: ClaimStatusSync :one +UPDATE outbound_messages SET + status_sync_status = 'syncing', + status_sync_attempts = status_sync_attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT id FROM outbound_messages + WHERE status_sync_status = 'pending' + AND (status_sync_next_at IS NULL OR julianday(status_sync_next_at) IS NULL OR julianday(status_sync_next_at) <= julianday('now')) + ORDER BY id + LIMIT 1 +) +AND status_sync_status = 'pending' +RETURNING id, account_id, swt_sid, event_id, occurred_at, gochat_message_id, retry_version, message_type, content, payload, delivery_status, result_version, external_id, external_error_code, claimed_at, attempts, next_attempt_at, status_sync_status, status_sync_attempts, status_sync_next_at, status_reported_at, last_error, created_at, updated_at +` + +func (q *Queries) ClaimStatusSync(ctx context.Context) (*OutboundMessage, error) { + row := q.db.QueryRowContext(ctx, claimStatusSync) + var i OutboundMessage + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.OccurredAt, + &i.GochatMessageID, + &i.RetryVersion, + &i.MessageType, + &i.Content, + &i.Payload, + &i.DeliveryStatus, + &i.ResultVersion, + &i.ExternalID, + &i.ExternalErrorCode, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.StatusSyncStatus, + &i.StatusSyncAttempts, + &i.StatusSyncNextAt, + &i.StatusReportedAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const completeOutboundMessage = `-- name: CompleteOutboundMessage :exec +UPDATE outbound_messages SET + delivery_status = 'delivered', + result_version = result_version + 1, + external_id = ?, + external_error_code = NULL, + status_sync_status = 'pending', + claimed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +type CompleteOutboundMessageParams struct { + ExternalID *string `json:"external_id"` + ID int64 `json:"id"` +} + +func (q *Queries) CompleteOutboundMessage(ctx context.Context, arg CompleteOutboundMessageParams) error { + _, err := q.db.ExecContext(ctx, completeOutboundMessage, arg.ExternalID, arg.ID) + return err +} + +const completeOutboundPart = `-- name: CompleteOutboundPart :exec +UPDATE outbound_parts SET + delivery_status = 'delivered', + external_id = ?, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'pending' +` + +type CompleteOutboundPartParams struct { + ExternalID *string `json:"external_id"` + ID int64 `json:"id"` +} + +func (q *Queries) CompleteOutboundPart(ctx context.Context, arg CompleteOutboundPartParams) error { + _, err := q.db.ExecContext(ctx, completeOutboundPart, arg.ExternalID, arg.ID) + return err +} + +const completeStatusSync = `-- name: CompleteStatusSync :exec +UPDATE outbound_messages SET + status_sync_status = 'synced', + status_reported_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status_sync_status = 'syncing' +` + +func (q *Queries) CompleteStatusSync(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, completeStatusSync, id) + return err +} + +const confirmOutboundEcho = `-- name: ConfirmOutboundEcho :execrows +UPDATE outbound_messages SET + delivery_status = CASE + WHEN EXISTS ( + SELECT 1 FROM outbound_parts + WHERE outbound_message_id = outbound_messages.id + AND delivery_status <> 'delivered' + ) THEN 'pending' + ELSE 'delivered' + END, + result_version = result_version + 1, + external_id = ?1, + external_error_code = NULL, + status_sync_status = CASE + WHEN EXISTS ( + SELECT 1 FROM outbound_parts + WHERE outbound_message_id = outbound_messages.id + AND delivery_status <> 'delivered' + ) THEN 'not_required' + ELSE 'pending' + END, + status_sync_next_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE outbound_messages.id = ?2 + AND delivery_status IN ('delivered', 'uncertain', 'failed') + AND (delivery_status <> 'failed' OR external_error_code = 'uncertain_timeout') +` + +type ConfirmOutboundEchoParams struct { + ExternalID *string `json:"external_id"` + ID int64 `json:"id"` +} + +func (q *Queries) ConfirmOutboundEcho(ctx context.Context, arg ConfirmOutboundEchoParams) (int64, error) { + result, err := q.db.ExecContext(ctx, confirmOutboundEcho, arg.ExternalID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const confirmOutboundPartEcho = `-- name: ConfirmOutboundPartEcho :execrows +UPDATE outbound_parts SET + delivery_status = 'delivered', + external_id = ?, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? + AND external_id IS NULL + AND delivery_status IN ('delivered', 'uncertain') +` + +type ConfirmOutboundPartEchoParams struct { + ExternalID *string `json:"external_id"` + ID int64 `json:"id"` +} + +func (q *Queries) ConfirmOutboundPartEcho(ctx context.Context, arg ConfirmOutboundPartEchoParams) (int64, error) { + result, err := q.db.ExecContext(ctx, confirmOutboundPartEcho, arg.ExternalID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const failExpiredUncertainMessages = `-- name: FailExpiredUncertainMessages :execrows +UPDATE outbound_messages SET + delivery_status = 'failed', + result_version = result_version + 1, + external_error_code = 'uncertain_timeout', + status_sync_status = 'pending', + claimed_at = NULL, + last_error = 'no matching Shangwutong echo arrived before the observation window expired', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'uncertain' + AND julianday(updated_at) <= julianday(?) +` + +func (q *Queries) FailExpiredUncertainMessages(ctx context.Context, julianday interface{}) (int64, error) { + result, err := q.db.ExecContext(ctx, failExpiredUncertainMessages, julianday) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const failOutboundMessage = `-- name: FailOutboundMessage :exec +UPDATE outbound_messages SET + delivery_status = 'failed', + result_version = result_version + 1, + external_error_code = ?, + status_sync_status = 'pending', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status IN ('delivering', 'uncertain') +` + +type FailOutboundMessageParams struct { + ExternalErrorCode *string `json:"external_error_code"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) FailOutboundMessage(ctx context.Context, arg FailOutboundMessageParams) error { + _, err := q.db.ExecContext(ctx, failOutboundMessage, arg.ExternalErrorCode, arg.LastError, arg.ID) + return err +} + +const failOutboundPart = `-- name: FailOutboundPart :exec +UPDATE outbound_parts SET + delivery_status = 'failed', + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'pending' +` + +type FailOutboundPartParams struct { + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) FailOutboundPart(ctx context.Context, arg FailOutboundPartParams) error { + _, err := q.db.ExecContext(ctx, failOutboundPart, arg.LastError, arg.ID) + return err +} + +const getOutboundByGoChatMessageID = `-- name: GetOutboundByGoChatMessageID :one +SELECT id, account_id, swt_sid, event_id, occurred_at, gochat_message_id, retry_version, message_type, content, payload, delivery_status, result_version, external_id, external_error_code, claimed_at, attempts, next_attempt_at, status_sync_status, status_sync_attempts, status_sync_next_at, status_reported_at, last_error, created_at, updated_at FROM outbound_messages WHERE gochat_message_id = ? LIMIT 1 +` + +func (q *Queries) GetOutboundByGoChatMessageID(ctx context.Context, gochatMessageID int64) (*OutboundMessage, error) { + row := q.db.QueryRowContext(ctx, getOutboundByGoChatMessageID, gochatMessageID) + var i OutboundMessage + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.OccurredAt, + &i.GochatMessageID, + &i.RetryVersion, + &i.MessageType, + &i.Content, + &i.Payload, + &i.DeliveryStatus, + &i.ResultVersion, + &i.ExternalID, + &i.ExternalErrorCode, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.StatusSyncStatus, + &i.StatusSyncAttempts, + &i.StatusSyncNextAt, + &i.StatusReportedAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const insertOutboundMessage = `-- name: InsertOutboundMessage :one +INSERT INTO outbound_messages ( + account_id, swt_sid, event_id, occurred_at, gochat_message_id, retry_version, + message_type, content, payload +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(gochat_message_id) DO NOTHING +RETURNING id, account_id, swt_sid, event_id, occurred_at, gochat_message_id, retry_version, message_type, content, payload, delivery_status, result_version, external_id, external_error_code, claimed_at, attempts, next_attempt_at, status_sync_status, status_sync_attempts, status_sync_next_at, status_reported_at, last_error, created_at, updated_at +` + +type InsertOutboundMessageParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + EventID string `json:"event_id"` + OccurredAt time.Time `json:"occurred_at"` + GochatMessageID int64 `json:"gochat_message_id"` + RetryVersion int64 `json:"retry_version"` + MessageType string `json:"message_type"` + Content *string `json:"content"` + Payload string `json:"payload"` +} + +func (q *Queries) InsertOutboundMessage(ctx context.Context, arg InsertOutboundMessageParams) (*OutboundMessage, error) { + row := q.db.QueryRowContext(ctx, insertOutboundMessage, + arg.AccountID, + arg.SwtSid, + arg.EventID, + arg.OccurredAt, + arg.GochatMessageID, + arg.RetryVersion, + arg.MessageType, + arg.Content, + arg.Payload, + ) + var i OutboundMessage + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.OccurredAt, + &i.GochatMessageID, + &i.RetryVersion, + &i.MessageType, + &i.Content, + &i.Payload, + &i.DeliveryStatus, + &i.ResultVersion, + &i.ExternalID, + &i.ExternalErrorCode, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.StatusSyncStatus, + &i.StatusSyncAttempts, + &i.StatusSyncNextAt, + &i.StatusReportedAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const insertOutboundPart = `-- name: InsertOutboundPart :one +INSERT INTO outbound_parts ( + outbound_message_id, part_index, part_type, attachment_id, + content, data_url, file_name, file_size, voice +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, outbound_message_id, part_index, part_type, attachment_id, content, data_url, file_name, file_size, voice, delivery_status, external_id, last_error, created_at, updated_at +` + +type InsertOutboundPartParams struct { + OutboundMessageID int64 `json:"outbound_message_id"` + PartIndex int64 `json:"part_index"` + PartType string `json:"part_type"` + AttachmentID *int64 `json:"attachment_id"` + Content *string `json:"content"` + DataUrl *string `json:"data_url"` + FileName *string `json:"file_name"` + FileSize *int64 `json:"file_size"` + Voice int64 `json:"voice"` +} + +func (q *Queries) InsertOutboundPart(ctx context.Context, arg InsertOutboundPartParams) (*OutboundPart, error) { + row := q.db.QueryRowContext(ctx, insertOutboundPart, + arg.OutboundMessageID, + arg.PartIndex, + arg.PartType, + arg.AttachmentID, + arg.Content, + arg.DataUrl, + arg.FileName, + arg.FileSize, + arg.Voice, + ) + var i OutboundPart + err := row.Scan( + &i.ID, + &i.OutboundMessageID, + &i.PartIndex, + &i.PartType, + &i.AttachmentID, + &i.Content, + &i.DataUrl, + &i.FileName, + &i.FileSize, + &i.Voice, + &i.DeliveryStatus, + &i.ExternalID, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const listOutboundEchoCandidates = `-- name: ListOutboundEchoCandidates :many +SELECT id, account_id, swt_sid, event_id, occurred_at, gochat_message_id, retry_version, message_type, content, payload, delivery_status, result_version, external_id, external_error_code, claimed_at, attempts, next_attempt_at, status_sync_status, status_sync_attempts, status_sync_next_at, status_reported_at, last_error, created_at, updated_at FROM outbound_messages +WHERE account_id = ? + AND swt_sid = ? + AND delivery_status IN ('delivered', 'uncertain', 'failed') + AND (delivery_status <> 'failed' OR external_error_code = 'uncertain_timeout') + AND EXISTS ( + SELECT 1 FROM outbound_parts + WHERE outbound_message_id = outbound_messages.id + AND external_id IS NULL + AND delivery_status IN ('delivered', 'uncertain') + ) +ORDER BY id +LIMIT 20 +` + +type ListOutboundEchoCandidatesParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` +} + +func (q *Queries) ListOutboundEchoCandidates(ctx context.Context, arg ListOutboundEchoCandidatesParams) ([]*OutboundMessage, error) { + rows, err := q.db.QueryContext(ctx, listOutboundEchoCandidates, arg.AccountID, arg.SwtSid) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*OutboundMessage{} + for rows.Next() { + var i OutboundMessage + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.OccurredAt, + &i.GochatMessageID, + &i.RetryVersion, + &i.MessageType, + &i.Content, + &i.Payload, + &i.DeliveryStatus, + &i.ResultVersion, + &i.ExternalID, + &i.ExternalErrorCode, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.StatusSyncStatus, + &i.StatusSyncAttempts, + &i.StatusSyncNextAt, + &i.StatusReportedAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listOutboundParts = `-- name: ListOutboundParts :many +SELECT id, outbound_message_id, part_index, part_type, attachment_id, content, data_url, file_name, file_size, voice, delivery_status, external_id, last_error, created_at, updated_at FROM outbound_parts +WHERE outbound_message_id = ? +ORDER BY part_index +` + +func (q *Queries) ListOutboundParts(ctx context.Context, outboundMessageID int64) ([]*OutboundPart, error) { + rows, err := q.db.QueryContext(ctx, listOutboundParts, outboundMessageID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*OutboundPart{} + for rows.Next() { + var i OutboundPart + if err := rows.Scan( + &i.ID, + &i.OutboundMessageID, + &i.PartIndex, + &i.PartType, + &i.AttachmentID, + &i.Content, + &i.DataUrl, + &i.FileName, + &i.FileSize, + &i.Voice, + &i.DeliveryStatus, + &i.ExternalID, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markOutboundPartUncertain = `-- name: MarkOutboundPartUncertain :exec +UPDATE outbound_parts SET + delivery_status = 'uncertain', + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'pending' +` + +type MarkOutboundPartUncertainParams struct { + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) MarkOutboundPartUncertain(ctx context.Context, arg MarkOutboundPartUncertainParams) error { + _, err := q.db.ExecContext(ctx, markOutboundPartUncertain, arg.LastError, arg.ID) + return err +} + +const markOutboundUncertain = `-- name: MarkOutboundUncertain :exec +UPDATE outbound_messages SET + delivery_status = 'uncertain', + result_version = result_version + 1, + external_error_code = ?, + status_sync_status = 'pending', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +type MarkOutboundUncertainParams struct { + ExternalErrorCode *string `json:"external_error_code"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) MarkOutboundUncertain(ctx context.Context, arg MarkOutboundUncertainParams) error { + _, err := q.db.ExecContext(ctx, markOutboundUncertain, arg.ExternalErrorCode, arg.LastError, arg.ID) + return err +} + +const recoverOutboundDeliveriesAsUncertain = `-- name: RecoverOutboundDeliveriesAsUncertain :execrows +UPDATE outbound_messages SET + delivery_status = 'uncertain', + result_version = result_version + 1, + external_error_code = 'connector_restart_uncertain', + status_sync_status = 'pending', + claimed_at = NULL, + last_error = 'connector restarted while request was delivering', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'delivering' +` + +func (q *Queries) RecoverOutboundDeliveriesAsUncertain(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, recoverOutboundDeliveriesAsUncertain) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const recoverOutboundPartDeliveriesAsUncertain = `-- name: RecoverOutboundPartDeliveriesAsUncertain :execrows +UPDATE outbound_parts SET + delivery_status = 'uncertain', + last_error = 'connector restarted while part request may have been in flight', + updated_at = CURRENT_TIMESTAMP +WHERE id IN ( + SELECT MIN(candidate.id) + FROM outbound_parts AS candidate + JOIN outbound_messages AS message ON message.id = candidate.outbound_message_id + WHERE message.delivery_status = 'delivering' + AND candidate.delivery_status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM outbound_parts AS uncertain + WHERE uncertain.outbound_message_id = candidate.outbound_message_id + AND uncertain.delivery_status = 'uncertain' + ) + GROUP BY candidate.outbound_message_id +) +` + +func (q *Queries) RecoverOutboundPartDeliveriesAsUncertain(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, recoverOutboundPartDeliveriesAsUncertain) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const recoverOutboundStatusSyncs = `-- name: RecoverOutboundStatusSyncs :execrows +UPDATE outbound_messages SET + status_sync_status = 'pending', + status_sync_next_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE status_sync_status = 'syncing' +` + +func (q *Queries) RecoverOutboundStatusSyncs(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, recoverOutboundStatusSyncs) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const resetUndeliveredOutboundParts = `-- name: ResetUndeliveredOutboundParts :exec +UPDATE outbound_parts SET + delivery_status = 'pending', + external_id = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE outbound_message_id = ? + AND delivery_status IN ('failed', 'uncertain') +` + +func (q *Queries) ResetUndeliveredOutboundParts(ctx context.Context, outboundMessageID int64) error { + _, err := q.db.ExecContext(ctx, resetUndeliveredOutboundParts, outboundMessageID) + return err +} + +const retryOutboundDelivery = `-- name: RetryOutboundDelivery :exec +UPDATE outbound_messages SET + delivery_status = 'pending', + claimed_at = NULL, + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering' +` + +type RetryOutboundDeliveryParams struct { + NextAttemptAt *time.Time `json:"next_attempt_at"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) RetryOutboundDelivery(ctx context.Context, arg RetryOutboundDeliveryParams) error { + _, err := q.db.ExecContext(ctx, retryOutboundDelivery, arg.NextAttemptAt, arg.LastError, arg.ID) + return err +} + +const retryOutboundMessage = `-- name: RetryOutboundMessage :execrows +UPDATE outbound_messages SET + event_id = ?1, + occurred_at = ?2, + retry_version = ?3, + message_type = ?4, + content = ?5, + payload = ?6, + delivery_status = 'pending', + status_sync_status = 'not_required', + next_attempt_at = NULL, + claimed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE gochat_message_id = ?7 + AND delivery_status = 'failed' + AND retry_version < ?3 +` + +type RetryOutboundMessageParams struct { + EventID string `json:"event_id"` + OccurredAt time.Time `json:"occurred_at"` + RetryVersion int64 `json:"retry_version"` + MessageType string `json:"message_type"` + Content *string `json:"content"` + Payload string `json:"payload"` + GochatMessageID int64 `json:"gochat_message_id"` +} + +func (q *Queries) RetryOutboundMessage(ctx context.Context, arg RetryOutboundMessageParams) (int64, error) { + result, err := q.db.ExecContext(ctx, retryOutboundMessage, + arg.EventID, + arg.OccurredAt, + arg.RetryVersion, + arg.MessageType, + arg.Content, + arg.Payload, + arg.GochatMessageID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const retryStatusSync = `-- name: RetryStatusSync :exec +UPDATE outbound_messages SET + status_sync_status = 'pending', + status_sync_next_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status_sync_status = 'syncing' +` + +type RetryStatusSyncParams struct { + StatusSyncNextAt *time.Time `json:"status_sync_next_at"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) RetryStatusSync(ctx context.Context, arg RetryStatusSyncParams) error { + _, err := q.db.ExecContext(ctx, retryStatusSync, arg.StatusSyncNextAt, arg.LastError, arg.ID) + return err +} diff --git a/channels/shangwutong/db/migrations/001_init.down.sql b/channels/shangwutong/db/migrations/001_init.down.sql new file mode 100644 index 00000000..d69661a3 --- /dev/null +++ b/channels/shangwutong/db/migrations/001_init.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS outbound_operations; +DROP TABLE IF EXISTS outbound_parts; +DROP TABLE IF EXISTS outbound_messages; +DROP TABLE IF EXISTS message_maps; +DROP TABLE IF EXISTS inbound_events; +DROP TABLE IF EXISTS conversation_maps; +DROP TABLE IF EXISTS accounts; diff --git a/channels/shangwutong/db/migrations/001_init.up.sql b/channels/shangwutong/db/migrations/001_init.up.sql new file mode 100644 index 00000000..eefca02e --- /dev/null +++ b/channels/shangwutong/db/migrations/001_init.up.sql @@ -0,0 +1,207 @@ +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gochat_account_id INTEGER NOT NULL, + gochat_inbox_id INTEGER NOT NULL UNIQUE, + gochat_inbox_identifier TEXT NOT NULL, + config_version INTEGER NOT NULL, + applied_config_version INTEGER NOT NULL DEFAULT 0, + config_sync_status TEXT NOT NULL DEFAULT 'pending', + session_id TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + pending_password TEXT, + credential_state TEXT NOT NULL DEFAULT 'ready', + enabled INTEGER NOT NULL DEFAULT 1, + desired_presence TEXT NOT NULL DEFAULT 'online', + actual_presence TEXT NOT NULL DEFAULT 'offline', + connection_status TEXT NOT NULL DEFAULT 'offline', + base_url TEXT, + site_id TEXT, + login_name TEXT, + ma_token TEXT, + maxwordid INTEGER NOT NULL DEFAULT -1, + maxotick INTEGER NOT NULL DEFAULT -1, + maxtmpid INTEGER NOT NULL DEFAULT -1, + gochat_hmac_token TEXT NOT NULL, + gochat_webhook_secret TEXT NOT NULL, + failure_count INTEGER NOT NULL DEFAULT 0, + last_error_code TEXT, + last_error_message TEXT, + last_login_at DATETIME, + last_heartbeat_at DATETIME, + last_config_sync_at DATETIME, + deleted_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(config_sync_status IN ('pending', 'applied', 'rejected', 'deleted')), + CHECK(desired_presence IN ('online', 'busy', 'away', 'offline')), + CHECK(actual_presence IN ('online', 'busy', 'away', 'offline')), + CHECK(enabled IN (0, 1)) +); + +CREATE UNIQUE INDEX idx_accounts_active_swt_identity + ON accounts(session_id, username) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_accounts_runnable + ON accounts(enabled, desired_presence, deleted_at); + +CREATE TABLE conversation_maps ( + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + gochat_contact_source_id TEXT NOT NULL, + gochat_contact_id INTEGER, + gochat_conversation_id INTEGER, + gochat_display_id INTEGER, + swt_assignee_name TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(account_id, swt_sid) +); + +CREATE TABLE inbound_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + seq_id INTEGER NOT NULL, + kind INTEGER NOT NULL, + swt_event_key TEXT NOT NULL UNIQUE, + event_subtype TEXT, + op_name TEXT, + text TEXT, + swt_timestamp TEXT, + raw_line TEXT NOT NULL, + normalized_payload TEXT, + mapping_strategy TEXT, + delivery_status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + gochat_message_id INTEGER, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(account_id, swt_sid, kind, seq_id), + CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'ignored', 'failed')) +); + +CREATE INDEX idx_inbound_events_ready + ON inbound_events(delivery_status, next_attempt_at, account_id, id); + +CREATE TABLE message_maps ( + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + swt_message_id TEXT, + swt_seq_id INTEGER NOT NULL, + kind INTEGER NOT NULL, + child_index INTEGER NOT NULL DEFAULT 0, + direction TEXT NOT NULL, + gochat_message_id INTEGER NOT NULL, + gochat_source_id TEXT, + content_fingerprint TEXT, + retracted_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(account_id, swt_sid, kind, swt_seq_id, child_index), + CHECK(direction IN ('incoming', 'outgoing')) +); + +CREATE INDEX idx_message_maps_gochat_message_id + ON message_maps(gochat_message_id); + +CREATE UNIQUE INDEX idx_message_maps_swt_message_id + ON message_maps(account_id, swt_sid, swt_message_id) + WHERE swt_message_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_message_maps_gochat_source_id + ON message_maps(gochat_source_id) + WHERE gochat_source_id IS NOT NULL; + +CREATE TABLE outbound_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + event_id TEXT NOT NULL UNIQUE, + occurred_at DATETIME NOT NULL, + gochat_message_id INTEGER NOT NULL UNIQUE, + retry_version INTEGER NOT NULL DEFAULT 0, + message_type TEXT NOT NULL, + content TEXT, + payload TEXT NOT NULL, + delivery_status TEXT NOT NULL DEFAULT 'pending', + result_version INTEGER NOT NULL DEFAULT 0, + external_id TEXT, + external_error_code TEXT, + claimed_at DATETIME, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + status_sync_status TEXT NOT NULL DEFAULT 'not_required', + status_sync_attempts INTEGER NOT NULL DEFAULT 0, + status_sync_next_at DATETIME, + status_reported_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')), + CHECK(status_sync_status IN ('not_required', 'pending', 'syncing', 'synced')) +); + +CREATE INDEX idx_outbound_messages_ready + ON outbound_messages(delivery_status, next_attempt_at, account_id, id); + +CREATE UNIQUE INDEX idx_outbound_one_delivering_per_account + ON outbound_messages(account_id) + WHERE delivery_status = 'delivering'; + +CREATE INDEX idx_outbound_status_sync_ready + ON outbound_messages(status_sync_status, status_sync_next_at, account_id, id); + +CREATE TABLE outbound_parts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + outbound_message_id INTEGER NOT NULL REFERENCES outbound_messages(id) ON DELETE CASCADE, + part_index INTEGER NOT NULL, + part_type TEXT NOT NULL, + attachment_id INTEGER, + content TEXT, + data_url TEXT, + file_name TEXT, + file_size INTEGER, + voice INTEGER NOT NULL DEFAULT 0, + delivery_status TEXT NOT NULL DEFAULT 'pending', + external_id TEXT, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(outbound_message_id, part_index), + CHECK(part_type IN ('text', 'image', 'file', 'audio', 'video', 'unsupported')), + CHECK(delivery_status IN ('pending', 'delivered', 'uncertain', 'failed')), + CHECK(voice IN (0, 1)) +); + +CREATE INDEX idx_outbound_parts_message + ON outbound_parts(outbound_message_id, part_index); + +CREATE TABLE outbound_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + event_id TEXT NOT NULL UNIQUE, + operation TEXT NOT NULL, + payload TEXT NOT NULL, + occurred_at DATETIME NOT NULL, + delivery_status TEXT NOT NULL DEFAULT 'pending', + claimed_at DATETIME, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(operation IN ('end_conversation')), + CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')) +); + +CREATE INDEX idx_outbound_operations_ready + ON outbound_operations(delivery_status, next_attempt_at, account_id, id); + +CREATE UNIQUE INDEX idx_outbound_one_delivering_operation_per_account + ON outbound_operations(account_id) + WHERE delivery_status = 'delivering'; diff --git a/channels/shangwutong/db/migrations/embed.go b/channels/shangwutong/db/migrations/embed.go new file mode 100644 index 00000000..773bd1ce --- /dev/null +++ b/channels/shangwutong/db/migrations/embed.go @@ -0,0 +1,8 @@ +package migrations + +import "embed" + +// FS contains the versioned Connector schema migrations. +// +//go:embed *.up.sql *.down.sql +var FS embed.FS diff --git a/channels/shangwutong/db/queries/accounts.sql b/channels/shangwutong/db/queries/accounts.sql new file mode 100644 index 00000000..f859ef0f --- /dev/null +++ b/channels/shangwutong/db/queries/accounts.sql @@ -0,0 +1,151 @@ +-- name: ListAccounts :many +SELECT * FROM accounts ORDER BY id; + +-- name: ListRunnableAccounts :many +SELECT * FROM accounts +WHERE enabled = 1 + AND desired_presence <> 'offline' + AND deleted_at IS NULL +ORDER BY id; + +-- name: GetAccountByID :one +SELECT * FROM accounts WHERE id = ? LIMIT 1; + +-- name: GetAccountByInboxID :one +SELECT * FROM accounts WHERE gochat_inbox_id = ? LIMIT 1; + +-- name: CreateAccount :one +INSERT INTO accounts ( + gochat_account_id, gochat_inbox_id, gochat_inbox_identifier, + config_version, session_id, username, password, enabled, + desired_presence, gochat_hmac_token, gochat_webhook_secret, + last_config_sync_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +RETURNING *; + +-- name: UpdateAccountConfig :one +UPDATE accounts SET + gochat_account_id = sqlc.arg(gochat_account_id), + gochat_inbox_identifier = sqlc.arg(gochat_inbox_identifier), + config_version = sqlc.arg(config_version), + pending_password = sqlc.narg(pending_password), + credential_state = sqlc.arg(credential_state), + enabled = sqlc.arg(enabled), + desired_presence = sqlc.arg(desired_presence), + gochat_hmac_token = sqlc.arg(gochat_hmac_token), + gochat_webhook_secret = sqlc.arg(gochat_webhook_secret), + config_sync_status = 'pending', + last_config_sync_at = CURRENT_TIMESTAMP, + deleted_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE gochat_inbox_id = sqlc.arg(gochat_inbox_id) + AND config_version < sqlc.arg(config_version) +RETURNING *; + +-- name: ApplyAccountSession :one +UPDATE accounts SET + password = COALESCE(pending_password, password), + pending_password = NULL, + credential_state = 'applied', + applied_config_version = config_version, + config_sync_status = 'applied', + base_url = ?, + site_id = ?, + login_name = ?, + ma_token = ?, + actual_presence = ?, + connection_status = 'connected', + failure_count = 0, + last_error_code = NULL, + last_error_message = NULL, + last_login_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING *; + +-- name: UpdateAccountRuntimeError :exec +UPDATE accounts SET + connection_status = ?, + credential_state = ?, + failure_count = failure_count + 1, + last_error_code = ?, + last_error_message = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: RejectPendingCredential :exec +UPDATE accounts SET + credential_state = ?, + config_sync_status = 'rejected', + connection_status = ?, + failure_count = failure_count + 1, + last_error_code = ?, + last_error_message = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND pending_password IS NOT NULL; + +-- name: MarkAccountConfigApplied :exec +UPDATE accounts SET + applied_config_version = config_version, + config_sync_status = 'applied', + credential_state = CASE + WHEN pending_password IS NULL THEN 'applied' + ELSE credential_state + END, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: UpdateAccountPresence :exec +UPDATE accounts SET + actual_presence = ?, + connection_status = ?, + failure_count = 0, + last_error_code = NULL, + last_error_message = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: ClearAccountSession :exec +UPDATE accounts SET + ma_token = NULL, + actual_presence = 'offline', + connection_status = 'relogin_required', + updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: StopAccountRuntime :exec +UPDATE accounts SET + actual_presence = 'offline', + connection_status = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: UpdateAccountCursor :exec +UPDATE accounts SET + maxwordid = ?, + maxotick = ?, + maxtmpid = ?, + last_heartbeat_at = CURRENT_TIMESTAMP, + connection_status = 'connected', + failure_count = 0, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: TouchAccountHeartbeat :exec +UPDATE accounts SET + last_heartbeat_at = CURRENT_TIMESTAMP, + connection_status = 'connected', + failure_count = 0, + updated_at = CURRENT_TIMESTAMP +WHERE id = ?; + +-- name: MarkAccountDeleted :exec +UPDATE accounts SET + enabled = 0, + desired_presence = 'offline', + actual_presence = 'offline', + connection_status = 'disabled', + config_sync_status = 'deleted', + deleted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE gochat_inbox_id = ? AND deleted_at IS NULL; diff --git a/channels/shangwutong/db/queries/inbound.sql b/channels/shangwutong/db/queries/inbound.sql new file mode 100644 index 00000000..1b76b1d2 --- /dev/null +++ b/channels/shangwutong/db/queries/inbound.sql @@ -0,0 +1,119 @@ +-- name: InsertInboundEvent :one +INSERT INTO inbound_events ( + account_id, swt_sid, seq_id, kind, swt_event_key, + event_subtype, op_name, text, swt_timestamp, raw_line, + normalized_payload, mapping_strategy, delivery_status +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(swt_event_key) DO NOTHING +RETURNING *; + +-- name: ClaimInboundEvent :one +UPDATE inbound_events SET + delivery_status = 'delivering', + attempts = attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM inbound_events AS candidate + WHERE candidate.delivery_status = 'pending' + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + AND NOT EXISTS ( + SELECT 1 FROM inbound_events AS earlier + WHERE earlier.account_id = candidate.account_id + AND earlier.swt_sid = candidate.swt_sid + AND earlier.id < candidate.id + AND earlier.delivery_status IN ('pending', 'delivering') + ) + ORDER BY candidate.id + LIMIT 1 +) +AND delivery_status = 'pending' +RETURNING *; + +-- name: GetInboundEventByKey :one +SELECT * FROM inbound_events +WHERE swt_event_key = ? +LIMIT 1; + +-- name: CompleteInboundEvent :exec +UPDATE inbound_events SET + delivery_status = 'delivered', + gochat_message_id = ?, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: CompleteInboundEventWithMapping :exec +UPDATE inbound_events SET + delivery_status = 'delivered', + gochat_message_id = sqlc.narg(gochat_message_id), + event_subtype = sqlc.narg(event_subtype), + normalized_payload = sqlc.narg(normalized_payload), + mapping_strategy = sqlc.arg(mapping_strategy), + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg(id) AND delivery_status = 'delivering'; + +-- name: RetryInboundEvent :exec +UPDATE inbound_events SET + delivery_status = 'pending', + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: RecoverInboundDeliveries :execrows +UPDATE inbound_events SET + delivery_status = 'pending', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'delivering'; + +-- name: FailInboundEvent :exec +UPDATE inbound_events SET + delivery_status = 'failed', + mapping_strategy = sqlc.narg(mapping_strategy), + last_error = sqlc.arg(last_error), + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg(id) AND delivery_status = 'delivering'; + +-- name: GetConversationMap :one +SELECT * FROM conversation_maps +WHERE account_id = ? AND swt_sid = ? +LIMIT 1; + +-- name: UpsertConversationMap :one +INSERT INTO conversation_maps ( + account_id, swt_sid, gochat_contact_source_id, gochat_contact_id, + gochat_conversation_id, gochat_display_id, swt_assignee_name +) VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(account_id, swt_sid) DO UPDATE SET + gochat_contact_source_id = excluded.gochat_contact_source_id, + gochat_contact_id = COALESCE(excluded.gochat_contact_id, conversation_maps.gochat_contact_id), + gochat_conversation_id = COALESCE(excluded.gochat_conversation_id, conversation_maps.gochat_conversation_id), + gochat_display_id = COALESCE(excluded.gochat_display_id, conversation_maps.gochat_display_id), + swt_assignee_name = COALESCE(excluded.swt_assignee_name, conversation_maps.swt_assignee_name), + updated_at = CURRENT_TIMESTAMP +RETURNING *; + +-- name: InsertMessageMap :exec +INSERT INTO message_maps ( + account_id, swt_sid, swt_message_id, swt_seq_id, kind, child_index, + direction, gochat_message_id, gochat_source_id, content_fingerprint +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(account_id, swt_sid, kind, swt_seq_id, child_index) DO UPDATE SET + swt_message_id = COALESCE(excluded.swt_message_id, message_maps.swt_message_id), + gochat_message_id = excluded.gochat_message_id, + gochat_source_id = COALESCE(excluded.gochat_source_id, message_maps.gochat_source_id), + content_fingerprint = COALESCE(excluded.content_fingerprint, message_maps.content_fingerprint), + updated_at = CURRENT_TIMESTAMP; + +-- name: GetMessageMapBySWTMessageID :one +SELECT * FROM message_maps +WHERE account_id = ? AND swt_sid = ? AND swt_message_id = ? +LIMIT 1; + +-- name: MarkMessageMapRetracted :exec +UPDATE message_maps SET + retracted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? AND swt_sid = ? AND swt_message_id = ?; diff --git a/channels/shangwutong/db/queries/metrics.sql b/channels/shangwutong/db/queries/metrics.sql new file mode 100644 index 00000000..940d730f --- /dev/null +++ b/channels/shangwutong/db/queries/metrics.sql @@ -0,0 +1,23 @@ +-- name: ListAccountMetricCounts :many +SELECT connection_status, actual_presence, COUNT(*) AS count +FROM accounts +WHERE deleted_at IS NULL +GROUP BY connection_status, actual_presence +ORDER BY connection_status, actual_presence; + +-- name: ListInboundQueueMetricCounts :many +SELECT delivery_status, COUNT(*) AS count +FROM inbound_events +GROUP BY delivery_status +ORDER BY delivery_status; + +-- name: ListOutboundQueueMetricCounts :many +SELECT delivery_status, COUNT(*) AS count +FROM outbound_messages +GROUP BY delivery_status +ORDER BY delivery_status; + +-- name: CountStatusSyncQueue :one +SELECT COUNT(*) +FROM outbound_messages +WHERE status_sync_status IN ('pending', 'syncing'); diff --git a/channels/shangwutong/db/queries/operations.sql b/channels/shangwutong/db/queries/operations.sql new file mode 100644 index 00000000..f66a639e --- /dev/null +++ b/channels/shangwutong/db/queries/operations.sql @@ -0,0 +1,102 @@ +-- name: InsertOutboundOperation :one +INSERT INTO outbound_operations ( + account_id, swt_sid, event_id, operation, payload, occurred_at +) VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT(event_id) DO NOTHING +RETURNING *; + +-- name: GetOutboundOperationByEventID :one +SELECT * FROM outbound_operations +WHERE event_id = ? +LIMIT 1; + +-- name: ClaimOutboundOperation :one +UPDATE outbound_operations SET + delivery_status = 'delivering', + claimed_at = CURRENT_TIMESTAMP, + attempts = attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM outbound_operations AS candidate + JOIN accounts AS account ON account.id = candidate.account_id + WHERE candidate.delivery_status = 'pending' + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + AND NOT EXISTS ( + SELECT 1 FROM outbound_operations AS earlier + WHERE earlier.account_id = candidate.account_id + AND earlier.id < candidate.id + AND earlier.delivery_status IN ('pending', 'delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS active_message + WHERE active_message.account_id = candidate.account_id + AND active_message.delivery_status IN ('delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS earlier_message + WHERE earlier_message.account_id = candidate.account_id + AND earlier_message.delivery_status = 'pending' + AND ( + julianday(earlier_message.occurred_at) < julianday(candidate.occurred_at) OR + (julianday(earlier_message.occurred_at) = julianday(candidate.occurred_at) AND earlier_message.event_id < candidate.event_id) + ) + ) + ORDER BY candidate.id + LIMIT 1 +) +AND delivery_status = 'pending' +RETURNING *; + +-- name: CompleteOutboundOperation :exec +UPDATE outbound_operations SET + delivery_status = 'delivered', + claimed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: RetryOutboundOperation :exec +UPDATE outbound_operations SET + delivery_status = 'pending', + claimed_at = NULL, + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: MarkOutboundOperationUncertain :exec +UPDATE outbound_operations SET + delivery_status = 'uncertain', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: FailOutboundOperation :exec +UPDATE outbound_operations SET + delivery_status = 'failed', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status IN ('delivering', 'uncertain'); + +-- name: RecoverOutboundOperationsAsUncertain :execrows +UPDATE outbound_operations SET + delivery_status = 'uncertain', + claimed_at = NULL, + last_error = 'connector restarted while operation was delivering', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'delivering'; + +-- name: FailExpiredUncertainOperations :execrows +UPDATE outbound_operations SET + delivery_status = 'failed', + claimed_at = NULL, + last_error = 'operation result remained uncertain beyond the observation window', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'uncertain' + AND julianday(updated_at) <= julianday(?); diff --git a/channels/shangwutong/db/queries/outbound.sql b/channels/shangwutong/db/queries/outbound.sql new file mode 100644 index 00000000..b024e28d --- /dev/null +++ b/channels/shangwutong/db/queries/outbound.sql @@ -0,0 +1,292 @@ +-- name: InsertOutboundMessage :one +INSERT INTO outbound_messages ( + account_id, swt_sid, event_id, occurred_at, gochat_message_id, retry_version, + message_type, content, payload +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(gochat_message_id) DO NOTHING +RETURNING *; + +-- name: GetOutboundByGoChatMessageID :one +SELECT * FROM outbound_messages WHERE gochat_message_id = ? LIMIT 1; + +-- name: InsertOutboundPart :one +INSERT INTO outbound_parts ( + outbound_message_id, part_index, part_type, attachment_id, + content, data_url, file_name, file_size, voice +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: ListOutboundParts :many +SELECT * FROM outbound_parts +WHERE outbound_message_id = ? +ORDER BY part_index; + +-- name: CompleteOutboundPart :exec +UPDATE outbound_parts SET + delivery_status = 'delivered', + external_id = ?, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'pending'; + +-- name: MarkOutboundPartUncertain :exec +UPDATE outbound_parts SET + delivery_status = 'uncertain', + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'pending'; + +-- name: FailOutboundPart :exec +UPDATE outbound_parts SET + delivery_status = 'failed', + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'pending'; + +-- name: ResetUndeliveredOutboundParts :exec +UPDATE outbound_parts SET + delivery_status = 'pending', + external_id = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE outbound_message_id = ? + AND delivery_status IN ('failed', 'uncertain'); + +-- name: ClaimOutboundMessage :one +UPDATE outbound_messages SET + delivery_status = 'delivering', + claimed_at = CURRENT_TIMESTAMP, + attempts = attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM outbound_messages AS candidate + JOIN accounts AS account ON account.id = candidate.account_id + WHERE candidate.delivery_status = 'pending' + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS earlier + WHERE earlier.account_id = candidate.account_id + AND earlier.id < candidate.id + AND earlier.delivery_status IN ('pending', 'delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_operations AS operation + WHERE operation.account_id = candidate.account_id + AND operation.delivery_status IN ('delivering', 'uncertain') + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_operations AS earlier_operation + WHERE earlier_operation.account_id = candidate.account_id + AND earlier_operation.delivery_status = 'pending' + AND ( + julianday(earlier_operation.occurred_at) < julianday(candidate.occurred_at) OR + (julianday(earlier_operation.occurred_at) = julianday(candidate.occurred_at) AND earlier_operation.event_id < candidate.event_id) + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM outbound_messages AS active + WHERE active.account_id = candidate.account_id + AND active.delivery_status = 'delivering' + ) + ORDER BY candidate.id + LIMIT 1 +) +AND delivery_status = 'pending' +RETURNING *; + +-- name: CompleteOutboundMessage :exec +UPDATE outbound_messages SET + delivery_status = 'delivered', + result_version = result_version + 1, + external_id = ?, + external_error_code = NULL, + status_sync_status = 'pending', + claimed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: MarkOutboundUncertain :exec +UPDATE outbound_messages SET + delivery_status = 'uncertain', + result_version = result_version + 1, + external_error_code = ?, + status_sync_status = 'pending', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: RetryOutboundDelivery :exec +UPDATE outbound_messages SET + delivery_status = 'pending', + claimed_at = NULL, + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status = 'delivering'; + +-- name: FailOutboundMessage :exec +UPDATE outbound_messages SET + delivery_status = 'failed', + result_version = result_version + 1, + external_error_code = ?, + status_sync_status = 'pending', + claimed_at = NULL, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND delivery_status IN ('delivering', 'uncertain'); + +-- name: RetryOutboundMessage :execrows +UPDATE outbound_messages SET + event_id = sqlc.arg(event_id), + occurred_at = sqlc.arg(occurred_at), + retry_version = sqlc.arg(retry_version), + message_type = sqlc.arg(message_type), + content = sqlc.narg(content), + payload = sqlc.arg(payload), + delivery_status = 'pending', + status_sync_status = 'not_required', + next_attempt_at = NULL, + claimed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE gochat_message_id = sqlc.arg(gochat_message_id) + AND delivery_status = 'failed' + AND retry_version < sqlc.arg(retry_version); + +-- name: RecoverOutboundDeliveriesAsUncertain :execrows +UPDATE outbound_messages SET + delivery_status = 'uncertain', + result_version = result_version + 1, + external_error_code = 'connector_restart_uncertain', + status_sync_status = 'pending', + claimed_at = NULL, + last_error = 'connector restarted while request was delivering', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'delivering'; + +-- name: RecoverOutboundPartDeliveriesAsUncertain :execrows +UPDATE outbound_parts SET + delivery_status = 'uncertain', + last_error = 'connector restarted while part request may have been in flight', + updated_at = CURRENT_TIMESTAMP +WHERE id IN ( + SELECT MIN(candidate.id) + FROM outbound_parts AS candidate + JOIN outbound_messages AS message ON message.id = candidate.outbound_message_id + WHERE message.delivery_status = 'delivering' + AND candidate.delivery_status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM outbound_parts AS uncertain + WHERE uncertain.outbound_message_id = candidate.outbound_message_id + AND uncertain.delivery_status = 'uncertain' + ) + GROUP BY candidate.outbound_message_id +); + +-- name: RecoverOutboundStatusSyncs :execrows +UPDATE outbound_messages SET + status_sync_status = 'pending', + status_sync_next_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE status_sync_status = 'syncing'; + +-- name: ClaimStatusSync :one +UPDATE outbound_messages SET + status_sync_status = 'syncing', + status_sync_attempts = status_sync_attempts + 1, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT id FROM outbound_messages + WHERE status_sync_status = 'pending' + AND (status_sync_next_at IS NULL OR julianday(status_sync_next_at) IS NULL OR julianday(status_sync_next_at) <= julianday('now')) + ORDER BY id + LIMIT 1 +) +AND status_sync_status = 'pending' +RETURNING *; + +-- name: CompleteStatusSync :exec +UPDATE outbound_messages SET + status_sync_status = 'synced', + status_reported_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status_sync_status = 'syncing'; + +-- name: RetryStatusSync :exec +UPDATE outbound_messages SET + status_sync_status = 'pending', + status_sync_next_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status_sync_status = 'syncing'; + +-- name: ListOutboundEchoCandidates :many +SELECT * FROM outbound_messages +WHERE account_id = ? + AND swt_sid = ? + AND delivery_status IN ('delivered', 'uncertain', 'failed') + AND (delivery_status <> 'failed' OR external_error_code = 'uncertain_timeout') + AND EXISTS ( + SELECT 1 FROM outbound_parts + WHERE outbound_message_id = outbound_messages.id + AND external_id IS NULL + AND delivery_status IN ('delivered', 'uncertain') + ) +ORDER BY id +LIMIT 20; + +-- name: ConfirmOutboundEcho :execrows +UPDATE outbound_messages SET + delivery_status = CASE + WHEN EXISTS ( + SELECT 1 FROM outbound_parts + WHERE outbound_message_id = outbound_messages.id + AND delivery_status <> 'delivered' + ) THEN 'pending' + ELSE 'delivered' + END, + result_version = result_version + 1, + external_id = sqlc.narg(external_id), + external_error_code = NULL, + status_sync_status = CASE + WHEN EXISTS ( + SELECT 1 FROM outbound_parts + WHERE outbound_message_id = outbound_messages.id + AND delivery_status <> 'delivered' + ) THEN 'not_required' + ELSE 'pending' + END, + status_sync_next_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE outbound_messages.id = sqlc.arg(id) + AND delivery_status IN ('delivered', 'uncertain', 'failed') + AND (delivery_status <> 'failed' OR external_error_code = 'uncertain_timeout'); + +-- name: ConfirmOutboundPartEcho :execrows +UPDATE outbound_parts SET + delivery_status = 'delivered', + external_id = ?, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? + AND external_id IS NULL + AND delivery_status IN ('delivered', 'uncertain'); + +-- name: FailExpiredUncertainMessages :execrows +UPDATE outbound_messages SET + delivery_status = 'failed', + result_version = result_version + 1, + external_error_code = 'uncertain_timeout', + status_sync_status = 'pending', + claimed_at = NULL, + last_error = 'no matching Shangwutong echo arrived before the observation window expired', + updated_at = CURRENT_TIMESTAMP +WHERE delivery_status = 'uncertain' + AND julianday(updated_at) <= julianday(?); diff --git a/channels/shangwutong/go.mod b/channels/shangwutong/go.mod new file mode 100644 index 00000000..838c4e41 --- /dev/null +++ b/channels/shangwutong/go.mod @@ -0,0 +1,78 @@ +module github.com/gochat/gochat/channels/shangwutong + +go 1.26.0 + +toolchain go1.26.4 + +require ( + github.com/gofiber/fiber/v3 v3.4.0 + github.com/sirupsen/logrus v1.9.4 + github.com/spf13/cobra v1.10.2 + golang.org/x/text v0.38.0 + modernc.org/sqlite v1.53.0 +) + +tool github.com/sqlc-dev/sqlc/cmd/sqlc + +require ( + cel.dev/expr v0.25.1 // indirect + filippo.io/edwards25519 v1.1.1 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gofiber/schema v1.8.0 // indirect + github.com/gofiber/utils/v2 v2.1.1 // indirect + github.com/google/cel-go v0.28.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ncruces/go-sqlite3 v0.32.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/sqlc-dev/doubleclick v1.0.0 // indirect + github.com/sqlc-dev/sqlc v1.31.1 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.72.0 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/channels/shangwutong/go.sum b/channels/shangwutong/go.sum new file mode 100644 index 00000000..b2446504 --- /dev/null +++ b/channels/shangwutong/go.sum @@ -0,0 +1,246 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw= +github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk= +github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis= +github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA= +github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I= +github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/ncruces/go-sqlite3 v0.32.0 h1:hNBUXp88LrfQCsuyXLqWTbTUG35sUuktDsqhhgHvU20= +github.com/ncruces/go-sqlite3 v0.32.0/go.mod h1:MIWTK60ONDl0oVY073zYvJP21C3Dly6P9bxVpgkLwdQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/pganalyze/pg_query_go/v6 v6.2.2 h1:O0L6zMC226R82RF3X5n0Ki6HjytDsoAzuzp4ATVAHNo= +github.com/pganalyze/pg_query_go/v6 v6.2.2/go.mod h1:Cn6+j4870kJz3iYNsb0VsNG04vpSWgEvBwc590J4qD0= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee h1:/IDPbpzkzA97t1/Z1+C3KlxbevjMeaI6BQYxvivu4u8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 h1:zYmP5fBH+i2yhhU6f5uOol6zxHtR2/sD47BsJLfy0oU= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= +github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/sqlc-dev/doubleclick v1.0.0 h1:2/OApfQ2eLgcfa/Fqs8WSMA6atH0G8j9hHbQIgMfAXI= +github.com/sqlc-dev/doubleclick v1.0.0/go.mod h1:ODHRroSrk/rr5neRHlWMSRijqOak8YmNaO3VAZCNl5Y= +github.com/sqlc-dev/sqlc v1.31.1 h1:+V+BjBJfFNPX/RFfL8eiZD9jk9lVJUEGGllWvnYNqbc= +github.com/sqlc-dev/sqlc v1.31.1/go.mod h1:6ZPww/Jd3G6MzJeW6NrqizjL+52vYNaaXP9yMeJ/Nao= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= +github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/channels/shangwutong/internal/account/manager.go b/channels/shangwutong/internal/account/manager.go new file mode 100644 index 00000000..e4be2a0d --- /dev/null +++ b/channels/shangwutong/internal/account/manager.go @@ -0,0 +1,292 @@ +package account + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/observability" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" + "github.com/sirupsen/logrus" +) + +type ProtocolClient interface { + Login(context.Context, swt.Credentials, swt.Presence, string) (swt.Session, error) + Heartbeat(context.Context, swt.Session, swt.Cursor, string, string) (swt.HeartbeatResult, error) + SetPresence(context.Context, swt.Session, swt.Presence) error +} + +type StatusReporter interface { + UpdateInboxStatus(context.Context, int64, gochat.InboxStatus) error +} + +type Manager struct { + store *store.Store + protocol ProtocolClient + reporter StatusReporter + logger *logrus.Entry + heartbeatSlots chan struct{} + heartbeatInterval time.Duration + statusRefreshInterval time.Duration + initialDelay func(time.Duration) time.Duration + metrics *observability.Metrics + + mu sync.Mutex + started bool + ctx context.Context + cancel context.CancelFunc + supervisors map[int64]*supervisor + statusQueue chan int64 + wg sync.WaitGroup +} + +func NewManager(database *store.Store, protocol ProtocolClient, reporter StatusReporter, logger *logrus.Entry, maxInflightHeartbeats int) (*Manager, error) { + if database == nil || protocol == nil { + return nil, errors.New("store and protocol client are required") + } + if maxInflightHeartbeats <= 0 { + return nil, errors.New("max inflight heartbeats must be positive") + } + if logger == nil { + base := logrus.New() + logger = logrus.NewEntry(base) + } + return &Manager{ + store: database, protocol: protocol, reporter: reporter, logger: logger, + heartbeatSlots: make(chan struct{}, maxInflightHeartbeats), + heartbeatInterval: 2500 * time.Millisecond, statusRefreshInterval: 30 * time.Second, + initialDelay: randomDelay, supervisors: make(map[int64]*supervisor), statusQueue: make(chan int64, 2048), + }, nil +} + +func (m *Manager) Start(ctx context.Context) error { + m.mu.Lock() + if m.started { + m.mu.Unlock() + return errors.New("account manager already started") + } + m.ctx, m.cancel = context.WithCancel(ctx) + m.started = true + m.mu.Unlock() + + accounts, err := m.store.Reader().ListAccounts(m.ctx) + if err != nil { + m.Stop() + return fmt.Errorf("list accounts: %w", err) + } + if m.reporter != nil { + for range 4 { + m.wg.Add(1) + go m.statusWorker() + } + m.wg.Add(1) + go m.statusRefresher() + } + for _, account := range accounts { + if store.AccountRunnable(account) { + m.ensure(account) + } + m.notifyStatus(account.ID) + } + return nil +} + +func (m *Manager) WakeInbox(ctx context.Context, inboxID int64) error { + account, err := m.store.Reader().GetAccountByInboxID(ctx, inboxID) + if err != nil { + return err + } + m.mu.Lock() + running := m.supervisors[account.ID] + started := m.started + m.mu.Unlock() + if running != nil { + running.wakeNow() + } else if started && store.AccountRunnable(account) { + m.ensure(account) + } + m.notifyStatus(account.ID) + return nil +} + +func (m *Manager) Stop() { + m.mu.Lock() + cancel := m.cancel + m.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (m *Manager) Wait() { m.wg.Wait() } + +func (m *Manager) Running() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.supervisors) +} + +func (m *Manager) SetMetrics(metrics *observability.Metrics) { m.metrics = metrics } + +func (m *Manager) WithSession(ctx context.Context, accountID int64, operation func(swt.Session) error) error { + if operation == nil { + return errors.New("session operation is required") + } + m.mu.Lock() + supervisor := m.supervisors[accountID] + m.mu.Unlock() + if supervisor == nil { + return errors.New("account supervisor is not running") + } + return supervisor.withSession(ctx, operation) +} + +func (m *Manager) InvalidateSession(ctx context.Context, accountID int64) error { + m.mu.Lock() + supervisor := m.supervisors[accountID] + m.mu.Unlock() + if supervisor == nil { + return errors.New("account supervisor is not running") + } + supervisor.clearSession() + if err := m.store.Writer().ClearAccountSession(ctx, accountID); err != nil { + return err + } + supervisor.wakeNow() + m.notifyStatus(accountID) + return nil +} + +func (m *Manager) SetTyping(accountID int64, sid string, typing bool) error { + m.mu.Lock() + supervisor := m.supervisors[accountID] + m.mu.Unlock() + if supervisor == nil { + return errors.New("account supervisor is not running") + } + return supervisor.setTyping(sid, typing) +} + +func (m *Manager) ensure(account *dbgen.Account) { + m.mu.Lock() + defer m.mu.Unlock() + if m.ctx == nil || m.ctx.Err() != nil || m.supervisors[account.ID] != nil { + return + } + supervisor := &supervisor{ + accountID: account.ID, store: m.store, protocol: m.protocol, + logger: m.logger.WithField("connector_account_id", account.ID), wake: make(chan struct{}, 1), + heartbeatSlots: m.heartbeatSlots, heartbeatInterval: m.heartbeatInterval, + initialDelay: m.initialDelay(m.heartbeatInterval), notifyStatus: m.notifyStatus, + metrics: m.metrics, + } + m.supervisors[account.ID] = supervisor + m.wg.Add(1) + go m.runSupervisor(supervisor) +} + +func (m *Manager) runSupervisor(supervisor *supervisor) { + defer m.wg.Done() + defer func() { + m.mu.Lock() + if m.supervisors[supervisor.accountID] == supervisor { + delete(m.supervisors, supervisor.accountID) + } + m.mu.Unlock() + }() + for { + err := runSupervisorSafely(m.ctx, supervisor) + if err == nil || m.ctx.Err() != nil { + return + } + m.logger.WithFields(logrus.Fields{ + "component": "account_supervisor", "operation": "run", "result": "restarting", + "connector_account_id": supervisor.accountID, + }).WithError(err).Error("account supervisor stopped unexpectedly") + select { + case <-m.ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +func runSupervisorSafely(ctx context.Context, supervisor *supervisor) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("panic: %v", recovered) + } + }() + return supervisor.run(ctx) +} + +func (m *Manager) notifyStatus(accountID int64) { + if m.reporter == nil { + return + } + select { + case m.statusQueue <- accountID: + default: + } +} + +func (m *Manager) statusWorker() { + defer m.wg.Done() + for { + select { + case <-m.ctx.Done(): + return + case accountID := <-m.statusQueue: + account, err := m.store.Reader().GetAccountByID(m.ctx, accountID) + if err != nil || account.DeletedAt != nil { + continue + } + requestCtx, cancel := context.WithTimeout(m.ctx, 15*time.Second) + err = m.reporter.UpdateInboxStatus(requestCtx, account.GochatInboxID, inboxStatus(account)) + cancel() + if err != nil && m.ctx.Err() == nil { + m.logger.WithFields(logrus.Fields{ + "component": "status_sync", "operation": "update_inbox_status", "result": "failed", + "connector_account_id": account.ID, + }).WithError(err).Warn("GoChat inbox status update failed") + } + } + } +} + +func (m *Manager) statusRefresher() { + defer m.wg.Done() + ticker := time.NewTicker(m.statusRefreshInterval) + defer ticker.Stop() + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + accounts, err := m.store.Reader().ListAccounts(m.ctx) + if err != nil { + continue + } + for _, account := range accounts { + m.notifyStatus(account.ID) + } + } + } +} + +func inboxStatus(account *dbgen.Account) gochat.InboxStatus { + credential := account.CredentialState + if credential == "ready" { + credential = "pending" + } + return gochat.InboxStatus{ + ConfigVersion: account.ConfigVersion, ActualPresence: account.ActualPresence, + ConnectionStatus: account.ConnectionStatus, CredentialStatus: credential, + LastHeartbeatAt: account.LastHeartbeatAt, LastErrorCode: account.LastErrorCode, + } +} diff --git a/channels/shangwutong/internal/account/manager_scale_test.go b/channels/shangwutong/internal/account/manager_scale_test.go new file mode 100644 index 00000000..7a349095 --- /dev/null +++ b/channels/shangwutong/internal/account/manager_scale_test.go @@ -0,0 +1,190 @@ +package account + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" + "github.com/sirupsen/logrus" +) + +func TestManagerMaintainsOneSupervisorPer500Accounts(t *testing.T) { + var soakDuration time.Duration + if raw := os.Getenv("SWT_SOAK_DURATION"); raw != "" { + var err error + soakDuration, err = time.ParseDuration(raw) + if err != nil || soakDuration <= 0 { + t.Fatalf("SWT_SOAK_DURATION must be a positive duration: %q", raw) + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + for index := 1; index <= 500; index++ { + if _, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: int64(index), GochatInboxIdentifier: fmt.Sprintf("inbox-%d", index), + ConfigVersion: 1, SessionID: fmt.Sprintf("BYT%08d", index), Username: fmt.Sprintf("agent-%d", index), + Password: "password", Enabled: 1, DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }); err != nil { + t.Fatalf("create account %d: %v", index, err) + } + } + protocol := &scaleProtocol{} + logger := logrus.New() + logger.SetOutput(io.Discard) + manager, err := NewManager(database, protocol, nil, logrus.NewEntry(logger), 64) + if err != nil { + t.Fatal(err) + } + manager.initialDelay = func(time.Duration) time.Duration { return 0 } + manager.heartbeatInterval = time.Hour + if soakDuration > 0 { + manager.heartbeatInterval = 2500 * time.Millisecond + } + if err := manager.Start(ctx); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for protocol.heartbeats.Load() < 500 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if protocol.heartbeats.Load() < 500 { + t.Fatalf("only %d/500 supervisors reached heartbeat", protocol.heartbeats.Load()) + } + if manager.Running() != 500 { + t.Fatalf("running supervisors = %d", manager.Running()) + } + if maximum := protocol.maxInflight.Load(); maximum <= 0 || maximum > 64 { + t.Fatalf("max concurrent heartbeats = %d", maximum) + } + for inboxID := int64(1); inboxID <= 500; inboxID++ { + if err := manager.WakeInbox(ctx, inboxID); err != nil { + t.Fatal(err) + } + if err := manager.WakeInbox(ctx, inboxID); err != nil { + t.Fatal(err) + } + } + if manager.Running() != 500 { + t.Fatalf("duplicate wake changed supervisor count to %d", manager.Running()) + } + if soakDuration > 0 { + initialHeartbeats := protocol.heartbeats.Load() + deadline := time.NewTimer(soakDuration) + ticker := time.NewTicker(time.Second) + defer deadline.Stop() + defer ticker.Stop() + soak: + for { + select { + case <-deadline.C: + break soak + case <-ticker.C: + if manager.Running() != 500 { + t.Fatalf("soak running supervisors = %d", manager.Running()) + } + if err := database.QuickCheck(ctx); err != nil { + t.Fatalf("soak SQLite quick check: %v", err) + } + } + } + if protocol.heartbeats.Load() <= initialHeartbeats { + t.Fatal("soak produced no additional heartbeats") + } + accountCount, starvedAccount := 0, "" + protocol.counts.Range(func(key, value any) bool { + accountCount++ + if value.(*atomic.Int64).Load() < 2 { + starvedAccount = key.(string) + return false + } + return true + }) + if accountCount != 500 || starvedAccount != "" { + t.Fatalf("heartbeat coverage accounts=%d starved=%q", accountCount, starvedAccount) + } + snapshot, err := database.MetricSnapshot(ctx) + if err != nil { + t.Fatal(err) + } + if generated := protocol.generatedEvents.Load(); generated == 0 || snapshot.InboundQueue["ignored"] != generated { + t.Fatalf("persisted events=%d generated=%d", snapshot.InboundQueue["ignored"], generated) + } + if gap := time.Duration(protocol.maxGap.Load()); gap > 2*manager.heartbeatInterval { + t.Fatalf("maximum heartbeat gap = %v", gap) + } + } + cancel() + manager.Stop() + manager.Wait() + if manager.Running() != 0 { + t.Fatalf("supervisors remaining after stop = %d", manager.Running()) + } + if err := database.IntegrityCheck(context.Background()); err != nil { + t.Fatal(err) + } +} + +type scaleProtocol struct { + heartbeats atomic.Int64 + inflight atomic.Int64 + maxInflight atomic.Int64 + maxGap atomic.Int64 + generatedEvents atomic.Int64 + last sync.Map + counts sync.Map +} + +func (*scaleProtocol) Login(_ context.Context, credentials swt.Credentials, _ swt.Presence, _ string) (swt.Session, error) { + return swt.Session{BaseURL: "https://example.test/", SiteID: credentials.SessionID, LoginName: credentials.Username, MAToken: "token"}, nil +} + +func (p *scaleProtocol) Heartbeat(_ context.Context, session swt.Session, _ swt.Cursor, _, _ string) (swt.HeartbeatResult, error) { + now := time.Now() + counter, _ := p.counts.LoadOrStore(session.SiteID, &atomic.Int64{}) + accountHeartbeat := counter.(*atomic.Int64).Add(1) + if previous, loaded := p.last.Swap(session.SiteID, now); loaded { + gap := now.Sub(previous.(time.Time)).Nanoseconds() + for { + maximum := p.maxGap.Load() + if gap <= maximum || p.maxGap.CompareAndSwap(maximum, gap) { + break + } + } + } + active := p.inflight.Add(1) + for { + maximum := p.maxInflight.Load() + if active <= maximum || p.maxInflight.CompareAndSwap(maximum, active) { + break + } + } + time.Sleep(time.Millisecond) + p.inflight.Add(-1) + p.heartbeats.Add(1) + result := swt.HeartbeatResult{Status: swt.HeartbeatOK} + if accountHeartbeat%12 == 0 { + p.generatedEvents.Add(1) + result.Events = []swt.HeartbeatEvent{{ + SessionID: "visitor-" + session.SiteID, Kind: 1, SeqID: accountHeartbeat, + Timestamp: now.UTC().Format(time.RFC3339Nano), RawLine: fmt.Sprintf("visitor-%s 1 %d", session.SiteID, accountHeartbeat), + }} + } + return result, nil +} + +func (*scaleProtocol) SetPresence(context.Context, swt.Session, swt.Presence) error { return nil } diff --git a/channels/shangwutong/internal/account/manager_test.go b/channels/shangwutong/internal/account/manager_test.go new file mode 100644 index 00000000..81746bb8 --- /dev/null +++ b/channels/shangwutong/internal/account/manager_test.go @@ -0,0 +1,283 @@ +package account + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" +) + +func TestManagerRestoresOneSupervisorAndDoesNotLogoutOnShutdown(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account := createRunnableAccount(t, ctx, database) + session := swt.Session{BaseURL: "http://example.test/", SiteID: "99917999", LoginName: "agent", MAToken: "token"} + if _, err := database.Writer().ApplyAccountSession(ctx, dbgen.ApplyAccountSessionParams{ + BaseUrl: &session.BaseURL, SiteID: &session.SiteID, LoginName: &session.LoginName, + MaToken: &session.MAToken, ActualPresence: "online", ID: account.ID, + }); err != nil { + t.Fatal(err) + } + if err := database.Writer().UpdateAccountCursor(ctx, dbgen.UpdateAccountCursorParams{ + Maxwordid: 42, Maxotick: 9, Maxtmpid: 7, ID: account.ID, + }); err != nil { + t.Fatal(err) + } + protocol := newFakeProtocol() + manager, err := NewManager(database, protocol, nil, nil, 2) + if err != nil { + t.Fatal(err) + } + manager.initialDelay = func(time.Duration) time.Duration { return 0 } + manager.heartbeatInterval = time.Hour + if err := manager.Start(ctx); err != nil { + t.Fatal(err) + } + heartbeat := waitSignal(t, protocol.heartbeats, "heartbeat") + if heartbeat.cursor != (swt.Cursor{MaxWordID: 42, MaxOTick: 9, MaxTmpID: 7}) { + t.Fatalf("restored cursor = %#v", heartbeat.cursor) + } + waitSignal(t, protocol.presences, "presence restore") + if err := manager.WakeInbox(ctx, account.GochatInboxID); err != nil { + t.Fatal(err) + } + if err := manager.WakeInbox(ctx, account.GochatInboxID); err != nil { + t.Fatal(err) + } + if manager.Running() != 1 { + t.Fatalf("running supervisors = %d", manager.Running()) + } + manager.Stop() + manager.Wait() + if protocol.offlineCalls() != 0 { + t.Fatalf("shutdown logout calls = %d", protocol.offlineCalls()) + } +} + +func TestSupervisorRejectsCandidatePasswordWithoutReplacingWorkingSession(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account := createRunnableAccount(t, ctx, database) + session := swt.Session{BaseURL: "http://example.test/", SiteID: "99917999", LoginName: "agent", MAToken: "old-token"} + if _, err := database.Writer().ApplyAccountSession(ctx, dbgen.ApplyAccountSessionParams{ + BaseUrl: &session.BaseURL, SiteID: &session.SiteID, LoginName: &session.LoginName, + MaToken: &session.MAToken, ActualPresence: "online", ID: account.ID, + }); err != nil { + t.Fatal(err) + } + if _, err := database.UpsertAccountConfig(ctx, store.AccountConfig{ + GoChatAccountID: 1, GoChatInboxID: account.GochatInboxID, GoChatInboxIdentifier: "identifier", + ConfigVersion: 2, SessionID: account.SessionID, Username: account.Username, Password: "bad-password", + Enabled: true, DesiredPresence: "online", GoChatHMACToken: "hmac", GoChatWebhookSecret: "secret", + }); err != nil { + t.Fatal(err) + } + protocol := newFakeProtocol() + protocol.loginErr = &swt.Error{Operation: "login", Code: "login_rejected", Retryable: false, Err: errors.New("rejected")} + manager, _ := NewManager(database, protocol, nil, nil, 1) + manager.initialDelay = func(time.Duration) time.Duration { return 0 } + manager.heartbeatInterval = time.Hour + if err := manager.Start(ctx); err != nil { + t.Fatal(err) + } + waitSignal(t, protocol.logins, "candidate login") + deadline := time.Now().Add(time.Second) + for { + loaded, loadErr := database.Reader().GetAccountByID(ctx, account.ID) + if loadErr != nil { + t.Fatal(loadErr) + } + if loaded.CredentialState == "rejected" { + if loaded.Password != "password" || loaded.PendingPassword == nil || *loaded.PendingPassword != "bad-password" || loaded.MaToken == nil || *loaded.MaToken != "old-token" { + t.Fatalf("account = %#v", loaded) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("credential state = %s", loaded.CredentialState) + } + time.Sleep(time.Millisecond) + } + manager.Stop() + manager.Wait() +} + +func TestManagerMergesTypingIntoNextHeartbeat(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account := createRunnableAccount(t, ctx, database) + session := swt.Session{BaseURL: "http://example.test/", SiteID: "99917999", LoginName: "agent", MAToken: "token"} + if _, err := database.Writer().ApplyAccountSession(ctx, dbgen.ApplyAccountSessionParams{ + BaseUrl: &session.BaseURL, SiteID: &session.SiteID, LoginName: &session.LoginName, + MaToken: &session.MAToken, ActualPresence: "online", ID: account.ID, + }); err != nil { + t.Fatal(err) + } + protocol := newFakeProtocol() + manager, _ := NewManager(database, protocol, nil, nil, 1) + manager.initialDelay = func(time.Duration) time.Duration { return 0 } + manager.heartbeatInterval = time.Hour + if err := manager.Start(ctx); err != nil { + t.Fatal(err) + } + waitSignal(t, protocol.heartbeats, "initial heartbeat") + waitSignal(t, protocol.presences, "presence restore") + if err := manager.SetTyping(account.ID, "visitor", true); err != nil { + t.Fatal(err) + } + call := waitSignal(t, protocol.heartbeats, "typing heartbeat") + if call.currentSID != "visitor" || call.typingSID != "visitor" { + t.Fatalf("heartbeat call = %#v", call) + } + manager.Stop() + manager.Wait() +} + +func TestManagerRestartsSupervisorAfterPanic(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account := createRunnableAccount(t, ctx, database) + session := swt.Session{BaseURL: "http://example.test/", SiteID: "99917999", LoginName: "agent", MAToken: "token"} + if _, err := database.Writer().ApplyAccountSession(ctx, dbgen.ApplyAccountSessionParams{ + BaseUrl: &session.BaseURL, SiteID: &session.SiteID, LoginName: &session.LoginName, + MaToken: &session.MAToken, ActualPresence: "online", ID: account.ID, + }); err != nil { + t.Fatal(err) + } + protocol := newFakeProtocol() + protocol.heartbeatPanics = 1 + manager, _ := NewManager(database, protocol, nil, nil, 1) + manager.initialDelay = func(time.Duration) time.Duration { return 0 } + manager.heartbeatInterval = time.Hour + if err := manager.Start(ctx); err != nil { + t.Fatal(err) + } + select { + case <-protocol.heartbeats: + case <-time.After(2 * time.Second): + t.Fatal("supervisor did not restart after panic") + } + if manager.Running() != 1 { + t.Fatalf("running supervisors = %d", manager.Running()) + } + manager.Stop() + manager.Wait() +} + +func TestRetryDelayUsesBoundedDownwardJitter(t *testing.T) { + for range 100 { + first := retryDelay(1, 30*time.Second) + if first < 800*time.Millisecond || first > time.Second { + t.Fatalf("first retry delay = %s", first) + } + capped := retryDelay(20, 30*time.Second) + if capped < 24*time.Second || capped > 30*time.Second { + t.Fatalf("capped retry delay = %s", capped) + } + } +} + +func createRunnableAccount(t *testing.T, ctx context.Context, database *store.Store) *dbgen.Account { + t.Helper() + account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "identifier", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + return account +} + +type fakeProtocol struct { + mu sync.Mutex + loginErr error + logins chan struct{} + heartbeats chan heartbeatCall + presences chan swt.Presence + offline int + heartbeatPanics int +} + +type heartbeatCall struct { + currentSID string + typingSID string + cursor swt.Cursor +} + +func newFakeProtocol() *fakeProtocol { + return &fakeProtocol{ + logins: make(chan struct{}, 8), heartbeats: make(chan heartbeatCall, 8), presences: make(chan swt.Presence, 8), + } +} + +func (f *fakeProtocol) Login(context.Context, swt.Credentials, swt.Presence, string) (swt.Session, error) { + f.logins <- struct{}{} + if f.loginErr != nil { + return swt.Session{}, f.loginErr + } + return swt.Session{BaseURL: "http://example.test/", SiteID: "99917999", LoginName: "agent", MAToken: "new-token"}, nil +} + +func (f *fakeProtocol) Heartbeat(_ context.Context, _ swt.Session, cursor swt.Cursor, currentSID, typingSID string) (swt.HeartbeatResult, error) { + f.mu.Lock() + if f.heartbeatPanics > 0 { + f.heartbeatPanics-- + f.mu.Unlock() + panic("heartbeat panic") + } + f.mu.Unlock() + f.heartbeats <- heartbeatCall{currentSID: currentSID, typingSID: typingSID, cursor: cursor} + return swt.HeartbeatResult{Status: swt.HeartbeatOK}, nil +} + +func (f *fakeProtocol) SetPresence(_ context.Context, _ swt.Session, presence swt.Presence) error { + f.mu.Lock() + if presence == swt.PresenceOffline { + f.offline++ + } + f.mu.Unlock() + f.presences <- presence + return nil +} + +func (f *fakeProtocol) offlineCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.offline +} + +func waitSignal[T any](t *testing.T, signal <-chan T, name string) T { + t.Helper() + select { + case value := <-signal: + return value + case <-time.After(time.Second): + var zero T + t.Fatalf("timed out waiting for %s", name) + return zero + } +} diff --git a/channels/shangwutong/internal/account/reconciler.go b/channels/shangwutong/internal/account/reconciler.go new file mode 100644 index 00000000..1414b930 --- /dev/null +++ b/channels/shangwutong/internal/account/reconciler.go @@ -0,0 +1,130 @@ +package account + +import ( + "context" + "fmt" + "sync" + + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/store" +) + +type ConfigSource interface { + ListInboxConfigs(context.Context) ([]gochat.InboxConfig, error) + GetInboxConfig(context.Context, int64) (gochat.InboxConfig, error) +} + +type Reconciler struct { + source ConfigSource + store *store.Store + wake func(context.Context, int64) error + mu sync.Mutex +} + +func NewReconciler(source ConfigSource, database *store.Store, wake func(context.Context, int64) error) *Reconciler { + return &Reconciler{source: source, store: database, wake: wake} +} + +func (r *Reconciler) ReconcileAll(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + + configs, err := r.source.ListInboxConfigs(ctx) + if err != nil { + return fmt.Errorf("list GoChat inbox configs: %w", err) + } + present := make(map[int64]struct{}, len(configs)) + for _, config := range configs { + if err := config.Validate(); err != nil { + return fmt.Errorf("validate inbox %d: %w", config.InboxID, err) + } + present[config.InboxID] = struct{}{} + if err := r.apply(ctx, config); err != nil { + return err + } + } + deleted, err := r.store.MarkMissingAccountsDeleted(ctx, present) + if err != nil { + return fmt.Errorf("tombstone missing inboxes: %w", err) + } + for _, inboxID := range deleted { + if err := r.notify(ctx, inboxID); err != nil { + return err + } + } + return nil +} + +func (r *Reconciler) ReconcileInbox(ctx context.Context, inboxID int64) error { + r.mu.Lock() + defer r.mu.Unlock() + config, err := r.source.GetInboxConfig(ctx, inboxID) + if err != nil { + return fmt.Errorf("get GoChat inbox %d config: %w", inboxID, err) + } + if err := config.Validate(); err != nil { + return fmt.Errorf("validate inbox %d: %w", inboxID, err) + } + return r.apply(ctx, config) +} + +func (r *Reconciler) BootstrapInbox(ctx context.Context, inboxID int64, verify func(gochat.InboxConfig) error) error { + r.mu.Lock() + defer r.mu.Unlock() + config, err := r.source.GetInboxConfig(ctx, inboxID) + if err != nil { + return fmt.Errorf("get GoChat inbox %d config: %w", inboxID, err) + } + if err := config.Validate(); err != nil { + return fmt.Errorf("validate inbox %d: %w", inboxID, err) + } + if verify == nil { + return fmt.Errorf("verify inbox %d: verifier is required", inboxID) + } + if err := verify(config); err != nil { + return fmt.Errorf("verify inbox %d: %w", inboxID, err) + } + return r.apply(ctx, config) +} + +func (r *Reconciler) DeleteInbox(ctx context.Context, inboxID int64) error { + r.mu.Lock() + defer r.mu.Unlock() + if err := r.store.Writer().MarkAccountDeleted(ctx, inboxID); err != nil { + return fmt.Errorf("tombstone inbox %d: %w", inboxID, err) + } + return r.notify(ctx, inboxID) +} + +func (r *Reconciler) apply(ctx context.Context, config gochat.InboxConfig) error { + result, err := r.store.UpsertAccountConfig(ctx, store.AccountConfig{ + GoChatAccountID: config.AccountID, + GoChatInboxID: config.InboxID, + GoChatInboxIdentifier: config.InboxIdentifier, + ConfigVersion: config.ConfigVersion, + SessionID: config.Credentials.SessionID, + Username: config.Credentials.Username, + Password: config.Credentials.Password, + Enabled: config.Enabled, + DesiredPresence: config.DesiredPresence, + GoChatHMACToken: config.Credentials.HMACToken, + GoChatWebhookSecret: config.Credentials.WebhookSecret, + }) + if err != nil { + return fmt.Errorf("apply inbox %d config: %w", config.InboxID, err) + } + if result.Changed { + return r.notify(ctx, config.InboxID) + } + return nil +} + +func (r *Reconciler) notify(ctx context.Context, inboxID int64) error { + if r.wake == nil { + return nil + } + if err := r.wake(ctx, inboxID); err != nil { + return fmt.Errorf("wake inbox %d: %w", inboxID, err) + } + return nil +} diff --git a/channels/shangwutong/internal/account/reconciler_test.go b/channels/shangwutong/internal/account/reconciler_test.go new file mode 100644 index 00000000..5ac20c06 --- /dev/null +++ b/channels/shangwutong/internal/account/reconciler_test.go @@ -0,0 +1,91 @@ +package account + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/store" +) + +func TestReconcileAllOnlyDeletesAfterCompleteSnapshot(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + source := &fakeConfigSource{configs: []gochat.InboxConfig{testInboxConfig(10)}} + reconciler := NewReconciler(source, database, nil) + if err := reconciler.ReconcileAll(ctx); err != nil { + t.Fatal(err) + } + source.listErr = errors.New("page two unavailable") + source.configs = nil + if err := reconciler.ReconcileAll(ctx); err == nil { + t.Fatal("expected reconcile failure") + } + account, err := database.Reader().GetAccountByInboxID(ctx, 10) + if err != nil || account.DeletedAt != nil { + t.Fatalf("account should remain active: %#v, %v", account, err) + } + source.listErr = nil + if err := reconciler.ReconcileAll(ctx); err != nil { + t.Fatal(err) + } + account, err = database.Reader().GetAccountByInboxID(ctx, 10) + if err != nil || account.DeletedAt == nil { + t.Fatalf("account should be tombstoned: %#v, %v", account, err) + } +} + +func TestReconcileInboxWakesOnlyForNewerConfig(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + source := &fakeConfigSource{config: testInboxConfig(10)} + wakes := 0 + reconciler := NewReconciler(source, database, func(context.Context, int64) error { + wakes++ + return nil + }) + if err := reconciler.ReconcileInbox(ctx, 10); err != nil { + t.Fatal(err) + } + if err := reconciler.ReconcileInbox(ctx, 10); err != nil { + t.Fatal(err) + } + if wakes != 1 { + t.Fatalf("wakes = %d", wakes) + } +} + +type fakeConfigSource struct { + configs []gochat.InboxConfig + config gochat.InboxConfig + listErr error +} + +func (f *fakeConfigSource) ListInboxConfigs(context.Context) ([]gochat.InboxConfig, error) { + return f.configs, f.listErr +} + +func (f *fakeConfigSource) GetInboxConfig(context.Context, int64) (gochat.InboxConfig, error) { + return f.config, nil +} + +func testInboxConfig(inboxID int64) gochat.InboxConfig { + return gochat.InboxConfig{ + SchemaVersion: 1, AccountID: 1, InboxID: inboxID, InboxIdentifier: "identifier", + Enabled: true, DesiredPresence: "online", ConfigVersion: 1, + Credentials: gochat.Credentials{ + SessionID: "BYT99917999", Username: "agent", Password: "password", + HMACToken: "hmac", WebhookSecret: "secret", + }, + } +} diff --git a/channels/shangwutong/internal/account/supervisor.go b/channels/shangwutong/internal/account/supervisor.go new file mode 100644 index 00000000..e554eec8 --- /dev/null +++ b/channels/shangwutong/internal/account/supervisor.go @@ -0,0 +1,448 @@ +package account + +import ( + "context" + "crypto/rand" + "errors" + "math/big" + "strings" + "sync" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/observability" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" + "github.com/sirupsen/logrus" +) + +type supervisor struct { + accountID int64 + store *store.Store + protocol ProtocolClient + logger *logrus.Entry + wake chan struct{} + heartbeatSlots chan struct{} + heartbeatInterval time.Duration + initialDelay time.Duration + notifyStatus func(int64) + metrics *observability.Metrics + + gate sync.RWMutex + session swt.Session + sessionVersion uint64 + lastHeartbeatWrite time.Time + typingMu sync.RWMutex + typingSID string +} + +func (s *supervisor) run(ctx context.Context) error { + if s.initialDelay > 0 { + if !s.wait(ctx, s.initialDelay) { + return nil + } + } + account, err := s.store.Reader().GetAccountByID(ctx, s.accountID) + if err != nil { + return err + } + restored := s.restoreSession(account) + backoffAttempt := 0 + + for { + if ctx.Err() != nil { + return nil + } + account, err = s.store.Reader().GetAccountByID(ctx, s.accountID) + if err != nil { + return err + } + if !store.AccountRunnable(account) { + s.stopForConfiguration(ctx, account) + return nil + } + + if account.PendingPassword != nil && account.CredentialState != "rejected" && account.CredentialState != "verification_required" { + if err := s.login(ctx, account, *account.PendingPassword, true); err != nil { + if s.handleLoginError(ctx, account, err, true) { + return nil + } + backoffAttempt++ + if !s.wait(ctx, retryDelay(backoffAttempt, 30*time.Second)) { + return nil + } + continue + } + restored, backoffAttempt = false, 0 + account, _ = s.store.Reader().GetAccountByID(ctx, s.accountID) + } + + if !s.hasSession() { + if account.PendingPassword != nil && (account.CredentialState == "rejected" || account.CredentialState == "verification_required") { + if !s.waitForWake(ctx) { + return nil + } + continue + } + if err := s.login(ctx, account, account.Password, false); err != nil { + if s.handleLoginError(ctx, account, err, false) { + return nil + } + backoffAttempt++ + if !s.wait(ctx, retryDelay(backoffAttempt, 30*time.Second)) { + return nil + } + continue + } + restored, backoffAttempt = false, 0 + account, _ = s.store.Reader().GetAccountByID(ctx, s.accountID) + } + + result, heartbeatErr := s.heartbeat(ctx, account) + if heartbeatErr != nil { + backoffAttempt++ + s.recordRuntimeError(ctx, account, "degraded", account.CredentialState, heartbeatErr) + if !s.wait(ctx, retryDelay(backoffAttempt, 30*time.Second)) { + return nil + } + continue + } + backoffAttempt = 0 + if result.Status == swt.HeartbeatReset { + s.clearSession() + if err := s.store.Writer().ClearAccountSession(ctx, s.accountID); err != nil { + return err + } + s.notify() + continue + } + if restored || account.ActualPresence != account.DesiredPresence { + if err := s.setPresence(ctx, account, swt.Presence(account.DesiredPresence)); err != nil { + s.recordRuntimeError(ctx, account, "degraded", account.CredentialState, err) + if !s.wait(ctx, retryDelay(1, 30*time.Second)) { + return nil + } + continue + } + restored = false + } + if !s.wait(ctx, s.heartbeatInterval) { + return nil + } + } +} + +func (s *supervisor) login(ctx context.Context, account *dbgen.Account, password string, pending bool) error { + presence := swt.Presence(account.DesiredPresence) + session, err := s.protocol.Login(ctx, swt.Credentials{ + SessionID: account.SessionID, Username: account.Username, Password: password, + }, presence, "") + if err != nil { + s.metrics.Login(loginMetricResult(err)) + return err + } + s.gate.Lock() + defer s.gate.Unlock() + updated, err := s.store.Writer().ApplyAccountSession(ctx, dbgen.ApplyAccountSessionParams{ + BaseUrl: &session.BaseURL, SiteID: &session.SiteID, LoginName: &session.LoginName, + MaToken: &session.MAToken, ActualPresence: account.DesiredPresence, ID: account.ID, + }) + if err != nil { + s.metrics.Login("retryable_error") + return err + } + s.session, s.sessionVersion = session, s.sessionVersion+1 + s.metrics.Login("success") + s.notify() + s.logger.WithFields(logrus.Fields{ + "component": "account_supervisor", "operation": "login", "result": "success", + "config_version": updated.ConfigVersion, "pending_credential": pending, + }).Info("商务通账号登录成功") + return nil +} + +func (s *supervisor) heartbeat(ctx context.Context, account *dbgen.Account) (result swt.HeartbeatResult, err error) { + started := time.Now() + defer func() { s.metrics.Heartbeat(heartbeatMetricResult(result, err), time.Since(started)) }() + select { + case s.heartbeatSlots <- struct{}{}: + defer func() { <-s.heartbeatSlots }() + case <-ctx.Done(): + return swt.HeartbeatResult{}, ctx.Err() + } + typingSID := s.currentTypingSID() + func() { + s.gate.RLock() + defer s.gate.RUnlock() + result, err = s.protocol.Heartbeat(ctx, s.session, swt.Cursor{ + MaxWordID: account.Maxwordid, MaxOTick: account.Maxotick, MaxTmpID: account.Maxtmpid, + }, typingSID, typingSID) + }() + if err != nil { + return result, err + } + if result.Status != swt.HeartbeatOK { + return result, nil + } + if len(result.Events) > 0 { + if _, err := s.store.PersistHeartbeat(ctx, account, result.Events); err != nil { + return result, err + } + s.lastHeartbeatWrite = time.Now() + for _, event := range result.Events { + if event.Kind == 11 { + s.notify() + break + } + } + } else if s.lastHeartbeatWrite.IsZero() || time.Since(s.lastHeartbeatWrite) >= 30*time.Second { + if err := s.store.Writer().TouchAccountHeartbeat(ctx, account.ID); err != nil { + return result, err + } + s.lastHeartbeatWrite = time.Now() + s.notify() + } + return result, nil +} + +func (s *supervisor) setPresence(ctx context.Context, account *dbgen.Account, presence swt.Presence) (err error) { + defer func() { s.metrics.Presence(presenceMetricResult(err)) }() + s.gate.Lock() + defer s.gate.Unlock() + if err := s.protocol.SetPresence(ctx, s.session, presence); err != nil { + return err + } + if err := s.store.Writer().UpdateAccountPresence(ctx, dbgen.UpdateAccountPresenceParams{ + ActualPresence: string(presence), ConnectionStatus: "connected", ID: account.ID, + }); err != nil { + return err + } + if account.PendingPassword == nil { + if err := s.store.Writer().MarkAccountConfigApplied(ctx, account.ID); err != nil { + return err + } + } + s.notify() + return nil +} + +func loginMetricResult(err error) string { + var protocolErr *swt.Error + if !errors.As(err, &protocolErr) || protocolErr.Retryable { + return "retryable_error" + } + if protocolErr.Code == "verification_required" { + return "verification_required" + } + return "rejected" +} + +func heartbeatMetricResult(result swt.HeartbeatResult, err error) string { + if err == nil { + if result.Status == swt.HeartbeatReset { + return "reset" + } + return "success" + } + var protocolErr *swt.Error + if !errors.As(err, &protocolErr) || protocolErr.Retryable { + return "retryable_error" + } + return "permanent_error" +} + +func presenceMetricResult(err error) string { + if err == nil { + return "success" + } + var protocolErr *swt.Error + if !errors.As(err, &protocolErr) || protocolErr.Retryable { + return "retryable_error" + } + return "permanent_error" +} + +func (s *supervisor) stopForConfiguration(ctx context.Context, account *dbgen.Account) { + if s.hasSession() { + stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + _ = s.setPresence(stopCtx, account, swt.PresenceOffline) + cancel() + } + connectionStatus := "offline" + if account.DeletedAt != nil || account.Enabled == 0 { + connectionStatus = "disabled" + } + _ = s.store.Writer().StopAccountRuntime(ctx, dbgen.StopAccountRuntimeParams{ + ConnectionStatus: connectionStatus, ID: account.ID, + }) + s.clearSession() + s.notify() +} + +func (s *supervisor) handleLoginError(ctx context.Context, account *dbgen.Account, err error, pending bool) bool { + var protocolErr *swt.Error + if !errors.As(err, &protocolErr) || protocolErr.Retryable { + s.recordRuntimeError(ctx, account, "degraded", account.CredentialState, err) + return false + } + credentialState, connectionStatus := "rejected", "auth_failed" + if protocolErr.Code == "verification_required" { + credentialState, connectionStatus = "verification_required", "verification_required" + } + if s.hasSession() { + connectionStatus = "connected" + } + code, message := protocolErr.Code, protocolErr.Error() + if pending { + _ = s.store.Writer().RejectPendingCredential(ctx, dbgen.RejectPendingCredentialParams{ + CredentialState: credentialState, ConnectionStatus: connectionStatus, + LastErrorCode: &code, LastErrorMessage: &message, ID: account.ID, + }) + } else { + _ = s.store.Writer().UpdateAccountRuntimeError(ctx, dbgen.UpdateAccountRuntimeErrorParams{ + ConnectionStatus: connectionStatus, CredentialState: credentialState, + LastErrorCode: &code, LastErrorMessage: &message, ID: account.ID, + }) + } + s.notify() + return !s.hasSession() +} + +func (s *supervisor) recordRuntimeError(ctx context.Context, account *dbgen.Account, connectionStatus, credentialState string, err error) { + code, message := "runtime_error", err.Error() + var protocolErr *swt.Error + if errors.As(err, &protocolErr) { + code = protocolErr.Code + } + _ = s.store.Writer().UpdateAccountRuntimeError(ctx, dbgen.UpdateAccountRuntimeErrorParams{ + ConnectionStatus: connectionStatus, CredentialState: credentialState, + LastErrorCode: &code, LastErrorMessage: &message, ID: account.ID, + }) + s.notify() +} + +func (s *supervisor) restoreSession(account *dbgen.Account) bool { + if account.BaseUrl == nil || account.SiteID == nil || account.LoginName == nil || account.MaToken == nil || *account.MaToken == "" { + return false + } + s.gate.Lock() + s.session = swt.Session{BaseURL: *account.BaseUrl, SiteID: *account.SiteID, LoginName: *account.LoginName, MAToken: *account.MaToken} + s.sessionVersion++ + s.gate.Unlock() + return true +} + +func (s *supervisor) hasSession() bool { + s.gate.RLock() + defer s.gate.RUnlock() + return s.session.MAToken != "" +} + +func (s *supervisor) clearSession() { + s.gate.Lock() + s.session = swt.Session{} + s.sessionVersion++ + s.gate.Unlock() +} + +func (s *supervisor) withSession(ctx context.Context, operation func(swt.Session) error) error { + s.gate.RLock() + defer s.gate.RUnlock() + if err := ctx.Err(); err != nil { + return err + } + if s.session.MAToken == "" { + return errors.New("account session is unavailable") + } + return operation(s.session) +} + +func (s *supervisor) wakeNow() { + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *supervisor) setTyping(sid string, typing bool) error { + sid = strings.TrimSpace(sid) + if typing && sid == "" { + return errors.New("sid is required") + } + s.typingMu.Lock() + if typing { + s.typingSID = sid + } else if sid == "" || s.typingSID == sid { + s.typingSID = "" + } + s.typingMu.Unlock() + s.wakeNow() + return nil +} + +func (s *supervisor) currentTypingSID() string { + s.typingMu.RLock() + defer s.typingMu.RUnlock() + return s.typingSID +} + +func (s *supervisor) wait(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-s.wake: + return true + case <-timer.C: + return true + } +} + +func (s *supervisor) waitForWake(ctx context.Context) bool { + select { + case <-ctx.Done(): + return false + case <-s.wake: + return true + } +} + +func (s *supervisor) notify() { + if s.notifyStatus != nil { + s.notifyStatus(s.accountID) + } +} + +func retryDelay(attempt int, maximum time.Duration) time.Duration { + if attempt < 1 { + attempt = 1 + } + delay := time.Second + for range attempt - 1 { + if delay >= maximum/2 { + delay = maximum + break + } + delay *= 2 + } + if delay > maximum { + delay = maximum + } + jitterWindow := delay / 5 + if jitterWindow <= 0 { + return delay + } + return delay - randomDelay(jitterWindow) +} + +func randomDelay(maximum time.Duration) time.Duration { + if maximum <= 0 { + return 0 + } + value, err := rand.Int(rand.Reader, big.NewInt(int64(maximum))) + if err != nil { + return 0 + } + return time.Duration(value.Int64()) +} diff --git a/channels/shangwutong/internal/command/root.go b/channels/shangwutong/internal/command/root.go new file mode 100644 index 00000000..cf27e0b5 --- /dev/null +++ b/channels/shangwutong/internal/command/root.go @@ -0,0 +1,312 @@ +package command + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gochat/gochat/channels/shangwutong/internal/account" + "github.com/gochat/gochat/channels/shangwutong/internal/config" + "github.com/gochat/gochat/channels/shangwutong/internal/delivery" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/httpapi" + "github.com/gochat/gochat/channels/shangwutong/internal/observability" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "shangwutong", + Short: "GoChat 商务通 Connector", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand(newServeCommand(), newMigrateCommand(), newReconcileCommand(), newBackupCommand(), newDoctorCommand()) + return root +} + +func newServeCommand() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "启动 Connector", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return serve(cmd.Context()) + }, + } +} + +func serve(ctx context.Context) error { + cfg, err := config.Load() + if err != nil { + return err + } + logger := observability.NewLogger() + entry := logrus.NewEntry(logger) + metrics := observability.NewMetrics() + database, err := store.Open(ctx, cfg.DBPath) + if err != nil { + return err + } + database.SetWriteObserver(metrics.SQLiteWrite) + defer database.Close() + if _, err := database.Writer().RecoverInboundDeliveries(ctx); err != nil { + return fmt.Errorf("recover inbound queue: %w", err) + } + if _, err := database.Writer().RecoverOutboundPartDeliveriesAsUncertain(ctx); err != nil { + return fmt.Errorf("recover outbound part queue: %w", err) + } + if _, err := database.Writer().RecoverOutboundDeliveriesAsUncertain(ctx); err != nil { + return fmt.Errorf("recover outbound queue: %w", err) + } + if _, err := database.Writer().RecoverOutboundOperationsAsUncertain(ctx); err != nil { + return fmt.Errorf("recover outbound operation queue: %w", err) + } + if _, err := database.Writer().RecoverOutboundStatusSyncs(ctx); err != nil { + return fmt.Errorf("recover outbound status sync queue: %w", err) + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 1024 + transport.MaxIdleConnsPerHost = 128 + sharedHTTPClient := &http.Client{Transport: transport, Timeout: 30 * time.Second} + gochatClient, err := gochat.NewClient(cfg.GoChatBaseURL, cfg.GoChatServiceToken, sharedHTTPClient) + if err != nil { + return err + } + protocolClient := swt.NewClient(sharedHTTPClient) + manager, err := account.NewManager(database, protocolClient, gochatClient, entry, cfg.MaxInflightHeartbeats) + if err != nil { + return err + } + manager.SetMetrics(metrics) + reconciler := account.NewReconciler(gochatClient, database, manager.WakeInbox) + server, err := httpapi.NewServer(database, reconciler, manager, entry, metrics) + if err != nil { + return err + } + if err := server.RefreshMetrics(ctx); err != nil { + return fmt.Errorf("load initial metric snapshot: %w", err) + } + outbound, err := delivery.NewOutbound(database, manager, protocolClient, gochatClient, entry, cfg.OutboundWorkers, cfg.GoChatBaseURL) + if err != nil { + return err + } + outbound.SetMetrics(metrics) + inbound, err := delivery.NewInbound(database, gochatClient, entry, cfg.InboundWorkers) + if err != nil { + return err + } + inbound.SetMetrics(metrics) + + signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stopSignals() + serviceCtx, cancelService := context.WithCancel(context.Background()) + defer cancelService() + if err := manager.Start(serviceCtx); err != nil { + return err + } + server.StartMetrics(serviceCtx) + inbound.Start(serviceCtx) + outbound.Start(serviceCtx) + go reconcileUntilSuccessful(serviceCtx, reconciler.ReconcileAll, entry, time.Second) + + listenErr := make(chan error, 1) + go func() { listenErr <- server.Listen(cfg.Listen) }() + select { + case err = <-listenErr: + case <-signalCtx.Done(): + } + + server.SetReady(false) + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + shutdownErr := server.Shutdown(shutdownCtx) + cancelService() + manager.Stop() + inbound.Wait() + outbound.Wait() + manager.Wait() + checkpointErr := database.Checkpoint(shutdownCtx) + return errors.Join(err, shutdownErr, checkpointErr) +} + +func reconcileUntilSuccessful(ctx context.Context, reconcile func(context.Context) error, entry *logrus.Entry, delay time.Duration) { + if delay <= 0 { + delay = time.Second + } + for { + err := reconcile(ctx) + if err == nil || ctx.Err() != nil { + return + } + entry.WithFields(logrus.Fields{ + "component": "config_reconcile", "operation": "startup", "result": "failed", + }).WithError(err).Warn("startup configuration reconcile failed; local accounts continue running") + waitDelay := delay + var apiErr *gochat.APIError + if errors.As(err, &apiErr) && apiErr.RetryAfter > waitDelay { + waitDelay = apiErr.RetryAfter + } + timer := time.NewTimer(waitDelay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if delay < 5*time.Minute { + delay *= 2 + if delay > 5*time.Minute { + delay = 5 * time.Minute + } + } + } +} + +func newMigrateCommand() *cobra.Command { + return &cobra.Command{ + Use: "migrate up|status", + Short: "执行或查看 SQLite migration", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := config.LoadDBPath() + if err != nil { + return err + } + switch args[0] { + case "up": + database, err := store.Open(cmd.Context(), path) + if err != nil { + return err + } + defer database.Close() + version, err := database.MigrationVersion(cmd.Context()) + if err == nil { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "migration version: %d\n", version) + } + return err + case "status": + version, err := store.InspectDatabase(cmd.Context(), path) + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "migration version: %d\n", version) + return nil + default: + return fmt.Errorf("unsupported migration action %q", args[0]) + } + }, + } +} + +func newReconcileCommand() *cobra.Command { + return &cobra.Command{ + Use: "reconcile", + Short: "触发运行中 Connector 全量同步配置", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + target, err := localAdminURL(os.Getenv("SWT_CONNECTOR_LISTEN")) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(cmd.Context(), http.MethodPost, target+"/internal/reconcile", nil) + if err != nil { + return err + } + response, err := (&http.Client{Timeout: 5 * time.Minute}).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("reconcile endpoint returned %s", response.Status) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "reconcile accepted") + return nil + }, + } +} + +func newBackupCommand() *cobra.Command { + var output string + command := &cobra.Command{ + Use: "backup", + Short: "在线备份 SQLite 数据库", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(output) == "" { + return errors.New("--output is required") + } + path, err := config.LoadDBPath() + if err != nil { + return err + } + database, err := store.Open(cmd.Context(), path) + if err != nil { + return err + } + defer database.Close() + if err := database.Backup(cmd.Context(), output); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "backup written: %s\n", output) + return nil + }, + } + command.Flags().StringVar(&output, "output", "", "备份输出路径") + return command +} + +func newDoctorCommand() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "只读检查配置、SQLite 和 GoChat API", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + version, err := store.InspectDatabase(cmd.Context(), cfg.DBPath) + if err != nil { + return fmt.Errorf("inspect SQLite: %w", err) + } + client, err := gochat.NewClient(cfg.GoChatBaseURL, cfg.GoChatServiceToken, nil) + if err != nil { + return err + } + configs, err := client.ListInboxConfigs(cmd.Context()) + if err != nil { + return fmt.Errorf("check GoChat connector API: %w", err) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ok: migration=%d inboxes=%d\n", version, len(configs)) + return nil + }, + } +} + +func localAdminURL(listen string) (string, error) { + listen = strings.TrimSpace(listen) + if listen == "" { + listen = ":9100" + } + host, port, err := net.SplitHostPort(listen) + if err != nil { + return "", fmt.Errorf("parse SWT_CONNECTOR_LISTEN: %w", err) + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + return "http://" + net.JoinHostPort(host, port), nil +} diff --git a/channels/shangwutong/internal/command/root_test.go b/channels/shangwutong/internal/command/root_test.go new file mode 100644 index 00000000..8e574cf0 --- /dev/null +++ b/channels/shangwutong/internal/command/root_test.go @@ -0,0 +1,127 @@ +package command + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/sirupsen/logrus" +) + +func TestRootCommandExposesOnlyPlannedOperations(t *testing.T) { + root := NewRootCommand() + want := map[string]bool{"serve": false, "migrate": false, "reconcile": false, "backup": false, "doctor": false} + for _, command := range root.Commands() { + if _, ok := want[command.Name()]; ok { + want[command.Name()] = true + } + } + for name, found := range want { + if !found { + t.Fatalf("missing command %s", name) + } + } +} + +func TestBackupRequiresOutput(t *testing.T) { + root := NewRootCommand() + root.SetArgs([]string{"backup"}) + root.SetOut(&bytes.Buffer{}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--output is required") { + t.Fatalf("error = %v", err) + } +} + +func TestMigrateRejectsUnknownAction(t *testing.T) { + t.Setenv("SWT_CONNECTOR_DB_PATH", filepath.Join(t.TempDir(), "connector.db")) + root := NewRootCommand() + root.SetArgs([]string{"migrate", "down"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unsupported migration action") { + t.Fatalf("error = %v", err) + } +} + +func TestLocalAdminURLUsesLoopbackForWildcardListen(t *testing.T) { + got, err := localAdminURL(":9200") + if err != nil || got != "http://127.0.0.1:9200" { + t.Fatalf("URL = %q, %v", got, err) + } +} + +func TestStartupReconcileRetriesUntilFirstCompleteSnapshot(t *testing.T) { + attempts := 0 + logger := logrus.New() + logger.SetOutput(io.Discard) + reconcileUntilSuccessful(context.Background(), func(context.Context) error { + attempts++ + if attempts == 1 { + return errors.New("GoChat unavailable") + } + return nil + }, logrus.NewEntry(logger), time.Millisecond) + if attempts != 2 { + t.Fatalf("attempts = %d", attempts) + } +} + +func TestOperationalCommands(t *testing.T) { + directory := t.TempDir() + databasePath, backupPath := filepath.Join(directory, "connector.db"), filepath.Join(directory, "backup.db") + t.Setenv("SWT_CONNECTOR_DB_PATH", databasePath) + for _, args := range [][]string{{"migrate", "up"}, {"migrate", "status"}, {"backup", "--output", backupPath}} { + root, output := NewRootCommand(), &bytes.Buffer{} + root.SetArgs(args) + root.SetOut(output) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", args, err) + } + if output.Len() == 0 { + t.Fatalf("%v produced no output", args) + } + } + if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 1 { + t.Fatalf("backup version = %d, %v", version, err) + } + + goChat := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/v1/connector/shangwutong/inboxes" || request.Header.Get("Authorization") != "Bearer service-token" { + http.Error(response, "unexpected request", http.StatusBadRequest) + return + } + response.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(response, `{"data":[],"next_cursor":""}`) + })) + defer goChat.Close() + t.Setenv("GOCHAT_BASE_URL", goChat.URL) + t.Setenv("GOCHAT_CONNECTOR_SERVICE_TOKEN", "service-token") + root, output := NewRootCommand(), &bytes.Buffer{} + root.SetArgs([]string{"doctor"}) + root.SetOut(output) + if err := root.Execute(); err != nil || !strings.Contains(output.String(), "inboxes=0") { + t.Fatalf("doctor output=%q err=%v", output.String(), err) + } + + reconcile := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/internal/reconcile" { + http.Error(response, "unexpected request", http.StatusBadRequest) + return + } + response.WriteHeader(http.StatusAccepted) + })) + defer reconcile.Close() + t.Setenv("SWT_CONNECTOR_LISTEN", strings.TrimPrefix(reconcile.URL, "http://")) + root, output = NewRootCommand(), &bytes.Buffer{} + root.SetArgs([]string{"reconcile"}) + root.SetOut(output) + if err := root.Execute(); err != nil || !strings.Contains(output.String(), "reconcile accepted") { + t.Fatalf("reconcile output=%q err=%v", output.String(), err) + } +} diff --git a/channels/shangwutong/internal/config/config.go b/channels/shangwutong/internal/config/config.go new file mode 100644 index 00000000..9d497ce3 --- /dev/null +++ b/channels/shangwutong/internal/config/config.go @@ -0,0 +1,137 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type Config struct { + Listen string + DBPath string + GoChatBaseURL string + GoChatServiceToken string + MaxInflightHeartbeats int + InboundWorkers int + OutboundWorkers int + ShutdownTimeout time.Duration +} + +func Load() (Config, error) { + config := Config{ + Listen: envOrDefault("SWT_CONNECTOR_LISTEN", ":9100"), + DBPath: strings.TrimSpace(os.Getenv("SWT_CONNECTOR_DB_PATH")), + GoChatBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GOCHAT_BASE_URL")), "/"), + GoChatServiceToken: strings.TrimSpace(os.Getenv("GOCHAT_CONNECTOR_SERVICE_TOKEN")), + MaxInflightHeartbeats: 64, + InboundWorkers: 8, + OutboundWorkers: 8, + ShutdownTimeout: 30 * time.Second, + } + var err error + if config.MaxInflightHeartbeats, err = parsePositiveInt("SWT_MAX_INFLIGHT_HEARTBEATS", config.MaxInflightHeartbeats); err != nil { + return Config{}, err + } + if config.InboundWorkers, err = parsePositiveInt("SWT_INBOUND_WORKERS", config.InboundWorkers); err != nil { + return Config{}, err + } + if config.OutboundWorkers, err = parsePositiveInt("SWT_OUTBOUND_WORKERS", config.OutboundWorkers); err != nil { + return Config{}, err + } + if raw := strings.TrimSpace(os.Getenv("SWT_SHUTDOWN_TIMEOUT")); raw != "" { + config.ShutdownTimeout, err = time.ParseDuration(raw) + if err != nil || config.ShutdownTimeout <= 0 { + return Config{}, errors.New("SWT_SHUTDOWN_TIMEOUT must be a positive duration") + } + } + if err := config.Validate(); err != nil { + return Config{}, err + } + return config, nil +} + +func LoadDBPath() (string, error) { + path := strings.TrimSpace(os.Getenv("SWT_CONNECTOR_DB_PATH")) + if path == "" { + return "", errors.New("SWT_CONNECTOR_DB_PATH is required") + } + if err := validateDBDirectory(path); err != nil { + return "", err + } + return path, nil +} + +func (c Config) Validate() error { + if strings.TrimSpace(c.Listen) == "" { + return errors.New("SWT_CONNECTOR_LISTEN is required") + } + if c.DBPath == "" { + return errors.New("SWT_CONNECTOR_DB_PATH is required") + } + if c.GoChatBaseURL == "" { + return errors.New("GOCHAT_BASE_URL is required") + } + parsed, err := url.Parse(c.GoChatBaseURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return errors.New("GOCHAT_BASE_URL must be an absolute HTTP(S) URL without userinfo or fragment") + } + if c.GoChatServiceToken == "" { + return errors.New("GOCHAT_CONNECTOR_SERVICE_TOKEN is required") + } + if c.MaxInflightHeartbeats <= 0 || c.InboundWorkers <= 0 || c.OutboundWorkers <= 0 || c.ShutdownTimeout <= 0 { + return errors.New("worker counts and shutdown timeout must be positive") + } + return validateDBDirectory(c.DBPath) +} + +func validateDBDirectory(dbPath string) error { + absolute, err := filepath.Abs(dbPath) + if err != nil { + return fmt.Errorf("resolve database path: %w", err) + } + directory := filepath.Dir(absolute) + info, err := os.Stat(directory) + if err != nil { + return fmt.Errorf("database directory: %w", err) + } + if !info.IsDir() { + return errors.New("database parent is not a directory") + } + probe, err := os.CreateTemp(directory, ".swt-write-check-*") + if err != nil { + return fmt.Errorf("database directory is not writable: %w", err) + } + probePath := probe.Name() + if closeErr := probe.Close(); closeErr != nil { + _ = os.Remove(probePath) + return fmt.Errorf("close database write probe: %w", closeErr) + } + if err := os.Remove(probePath); err != nil { + return fmt.Errorf("remove database write probe: %w", err) + } + return nil +} + +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func parsePositiveInt(key string, fallback int) (int, error) { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", key) + } + return value, nil +} diff --git a/channels/shangwutong/internal/config/config_test.go b/channels/shangwutong/internal/config/config_test.go new file mode 100644 index 00000000..76db60ce --- /dev/null +++ b/channels/shangwutong/internal/config/config_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "path/filepath" + "testing" + "time" +) + +func TestLoad(t *testing.T) { + t.Setenv("SWT_CONNECTOR_DB_PATH", filepath.Join(t.TempDir(), "connector.db")) + t.Setenv("GOCHAT_BASE_URL", "http://gochat:3000/") + t.Setenv("GOCHAT_CONNECTOR_SERVICE_TOKEN", "token") + t.Setenv("SWT_OUTBOUND_WORKERS", "4") + t.Setenv("SWT_SHUTDOWN_TIMEOUT", "12s") + config, err := Load() + if err != nil { + t.Fatal(err) + } + if config.GoChatBaseURL != "http://gochat:3000" || config.OutboundWorkers != 4 || config.ShutdownTimeout != 12*time.Second { + t.Fatalf("unexpected config: %#v", config) + } +} + +func TestLoadRejectsMissingRequiredValues(t *testing.T) { + t.Setenv("SWT_CONNECTOR_DB_PATH", "") + t.Setenv("GOCHAT_BASE_URL", "") + t.Setenv("GOCHAT_CONNECTOR_SERVICE_TOKEN", "") + if _, err := Load(); err == nil { + t.Fatal("expected validation error") + } +} + +func TestValidateRejectsUnsafeBaseURL(t *testing.T) { + config := Config{ + Listen: ":9100", + DBPath: filepath.Join(t.TempDir(), "connector.db"), + GoChatBaseURL: "http://user:pass@gochat:3000/#fragment", + GoChatServiceToken: "token", + MaxInflightHeartbeats: 1, + InboundWorkers: 1, + OutboundWorkers: 1, + ShutdownTimeout: time.Second, + } + if err := config.Validate(); err == nil { + t.Fatal("expected URL validation error") + } +} diff --git a/channels/shangwutong/internal/delivery/inbound.go b/channels/shangwutong/internal/delivery/inbound.go new file mode 100644 index 00000000..9bbc1940 --- /dev/null +++ b/channels/shangwutong/internal/delivery/inbound.go @@ -0,0 +1,556 @@ +package delivery + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/observability" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/sirupsen/logrus" +) + +const echoMatchWindow = 10 * time.Minute + +type InboundClient interface { + EnsureContact(context.Context, string, string, gochat.ContactRequest) (gochat.Contact, error) + UpdateContact(context.Context, string, string, string, gochat.ContactRequest) (gochat.Contact, error) + EnsureConversation(context.Context, string, string, map[string]any) (gochat.PublicConversation, error) + ImportMessage(context.Context, int64, int64, gochat.MessageImport) (gochat.ImportedMessage, error) + ImportMessageWithAttachments(context.Context, int64, int64, gochat.MessageImport, []gochat.AttachmentUpload) (gochat.ImportedMessage, error) + UpdateConversationAttributes(context.Context, int64, int64, map[string]any, string) error + SetConversationStatus(context.Context, int64, int64, string, string) error + SetVisitorTyping(context.Context, string, string, int64, bool) error + RetractMessage(context.Context, int64, int64, int64, string) error +} + +type Inbound struct { + store *store.Store + client InboundClient + logger *logrus.Entry + workers int + media mediaFetcher + metrics *observability.Metrics + wg sync.WaitGroup +} + +func NewInbound(database *store.Store, client InboundClient, logger *logrus.Entry, workers int) (*Inbound, error) { + if database == nil || client == nil || workers <= 0 { + return nil, errors.New("store, GoChat client and positive worker count are required") + } + if logger == nil { + logger = logrus.NewEntry(logrus.New()) + } + return &Inbound{store: database, client: client, logger: logger, workers: workers, media: newMediaDownloader()}, nil +} + +func (i *Inbound) Start(ctx context.Context) { + for range i.workers { + i.wg.Add(1) + go i.loop(ctx) + } +} + +func (i *Inbound) Wait() { i.wg.Wait() } + +func (i *Inbound) SetMetrics(metrics *observability.Metrics) { i.metrics = metrics } + +func (i *Inbound) loop(ctx context.Context) { + defer i.wg.Done() + for ctx.Err() == nil { + worked, err := i.processInbound(ctx) + if err != nil && ctx.Err() == nil { + i.logger.WithFields(logrus.Fields{ + "component": "inbound_worker", "operation": "deliver", "result": "failed", + }).WithError(err).Warn("inbound event delivery failed") + } + if !worked && !wait(ctx, 200*time.Millisecond) { + return + } + } +} + +func (i *Inbound) processInbound(ctx context.Context) (bool, error) { + event, err := i.store.Writer().ClaimInboundEvent(ctx) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + account, err := i.store.Reader().GetAccountByID(ctx, event.AccountID) + if err != nil { + return true, i.retryOrFail(event, mappedEvent{Strategy: "raw_only", RawOnly: true}, err) + } + text, operator, rawTimestamp := value(event.Text), value(event.OpName), value(event.SwtTimestamp) + sourceID := event.SwtEventKey + ":0" + mapped := mapInboundEvent(event.Kind, event.SeqID, text, operator, rawTimestamp, sourceID, account.GochatInboxID, event.CreatedAt) + + if event.Kind == 3 && mapped.Message != nil { + handled, echoErr := i.confirmOutboundEcho(ctx, account, event, mapped) + if handled { + return true, echoErr + } + } + if mapped.RetractionTarget != "" { + return true, i.deliverRetraction(ctx, account, event, mapped) + } + if mapped.RawOnly && !mapped.RequiresContact && !mapped.RequiresConversation && mapped.Message == nil { + return true, i.complete(event, mapped, nil) + } + + state, err := i.ensureResources(ctx, account, event, mapped, false) + if err != nil { + return true, i.retryOrFail(event, mapped, err) + } + imported, err := i.applyMappedEvent(ctx, account, event, &mapped, &state) + if isNotFound(err) && mapped.RequiresConversation { + state.conversationID, state.displayID = 0, 0 + state, err = i.ensureResources(ctx, account, event, mappedEvent{RequiresContact: true, RequiresConversation: true}, true) + if err == nil { + imported, err = i.applyMappedEvent(ctx, account, event, &mapped, &state) + } + } + if err != nil { + return true, i.retryOrFail(event, mapped, err) + } + return true, i.persistDelivered(event, mapped, state, imported) +} + +type inboundState struct { + contactID int64 + conversationID int64 + displayID int64 +} + +type outboundEchoMatch struct { + message *dbgen.OutboundMessage + part *dbgen.OutboundPart +} + +func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, event *dbgen.InboundEvent, mapped mappedEvent, forceConversation bool) (inboundState, error) { + state := inboundState{} + conversationMap, err := i.store.Reader().GetConversationMap(ctx, dbgen.GetConversationMapParams{AccountID: account.ID, SwtSid: event.SwtSid}) + if err == nil { + state.contactID = pointed(conversationMap.GochatContactID) + state.conversationID = pointed(conversationMap.GochatConversationID) + state.displayID = pointed(conversationMap.GochatDisplayID) + } else if !errors.Is(err, sql.ErrNoRows) { + return state, err + } + if forceConversation { + state.conversationID, state.displayID = 0, 0 + } + + contactAttributes := cloneMap(mapped.ContactAttributes) + contactAttributes["swt_inbox_id"] = account.GochatInboxID + contactRequest := gochat.ContactRequest{ + SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone, + CustomAttributes: contactAttributes, + } + needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0 + if mapped.RequiresContact && needsContactWrite { + var contact gochat.Contact + if state.contactID == 0 { + contactRequest.Name = firstText(contactRequest.Name, "商务通访客") + contact, err = i.client.EnsureContact(ctx, account.GochatInboxIdentifier, account.GochatHmacToken, contactRequest) + } else { + contact, err = i.client.UpdateContact(ctx, account.GochatInboxIdentifier, account.GochatHmacToken, event.SwtSid, contactRequest) + if isNotFound(err) { + contactRequest.Name = firstText(contactRequest.Name, "商务通访客") + contact, err = i.client.EnsureContact(ctx, account.GochatInboxIdentifier, account.GochatHmacToken, contactRequest) + } + } + if err != nil { + return state, err + } + state.contactID = contact.ID + } + if !mapped.RequiresConversation || (state.conversationID > 0 && state.displayID > 0) { + return state, nil + } + if state.contactID == 0 { + contactRequest.Name = "商务通访客" + contact, contactErr := i.client.EnsureContact(ctx, account.GochatInboxIdentifier, account.GochatHmacToken, contactRequest) + if contactErr != nil { + return state, contactErr + } + state.contactID = contact.ID + } + attributes := cloneMap(mapped.ConversationAttrs) + attributes["swt_sid"], attributes["swt_inbox_id"] = event.SwtSid, account.GochatInboxID + conversation, err := i.client.EnsureConversation(ctx, account.GochatInboxIdentifier, event.SwtSid, attributes) + if err != nil { + return state, err + } + state.conversationID, state.displayID = conversation.InternalID, conversation.ID + return state, nil +} + +func (i *Inbound) applyMappedEvent(ctx context.Context, account *dbgen.Account, event *dbgen.InboundEvent, mapped *mappedEvent, state *inboundState) (*gochat.ImportedMessage, error) { + if len(mapped.ConversationAttrs) > 0 && state.conversationID > 0 { + if err := i.client.UpdateConversationAttributes(ctx, account.GochatAccountID, state.conversationID, mapped.ConversationAttrs, event.SwtEventKey+":attributes"); err != nil { + return nil, err + } + } + if mapped.ConversationStatus != "" { + if err := i.client.SetConversationStatus(ctx, account.GochatAccountID, state.conversationID, mapped.ConversationStatus, event.SwtEventKey+":status"); err != nil { + return nil, err + } + } + if mapped.Typing != nil { + if err := i.client.SetVisitorTyping(ctx, account.GochatInboxIdentifier, event.SwtSid, state.displayID, *mapped.Typing); err != nil { + return nil, err + } + } + if mapped.Message == nil { + return nil, nil + } + message := *mapped.Message + var imported gochat.ImportedMessage + var err error + if len(mapped.Media) == 0 { + imported, err = i.client.ImportMessage(ctx, account.GochatAccountID, state.conversationID, message) + } else { + uploads := make([]gochat.AttachmentUpload, 0, len(mapped.Media)) + cleanups := make([]func(), 0, len(mapped.Media)) + for _, reference := range mapped.Media { + upload, cleanup, fetchErr := i.media.Fetch(ctx, reference) + if fetchErr != nil { + for _, cleanup := range cleanups { + cleanup() + } + var classified *mediaFetchError + if (!errors.As(fetchErr, &classified) || classified.Retryable) && event.Attempts < 3 { + return nil, fetchErr + } + message.Content = mediaFallback(message.Content, mapped.Media) + mapped.Strategy = "fallback_text" + if attributes, ok := message.ContentAttributes["swt"].(map[string]any); ok { + attributes["unparsed"] = true + attributes["media_fallback"] = true + } + imported, err = i.client.ImportMessage(ctx, account.GochatAccountID, state.conversationID, message) + break + } + uploads, cleanups = append(uploads, upload), append(cleanups, cleanup) + } + if len(uploads) == len(mapped.Media) { + for _, cleanup := range cleanups { + defer cleanup() + } + imported, err = i.client.ImportMessageWithAttachments(ctx, account.GochatAccountID, state.conversationID, message, uploads) + } + } + if err != nil { + return nil, err + } + if imported.ID <= 0 { + return nil, errors.New("GoChat message import returned no message ID") + } + return &imported, nil +} + +func mediaFallback(content string, references []mediaReference) string { + parts := make([]string, 0, len(references)+1) + if strings.TrimSpace(content) != "" { + parts = append(parts, strings.TrimSpace(content)) + } + for _, reference := range references { + parts = append(parts, "[附件无法下载] "+reference.URL) + } + return strings.Join(parts, "\n") +} + +func (i *Inbound) confirmOutboundEcho(ctx context.Context, account *dbgen.Account, event *dbgen.InboundEvent, mapped mappedEvent) (bool, error) { + candidates, err := i.store.Reader().ListOutboundEchoCandidates(ctx, dbgen.ListOutboundEchoCandidatesParams{AccountID: account.ID, SwtSid: event.SwtSid}) + if err != nil { + return true, i.retryOrFail(event, mapped, err) + } + eventTime := parseSWTTime(value(event.SwtTimestamp), event.CreatedAt) + matches := make([]outboundEchoMatch, 0, 1) + for _, candidate := range candidates { + parts, partsErr := i.store.Reader().ListOutboundParts(ctx, candidate.ID) + if partsErr != nil { + return true, i.retryOrFail(event, mapped, partsErr) + } + for _, part := range parts { + if outboundPartMatchesEcho(part, mapped, eventTime) { + matches = append(matches, outboundEchoMatch{message: candidate, part: part}) + } + } + } + if len(matches) == 0 { + return false, nil + } + if len(matches) > 1 { + i.metrics.Delivery("outbound", "ambiguous") + i.logger.WithFields(logrus.Fields{ + "component": "inbound_worker", "operation": "outbound_echo_match", "result": "ambiguous", + "connector_account_id": account.ID, "gochat_inbox_id": account.GochatInboxID, + "swt_sid": event.SwtSid, "swt_seq_id": event.SeqID, "candidate_parts": len(matches), + }).Warn("kind=3 echo has multiple outbound part candidates; importing without confirming delivery") + return false, nil + } + match := matches[0] + externalID := fmt.Sprintf("%d", event.SeqID) + mapped.Strategy = "outbound_echo" + normalized := mapped.normalizedJSON() + fingerprint := contentFingerprint(mapped.Message.Content) + err = i.store.WithTx(context.Background(), func(queries *dbgen.Queries) error { + rows, confirmErr := queries.ConfirmOutboundPartEcho(context.Background(), dbgen.ConfirmOutboundPartEchoParams{ + ExternalID: &externalID, ID: match.part.ID, + }) + if confirmErr != nil { + return confirmErr + } + if rows != 1 { + return errors.New("outbound echo part was already confirmed by another worker") + } + rows, confirmErr = queries.ConfirmOutboundEcho(context.Background(), dbgen.ConfirmOutboundEchoParams{ + ExternalID: &externalID, ID: match.message.ID, + }) + if confirmErr != nil { + return confirmErr + } + if rows != 1 { + return errors.New("outbound echo message is no longer confirmable") + } + if err := queries.InsertMessageMap(context.Background(), dbgen.InsertMessageMapParams{ + AccountID: account.ID, SwtSid: event.SwtSid, SwtMessageID: &externalID, SwtSeqID: event.SeqID, + Kind: event.Kind, Direction: "outgoing", GochatMessageID: match.message.GochatMessageID, ContentFingerprint: &fingerprint, + }); err != nil { + return err + } + strategy := mapped.Strategy + return queries.CompleteInboundEventWithMapping(context.Background(), dbgen.CompleteInboundEventWithMappingParams{ + GochatMessageID: &match.message.GochatMessageID, EventSubtype: optionalInboundText(mapped.Subtype), + NormalizedPayload: normalized, MappingStrategy: &strategy, ID: event.ID, + }) + }) + if err == nil { + i.metrics.Mapping(event.Kind, mapped.Strategy, "delivered") + i.metrics.Delivery("outbound", "delivered") + } + return true, err +} + +func outboundPartMatchesEcho(part *dbgen.OutboundPart, mapped mappedEvent, eventTime time.Time) bool { + if part == nil || part.ExternalID != nil || (part.DeliveryStatus != "delivered" && part.DeliveryStatus != "uncertain") { + return false + } + if delta := eventTime.Sub(part.UpdatedAt); delta < -echoMatchWindow || delta > echoMatchWindow { + return false + } + if len(mapped.Media) > 1 { + return false + } + if len(mapped.Media) == 1 { + return part.PartType == mapped.Media[0].FileType + } + return part.PartType == "text" && part.Content != nil && cleanText(*part.Content) == mapped.Message.Content +} + +func (i *Inbound) deliverRetraction(ctx context.Context, account *dbgen.Account, event *dbgen.InboundEvent, mapped mappedEvent) error { + target := mapped.RetractionTarget + messageMap, err := i.store.Reader().GetMessageMapBySWTMessageID(ctx, dbgen.GetMessageMapBySWTMessageIDParams{ + AccountID: account.ID, SwtSid: event.SwtSid, SwtMessageID: &target, + }) + if errors.Is(err, sql.ErrNoRows) { + return i.fail(event, mapped, errors.New("unmapped_retraction: target message is not mapped")) + } + if err != nil { + return i.retryOrFail(event, mapped, err) + } + conversationMap, err := i.store.Reader().GetConversationMap(ctx, dbgen.GetConversationMapParams{AccountID: account.ID, SwtSid: event.SwtSid}) + if err != nil || conversationMap.GochatConversationID == nil { + if err == nil { + err = errors.New("unmapped_retraction: conversation is not mapped") + } + return i.fail(event, mapped, err) + } + if messageMap.RetractedAt == nil { + if err := i.client.RetractMessage(ctx, account.GochatAccountID, *conversationMap.GochatConversationID, messageMap.GochatMessageID, event.SwtEventKey+":retract"); err != nil { + return i.retryOrFail(event, mapped, err) + } + } + err = i.store.WithTx(context.Background(), func(queries *dbgen.Queries) error { + if err := queries.MarkMessageMapRetracted(context.Background(), dbgen.MarkMessageMapRetractedParams{ + AccountID: account.ID, SwtSid: event.SwtSid, SwtMessageID: &target, + }); err != nil { + return err + } + strategy := mapped.Strategy + return queries.CompleteInboundEventWithMapping(context.Background(), dbgen.CompleteInboundEventWithMappingParams{ + GochatMessageID: &messageMap.GochatMessageID, EventSubtype: optionalInboundText(mapped.Subtype), + NormalizedPayload: mapped.normalizedJSON(), MappingStrategy: &strategy, ID: event.ID, + }) + }) + if err == nil { + i.metrics.Mapping(event.Kind, mapped.Strategy, "delivered") + i.metrics.Delivery("inbound", "delivered") + } + return err +} + +func (i *Inbound) persistDelivered(event *dbgen.InboundEvent, mapped mappedEvent, state inboundState, imported *gochat.ImportedMessage) error { + err := i.store.WithTx(context.Background(), func(queries *dbgen.Queries) error { + if mapped.RequiresContact || mapped.RequiresConversation { + params := dbgen.UpsertConversationMapParams{ + AccountID: event.AccountID, SwtSid: event.SwtSid, GochatContactSourceID: event.SwtSid, + GochatContactID: pointer(state.contactID), GochatConversationID: pointer(state.conversationID), GochatDisplayID: pointer(state.displayID), + } + if assignee, ok := mapped.ConversationAttrs["swt_assignee_name"].(string); ok && assignee != "" { + params.SwtAssigneeName = &assignee + } + if _, err := queries.UpsertConversationMap(context.Background(), params); err != nil { + return err + } + } + var messageID *int64 + if imported != nil { + messageID = &imported.ID + if mapped.Message.MessageType == "incoming" || mapped.Message.MessageType == "outgoing" { + direction := mapped.Message.MessageType + var swtMessageID *string + if event.Kind == 2 || event.Kind == 3 { + value := fmt.Sprintf("%d", event.SeqID) + swtMessageID = &value + } + fingerprint, sourceID := contentFingerprint(mapped.Message.Content), mapped.Message.SourceID + if err := queries.InsertMessageMap(context.Background(), dbgen.InsertMessageMapParams{ + AccountID: event.AccountID, SwtSid: event.SwtSid, SwtMessageID: swtMessageID, SwtSeqID: event.SeqID, + Kind: event.Kind, Direction: direction, GochatMessageID: imported.ID, GochatSourceID: &sourceID, ContentFingerprint: &fingerprint, + }); err != nil { + return err + } + } + } + strategy := mapped.Strategy + return queries.CompleteInboundEventWithMapping(context.Background(), dbgen.CompleteInboundEventWithMappingParams{ + GochatMessageID: messageID, EventSubtype: optionalInboundText(mapped.Subtype), + NormalizedPayload: mapped.normalizedJSON(), MappingStrategy: &strategy, ID: event.ID, + }) + }) + if err == nil { + i.recordMappingResult(event, mapped, "delivered") + i.metrics.Delivery("inbound", "delivered") + } + return err +} + +func (i *Inbound) complete(event *dbgen.InboundEvent, mapped mappedEvent, messageID *int64) error { + strategy := mapped.Strategy + err := i.store.Writer().CompleteInboundEventWithMapping(context.Background(), dbgen.CompleteInboundEventWithMappingParams{ + GochatMessageID: messageID, EventSubtype: optionalInboundText(mapped.Subtype), + NormalizedPayload: mapped.normalizedJSON(), MappingStrategy: &strategy, ID: event.ID, + }) + if err == nil { + i.recordMappingResult(event, mapped, "delivered") + i.metrics.Delivery("inbound", "delivered") + } + return err +} + +func (i *Inbound) retryOrFail(event *dbgen.InboundEvent, mapped mappedEvent, deliveryErr error) error { + var apiErr *gochat.APIError + retryable := !errors.As(deliveryErr, &apiErr) || apiErr.Retryable + if retryable && event.Attempts < 10 { + detail := deliveryErr.Error() + next := time.Now().Add(backoffForError(event.Attempts, 5*time.Minute, deliveryErr)) + persistErr := i.store.Writer().RetryInboundEvent(context.Background(), dbgen.RetryInboundEventParams{ + NextAttemptAt: &next, LastError: &detail, ID: event.ID, + }) + i.metrics.Mapping(event.Kind, mapped.Strategy, "retry") + i.metrics.Delivery("inbound", "retry") + return errors.Join(deliveryErr, persistErr) + } + return i.fail(event, mapped, deliveryErr) +} + +func (i *Inbound) fail(event *dbgen.InboundEvent, mapped mappedEvent, deliveryErr error) error { + detail, strategy := deliveryErr.Error(), mapped.Strategy + persistErr := i.store.Writer().FailInboundEvent(context.Background(), dbgen.FailInboundEventParams{ + MappingStrategy: &strategy, LastError: &detail, ID: event.ID, + }) + i.recordMappingResult(event, mapped, "failed") + i.metrics.Delivery("inbound", "failed") + if strings.Contains(detail, "unmapped_retraction") { + direction := "outgoing" + if event.Kind == -4 { + direction = "incoming" + } + i.metrics.UnmappedRetraction(direction) + } + return errors.Join(deliveryErr, persistErr) +} + +func (i *Inbound) recordMappingResult(event *dbgen.InboundEvent, mapped mappedEvent, result string) { + i.metrics.Mapping(event.Kind, mapped.Strategy, result) + if mapped.RawOnly && !knownInboundKind(event.Kind) { + i.metrics.Unknown(event.Kind) + } +} + +func knownInboundKind(kind int64) bool { + switch kind { + case -8, -5, -4, 0, 2, 3, 5, 7, 8, 11, 12, 15, 26, 29, 30, 31, 34, 35, 39, 41, 52, 56, 58, 61, 65, 66, 67, 71: + return true + default: + return false + } +} + +func isNotFound(err error) bool { + var apiErr *gochat.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == 404 +} + +func contentFingerprint(content string) string { + digest := sha256.Sum256([]byte(strings.TrimSpace(content))) + return hex.EncodeToString(digest[:]) +} + +func cloneMap(source map[string]any) map[string]any { + clone := make(map[string]any, len(source)+2) + for key, value := range source { + clone[key] = value + } + return clone +} + +func pointer(value int64) *int64 { + if value <= 0 { + return nil + } + return &value +} + +func pointed(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func value(value *string) string { + if value == nil { + return "" + } + return *value +} + +func optionalInboundText(value string) *string { + if value == "" { + return nil + } + return &value +} diff --git a/channels/shangwutong/internal/delivery/inbound_test.go b/channels/shangwutong/internal/delivery/inbound_test.go new file mode 100644 index 00000000..bceb2a7e --- /dev/null +++ b/channels/shangwutong/internal/delivery/inbound_test.go @@ -0,0 +1,460 @@ +package delivery + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" +) + +func TestInboundVisitorMessageCreatesResourcesAndPersistsMappings(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 2, Text: "

您好
咨询

", SeqID: 42, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 2 message 42 timestamp", + }) + client := &inboundRecorder{} + worker, err := NewInbound(database, client, nil, 1) + if err != nil { + t.Fatal(err) + } + if worked, err := worker.processInbound(ctx); err != nil || !worked { + t.Fatalf("process inbound = %v, %v", worked, err) + } + if len(client.imports) != 1 || client.imports[0].SourceID != "swt:10:visitor:2:42:0" || client.imports[0].Content != "您好\n咨询" { + t.Fatalf("imports = %#v", client.imports) + } + event, err := database.Reader().GetInboundEventByKey(ctx, "swt:10:visitor:2:42") + if err != nil || event.DeliveryStatus != "delivered" || event.MappingStrategy == nil || *event.MappingStrategy != "native_message" { + t.Fatalf("event = %#v, %v", event, err) + } + messageMap, err := database.Reader().GetMessageMapBySWTMessageID(ctx, dbgen.GetMessageMapBySWTMessageIDParams{ + AccountID: account.ID, SwtSid: "visitor", SwtMessageID: stringPointer("42"), + }) + if err != nil || messageMap.GochatMessageID != 9001 || messageMap.GochatSourceID == nil || *messageMap.GochatSourceID != client.imports[0].SourceID { + t.Fatalf("message map = %#v, %v", messageMap, err) + } + conversationMap, err := database.Reader().GetConversationMap(ctx, dbgen.GetConversationMapParams{AccountID: account.ID, SwtSid: "visitor"}) + if err != nil || pointed(conversationMap.GochatConversationID) != 100 || pointed(conversationMap.GochatDisplayID) != 88 { + t.Fatalf("conversation map = %#v, %v", conversationMap, err) + } +} + +func TestInboundKind3ConfirmsUniqueOutboundEcho(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Content: stringPointer("hello"), Payload: deliveryPayload(t, nil), + }, false); err != nil { + t.Fatal(err) + } + queued, err := database.Writer().ClaimOutboundMessage(ctx) + if err != nil { + t.Fatal(err) + } + completeOutboundParts(t, database, queued.ID) + if err := database.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ID: queued.ID}); err != nil { + t.Fatal(err) + } + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 3, OpName: "agent", Text: "hello", SeqID: 51, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 3 agent|hello 51 timestamp", + }) + client := &inboundRecorder{} + worker, _ := NewInbound(database, client, nil, 1) + if worked, err := worker.processInbound(ctx); err != nil || !worked { + t.Fatalf("process echo = %v, %v", worked, err) + } + if len(client.imports) != 0 { + t.Fatalf("echo must not be imported again: %#v", client.imports) + } + queued, err = database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + if err != nil || queued.ExternalID == nil || *queued.ExternalID != "51" || queued.StatusSyncStatus != "pending" { + t.Fatalf("outbound = %#v, %v", queued, err) + } + messageMap, err := database.Reader().GetMessageMapBySWTMessageID(ctx, dbgen.GetMessageMapBySWTMessageIDParams{ + AccountID: account.ID, SwtSid: "visitor", SwtMessageID: stringPointer("51"), + }) + if err != nil || messageMap.GochatMessageID != 77 { + t.Fatalf("message map = %#v, %v", messageMap, err) + } +} + +func TestInboundKind3ConfirmsMultipleOutboundPartsIndependently(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + content, dataURL := "hello", "https://gochat.test/document.pdf" + queued, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Content: &content, Payload: deliveryPayload(t, nil), + Parts: []store.OutboundPartInput{ + {Type: "text", Content: &content}, + {Type: "file", DataURL: &dataURL}, + }, + }, false) + if err != nil { + t.Fatal(err) + } + queued, err = database.Writer().ClaimOutboundMessage(ctx) + if err != nil { + t.Fatal(err) + } + completeOutboundParts(t, database, queued.ID) + if err := database.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ID: queued.ID}); err != nil { + t.Fatal(err) + } + + client := &inboundRecorder{} + worker, _ := NewInbound(database, client, nil, 1) + for _, event := range []swt.HeartbeatEvent{ + {SessionID: "visitor", Kind: 3, OpName: "agent", Text: "hello", SeqID: 51, Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 3 agent|hello 51 timestamp"}, + {SessionID: "visitor", Kind: 3, OpName: "agent", Text: "filemsg|0|https://media.example/document.pdf", SeqID: 52, Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 3 agent|file 52 timestamp"}, + } { + persistInboundEvent(t, database, account, event) + if worked, processErr := worker.processInbound(ctx); processErr != nil || !worked { + t.Fatalf("process echo %d = %v, %v", event.SeqID, worked, processErr) + } + } + if len(client.imports) != 0 { + t.Fatalf("echoes must not be imported again: %#v", client.imports) + } + parts, err := database.Reader().ListOutboundParts(ctx, queued.ID) + if err != nil || len(parts) != 2 || value(parts[0].ExternalID) != "51" || value(parts[1].ExternalID) != "52" { + t.Fatalf("parts = %#v, %v", parts, err) + } + for _, messageID := range []string{"51", "52"} { + messageMap, mapErr := database.Reader().GetMessageMapBySWTMessageID(ctx, dbgen.GetMessageMapBySWTMessageIDParams{ + AccountID: account.ID, SwtSid: "visitor", SwtMessageID: &messageID, + }) + if mapErr != nil || messageMap.GochatMessageID != 77 { + t.Fatalf("message map %s = %#v, %v", messageID, messageMap, mapErr) + } + } + queued, err = database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + if err != nil || value(queued.ExternalID) != "52" { + t.Fatalf("latest outbound external ID = %#v, %v", queued, err) + } + results := &resultRecorder{} + outbound, _ := NewOutbound(database, sessionStub{}, senderStub{}, results, nil, 1) + if worked, syncErr := outbound.processStatus(ctx); syncErr != nil || !worked { + t.Fatalf("status sync = %v, %v", worked, syncErr) + } + if len(results.result.ExternalIDs) != 2 || results.result.ExternalIDs[0] != "51" || results.result.ExternalIDs[1] != "52" || results.result.ExternalID != nil { + t.Fatalf("status external IDs = %#v", results.result) + } +} + +func TestInboundAmbiguousKind3DoesNotGuessOutboundPart(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + for _, messageID := range []int64{77, 78} { + content := "same text" + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: fmt.Sprintf("message:%d:created", messageID), + OccurredAt: time.Now(), GoChatMessageID: messageID, MessageType: "text", Content: &content, Payload: fmt.Sprintf(`{"message_id":%d}`, messageID), + }, false); err != nil { + t.Fatal(err) + } + queued, err := database.Writer().ClaimOutboundMessage(ctx) + if err != nil { + t.Fatal(err) + } + completeOutboundParts(t, database, queued.ID) + if err := database.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ID: queued.ID}); err != nil { + t.Fatal(err) + } + } + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 3, OpName: "other agent", Text: "same text", SeqID: 60, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 3 other|same 60 timestamp", + }) + client := &inboundRecorder{} + worker, _ := NewInbound(database, client, nil, 1) + if worked, err := worker.processInbound(ctx); err != nil || !worked { + t.Fatalf("process ambiguous echo = %v, %v", worked, err) + } + if len(client.imports) != 1 || client.imports[0].Content != "same text" { + t.Fatalf("ambiguous external message should remain visible: %#v", client.imports) + } + for _, messageID := range []int64{77, 78} { + queued, err := database.Reader().GetOutboundByGoChatMessageID(ctx, messageID) + if err != nil || queued.ExternalID != nil { + t.Fatalf("outbound %d = %#v, %v", messageID, queued, err) + } + } +} + +func TestInboundAmbiguousMediaPartsInOneMessageAreNotGuessed(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + firstURL, secondURL := "https://gochat.test/first.png", "https://gochat.test/second.png" + queued, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Payload: deliveryPayload(t, nil), + Parts: []store.OutboundPartInput{{Type: "image", DataURL: &firstURL}, {Type: "image", DataURL: &secondURL}}, + }, false) + if err != nil { + t.Fatal(err) + } + queued, err = database.Writer().ClaimOutboundMessage(ctx) + if err != nil { + t.Fatal(err) + } + completeOutboundParts(t, database, queued.ID) + if err := database.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ID: queued.ID}); err != nil { + t.Fatal(err) + } + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 3, OpName: "agent", Text: ``, SeqID: 61, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 3 agent|image 61 timestamp", + }) + client := &inboundRecorder{} + worker, _ := NewInbound(database, client, nil, 1) + worker.media = &mediaFetcherRecorder{} + if worked, err := worker.processInbound(ctx); err != nil || !worked { + t.Fatalf("process ambiguous media echo = %v, %v", worked, err) + } + if len(client.imports) != 1 || client.attachmentCount != 1 { + t.Fatalf("ambiguous media must be preserved as external outgoing: %#v", client) + } + parts, err := database.Reader().ListOutboundParts(ctx, queued.ID) + if err != nil || parts[0].ExternalID != nil || parts[1].ExternalID != nil { + t.Fatalf("ambiguous parts = %#v, %v", parts, err) + } +} + +func TestInboundLateKind3CorrectsUncertainTimeout(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Content: stringPointer("late echo"), Payload: deliveryPayload(t, nil), + }, false); err != nil { + t.Fatal(err) + } + outbound, _ := NewOutbound(database, sessionStub{}, senderStub{err: &swt.Error{ + Operation: "send_text", Code: "network_result_uncertain", Uncertain: true, Err: errors.New("timeout"), + }}, &resultRecorder{}, nil, 1) + if worked, err := outbound.processOutbound(ctx); err != nil || !worked { + t.Fatalf("uncertain delivery = %v, %v", worked, err) + } + if messages, _, err := outbound.expireUncertain(ctx, time.Now().Add(uncertainObservationWindow+time.Second)); err != nil || messages != 1 { + t.Fatalf("expire uncertain = %d, %v", messages, err) + } + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 3, OpName: "agent", Text: "late echo", SeqID: 70, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 3 agent|late echo 70 timestamp", + }) + client := &inboundRecorder{} + inbound, _ := NewInbound(database, client, nil, 1) + if worked, err := inbound.processInbound(ctx); err != nil || !worked { + t.Fatalf("process late echo = %v, %v", worked, err) + } + queued, err := database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + if err != nil || queued.DeliveryStatus != "delivered" || value(queued.ExternalID) != "70" || queued.ExternalErrorCode != nil || queued.StatusSyncStatus != "pending" { + t.Fatalf("corrected outbound = %#v, %v", queued, err) + } + if len(client.imports) != 0 { + t.Fatalf("late echo must not be imported again: %#v", client.imports) + } +} + +func TestInboundRetractionUsesExactMessageMap(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + client := &inboundRecorder{} + worker, _ := NewInbound(database, client, nil, 1) + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 2, Text: "hello", SeqID: 42, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 2 hello 42 timestamp", + }) + if _, err := worker.processInbound(ctx); err != nil { + t.Fatal(err) + } + loaded, _ := database.Reader().GetAccountByID(ctx, account.ID) + persistInboundEvent(t, database, loaded, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: -4, Text: "42", SeqID: 43, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor -4 42 43 timestamp", + }) + if _, err := worker.processInbound(ctx); err != nil { + t.Fatal(err) + } + if len(client.retractions) != 1 || client.retractions[0] != 9001 { + t.Fatalf("retractions = %#v", client.retractions) + } + messageMap, err := database.Reader().GetMessageMapBySWTMessageID(ctx, dbgen.GetMessageMapBySWTMessageIDParams{ + AccountID: account.ID, SwtSid: "visitor", SwtMessageID: stringPointer("42"), + }) + if err != nil || messageMap.RetractedAt == nil { + t.Fatalf("message map = %#v, %v", messageMap, err) + } +} + +func TestInboundUnmappedRetractionFailsWithoutDeleting(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: -5, Text: "999", SeqID: 1, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor -5 999 1 timestamp", + }) + client := &inboundRecorder{} + worker, _ := NewInbound(database, client, nil, 1) + if worked, err := worker.processInbound(ctx); !worked || err == nil { + t.Fatalf("process unmapped retraction = %v, %v", worked, err) + } + event, err := database.Reader().GetInboundEventByKey(ctx, "swt:10:visitor:-5:1") + if err != nil || event.DeliveryStatus != "failed" || len(client.retractions) != 0 { + t.Fatalf("event = %#v, retractions = %#v, err = %v", event, client.retractions, err) + } +} + +func TestInboundRetryableGoChatFailureQueuesAndDrainsEventsInOrder(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + for seqID := int64(42); seqID <= 44; seqID++ { + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 2, Text: fmt.Sprintf("message-%d", seqID), SeqID: seqID, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: fmt.Sprintf("visitor 2 message-%d %d timestamp", seqID, seqID), + }) + } + client := &inboundRecorder{importErr: &gochat.APIError{StatusCode: 503, Code: "unavailable", Retryable: true, Message: "down"}} + worker, _ := NewInbound(database, client, nil, 1) + if worked, err := worker.processInbound(ctx); !worked || err == nil { + t.Fatalf("process retryable event = %v, %v", worked, err) + } + event, err := database.Reader().GetInboundEventByKey(ctx, "swt:10:visitor:2:42") + if err != nil || event.DeliveryStatus != "pending" || event.NextAttemptAt == nil || event.Attempts != 1 { + t.Fatalf("event = %#v, %v", event, err) + } + client.importErr = nil + if delay := time.Until(*event.NextAttemptAt); delay > 0 { + time.Sleep(delay + 20*time.Millisecond) + } + for range 3 { + if worked, err := worker.processInbound(ctx); !worked || err != nil { + t.Fatalf("drain recovered event = %v, %v", worked, err) + } + } + wantSources := []string{ + "swt:10:visitor:2:42:0", // failed attempt + "swt:10:visitor:2:42:0", "swt:10:visitor:2:43:0", "swt:10:visitor:2:44:0", + } + if len(client.imports) != len(wantSources) { + t.Fatalf("imports = %#v", client.imports) + } + for index, sourceID := range wantSources { + if client.imports[index].SourceID != sourceID { + t.Fatalf("import[%d].source_id = %q, want %q", index, client.imports[index].SourceID, sourceID) + } + } + for seqID := int64(42); seqID <= 44; seqID++ { + stored, err := database.Reader().GetInboundEventByKey(ctx, fmt.Sprintf("swt:10:visitor:2:%d", seqID)) + if err != nil || stored.DeliveryStatus != "delivered" { + t.Fatalf("event %d = %#v, %v", seqID, stored, err) + } + } +} + +func TestInboundVoiceMessageUsesMultipartAttachmentImport(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 2, Text: "voice_msg|0|https://media.example/voice.amr", SeqID: 42, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 2 voice 42 timestamp", + }) + client := &inboundRecorder{} + fetcher := &mediaFetcherRecorder{} + worker, _ := NewInbound(database, client, nil, 1) + worker.media = fetcher + if worked, err := worker.processInbound(ctx); err != nil || !worked { + t.Fatalf("process voice = %v, %v", worked, err) + } + if client.attachmentCount != 1 || len(client.imports) != 1 || client.imports[0].Content != "" || !fetcher.cleaned { + t.Fatalf("client=%#v fetcher=%#v", client, fetcher) + } +} + +func persistInboundEvent(t *testing.T, database *store.Store, account *dbgen.Account, event swt.HeartbeatEvent) { + t.Helper() + if _, err := database.PersistHeartbeat(context.Background(), account, []swt.HeartbeatEvent{event}); err != nil { + t.Fatal(err) + } +} + +func completeOutboundParts(t *testing.T, database *store.Store, outboundMessageID int64) { + t.Helper() + parts, err := database.Reader().ListOutboundParts(context.Background(), outboundMessageID) + if err != nil { + t.Fatal(err) + } + for _, part := range parts { + if err := database.Writer().CompleteOutboundPart(context.Background(), dbgen.CompleteOutboundPartParams{ID: part.ID}); err != nil { + t.Fatal(err) + } + } +} + +type inboundRecorder struct { + imports []gochat.MessageImport + importErr error + retractions []int64 + attachmentCount int +} + +func (r *inboundRecorder) EnsureContact(_ context.Context, _, _ string, request gochat.ContactRequest) (gochat.Contact, error) { + return gochat.Contact{ID: 456, SourceID: request.SourceID, Name: request.Name}, nil +} + +func (r *inboundRecorder) UpdateContact(_ context.Context, _, _, sourceID string, request gochat.ContactRequest) (gochat.Contact, error) { + return gochat.Contact{ID: 456, SourceID: sourceID, Name: request.Name}, nil +} + +func (r *inboundRecorder) EnsureConversation(context.Context, string, string, map[string]any) (gochat.PublicConversation, error) { + return gochat.PublicConversation{ID: 88, InternalID: 100, InboxID: 10, Status: "open"}, nil +} + +func (r *inboundRecorder) ImportMessage(_ context.Context, _, _ int64, message gochat.MessageImport) (gochat.ImportedMessage, error) { + r.imports = append(r.imports, message) + if r.importErr != nil { + return gochat.ImportedMessage{}, r.importErr + } + return gochat.ImportedMessage{ID: 9001, SourceID: message.SourceID, External: true}, nil +} + +func (r *inboundRecorder) ImportMessageWithAttachments(ctx context.Context, accountID, conversationID int64, message gochat.MessageImport, _ []gochat.AttachmentUpload) (gochat.ImportedMessage, error) { + r.attachmentCount++ + return r.ImportMessage(ctx, accountID, conversationID, message) +} + +func (*inboundRecorder) UpdateConversationAttributes(context.Context, int64, int64, map[string]any, string) error { + return nil +} + +func (*inboundRecorder) SetConversationStatus(context.Context, int64, int64, string, string) error { + return nil +} + +func (*inboundRecorder) SetVisitorTyping(context.Context, string, string, int64, bool) error { + return nil +} + +func (r *inboundRecorder) RetractMessage(_ context.Context, _, _, messageID int64, _ string) error { + r.retractions = append(r.retractions, messageID) + return nil +} + +var _ InboundClient = (*inboundRecorder)(nil) + +type mediaFetcherRecorder struct{ cleaned bool } + +func (f *mediaFetcherRecorder) Fetch(context.Context, mediaReference) (gochat.AttachmentUpload, func(), error) { + return gochat.AttachmentUpload{Path: "unused", Name: "voice.amr", ContentType: "audio/amr", Voice: true}, func() { f.cleaned = true }, nil +} diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go new file mode 100644 index 00000000..8139f4c4 --- /dev/null +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -0,0 +1,584 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "html" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + htmlpkg "golang.org/x/net/html" +) + +type mappedEvent struct { + Strategy string + Subtype string + ContactName string + ContactPhone string + ContactAttributes map[string]any + ConversationAttrs map[string]any + ConversationStatus string + Typing *bool + Message *gochat.MessageImport + Media []mediaReference + RetractionTarget string + RequiresContact bool + RequiresConversation bool + RawOnly bool +} + +type mediaReference struct { + URL string `json:"url"` + Name string `json:"name,omitempty"` + FileType string `json:"file_type"` + Voice bool `json:"voice,omitempty"` +} + +func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sourceID string, inboxID int64, fallbackTime time.Time) mappedEvent { + mapped := mappedEvent{Strategy: "raw_only", RawOnly: true} + baseAttributes := map[string]any{"swt": map[string]any{ + "kind": kind, "seq_id": seqID, "raw_timestamp": rawTimestamp, "historical": false, "unparsed": false, + }} + message := func(messageType, contentType, content, strategy string) { + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = strategy, false, true, true + mapped.Message = &gochat.MessageImport{ + MessageType: messageType, ContentType: contentType, Content: content, SourceID: sourceID, + External: true, ExternalCreatedAt: parseSWTTime(rawTimestamp, fallbackTime), + ContentAttributes: baseAttributes, AdditionalAttributes: map[string]any{}, + } + if kind == 2 || kind == 3 { + mapped.Message.ExternalSourceIDs = map[string]any{"shangwutong": strconv.FormatInt(seqID, 10)} + } + } + activity := func(content string) { message("activity", "text", content, "activity") } + + switch kind { + case 2: + content, subtype, fallback, media := normalizeMessageContent(text) + message("incoming", "text", content, chooseStrategy(fallback, "fallback_text", "native_message")) + mapped.Media = media + mapped.Subtype = subtype + mapped.Message.AdditionalAttributes["senderName"] = "商务通访客" + case 3: + content, subtype, fallback, media := normalizeMessageContent(text) + message("outgoing", "text", content, chooseStrategy(fallback, "fallback_text", "native_message")) + mapped.Media = media + mapped.Subtype = subtype + mapped.Message.AdditionalAttributes["senderName"] = firstText(operator, "商务通客服") + baseAttributes["swt"].(map[string]any)["operator_name"] = operator + case 67: + content, subtype, fallback, media := normalizeMessageContent(text) + message("outgoing", "text", content, chooseStrategy(fallback, "fallback_text", "native_message")) + mapped.Media = media + mapped.Subtype = subtype + mapped.Message.AdditionalAttributes["senderName"] = "商务通机器人" + case -4, -5: + if _, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64); err == nil { + mapped.RetractionTarget, mapped.RawOnly = strings.TrimSpace(text), false + mapped.Strategy, mapped.RequiresConversation = "native_message", true + } + case 0: + mapped.RequiresContact, mapped.RawOnly = true, false + mapped.ConversationAttrs = map[string]any{} + switch strings.TrimSpace(text) { + case "0": + mapped.Strategy, mapped.ConversationAttrs["swt_state"] = "conversation_attributes", "new_visitor" + mapped.ContactAttributes = map[string]any{"swt_state": "new_visitor"} + case "1": + mapped.RequiresConversation = true + mapped.Strategy, mapped.ConversationAttrs["swt_state"] = "activity", "inviting" + activity("已发起邀请") + case "3": + mapped.RequiresConversation = true + mapped.Strategy, mapped.ConversationStatus, mapped.ConversationAttrs["swt_state"] = "activity", "open", "waiting" + mapped.ConversationAttrs["swt_waiting_since"] = fallbackTime.UTC().Format(time.RFC3339Nano) + activity("访客等待接待") + case "5": + mapped.RequiresConversation = true + mapped.Strategy, mapped.ConversationStatus, mapped.ConversationAttrs["swt_state"] = "conversation_attributes", "open", "chatting" + case "6": + mapped.RequiresConversation = true + mapped.Strategy, mapped.ConversationStatus, mapped.ConversationAttrs["swt_state"] = "activity", "open", "internal_transfer" + activity("会话正在内部转接") + case "7": + mapped.RequiresConversation = true + mapped.Strategy, mapped.ConversationStatus, mapped.ConversationAttrs["swt_state"] = "activity", "open", "transferring" + activity("会话正在转接") + case "8": + mapped.RequiresConversation = true + mapped.Strategy, mapped.ConversationStatus, mapped.ConversationAttrs["swt_state"] = "activity", "open", "transfer_accepted" + activity("会话转接已接受") + case "10": + mapped.RequiresConversation = true + mapped.Strategy, mapped.ConversationAttrs["swt_state"] = "activity", "left" + mapped.ConversationAttrs["swt_visitor_left_at"] = fallbackTime.UTC().Format(time.RFC3339Nano) + activity("访客已离开") + default: + mapped.RawOnly, mapped.Strategy = true, "raw_only" + } + case 7: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true + mapped.ContactAttributes = parseEnvironment(text) + case 8: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "conversation_attributes", false, true + mapped.ConversationAttrs = parseSource(text) + mapped.RequiresConversation = len(mapped.ConversationAttrs) > 0 + case 11: + // Account presence is applied in the heartbeat transaction. + case 12: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = map[string]any{"swt_operator_alias": cleanText(text)} + case 15: + content, subtype, fallback, media := normalizeMessageContent(text) + message("incoming", "text", firstText(content, "访客发送了文件"), "fallback_text") + mapped.Subtype, mapped.Media = subtype, media + if len(media) > 0 && !fallback { + mapped.Strategy = "native_message" + } + case 26: + mapped.ContactName, mapped.ContactPhone = callbackIdentity(text) + activity("访客请求电话回拨") + mapped.Strategy = "activity" + case 29: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = map[string]any{"swt_label_color": cleanText(text)} + case 30: + activity(cleanText(text)) + case 31: + mapped = mapSystemEvent(seqID, text, operator, rawTimestamp, sourceID, inboxID, fallbackTime, baseAttributes) + case 34: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = map[string]any{"swt_robot_state": cleanText(text)} + case 35: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true + mapped.ContactAttributes = map[string]any{"swt_third_party_source": truncate(cleanText(text), 2048)} + case 41, 61: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true + mapped.ContactName = cleanText(text) + case 52: + // History formats vary by server version; unverified batches stay replayable raw. + case 56: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = map[string]any{"swt_conversation_type": cleanText(text)} + case 58: + mapped.ConversationAttrs = map[string]any{"swt_state": "left", "swt_visitor_left_at": fallbackTime.UTC().Format(time.RFC3339Nano)} + activity("访客已离开") + case 65: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true + mapped.ContactAttributes = map[string]any{"swt_xst_profile": truncate(cleanText(text), 2048)} + case 66: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = parseSearchSource(text) + case 71: + mapped.ConversationAttrs = map[string]any{"swt_state": "page_closed", "swt_page_closed_at": fallbackTime.UTC().Format(time.RFC3339Nano)} + activity("访客已关闭页面") + } + if mapped.Message != nil { + swtAttrs := mapped.Message.ContentAttributes["swt"].(map[string]any) + swtAttrs["subtype"] = mapped.Subtype + } + return mapped +} + +func mapSystemEvent(seqID int64, text, operator, rawTimestamp, sourceID string, inboxID int64, fallbackTime time.Time, baseAttributes map[string]any) mappedEvent { + parts := strings.Split(text, "|") + subtype := strings.TrimSpace(parts[0]) + mapped := mappedEvent{Subtype: subtype, Strategy: "raw_only", RawOnly: true} + activity := func(content string) { + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "activity", false, true, true + mapped.Message = &gochat.MessageImport{ + MessageType: "activity", ContentType: "text", Content: content, SourceID: sourceID, External: true, + ExternalCreatedAt: parseSWTTime(rawTimestamp, fallbackTime), ContentAttributes: baseAttributes, + } + } + outgoing := func(content, sender string) { + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "native_message", false, true, true + mapped.Message = &gochat.MessageImport{ + MessageType: "outgoing", ContentType: "text", Content: cleanText(content), SourceID: sourceID, External: true, + ExternalCreatedAt: parseSWTTime(rawTimestamp, fallbackTime), ContentAttributes: baseAttributes, + AdditionalAttributes: map[string]any{"senderName": sender}, + } + } + value := func(index int) string { + if index >= len(parts) { + return "" + } + return cleanText(parts[index]) + } + switch subtype { + case "distribute_chat", "guest_direct_chat": + mapped.ConversationAttrs = map[string]any{"swt_assignee_name": value(1)} + activity("会话已分配给客服 " + value(1)) + case "distribute_lastoname", "lastoname_is": + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = map[string]any{"swt_assignee_name": value(1)} + case "guest_open_chat", "guest_continue_chat": + mapped.ConversationStatus = "open" + activity("访客请求继续对话") + case "cdcheck_chat_ended": + mapped.ConversationStatus = "resolved" + activity("商务通会话已结束") + case "away_timeout_chat": + activity("访客离开超时") + case "robot_chat": + mapped.ConversationAttrs = map[string]any{"swt_robot_state": "active"} + activity("商务通机器人已接管会话") + case "robot_chat_fenpei": + mapped.ConversationAttrs = map[string]any{"swt_robot_state": "active", "swt_assignee_name": value(1)} + activity("商务通机器人已将会话分配给客服 " + value(1)) + case "invite0", "invite1", "invite2", "invite3", "inviteyuyue", "invitepingjia": + activity("已发起商务通邀请:" + value(1)) + case "guest_refuse_invite": + activity("访客拒绝了邀请:" + value(1)) + case "distribute1_chat": + activity("商务通会话分配列表已更新") + case "ACT_CL": + payload := value(1) + if strings.HasPrefix(payload, "wx_") { + mapped.ContactAttributes = map[string]any{"swt_wechat_id": strings.TrimPrefix(payload, "wx_")} + activity("访客提供了微信号") + } else if strings.HasPrefix(payload, "num_") { + _, mapped.ContactPhone = callbackIdentity(strings.TrimPrefix(payload, "num_")) + activity("访客提供了联系电话") + } else { + activity("访客进入了拨号界面") + } + case "ACT_XST": + xstSubtype := value(1) + mapped.Subtype = "ACT_XST|" + xstSubtype + if xstSubtype == "NotShow" { + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = map[string]any{"swt_xst_state": truncate(value(2), 255)} + return mapped + } + payload := strings.Join(parts[2:], "|") + switch xstSubtype { + case "RobotAutoMsg", "SWTSettingAutoMsg", "BaiduAutoMsg": + outgoing(strings.TrimPrefix(payload, "百度自动回复:"), "商务通机器人") + case "SystemAutoMsg": + outgoing(payload, "商务通自动回复") + case "ByteDanceLeaveMsg": + activity("收到字节跳动访客留言") + case "ByteDanceSysMsg": + activity("字节跳动系统消息:" + truncate(cleanText(payload), 500)) + case "ByteDanceCSFenPeiMsg": + mapped.ConversationAttrs = map[string]any{"swt_assignee_name": truncate(cleanText(payload), 255)} + activity("字节跳动会话客服分配已更新") + default: + if cleaned := cleanText(strings.Join(parts[1:], "|")); cleaned != "" { + activity(truncate(cleaned, 1000)) + } + } + default: + if cleaned := cleanText(text); cleaned != "" { + activity(truncate(cleaned, 1000)) + } + } + if mapped.Message != nil { + baseAttributes["swt"].(map[string]any)["subtype"] = mapped.Subtype + baseAttributes["swt"].(map[string]any)["operator_name"] = operator + } + _ = seqID + _ = inboxID + return mapped +} + +func normalizeMessageContent(value string) (content, subtype string, fallback bool, media []mediaReference) { + value = strings.TrimSpace(value) + parts := strings.Split(value, "|") + switch { + case strings.HasPrefix(value, "voice_msg|"): + resource := safeHTTPURL(last(parts)) + if resource == "" { + return "[语音消息]", "voice", true, nil + } + return "", "voice", false, []mediaReference{{URL: resource, Name: mediaName(resource, "voice.amr"), FileType: "audio", Voice: true}} + case strings.HasPrefix(value, "filemsg|"): + resource := safeHTTPURL(last(parts)) + if resource == "" { + return "[文件消息]", "file", true, nil + } + return "", "file", false, []mediaReference{{URL: resource, Name: mediaName(resource, "attachment"), FileType: "file"}} + case strings.HasPrefix(value, "baidunmdata_msg|"): + label := "商务通卡片" + if len(parts) > 1 && parts[1] == "1" { + label = "商品卡片" + } else if len(parts) > 1 && parts[1] == "3" { + label = "案例卡片" + } else { + label = "优惠券卡片" + } + return "[" + label + "] " + safeHTTPURL(last(parts)), "rich_card", true, nil + case strings.HasPrefix(value, "{"): + var payload map[string]any + if json.Unmarshal([]byte(value), &payload) == nil { + kind, _ := payload["type"].(string) + if text, _ := payload["text"].(string); strings.TrimSpace(text) != "" { + return cleanText(text), firstText(kind, "json_text"), false, nil + } + if resource, _ := payload["url"].(string); strings.TrimSpace(resource) != "" { + resource = safeHTTPURL(resource) + fileType, voice := jsonMediaType(kind) + if resource != "" && fileType != "" { + return "", firstText(kind, "json_media"), false, []mediaReference{{ + URL: resource, Name: mediaName(resource, "attachment"), FileType: fileType, Voice: voice, + }} + } + return "[" + firstText(kind, "媒体消息") + "] " + resource, firstText(kind, "json_media"), true, nil + } + } + return truncate(cleanText(value), 1000), "unknown_json", true, nil + default: + images := imageReferences(value) + return cleanText(value), "text", false, images + } +} + +func imageReferences(value string) []mediaReference { + tokenizer := htmlpkg.NewTokenizer(strings.NewReader(value)) + seen := map[string]struct{}{} + var references []mediaReference + for { + switch tokenizer.Next() { + case htmlpkg.ErrorToken: + return references + case htmlpkg.SelfClosingTagToken, htmlpkg.StartTagToken: + token := tokenizer.Token() + if !strings.EqualFold(token.Data, "img") { + continue + } + for _, attribute := range token.Attr { + if !strings.EqualFold(attribute.Key, "src") { + continue + } + resource := safeHTTPURL(attribute.Val) + if resource == "" { + continue + } + if _, duplicate := seen[resource]; duplicate { + continue + } + seen[resource] = struct{}{} + references = append(references, mediaReference{URL: resource, Name: mediaName(resource, "image"), FileType: "image"}) + } + } + } +} + +func jsonMediaType(kind string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "voice", "audio": + return "audio", true + case "image", "picture": + return "image", false + case "video": + return "video", false + case "file": + return "file", false + default: + return "", false + } +} + +func mediaName(resource, fallback string) string { + parsed, err := url.Parse(resource) + if err == nil { + if name := strings.TrimSpace(path.Base(parsed.Path)); name != "" && name != "." && name != "/" { + return name + } + } + return fallback +} + +func cleanText(value string) string { + value = html.UnescapeString(value) + tokenizer := htmlpkg.NewTokenizer(strings.NewReader(value)) + var builder strings.Builder + skipDepth := 0 + for { + switch tokenizer.Next() { + case htmlpkg.ErrorToken: + return strings.TrimSpace(regexp.MustCompile(`\n{3,}`).ReplaceAllString(builder.String(), "\n\n")) + case htmlpkg.StartTagToken: + name, _ := tokenizer.TagName() + tag := strings.ToLower(string(name)) + if tag == "script" || tag == "style" { + skipDepth++ + } else if skipDepth == 0 && (tag == "br" || tag == "p" || tag == "div") { + builder.WriteByte('\n') + } + case htmlpkg.EndTagToken: + name, _ := tokenizer.TagName() + tag := strings.ToLower(string(name)) + if (tag == "script" || tag == "style") && skipDepth > 0 { + skipDepth-- + } else if skipDepth == 0 && (tag == "p" || tag == "div") { + builder.WriteByte('\n') + } + case htmlpkg.TextToken: + if skipDepth == 0 { + builder.Write(tokenizer.Text()) + } + } + } +} + +func parseEnvironment(text string) map[string]any { + parts := strings.Fields(text) + indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 2: "swt_isp", 7: "swt_resolution", 8: "swt_color_depth", 9: "swt_language", 10: "swt_timezone", 11: "swt_os", 13: "swt_browser", 14: "swt_browser_version"} + result := map[string]any{} + for index, key := range indexes { + if index < len(parts) && parts[index] != "0" && parts[index] != "null" { + result[key] = truncate(parts[index], 512) + } + } + if len(parts) > 19 { + result["swt_user_agent"] = truncate(strings.Join(parts[19:], " "), 1024) + } + return result +} + +func parseSource(text string) map[string]any { + parts := strings.Fields(text) + result := map[string]any{} + if len(parts) > 0 { + result["swt_source_url"] = safeHTTPURL(parts[0]) + } + if len(parts) > 2 { + result["swt_source_description"] = truncate(cleanText(parts[2]), 512) + } + if len(parts) > 6 { + result["swt_source_type"] = truncate(parts[6], 128) + } + return result +} + +func parseSearchSource(text string) map[string]any { + parts := strings.Split(text, "|") + if len(parts) == 1 { + parts = strings.Fields(text) + } + result := map[string]any{} + keys := []string{"swt_search_keyword", "swt_referrer", "swt_search_engine"} + for index, key := range keys { + if index < len(parts) && strings.TrimSpace(parts[index]) != "" { + result[key] = truncate(cleanText(parts[index]), 1024) + } + } + return result +} + +var phonePattern = regexp.MustCompile(`(?:\+?86)?1[3-9]\d{9}`) + +func callbackIdentity(text string) (string, string) { + cleaned := cleanText(text) + phone := phonePattern.FindString(cleaned) + if phone != "" && !strings.HasPrefix(phone, "+") { + phone = "+86" + strings.TrimPrefix(phone, "86") + } + name := strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1)) + return truncate(name, 255), phone +} + +func parseSWTTime(value string, fallback time.Time) time.Time { + value = strings.TrimSpace(value) + for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006/01/02 15:04:05"} { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return parsed.UTC() + } + } + if numeric, err := strconv.ParseInt(value, 10, 64); err == nil { + const dotNetUnixEpochTicks int64 = 621355968000000000 + if numeric >= dotNetUnixEpochTicks { + delta := numeric - dotNetUnixEpochTicks + return time.Unix(delta/10_000_000, (delta%10_000_000)*100).UTC() + } + if numeric > 1_000_000_000_000 { + return time.UnixMilli(numeric).UTC() + } + } + return fallback.UTC() +} + +func safeHTTPURL(value string) string { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil { + return "" + } + parsed.Fragment = "" + return parsed.String() +} + +func truncate(value string, max int) string { + runes := []rune(value) + if len(runes) <= max { + return value + } + return string(runes[:max]) +} + +func firstText(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func chooseStrategy(condition bool, whenTrue, whenFalse string) string { + if condition { + return whenTrue + } + return whenFalse +} + +func last(values []string) string { + if len(values) == 0 { + return "" + } + return values[len(values)-1] +} + +func (m mappedEvent) normalizedJSON() *string { + payload := map[string]any{"strategy": m.Strategy, "subtype": m.Subtype} + if m.ContactName != "" { + payload["contact_name"] = m.ContactName + } + if m.ContactPhone != "" { + payload["contact_phone"] = m.ContactPhone + } + if m.Message != nil { + payload["message"] = m.Message + } + if len(m.Media) > 0 { + payload["media"] = m.Media + } + if len(m.ContactAttributes) > 0 { + payload["contact_attributes"] = m.ContactAttributes + } + if len(m.ConversationAttrs) > 0 { + payload["conversation_attributes"] = m.ConversationAttrs + } + if m.ConversationStatus != "" { + payload["conversation_status"] = m.ConversationStatus + } + if m.RetractionTarget != "" { + payload["retraction_target"] = m.RetractionTarget + } + encoded, err := json.Marshal(payload) + if err != nil { + return nil + } + value := string(encoded) + return &value +} + +func (m mappedEvent) String() string { + return fmt.Sprintf("strategy=%s subtype=%s", m.Strategy, m.Subtype) +} diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go new file mode 100644 index 00000000..f33f85e9 --- /dev/null +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -0,0 +1,163 @@ +package delivery + +import ( + "strings" + "testing" + "time" +) + +func TestMapInboundEventCoreStrategies(t *testing.T) { + now := time.Date(2026, 8, 1, 8, 30, 0, 0, time.UTC) + tests := []struct { + name string + kind int64 + text string + strategy string + content string + status string + retractionTarget string + }{ + {name: "html text", kind: 2, text: "

您好
世界

", strategy: "native_message", content: "您好\n世界"}, + {name: "voice attachment", kind: 2, text: "voice_msg|source|https://media.example/voice.amr", strategy: "native_message"}, + {name: "waiting", kind: 0, text: "3", strategy: "activity", content: "访客等待接待", status: "open"}, + {name: "retraction", kind: -4, text: "98765", strategy: "native_message", retractionTarget: "98765"}, + {name: "ended", kind: 31, text: "cdcheck_chat_ended", strategy: "activity", content: "商务通会话已结束", status: "resolved"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mapped := mapInboundEvent(test.kind, 42, test.text, "operator", "2026-08-01 16:30:00", "swt:10:sid:2:42:0", 10, now) + if mapped.Strategy != test.strategy || mapped.ConversationStatus != test.status || mapped.RetractionTarget != test.retractionTarget { + t.Fatalf("mapped = %#v", mapped) + } + if test.content != "" && (mapped.Message == nil || mapped.Message.Content != test.content) { + t.Fatalf("message = %#v", mapped.Message) + } + if mapped.Message != nil && strings.Contains(mapped.Message.Content, "bad()") { + t.Fatalf("unsafe HTML survived: %q", mapped.Message.Content) + } + }) + } +} + +func TestKind2UsesSequenceAsExternalMessageID(t *testing.T) { + mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now()) + if mapped.Message == nil || mapped.Message.ExternalSourceIDs["shangwutong"] != "98765" { + t.Fatalf("external source IDs = %#v", mapped.Message) + } +} + +func TestParseSWTTimeSupportsDotNetTicks(t *testing.T) { + got := parseSWTTime("639183484614616556", time.Time{}) + if got.Year() != 2026 || got.Month() != time.June || got.Location() != time.UTC { + t.Fatalf("parsed time = %s", got) + } +} + +func TestKnownKindMappingMatrix(t *testing.T) { + tests := []struct { + kind int64 + text string + strategy string + rawOnly bool + }{ + {-8, "ACT_COMMON|chat_firstresponsetime|sid|time|agent", "raw_only", true}, + {-5, "42", "native_message", false}, {-4, "42", "native_message", false}, + {0, "5", "conversation_attributes", false}, {2, "hello", "native_message", false}, + {3, "hello", "native_message", false}, {5, "unverified", "raw_only", true}, + {7, "127.0.0.1 Beijing ISP 0 0 0 0 1920x1080 24 zh-CN +8 Windows 0 Chrome 149", "contact_attributes", false}, + {8, "https://example.test 0 Landing 0 0 0 friendlink", "conversation_attributes", false}, + {11, "3", "raw_only", true}, {12, "alias", "conversation_attributes", false}, + {15, "filemsg|name|https://media.example/file", "native_message", false}, + {26, "张三|13800138000", "activity", false}, {29, "red", "conversation_attributes", false}, + {30, "system", "activity", false}, {31, "guest_open_chat", "activity", false}, + {34, "active", "conversation_attributes", false}, {35, "source", "contact_attributes", false}, + {39, "unverified", "raw_only", true}, {41, "访客", "contact_attributes", false}, + {52, "unverified#history", "raw_only", true}, {56, "web", "conversation_attributes", false}, + {58, "left", "activity", false}, {61, "访客", "contact_attributes", false}, + {65, "profile", "contact_attributes", false}, {66, "keyword|referrer|engine", "conversation_attributes", false}, + {67, "robot reply", "native_message", false}, {71, "closed", "activity", false}, + {999, "unknown", "raw_only", true}, + } + for _, test := range tests { + mapped := mapInboundEvent(test.kind, 42, test.text, "agent", "", "swt:10:sid:event:42:0", 10, time.Now()) + if mapped.Strategy != test.strategy || mapped.RawOnly != test.rawOnly { + t.Fatalf("kind=%d mapped=%#v", test.kind, mapped) + } + if test.kind != 999 && !knownInboundKind(test.kind) { + t.Fatalf("documented kind %d counted as unknown", test.kind) + } + } +} + +func TestKind0StateMatrix(t *testing.T) { + states := map[string]string{ + "0": "new_visitor", "1": "inviting", "3": "waiting", "5": "chatting", + "6": "internal_transfer", "7": "transferring", "8": "transfer_accepted", "10": "left", + } + for input, expected := range states { + mapped := mapInboundEvent(0, 42, input, "", "", "source", 10, time.Now()) + if mapped.ConversationAttrs["swt_state"] != expected || mapped.RawOnly { + t.Fatalf("state %s mapped=%#v", input, mapped) + } + } + if mapped := mapInboundEvent(0, 42, "999", "", "", "source", 10, time.Now()); !mapped.RawOnly { + t.Fatalf("unknown state = %#v", mapped) + } +} + +func TestKind31SubtypeMatrix(t *testing.T) { + tests := map[string]string{ + "distribute_chat|agent": "activity", "distribute_lastoname|agent": "conversation_attributes", + "guest_direct_chat|agent": "activity", "guest_open_chat": "activity", "guest_continue_chat": "activity", + "cdcheck_chat_ended": "activity", "away_timeout_chat": "activity", "robot_chat": "activity", + "robot_chat_fenpei|agent": "activity", "invite0|agent": "activity", "invite1|agent": "activity", + "invite2|custom": "activity", "invite3|agent": "activity", "inviteyuyue|agent": "activity", + "invitepingjia|agent": "activity", "guest_refuse_invite|reason": "activity", "distribute1_chat|list": "activity", + "lastoname_is|agent": "conversation_attributes", "ACT_CL|wx_id": "activity", "ACT_CL|num_13800138000": "activity", + "ACT_CL|dial": "activity", "ACT_XST|RobotAutoMsg|hello": "native_message", + "ACT_XST|SWTSettingAutoMsg|hello": "native_message", "ACT_XST|SystemAutoMsg|hello": "native_message", + "ACT_XST|BaiduAutoMsg|百度自动回复:hello": "native_message", + "ACT_XST|NotShow|QuDaoVisitorInfo|private": "conversation_attributes", + "ACT_XST|ByteDanceLeaveMsg|payload": "activity", "ACT_XST|ByteDanceSysMsg|payload": "activity", + "ACT_XST|ByteDanceCSFenPeiMsg|agent": "activity", "ACT_XST|human notice": "activity", + } + for input, expected := range tests { + mapped := mapInboundEvent(31, 42, input, "agent", "", "source", 10, time.Now()) + if mapped.Strategy != expected || mapped.RawOnly { + t.Fatalf("subtype %q mapped=%#v", input, mapped) + } + } +} + +func TestNormalizeMessageContentExtractsMultipleImagesAndJSONMedia(t *testing.T) { + content, subtype, fallback, media := normalizeMessageContent(`

说明

`) + if content != "说明" || subtype != "text" || fallback || len(media) != 2 || media[0].FileType != "image" || media[1].URL != "https://media.example/b.png" { + t.Fatalf("html content=%q subtype=%q fallback=%v media=%#v", content, subtype, fallback, media) + } + for _, test := range []struct { + payload string + fileType string + voice bool + }{ + {payload: `{"type":"image","url":"https://media.example/image.png"}`, fileType: "image"}, + {payload: `{"type":"audio","url":"https://media.example/voice.mp3"}`, fileType: "audio", voice: true}, + {payload: `{"type":"video","url":"https://media.example/video.mp4"}`, fileType: "video"}, + {payload: `{"type":"file","url":"https://media.example/manual.pdf"}`, fileType: "file"}, + } { + _, _, fallback, media = normalizeMessageContent(test.payload) + if fallback || len(media) != 1 || media[0].FileType != test.fileType || media[0].Voice != test.voice { + t.Fatalf("payload=%s fallback=%v media=%#v", test.payload, fallback, media) + } + } +} + +func TestNormalizeMessageContentUsesSafeFallbacksForCardsAndUnknownJSON(t *testing.T) { + content, subtype, fallback, media := normalizeMessageContent("baidunmdata_msg|1|https://example.test/product") + if !fallback || subtype != "rich_card" || !strings.Contains(content, "商品卡片") || len(media) != 0 { + t.Fatalf("card content=%q subtype=%q fallback=%v media=%#v", content, subtype, fallback, media) + } + content, subtype, fallback, media = normalizeMessageContent(`{"unknown":true}`) + if !fallback || subtype != "unknown_json" || content == "" || len(media) != 0 { + t.Fatalf("json content=%q subtype=%q fallback=%v media=%#v", content, subtype, fallback, media) + } +} diff --git a/channels/shangwutong/internal/delivery/media.go b/channels/shangwutong/internal/delivery/media.go new file mode 100644 index 00000000..40b609df --- /dev/null +++ b/channels/shangwutong/internal/delivery/media.go @@ -0,0 +1,226 @@ +package delivery + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" +) + +const maxInboundMediaBytes int64 = 32 << 20 + +type mediaFetcher interface { + Fetch(context.Context, mediaReference) (gochat.AttachmentUpload, func(), error) +} + +type mediaFetchError struct { + Code string + Retryable bool + Err error +} + +func (e *mediaFetchError) Error() string { return e.Code + ": " + e.Err.Error() } +func (e *mediaFetchError) Unwrap() error { return e.Err } + +func permanentMediaError(code string, err error) error { + return &mediaFetchError{Code: code, Err: err} +} + +func retryableMediaError(code string, err error) error { + return &mediaFetchError{Code: code, Retryable: true, Err: err} +} + +type mediaDownloader struct { + client *http.Client + tempDir string + maxBytes int64 + allowedHosts map[string]struct{} +} + +func newMediaDownloader(allowedHosts ...string) *mediaDownloader { + allowed := make(map[string]struct{}, len(allowedHosts)) + for _, host := range allowedHosts { + if parsed, err := url.Parse(host); err == nil && parsed.Hostname() != "" { + allowed[strings.ToLower(parsed.Hostname())] = struct{}{} + } else if host = strings.TrimSpace(strings.ToLower(host)); host != "" { + allowed[host] = struct{}{} + } + } + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + transport := &http.Transport{ + Proxy: nil, MaxIdleConns: 32, MaxIdleConnsPerHost: 4, + TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 15 * time.Second, + } + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, retryableMediaError("media_dns_failed", err) + } + trusted := isAllowedHost(host, allowed) + for _, address := range addresses { + if trusted || publicMediaAddr(address) { + return dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), port)) + } + } + return nil, permanentMediaError("unsafe_media_url", errors.New("media host resolves only to private or reserved addresses")) + } + downloader := &mediaDownloader{tempDir: os.TempDir(), maxBytes: maxInboundMediaBytes, allowedHosts: allowed} + downloader.client = &http.Client{ + Transport: transport, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return permanentMediaError("media_redirect_limit", errors.New("too many media redirects")) + } + if err := validateMediaURL(request.URL, allowed); err != nil { + return permanentMediaError("unsafe_media_url", err) + } + return nil + }, + } + return downloader +} + +func (d *mediaDownloader) Fetch(ctx context.Context, reference mediaReference) (gochat.AttachmentUpload, func(), error) { + parsed, err := url.Parse(reference.URL) + if err != nil { + return gochat.AttachmentUpload{}, nil, permanentMediaError("invalid_media_url", err) + } + if err := validateMediaURL(parsed, d.allowedHosts); err != nil { + return gochat.AttachmentUpload{}, nil, permanentMediaError("unsafe_media_url", err) + } + requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, parsed.String(), nil) + if err != nil { + return gochat.AttachmentUpload{}, nil, permanentMediaError("invalid_media_url", err) + } + request.Header.Set("Accept", "image/*,audio/*,video/*,application/octet-stream;q=0.8,*/*;q=0.1") + response, err := d.client.Do(request) + if err != nil { + var classified *mediaFetchError + if errors.As(err, &classified) { + return gochat.AttachmentUpload{}, nil, classified + } + return gochat.AttachmentUpload{}, nil, retryableMediaError("media_download_failed", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + err := fmt.Errorf("media download returned %s", response.Status) + if response.StatusCode == http.StatusRequestTimeout || response.StatusCode == http.StatusTooEarly || response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { + return gochat.AttachmentUpload{}, nil, retryableMediaError("media_http_status", err) + } + return gochat.AttachmentUpload{}, nil, permanentMediaError("media_http_status", err) + } + if response.ContentLength > d.maxBytes { + return gochat.AttachmentUpload{}, nil, permanentMediaError("media_too_large", errors.New("media exceeds size limit")) + } + buffered := bufio.NewReader(io.LimitReader(response.Body, d.maxBytes+1)) + sample, _ := buffered.Peek(512) + if len(sample) == 0 { + return gochat.AttachmentUpload{}, nil, permanentMediaError("invalid_media_type", errors.New("media response is empty")) + } + detectedType := http.DetectContentType(sample) + if strings.HasPrefix(detectedType, "text/html") { + return gochat.AttachmentUpload{}, nil, permanentMediaError("invalid_media_type", errors.New("media response contains HTML")) + } + contentType := response.Header.Get("Content-Type") + if parsedType, _, parseErr := mime.ParseMediaType(contentType); parseErr == nil { + contentType = parsedType + } + if contentType == "" || contentType == "application/octet-stream" { + contentType = detectedType + } + if err := validateMediaType(reference.FileType, contentType); err != nil { + return gochat.AttachmentUpload{}, nil, permanentMediaError("invalid_media_type", err) + } + file, err := os.CreateTemp(d.tempDir, "swt-media-*") + if err != nil { + return gochat.AttachmentUpload{}, nil, retryableMediaError("media_tempfile_failed", err) + } + path := file.Name() + cleanup := func() { _ = os.Remove(path) } + written, copyErr := io.Copy(file, buffered) + closeErr := file.Close() + if copyErr != nil || closeErr != nil || written > d.maxBytes { + cleanup() + if written > d.maxBytes { + return gochat.AttachmentUpload{}, nil, permanentMediaError("media_too_large", errors.New("media exceeds size limit")) + } + return gochat.AttachmentUpload{}, nil, retryableMediaError("media_io_failed", errors.Join(copyErr, closeErr)) + } + name := filepath.Base(strings.TrimSpace(reference.Name)) + if name == "" || name == "." { + name = "attachment" + } + return gochat.AttachmentUpload{Path: path, Name: name, ContentType: contentType, Voice: reference.Voice}, cleanup, nil +} + +func validateMediaURL(target *url.URL, allowed map[string]struct{}) error { + if target == nil || (target.Scheme != "http" && target.Scheme != "https") || target.Hostname() == "" || target.User != nil { + return errors.New("invalid media URL") + } + host := strings.ToLower(target.Hostname()) + if isAllowedHost(host, allowed) { + return nil + } + if address := net.ParseIP(host); address != nil && !publicMediaIP(address) { + return errors.New("private or reserved media address is not allowed") + } + return nil +} + +func isAllowedHost(host string, allowed map[string]struct{}) bool { + _, ok := allowed[strings.ToLower(strings.TrimSpace(host))] + return ok +} + +func publicMediaIP(address net.IP) bool { + parsed, ok := netip.AddrFromSlice(address) + return ok && publicMediaAddr(parsed) +} + +func publicMediaAddr(address netip.Addr) bool { + address = address.Unmap() + return address.IsValid() && !address.IsPrivate() && !address.IsLoopback() && !address.IsLinkLocalUnicast() && + !address.IsLinkLocalMulticast() && !address.IsUnspecified() && !address.IsMulticast() +} + +func validateMediaType(fileType, contentType string) error { + switch fileType { + case "image": + if !strings.HasPrefix(contentType, "image/") { + return fmt.Errorf("expected image media, got %s", contentType) + } + case "audio": + if !strings.HasPrefix(contentType, "audio/") { + return fmt.Errorf("expected audio media, got %s", contentType) + } + case "video": + if !strings.HasPrefix(contentType, "video/") { + return fmt.Errorf("expected video media, got %s", contentType) + } + case "file": + if contentType == "text/html" || contentType == "application/xhtml+xml" { + return fmt.Errorf("unexpected HTML media response: %s", contentType) + } + default: + return fmt.Errorf("unsupported media type %q", fileType) + } + return nil +} diff --git a/channels/shangwutong/internal/delivery/media_test.go b/channels/shangwutong/internal/delivery/media_test.go new file mode 100644 index 00000000..af0973ab --- /dev/null +++ b/channels/shangwutong/internal/delivery/media_test.go @@ -0,0 +1,100 @@ +package delivery + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" +) + +func TestMediaURLRejectsPrivateTargetsUnlessExplicitlyTrusted(t *testing.T) { + for _, raw := range []string{"http://127.0.0.1/file", "http://[::1]/file", "http://169.254.169.254/latest"} { + parsed, _ := url.Parse(raw) + if err := validateMediaURL(parsed, nil); err == nil { + t.Fatalf("URL should be rejected: %s", raw) + } + } + parsed, _ := url.Parse("http://127.0.0.1/file") + if err := validateMediaURL(parsed, map[string]struct{}{"127.0.0.1": {}}); err != nil { + t.Fatalf("trusted URL rejected: %v", err) + } +} + +func TestMediaDownloaderEnforcesTypeSizeAndCleanup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "image/png") + _, _ = response.Write([]byte("\x89PNG\r\n\x1a\nimage")) + })) + defer server.Close() + downloader := newMediaDownloader(server.URL) + downloader.tempDir = t.TempDir() + upload, cleanup, err := downloader.Fetch(context.Background(), mediaReference{URL: server.URL + "/image.png", Name: "image.png", FileType: "image"}) + if err != nil || upload.ContentType != "image/png" { + t.Fatalf("upload=%#v err=%v", upload, err) + } + if _, err := os.Stat(upload.Path); err != nil { + t.Fatal(err) + } + info, err := os.Stat(upload.Path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("temporary file mode = %v", info.Mode().Perm()) + } + cleanup() + if _, err := os.Stat(upload.Path); !os.IsNotExist(err) { + t.Fatalf("temporary file was not removed: %v", err) + } + + downloader.maxBytes = 4 + if _, _, err := downloader.Fetch(context.Background(), mediaReference{URL: server.URL + "/large.png", FileType: "image"}); err == nil { + t.Fatal("expected media size error") + } else { + var classified *mediaFetchError + if !errors.As(err, &classified) || classified.Retryable || classified.Code != "media_too_large" { + t.Fatalf("size error = %#v", err) + } + } +} + +func TestMediaDownloaderClassifiesHTTPAndMIMEFailures(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/retry": + response.WriteHeader(http.StatusServiceUnavailable) + case "/missing": + response.WriteHeader(http.StatusNotFound) + case "/disguised-html": + response.Header().Set("Content-Type", "image/png") + _, _ = response.Write([]byte("not an image")) + case "/empty": + response.Header().Set("Content-Type", "image/png") + default: + response.Header().Set("Content-Type", "text/html") + _, _ = response.Write([]byte("not an image")) + } + })) + defer server.Close() + downloader := newMediaDownloader(server.URL) + for _, test := range []struct { + path string + retryable bool + code string + }{ + {path: "/retry", retryable: true, code: "media_http_status"}, + {path: "/missing", code: "media_http_status"}, + {path: "/bad-type", code: "invalid_media_type"}, + {path: "/disguised-html", code: "invalid_media_type"}, + {path: "/empty", code: "invalid_media_type"}, + } { + _, _, err := downloader.Fetch(context.Background(), mediaReference{URL: server.URL + test.path, FileType: "image"}) + var classified *mediaFetchError + if !errors.As(err, &classified) || classified.Retryable != test.retryable || classified.Code != test.code { + t.Fatalf("%s error = %#v", test.path, err) + } + } +} diff --git a/channels/shangwutong/internal/delivery/outbound.go b/channels/shangwutong/internal/delivery/outbound.go new file mode 100644 index 00000000..63609bb1 --- /dev/null +++ b/channels/shangwutong/internal/delivery/outbound.go @@ -0,0 +1,532 @@ +package delivery + +import ( + "context" + "database/sql" + "errors" + "fmt" + randv2 "math/rand/v2" + "os" + "strings" + "sync" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/observability" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" + "github.com/sirupsen/logrus" +) + +const ( + uncertainObservationWindow = 5 * time.Minute + uncertainSweepInterval = time.Second +) + +type SessionProvider interface { + WithSession(context.Context, int64, func(swt.Session) error) error + InvalidateSession(context.Context, int64) error +} + +type MessageSender interface { + SendText(context.Context, swt.Session, string, string) (swt.SendResult, error) + SendImage(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) + SendFile(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) + SendVoice(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) + EndConversation(context.Context, swt.Session, string) error +} + +type ResultClient interface { + UpdateMessageStatus(context.Context, int64, int64, gochat.MessageResult) error +} + +type Outbound struct { + store *store.Store + sessions SessionProvider + sender MessageSender + results ResultClient + logger *logrus.Entry + workers int + media mediaFetcher + metrics *observability.Metrics + wg sync.WaitGroup +} + +func NewOutbound(database *store.Store, sessions SessionProvider, sender MessageSender, results ResultClient, logger *logrus.Entry, workers int, trustedMediaHosts ...string) (*Outbound, error) { + if database == nil || sessions == nil || sender == nil || results == nil || workers <= 0 { + return nil, errors.New("store, sessions, sender, result client and positive worker count are required") + } + if logger == nil { + logger = logrus.NewEntry(logrus.New()) + } + return &Outbound{ + store: database, sessions: sessions, sender: sender, results: results, + logger: logger, workers: workers, media: newMediaDownloader(trustedMediaHosts...), + }, nil +} + +func (o *Outbound) Start(ctx context.Context) { + for range o.workers { + o.wg.Add(1) + go o.deliveryLoop(ctx) + } + statusWorkers := min(o.workers, 4) + for range statusWorkers { + o.wg.Add(1) + go o.statusLoop(ctx) + } + operationWorkers := min(o.workers, 4) + for range operationWorkers { + o.wg.Add(1) + go o.operationLoop(ctx) + } + o.wg.Add(1) + go o.uncertainLoop(ctx) +} + +func (o *Outbound) uncertainLoop(ctx context.Context) { + defer o.wg.Done() + for ctx.Err() == nil { + messages, operations, err := o.expireUncertain(ctx, time.Now()) + if err != nil && ctx.Err() == nil { + o.logger.WithFields(logrus.Fields{ + "component": "uncertain_observer", "operation": "expire", "result": "failed", + }).WithError(err).Error("uncertain delivery observation failed") + } else if messages > 0 || operations > 0 { + o.logger.WithFields(logrus.Fields{ + "component": "uncertain_observer", "operation": "expire", "result": "completed", + "messages": messages, "operations": operations, + }).Warn("uncertain deliveries exceeded the observation window") + } + if !wait(ctx, uncertainSweepInterval) { + return + } + } +} + +func (o *Outbound) expireUncertain(ctx context.Context, now time.Time) (int64, int64, error) { + cutoff := now.Add(-uncertainObservationWindow) + messages, err := o.store.Writer().FailExpiredUncertainMessages(ctx, cutoff) + if err != nil { + return 0, 0, err + } + operations, err := o.store.Writer().FailExpiredUncertainOperations(ctx, cutoff) + if err != nil { + return messages, 0, err + } + return messages, operations, nil +} + +func (o *Outbound) operationLoop(ctx context.Context) { + defer o.wg.Done() + for ctx.Err() == nil { + worked, err := o.processOperation(ctx) + if err != nil && ctx.Err() == nil { + o.logger.WithFields(logrus.Fields{ + "component": "outbound_operation_worker", "operation": "deliver", "result": "failed", + }).WithError(err).Error("outbound operation failed") + } + if !worked && !wait(ctx, 200*time.Millisecond) { + return + } + } +} + +func (o *Outbound) Wait() { o.wg.Wait() } + +func (o *Outbound) SetMetrics(metrics *observability.Metrics) { o.metrics = metrics } + +func (o *Outbound) deliveryLoop(ctx context.Context) { + defer o.wg.Done() + for ctx.Err() == nil { + worked, err := o.processOutbound(ctx) + if err != nil && ctx.Err() == nil { + o.logger.WithFields(logrus.Fields{ + "component": "outbound_worker", "operation": "deliver", "result": "failed", + }).WithError(err).Error("outbound worker failed") + } + if !worked && !wait(ctx, 200*time.Millisecond) { + return + } + } +} + +func (o *Outbound) statusLoop(ctx context.Context) { + defer o.wg.Done() + for ctx.Err() == nil { + worked, err := o.processStatus(ctx) + if err != nil && ctx.Err() == nil { + o.logger.WithFields(logrus.Fields{ + "component": "status_sync", "operation": "message_result", "result": "failed", + }).WithError(err).Warn("message result sync failed") + } + if !worked && !wait(ctx, 200*time.Millisecond) { + return + } + } +} + +func (o *Outbound) processOutbound(ctx context.Context) (bool, error) { + message, err := o.store.Writer().ClaimOutboundMessage(ctx) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + parts, err := o.store.Reader().ListOutboundParts(ctx, message.ID) + if err != nil { + return true, o.retry(message, err.Error()) + } + if len(parts) == 0 { + return true, o.fail(message, "unsupported_outbound_content", "message has no sendable content") + } + for _, part := range parts { + if part.PartType == "video" || part.PartType == "unsupported" { + return true, o.failPart(message, part, "unsupported_outbound_content", fmt.Sprintf("outbound %s is not supported", part.PartType)) + } + } + for _, part := range parts { + if part.DeliveryStatus == "delivered" { + continue + } + if part.DeliveryStatus != "pending" { + return true, o.fail(message, "outbound_part_state_conflict", "outbound part is not pending") + } + if err := o.sendPart(ctx, message, part); err != nil { + return true, o.handlePartError(message, part, err) + } + if err := o.store.Writer().CompleteOutboundPart(ctx, dbgen.CompleteOutboundPartParams{ID: part.ID}); err != nil { + return true, err + } + } + err = o.store.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ExternalID: nil, ID: message.ID}) + if err == nil { + o.metrics.Delivery("outbound", "delivered") + } + return true, err +} + +func (o *Outbound) sendPart(ctx context.Context, message *dbgen.OutboundMessage, part *dbgen.OutboundPart) error { + if part.PartType == "video" || part.PartType == "unsupported" { + return &permanentDeliveryError{code: "unsupported_outbound_content", err: fmt.Errorf("outbound %s is not supported", part.PartType)} + } + if part.PartType == "text" { + content := "" + if part.Content != nil { + content = *part.Content + } + return o.sessions.WithSession(ctx, message.AccountID, func(session swt.Session) error { + _, err := o.sender.SendText(ctx, session, message.SwtSid, content) + return err + }) + } + if part.DataUrl == nil || strings.TrimSpace(*part.DataUrl) == "" { + return &permanentDeliveryError{code: "invalid_attachment", err: errors.New("attachment data_url is missing")} + } + if part.FileSize != nil && *part.FileSize > maxInboundMediaBytes { + return &permanentDeliveryError{code: "attachment_too_large", err: errors.New("attachment exceeds size limit")} + } + reference := mediaReference{URL: *part.DataUrl, FileType: part.PartType, Voice: part.Voice != 0} + if part.FileName != nil { + reference.Name = *part.FileName + } + upload, cleanup, err := o.media.Fetch(ctx, reference) + if err != nil { + return err + } + defer cleanup() + file, err := os.Open(upload.Path) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + protocolUpload := swt.Upload{Name: upload.Name, ContentType: upload.ContentType, Size: info.Size(), Reader: file} + return o.sessions.WithSession(ctx, message.AccountID, func(session swt.Session) error { + switch part.PartType { + case "image": + _, err = o.sender.SendImage(ctx, session, message.SwtSid, protocolUpload) + case "file": + _, err = o.sender.SendFile(ctx, session, message.SwtSid, protocolUpload) + case "audio": + _, err = o.sender.SendVoice(ctx, session, message.SwtSid, protocolUpload) + default: + err = fmt.Errorf("unsupported outbound part type %q", part.PartType) + } + return err + }) +} + +func (o *Outbound) handlePartError(message *dbgen.OutboundMessage, part *dbgen.OutboundPart, deliveryErr error) error { + var permanent *permanentDeliveryError + if errors.As(deliveryErr, &permanent) { + return o.failPart(message, part, permanent.code, permanent.Error()) + } + var mediaErr *mediaFetchError + if errors.As(deliveryErr, &mediaErr) && !mediaErr.Retryable { + return o.failPart(message, part, mediaErr.Code, mediaErr.Error()) + } + var protocolErr *swt.Error + if errors.As(deliveryErr, &protocolErr) { + if protocolErr.Uncertain { + return o.uncertainPart(message, part, protocolErr.Code, protocolErr.Error()) + } + if protocolErr.Code == "tickint_reset" || protocolErr.Code == "cache_null" { + _ = o.sessions.InvalidateSession(context.Background(), message.AccountID) + return o.retry(message, protocolErr.Error()) + } + if !protocolErr.Retryable || message.Attempts >= 10 { + return o.failPart(message, part, protocolErr.Code, protocolErr.Error()) + } + } + if message.Attempts < 10 { + return o.retry(message, deliveryErr.Error()) + } + return o.failPart(message, part, "delivery_exhausted", deliveryErr.Error()) +} + +func (o *Outbound) uncertainPart(message *dbgen.OutboundMessage, part *dbgen.OutboundPart, code, detail string) error { + err := o.store.WithTx(context.Background(), func(queries *dbgen.Queries) error { + if err := queries.MarkOutboundPartUncertain(context.Background(), dbgen.MarkOutboundPartUncertainParams{LastError: &detail, ID: part.ID}); err != nil { + return err + } + return queries.MarkOutboundUncertain(context.Background(), dbgen.MarkOutboundUncertainParams{ + ExternalErrorCode: &code, LastError: &detail, ID: message.ID, + }) + }) + if err == nil { + o.metrics.Delivery("outbound", "uncertain") + } + return err +} + +func (o *Outbound) failPart(message *dbgen.OutboundMessage, part *dbgen.OutboundPart, code, detail string) error { + err := o.store.WithTx(context.Background(), func(queries *dbgen.Queries) error { + if err := queries.FailOutboundPart(context.Background(), dbgen.FailOutboundPartParams{LastError: &detail, ID: part.ID}); err != nil { + return err + } + return queries.FailOutboundMessage(context.Background(), dbgen.FailOutboundMessageParams{ + ExternalErrorCode: &code, LastError: &detail, ID: message.ID, + }) + }) + if err == nil { + o.metrics.Delivery("outbound", "failed") + } + return err +} + +type permanentDeliveryError struct { + code string + err error +} + +func (e *permanentDeliveryError) Error() string { return e.err.Error() } +func (e *permanentDeliveryError) Unwrap() error { return e.err } + +func (o *Outbound) processStatus(ctx context.Context) (bool, error) { + message, err := o.store.Writer().ClaimStatusSync(ctx) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + account, err := o.store.Reader().GetAccountByID(ctx, message.AccountID) + if err != nil { + return true, o.retryStatus(message, err) + } + result := gochat.MessageResult{ + ResultVersion: message.ResultVersion, + ExternalID: message.ExternalID, + OccurredAt: message.UpdatedAt.UTC(), + } + parts, err := o.store.Reader().ListOutboundParts(ctx, message.ID) + if err != nil { + return true, o.retryStatus(message, err) + } + for _, part := range parts { + if part.ExternalID == nil || strings.TrimSpace(*part.ExternalID) == "" { + continue + } + externalID := strings.TrimSpace(*part.ExternalID) + duplicate := false + for _, existing := range result.ExternalIDs { + if existing == externalID { + duplicate = true + break + } + } + if !duplicate { + result.ExternalIDs = append(result.ExternalIDs, externalID) + } + } + if len(result.ExternalIDs) > 1 { + result.ExternalID = nil + } else if len(result.ExternalIDs) == 1 { + result.ExternalID = &result.ExternalIDs[0] + result.ExternalIDs = nil + } + switch message.DeliveryStatus { + case "delivered": + result.Status = "sent" + case "uncertain": + result.Status = "uncertain" + result.ErrorCode, result.ErrorMessage = message.ExternalErrorCode, message.LastError + case "failed": + result.Status = "failed" + result.ErrorCode, result.ErrorMessage = message.ExternalErrorCode, message.LastError + default: + return true, o.retryStatus(message, fmt.Errorf("invalid terminal delivery status %q", message.DeliveryStatus)) + } + requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + err = o.results.UpdateMessageStatus(requestCtx, account.GochatInboxID, message.GochatMessageID, result) + cancel() + if err != nil { + return true, o.retryStatus(message, err) + } + err = o.store.Writer().CompleteStatusSync(ctx, message.ID) + if err == nil { + o.metrics.StatusSync("success", result.Status) + } + return true, err +} + +func (o *Outbound) processOperation(ctx context.Context) (bool, error) { + operation, err := o.store.Writer().ClaimOutboundOperation(ctx) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + err = o.sessions.WithSession(ctx, operation.AccountID, func(session swt.Session) error { + switch operation.Operation { + case "end_conversation": + return o.sender.EndConversation(ctx, session, operation.SwtSid) + default: + return fmt.Errorf("unsupported outbound operation %q", operation.Operation) + } + }) + if err == nil { + return true, o.store.Writer().CompleteOutboundOperation(ctx, operation.ID) + } + var protocolErr *swt.Error + if errors.As(err, &protocolErr) { + if protocolErr.Uncertain { + detail := protocolErr.Error() + return true, o.store.Writer().MarkOutboundOperationUncertain(ctx, dbgen.MarkOutboundOperationUncertainParams{LastError: &detail, ID: operation.ID}) + } + if protocolErr.Code == "tickint_reset" || protocolErr.Code == "cache_null" { + _ = o.sessions.InvalidateSession(ctx, operation.AccountID) + return true, o.retryOperation(operation, protocolErr.Error()) + } + if protocolErr.Retryable && operation.Attempts < 10 { + return true, o.retryOperation(operation, protocolErr.Error()) + } + return true, o.failOperation(operation, protocolErr.Error()) + } + if operation.Attempts < 10 { + return true, o.retryOperation(operation, err.Error()) + } + return true, o.failOperation(operation, err.Error()) +} + +func (o *Outbound) retry(message *dbgen.OutboundMessage, detail string) error { + next := time.Now().Add(backoff(message.Attempts, 5*time.Minute)) + err := o.store.Writer().RetryOutboundDelivery(context.Background(), dbgen.RetryOutboundDeliveryParams{ + NextAttemptAt: &next, LastError: &detail, ID: message.ID, + }) + if err == nil { + o.metrics.Delivery("outbound", "retry") + } + return err +} + +func (o *Outbound) fail(message *dbgen.OutboundMessage, code, detail string) error { + err := o.store.Writer().FailOutboundMessage(context.Background(), dbgen.FailOutboundMessageParams{ + ExternalErrorCode: &code, LastError: &detail, ID: message.ID, + }) + if err == nil { + o.metrics.Delivery("outbound", "failed") + } + return err +} + +func (o *Outbound) retryStatus(message *dbgen.OutboundMessage, err error) error { + detail := err.Error() + next := time.Now().Add(backoffForError(message.StatusSyncAttempts, 5*time.Minute, err)) + retryErr := o.store.Writer().RetryStatusSync(context.Background(), dbgen.RetryStatusSyncParams{ + StatusSyncNextAt: &next, LastError: &detail, ID: message.ID, + }) + status := "unknown" + switch message.DeliveryStatus { + case "delivered": + status = "sent" + case "failed", "uncertain": + status = message.DeliveryStatus + } + o.metrics.StatusSync("retry", status) + return errors.Join(err, retryErr) +} + +func backoffForError(attempts int64, maximum time.Duration, err error) time.Duration { + delay := backoff(attempts, maximum) + var apiErr *gochat.APIError + if !errors.As(err, &apiErr) || apiErr.RetryAfter <= delay { + return delay + } + jitterWindow := apiErr.RetryAfter / 5 + if jitterWindow <= 0 { + return apiErr.RetryAfter + } + return apiErr.RetryAfter + time.Duration(randv2.Int64N(int64(jitterWindow))) +} + +func (o *Outbound) retryOperation(operation *dbgen.OutboundOperation, detail string) error { + next := time.Now().Add(backoff(operation.Attempts, 5*time.Minute)) + return o.store.Writer().RetryOutboundOperation(context.Background(), dbgen.RetryOutboundOperationParams{ + NextAttemptAt: &next, LastError: &detail, ID: operation.ID, + }) +} + +func (o *Outbound) failOperation(operation *dbgen.OutboundOperation, detail string) error { + return o.store.Writer().FailOutboundOperation(context.Background(), dbgen.FailOutboundOperationParams{LastError: &detail, ID: operation.ID}) +} + +func backoff(attempts int64, maximum time.Duration) time.Duration { + if attempts < 1 { + attempts = 1 + } + delay := time.Second + for i := int64(1); i < attempts; i++ { + if delay >= maximum/2 { + delay = maximum + break + } + delay *= 2 + } + if delay > maximum { + delay = maximum + } + jitterWindow := delay / 5 + if jitterWindow <= 0 { + return delay + } + return delay - time.Duration(randv2.Int64N(int64(jitterWindow))) +} + +func wait(ctx context.Context, duration time.Duration) bool { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/channels/shangwutong/internal/delivery/outbound_test.go b/channels/shangwutong/internal/delivery/outbound_test.go new file mode 100644 index 00000000..a6bcd051 --- /dev/null +++ b/channels/shangwutong/internal/delivery/outbound_test.go @@ -0,0 +1,397 @@ +package delivery + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" +) + +func TestOutboundSuccessIsPersistedBeforeStatusSync(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + payload := deliveryPayload(t, nil) + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Content: stringPointer("hello"), Payload: payload, + }, false); err != nil { + t.Fatal(err) + } + results := &resultRecorder{} + worker, err := NewOutbound(database, sessionStub{}, senderStub{}, results, nil, 1) + if err != nil { + t.Fatal(err) + } + worked, err := worker.processOutbound(ctx) + if err != nil || !worked { + t.Fatalf("delivery = %v, %v", worked, err) + } + queued, err := database.Writer().GetOutboundByGoChatMessageID(ctx, 77) + if err != nil || queued.DeliveryStatus != "delivered" || queued.StatusSyncStatus != "pending" { + t.Fatalf("queued = %#v, %v", queued, err) + } + worked, err = worker.processStatus(ctx) + if err != nil || !worked || results.result.Status != "sent" || results.inboxID != account.GochatInboxID { + t.Fatalf("status sync = %v, %v, %#v", worked, err, results) + } + queued, _ = database.Writer().GetOutboundByGoChatMessageID(ctx, 77) + if queued.StatusSyncStatus != "synced" { + t.Fatalf("status sync state = %s", queued.StatusSyncStatus) + } +} + +func TestOutboundTimeoutBecomesUncertainAndIsReported(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Content: stringPointer("hello"), Payload: deliveryPayload(t, nil), + }, false); err != nil { + t.Fatal(err) + } + results := &resultRecorder{} + worker, _ := NewOutbound(database, sessionStub{}, senderStub{err: &swt.Error{ + Operation: "send_text", Code: "network_result_uncertain", Uncertain: true, Err: errors.New("timeout"), + }}, results, nil, 1) + if worked, err := worker.processOutbound(ctx); err != nil || !worked { + t.Fatalf("delivery = %v, %v", worked, err) + } + if worked, err := worker.processStatus(ctx); err != nil || !worked { + t.Fatalf("status = %v, %v", worked, err) + } + if results.result.Status != "uncertain" || results.result.ErrorCode == nil { + t.Fatalf("result = %#v", results.result) + } +} + +func TestOutboundTokenResetInvalidatesSessionAndRetries(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Content: stringPointer("hello"), Payload: deliveryPayload(t, nil), + }, false); err != nil { + t.Fatal(err) + } + sessions := &sessionRecorder{} + worker, _ := NewOutbound(database, sessions, senderStub{err: &swt.Error{ + Operation: "send_text", Code: "tickint_reset", Retryable: true, Err: errors.New("session reset"), + }}, &resultRecorder{}, nil, 1) + if worked, err := worker.processOutbound(ctx); err != nil || !worked { + t.Fatalf("delivery = %v, %v", worked, err) + } + queued, err := database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + if err != nil || sessions.invalidations != 1 || queued.DeliveryStatus != "pending" || queued.NextAttemptAt == nil { + t.Fatalf("sessions=%#v queued=%#v err=%v", sessions, queued, err) + } +} + +func TestOutboundUncertainObservationExpiresMessageAndOperation(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), GoChatMessageID: 77, + MessageType: "text", Content: stringPointer("hello"), Payload: deliveryPayload(t, nil), + }, false); err != nil { + t.Fatal(err) + } + if _, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "conversation:100:status:1", + Operation: "end_conversation", Payload: `{}`, OccurredAt: time.Now().Add(time.Second), + }); err != nil { + t.Fatal(err) + } + uncertain := &swt.Error{Operation: "send", Code: "network_result_uncertain", Uncertain: true, Err: errors.New("timeout")} + worker, _ := NewOutbound(database, sessionStub{}, senderStub{err: uncertain}, &resultRecorder{}, nil, 1) + if worked, err := worker.processOutbound(ctx); err != nil || !worked { + t.Fatalf("message delivery = %v, %v", worked, err) + } + // The uncertain message is an account-level barrier, so expire it before the later operation can be claimed. + messages, operations, err := worker.expireUncertain(ctx, time.Now().Add(uncertainObservationWindow+time.Second)) + if err != nil || messages != 1 || operations != 0 { + t.Fatalf("first expiry = messages:%d operations:%d err:%v", messages, operations, err) + } + queued, err := database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + if err != nil || queued.DeliveryStatus != "failed" || value(queued.ExternalErrorCode) != "uncertain_timeout" || queued.StatusSyncStatus != "pending" { + t.Fatalf("expired message = %#v, %v", queued, err) + } + if worked, err := worker.processOperation(ctx); err != nil || !worked { + t.Fatalf("operation delivery = %v, %v", worked, err) + } + messages, operations, err = worker.expireUncertain(ctx, time.Now().Add(uncertainObservationWindow+time.Second)) + if err != nil || messages != 0 || operations != 1 { + t.Fatalf("second expiry = messages:%d operations:%d err:%v", messages, operations, err) + } + operation, err := database.Reader().GetOutboundOperationByEventID(ctx, "conversation:100:status:1") + if err != nil || operation.DeliveryStatus != "failed" { + t.Fatalf("expired operation = %#v, %v", operation, err) + } +} + +func TestOutboundConversationEndOperationUsesSameDurableWorker(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "conversation:100:status:1", + Operation: "end_conversation", Payload: `{}`, OccurredAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + sender := &operationSender{} + worker, err := NewOutbound(database, sessionStub{}, sender, &resultRecorder{}, nil, 1) + if err != nil { + t.Fatal(err) + } + if worked, err := worker.processOperation(ctx); err != nil || !worked { + t.Fatalf("operation = %v, %v", worked, err) + } + loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID) + if err != nil || loaded.DeliveryStatus != "delivered" || sender.sid != "visitor" { + t.Fatalf("loaded = %#v sender=%#v err=%v", loaded, sender, err) + } +} + +func TestOutboundRetrySkipsAlreadyDeliveredParts(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + content, dataURL, fileName, fileSize := "hello", "https://gochat.test/image.png", "image.png", int64(9) + input := store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), + GoChatMessageID: 77, MessageType: "text", Content: &content, Payload: deliveryPayload(t, nil), + Parts: []store.OutboundPartInput{ + {Type: "text", Content: &content}, + {Type: "image", AttachmentID: int64Pointer(5), DataURL: &dataURL, FileName: &fileName, FileSize: &fileSize}, + }, + } + if _, _, err := database.EnqueueOutbound(ctx, input, false); err != nil { + t.Fatal(err) + } + sender := &partialSender{failImage: true} + worker, _ := NewOutbound(database, sessionStub{}, sender, &resultRecorder{}, nil, 1) + worker.media = testUploadFetcher(t) + if worked, err := worker.processOutbound(ctx); !worked || err != nil { + t.Fatalf("first delivery = %v, %v", worked, err) + } + queued, _ := database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + parts, _ := database.Reader().ListOutboundParts(ctx, queued.ID) + if queued.DeliveryStatus != "failed" || parts[0].DeliveryStatus != "delivered" || parts[1].DeliveryStatus != "failed" { + t.Fatalf("queued=%#v parts=%#v", queued, parts) + } + + input.EventID, input.RetryVersion, input.OccurredAt, input.Payload = "message:77:retry:1", 1, time.Now().Add(time.Second), `{"retry":1}` + if _, duplicate, err := database.EnqueueOutbound(ctx, input, true); err != nil || duplicate { + t.Fatalf("retry enqueue duplicate=%v err=%v", duplicate, err) + } + sender.failImage = false + if worked, err := worker.processOutbound(ctx); err != nil || !worked { + t.Fatalf("retry delivery = %v, %v", worked, err) + } + queued, _ = database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + parts, _ = database.Reader().ListOutboundParts(ctx, queued.ID) + if sender.textCalls != 1 || sender.imageCalls != 2 || queued.DeliveryStatus != "delivered" || parts[0].DeliveryStatus != "delivered" || parts[1].DeliveryStatus != "delivered" { + t.Fatalf("sender=%#v queued=%#v parts=%#v", sender, queued, parts) + } +} + +func TestOutboundUnsupportedPartFailsBeforeAnyPartialSend(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + content, videoURL := "do not send partially", "https://gochat.test/video.mp4" + if _, _, err := database.EnqueueOutbound(ctx, store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), + GoChatMessageID: 77, MessageType: "text", Content: &content, Payload: deliveryPayload(t, nil), + Parts: []store.OutboundPartInput{{Type: "text", Content: &content}, {Type: "video", DataURL: &videoURL}}, + }, false); err != nil { + t.Fatal(err) + } + sender := &partialSender{} + worker, _ := NewOutbound(database, sessionStub{}, sender, &resultRecorder{}, nil, 1) + if worked, err := worker.processOutbound(ctx); err != nil || !worked { + t.Fatalf("delivery = %v, %v", worked, err) + } + queued, err := database.Reader().GetOutboundByGoChatMessageID(ctx, 77) + if err != nil || queued.DeliveryStatus != "failed" || value(queued.ExternalErrorCode) != "unsupported_outbound_content" || sender.textCalls != 0 || sender.imageCalls != 0 { + t.Fatalf("queued=%#v sender=%#v err=%v", queued, sender, err) + } +} + +func TestBackoffUsesBoundedDownwardJitter(t *testing.T) { + for range 100 { + first := backoff(1, 5*time.Minute) + if first < 800*time.Millisecond || first > time.Second { + t.Fatalf("first backoff = %s", first) + } + capped := backoff(20, 5*time.Minute) + if capped < 4*time.Minute || capped > 5*time.Minute { + t.Fatalf("capped backoff = %s", capped) + } + } + limited := backoffForError(1, 5*time.Minute, &gochat.APIError{RetryAfter: 10 * time.Second}) + if limited < 10*time.Second || limited >= 12*time.Second { + t.Fatalf("Retry-After backoff = %s", limited) + } +} + +func deliveryDatabase(t *testing.T, ctx context.Context) (*store.Store, *dbgen.Account) { + t.Helper() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 10, GochatInboxIdentifier: "identifier", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + return database, account +} + +func deliveryPayload(t *testing.T, attachments []gochat.WebhookAttachment) string { + t.Helper() + data, _ := json.Marshal(gochat.MessageWebhookData{ + Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "text", Content: "hello", Attachments: attachments}, + Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + }) + payload, err := json.Marshal(gochat.WebhookEnvelope{ + SchemaVersion: 1, Event: "message_created", EventID: "message:77:created", OccurredAt: time.Now(), + AccountID: 1, InboxID: 10, Data: data, + }) + if err != nil { + t.Fatal(err) + } + return string(payload) +} + +type sessionStub struct{} + +func (sessionStub) WithSession(ctx context.Context, _ int64, operation func(swt.Session) error) error { + return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "agent", MAToken: "token"}) +} + +func (sessionStub) InvalidateSession(context.Context, int64) error { return nil } + +type sessionRecorder struct{ invalidations int } + +func (*sessionRecorder) WithSession(ctx context.Context, _ int64, operation func(swt.Session) error) error { + return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "agent", MAToken: "token"}) +} + +func (s *sessionRecorder) InvalidateSession(context.Context, int64) error { + s.invalidations++ + return nil +} + +type senderStub struct{ err error } + +func (s senderStub) SendText(context.Context, swt.Session, string, string) (swt.SendResult, error) { + return swt.SendResult{Status: "ok"}, s.err +} + +func (s senderStub) SendImage(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{Status: "ok"}, s.err +} + +func (s senderStub) SendFile(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{Status: "ok"}, s.err +} + +func (s senderStub) SendVoice(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{Status: "ok"}, s.err +} + +func (s senderStub) EndConversation(context.Context, swt.Session, string) error { return s.err } + +type operationSender struct{ sid string } + +func (*operationSender) SendText(context.Context, swt.Session, string, string) (swt.SendResult, error) { + return swt.SendResult{}, nil +} + +func (*operationSender) SendImage(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{}, nil +} + +func (*operationSender) SendFile(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{}, nil +} + +func (*operationSender) SendVoice(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{}, nil +} + +func (s *operationSender) EndConversation(_ context.Context, _ swt.Session, sid string) error { + s.sid = sid + return nil +} + +type partialSender struct { + textCalls int + imageCalls int + failImage bool +} + +func (s *partialSender) SendText(context.Context, swt.Session, string, string) (swt.SendResult, error) { + s.textCalls++ + return swt.SendResult{Status: "ok"}, nil +} + +func (s *partialSender) SendImage(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + s.imageCalls++ + if s.failImage { + return swt.SendResult{}, &swt.Error{Operation: "send_image", Code: "state_err", Err: errors.New("rejected")} + } + return swt.SendResult{Status: "ok"}, nil +} + +func (*partialSender) SendFile(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{Status: "ok"}, nil +} + +func (*partialSender) SendVoice(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error) { + return swt.SendResult{Status: "ok"}, nil +} + +func (*partialSender) EndConversation(context.Context, swt.Session, string) error { return nil } + +func testUploadFetcher(t *testing.T) mediaFetcher { + t.Helper() + path := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(path, []byte("image"), 0o600); err != nil { + t.Fatal(err) + } + return &staticMediaFetcher{upload: gochat.AttachmentUpload{Path: path, Name: "image.png", ContentType: "image/png"}} +} + +type staticMediaFetcher struct{ upload gochat.AttachmentUpload } + +func (f *staticMediaFetcher) Fetch(context.Context, mediaReference) (gochat.AttachmentUpload, func(), error) { + return f.upload, func() {}, nil +} + +func int64Pointer(value int64) *int64 { return &value } + +type resultRecorder struct { + inboxID int64 + messageID int64 + result gochat.MessageResult +} + +func (r *resultRecorder) UpdateMessageStatus(_ context.Context, inboxID, messageID int64, result gochat.MessageResult) error { + r.inboxID, r.messageID, r.result = inboxID, messageID, result + return nil +} + +func stringPointer(value string) *string { return &value } diff --git a/channels/shangwutong/internal/gochat/client.go b/channels/shangwutong/internal/gochat/client.go new file mode 100644 index 00000000..c3bc19bb --- /dev/null +++ b/channels/shangwutong/internal/gochat/client.go @@ -0,0 +1,304 @@ +package gochat + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const maxResponseBytes = 8 << 20 + +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +func NewClient(baseURL, token string, httpClient *http.Client) (*Client, error) { + parsed, err := url.Parse(strings.TrimRight(baseURL, "/")) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return nil, errors.New("invalid GoChat base URL") + } + if strings.TrimSpace(token) == "" { + return nil, errors.New("GoChat service token is required") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &Client{baseURL: parsed.String(), token: token, httpClient: httpClient}, nil +} + +type Credentials struct { + SessionID string `json:"session_id"` + Username string `json:"username"` + Password string `json:"password"` + HMACToken string `json:"hmac_token"` + WebhookSecret string `json:"webhook_secret"` +} + +type InboxConfig struct { + SchemaVersion int64 `json:"schema_version"` + AccountID int64 `json:"account_id"` + InboxID int64 `json:"inbox_id"` + InboxIdentifier string `json:"inbox_identifier"` + Enabled bool `json:"enabled"` + DesiredPresence string `json:"desired_presence"` + ConfigVersion int64 `json:"config_version"` + Credentials Credentials `json:"credentials"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (c InboxConfig) Validate() error { + if c.SchemaVersion != 1 { + return fmt.Errorf("unsupported config schema version %d", c.SchemaVersion) + } + if c.AccountID <= 0 || c.InboxID <= 0 || c.ConfigVersion <= 0 || c.InboxIdentifier == "" { + return errors.New("config identifiers and version are required") + } + if c.Credentials.SessionID == "" || c.Credentials.Username == "" || c.Credentials.Password == "" || c.Credentials.HMACToken == "" || c.Credentials.WebhookSecret == "" { + return errors.New("config credentials are incomplete") + } + switch c.DesiredPresence { + case "online", "busy", "away", "offline": + default: + return fmt.Errorf("invalid desired_presence %q", c.DesiredPresence) + } + return nil +} + +type configPage struct { + Data []InboxConfig `json:"data"` + NextCursor string `json:"next_cursor"` +} + +func (c *Client) ListInboxConfigs(ctx context.Context) ([]InboxConfig, error) { + configs := make([]InboxConfig, 0) + cursor := "" + seen := make(map[string]struct{}) + for { + query := url.Values{"limit": {"100"}} + if cursor != "" { + query.Set("cursor", cursor) + } + var page configPage + if err := c.doJSON(ctx, http.MethodGet, "/api/v1/connector/shangwutong/inboxes?"+query.Encode(), nil, &page, ""); err != nil { + return nil, err + } + for _, config := range page.Data { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("validate inbox %d: %w", config.InboxID, err) + } + configs = append(configs, config) + } + if page.NextCursor == "" { + return configs, nil + } + if _, duplicate := seen[page.NextCursor]; duplicate { + return nil, errors.New("GoChat config pagination cursor loop") + } + seen[page.NextCursor] = struct{}{} + cursor = page.NextCursor + } +} + +func (c *Client) GetInboxConfig(ctx context.Context, inboxID int64) (InboxConfig, error) { + var config InboxConfig + path := "/api/v1/connector/shangwutong/inboxes/" + strconv.FormatInt(inboxID, 10) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &config, ""); err != nil { + return InboxConfig{}, err + } + if err := config.Validate(); err != nil { + return InboxConfig{}, err + } + return config, nil +} + +type InboxStatus 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 (c *Client) UpdateInboxStatus(ctx context.Context, inboxID int64, status InboxStatus) error { + if inboxID <= 0 || status.ConfigVersion <= 0 { + return errors.New("inbox ID and config version must be positive") + } + if !validEnum(status.ActualPresence, "online", "busy", "away", "offline") || + !validEnum(status.ConnectionStatus, "pending", "logging_in", "connected", "degraded", "relogin_required", "verification_required", "auth_failed", "disabled", "offline") || + !validEnum(status.CredentialStatus, "pending", "verifying", "applied", "rejected", "verification_required") { + return errors.New("invalid inbox status") + } + path := "/api/v1/connector/shangwutong/inboxes/" + strconv.FormatInt(inboxID, 10) + "/status" + return c.doJSON(ctx, http.MethodPut, path, status, nil, "") +} + +type MessageResult struct { + ResultVersion int64 `json:"result_version"` + Status string `json:"status"` + ExternalID *string `json:"external_id"` + ExternalIDs []string `json:"external_ids,omitempty"` + ErrorCode *string `json:"error_code"` + ErrorMessage *string `json:"error_message"` + OccurredAt time.Time `json:"occurred_at"` +} + +func (c *Client) UpdateMessageStatus(ctx context.Context, inboxID, messageID int64, result MessageResult) error { + if inboxID <= 0 || messageID <= 0 || result.ResultVersion <= 0 || result.OccurredAt.IsZero() || !validEnum(result.Status, "sent", "failed", "uncertain") { + return errors.New("invalid message result") + } + if len(result.ExternalIDs) > 100 || (len(result.ExternalIDs) > 0 && result.Status != "sent") { + return errors.New("invalid message result external IDs") + } + for _, externalID := range result.ExternalIDs { + if _, err := strconv.ParseUint(strings.TrimSpace(externalID), 10, 64); err != nil { + return errors.New("invalid message result external IDs") + } + } + path := "/api/v1/connector/shangwutong/inboxes/" + strconv.FormatInt(inboxID, 10) + "/messages/" + strconv.FormatInt(messageID, 10) + "/status" + idempotencyKey := fmt.Sprintf("swt-delivery:%d:%d:%d", inboxID, messageID, result.ResultVersion) + return c.doJSON(ctx, http.MethodPut, path, result, nil, idempotencyKey) +} + +func validEnum(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +type APIError struct { + StatusCode int + Code string + Retryable bool + RetryAfter time.Duration + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("GoChat API %d %s: %s", e.StatusCode, e.Code, e.Message) +} + +func (c *Client) doJSON(ctx context.Context, method, path string, requestBody, responseBody any, idempotencyKey string) error { + var body io.Reader + if requestBody != nil { + encoded, err := json.Marshal(requestBody) + if err != nil { + return err + } + body = bytes.NewReader(encoded) + } + request, err := c.newRequest(ctx, method, path, body, idempotencyKey) + if err != nil { + return err + } + if requestBody != nil { + request.Header.Set("Content-Type", "application/json") + } + return c.doRequest(request, responseBody) +} + +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader, idempotencyKey string) (*http.Request, error) { + request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+c.token) + request.Header.Set("X-GoChat-Schema-Version", "1") + request.Header.Set("X-Request-ID", clientRequestID()) + request.Header.Set("Accept", "application/json") + if idempotencyKey != "" { + request.Header.Set("Idempotency-Key", idempotencyKey) + } + return request, nil +} + +func (c *Client) doRequest(request *http.Request, responseBody any) error { + response, err := c.httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1)) + if err != nil { + return err + } + if len(payload) > maxResponseBytes { + return errors.New("GoChat response exceeds size limit") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + apiErr := &APIError{ + StatusCode: response.StatusCode, Code: "http_error", Retryable: response.StatusCode == 429 || response.StatusCode >= 500, + RetryAfter: parseRetryAfter(response.Header.Get("Retry-After"), time.Now()), Message: strings.TrimSpace(string(payload)), + } + var envelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + } `json:"error"` + } + if json.Unmarshal(payload, &envelope) == nil && envelope.Error.Code != "" { + apiErr.Code, apiErr.Message = envelope.Error.Code, envelope.Error.Message + apiErr.Retryable = envelope.Error.Retryable || response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 + } + return apiErr + } + if responseBody == nil || len(payload) == 0 { + return nil + } + if err := json.Unmarshal(payload, responseBody); err != nil { + return fmt.Errorf("decode GoChat response: %w", err) + } + return nil +} + +func parseRetryAfter(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + if seconds <= 0 { + return 0 + } + if seconds > int64((24*time.Hour)/time.Second) { + return 24 * time.Hour + } + return time.Duration(seconds) * time.Second + } + when, err := http.ParseTime(value) + if err != nil || !when.After(now) { + return 0 + } + delay := when.Sub(now) + if delay > 24*time.Hour { + return 24 * time.Hour + } + return delay +} + +func clientRequestID() string { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value) + return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:] +} diff --git a/channels/shangwutong/internal/gochat/client_test.go b/channels/shangwutong/internal/gochat/client_test.go new file mode 100644 index 00000000..6b60dbe6 --- /dev/null +++ b/channels/shangwutong/internal/gochat/client_test.go @@ -0,0 +1,98 @@ +package gochat + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestListInboxConfigsPaginatesAndAuthenticates(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + requests++ + if request.Header.Get("Authorization") != "Bearer token" { + t.Fatalf("authorization = %q", request.Header.Get("Authorization")) + } + page := configPage{Data: []InboxConfig{validConfig(int64(requests))}} + if requests == 1 { + page.NextCursor = "next" + } else if request.URL.Query().Get("cursor") != "next" { + t.Fatalf("cursor = %q", request.URL.Query().Get("cursor")) + } + _ = json.NewEncoder(response).Encode(page) + })) + defer server.Close() + client, err := NewClient(server.URL, "token", nil) + if err != nil { + t.Fatal(err) + } + configs, err := client.ListInboxConfigs(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(configs) != 2 || requests != 2 { + t.Fatalf("configs = %d, requests = %d", len(configs), requests) + } +} + +func TestUpdateMessageStatusUsesStableIdempotencyKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if got := request.Header.Get("Idempotency-Key"); got != "swt-delivery:2:3:4" { + t.Fatalf("idempotency key = %q", got) + } + var result MessageResult + if err := json.NewDecoder(request.Body).Decode(&result); err != nil || len(result.ExternalIDs) != 2 || result.ExternalIDs[1] != "12" { + t.Fatalf("result = %#v, %v", result, err) + } + response.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + client, _ := NewClient(server.URL, "token", nil) + if err := client.UpdateMessageStatus(context.Background(), 2, 3, MessageResult{ResultVersion: 4, Status: "sent", ExternalIDs: []string{"11", "12"}, OccurredAt: time.Now()}); err != nil { + t.Fatal(err) + } +} + +func TestAPIErrorClassification(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusServiceUnavailable) + _, _ = response.Write([]byte(`{"error":{"code":"unavailable","message":"later","retryable":true}}`)) + })) + defer server.Close() + client, _ := NewClient(server.URL, "token", nil) + _, err := client.GetInboxConfig(context.Background(), 1) + apiErr, ok := err.(*APIError) + if !ok || !apiErr.Retryable || apiErr.Code != "unavailable" { + t.Fatalf("error = %#v", err) + } +} + +func TestAPIErrorParsesRetryAfter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Retry-After", "7") + response.WriteHeader(http.StatusTooManyRequests) + _, _ = response.Write([]byte(`{"error":{"code":"rate_limited","message":"later","retryable":true}}`)) + })) + defer server.Close() + client, _ := NewClient(server.URL, "token", nil) + _, err := client.GetInboxConfig(context.Background(), 1) + apiErr, ok := err.(*APIError) + if !ok || apiErr.RetryAfter != 7*time.Second { + t.Fatalf("error = %#v", err) + } + date := time.Now().UTC().Add(2 * time.Minute).Truncate(time.Second) + if delay := parseRetryAfter(date.Format(http.TimeFormat), date.Add(-time.Minute)); delay != time.Minute { + t.Fatalf("HTTP-date retry delay = %s", delay) + } +} + +func validConfig(inboxID int64) InboxConfig { + return InboxConfig{ + SchemaVersion: 1, AccountID: 1, InboxID: inboxID, InboxIdentifier: "identifier", + Enabled: true, DesiredPresence: "online", ConfigVersion: 1, UpdatedAt: time.Now(), + Credentials: Credentials{SessionID: "BYT99917999", Username: "agent", Password: "password", HMACToken: "hmac", WebhookSecret: "secret"}, + } +} diff --git a/channels/shangwutong/internal/gochat/messaging.go b/channels/shangwutong/internal/gochat/messaging.go new file mode 100644 index 00000000..26f0e21d --- /dev/null +++ b/channels/shangwutong/internal/gochat/messaging.go @@ -0,0 +1,274 @@ +package gochat + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type ContactRequest struct { + SourceID string `json:"source_id"` + Identifier string `json:"identifier"` + IdentifierHash string `json:"identifier_hash"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + CustomAttributes map[string]any `json:"custom_attributes,omitempty"` +} + +type Contact struct { + SourceID string `json:"source_id"` + PubsubToken string `json:"pubsub_token"` + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` +} + +func (c *Client) EnsureContact(ctx context.Context, inboxIdentifier, hmacToken string, request ContactRequest) (Contact, error) { + if strings.TrimSpace(request.SourceID) == "" { + return Contact{}, errors.New("contact source_id is required") + } + if strings.TrimSpace(request.Name) == "" { + request.Name = "商务通访客" + } + request.Identifier = request.SourceID + request.IdentifierHash = contactIdentifierHash(hmacToken, request.Identifier) + var contact Contact + path := "/public/api/v1/inboxes/" + url.PathEscape(inboxIdentifier) + "/contacts" + if err := c.doJSON(ctx, http.MethodPost, path, request, &contact, request.SourceID); err != nil { + return Contact{}, err + } + return contact, nil +} + +func (c *Client) UpdateContact(ctx context.Context, inboxIdentifier, hmacToken, sourceID string, request ContactRequest) (Contact, error) { + request.SourceID, request.Identifier = sourceID, sourceID + request.IdentifierHash = contactIdentifierHash(hmacToken, sourceID) + var contact Contact + path := "/public/api/v1/inboxes/" + url.PathEscape(inboxIdentifier) + "/contacts/" + url.PathEscape(sourceID) + if err := c.doJSON(ctx, http.MethodPatch, path, request, &contact, "swt-contact:"+sourceID); err != nil { + return Contact{}, err + } + return contact, nil +} + +type PublicConversation struct { + ID int64 `json:"id"` + InternalID int64 `json:"internal_id"` + InboxID int64 `json:"inbox_id"` + Status string `json:"status"` + CustomAttributes map[string]any `json:"custom_attributes"` +} + +func (c *Client) EnsureConversation(ctx context.Context, inboxIdentifier, sourceID string, customAttributes map[string]any) (PublicConversation, error) { + basePath := "/public/api/v1/inboxes/" + url.PathEscape(inboxIdentifier) + "/contacts/" + url.PathEscape(sourceID) + "/conversations" + var conversations []PublicConversation + if err := c.doJSON(ctx, http.MethodGet, basePath, nil, &conversations, ""); err != nil { + return PublicConversation{}, err + } + for index := len(conversations) - 1; index >= 0; index-- { + if conversations[index].InternalID > 0 && conversations[index].Status == "open" { + return conversations[index], nil + } + } + var conversation PublicConversation + body := map[string]any{"custom_attributes": customAttributes} + if err := c.doJSON(ctx, http.MethodPost, basePath, body, &conversation, "swt-conversation:"+sourceID); err != nil { + return PublicConversation{}, err + } + if conversation.InternalID <= 0 || conversation.ID <= 0 { + return PublicConversation{}, errors.New("GoChat conversation response is missing internal_id or display id") + } + return conversation, nil +} + +type MessageImport struct { + MessageType string `json:"message_type"` + ContentType string `json:"content_type"` + Content string `json:"content"` + Private bool `json:"private"` + SourceID string `json:"source_id"` + ExternalSourceIDs map[string]any `json:"external_source_ids,omitempty"` + External bool `json:"external"` + ExternalCreatedAt time.Time `json:"external_created_at"` + ContentAttributes map[string]any `json:"content_attributes,omitempty"` + AdditionalAttributes map[string]any `json:"additional_attributes,omitempty"` +} + +type ImportedMessage struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + InboxID int64 `json:"inbox_id"` + ConversationID int64 `json:"conversation_id"` + MessageType any `json:"message_type"` + ContentType string `json:"content_type"` + Content string `json:"content"` + SourceID string `json:"source_id"` + ExternalSourceIDs map[string]any `json:"external_source_ids"` + External bool `json:"external"` + Status string `json:"status"` + IdempotentReplay bool `json:"idempotent_replay"` +} + +type AttachmentUpload struct { + Path string + Name string + ContentType string + Voice bool +} + +func (c *Client) ImportMessage(ctx context.Context, accountID, conversationID int64, message MessageImport) (ImportedMessage, error) { + if accountID <= 0 || conversationID <= 0 || !message.External || strings.TrimSpace(message.SourceID) == "" || message.ExternalCreatedAt.IsZero() { + return ImportedMessage{}, errors.New("invalid GoChat message import") + } + path := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages", accountID, conversationID) + var imported ImportedMessage + if err := c.doJSON(ctx, http.MethodPost, path, message, &imported, message.SourceID); err != nil { + return ImportedMessage{}, err + } + return imported, nil +} + +func (c *Client) ImportMessageWithAttachments(ctx context.Context, accountID, conversationID int64, message MessageImport, attachments []AttachmentUpload) (ImportedMessage, error) { + if len(attachments) == 0 { + return c.ImportMessage(ctx, accountID, conversationID, message) + } + if accountID <= 0 || conversationID <= 0 || !message.External || strings.TrimSpace(message.SourceID) == "" || message.ExternalCreatedAt.IsZero() { + return ImportedMessage{}, errors.New("invalid GoChat message import") + } + reader, writer := io.Pipe() + multipartWriter := multipart.NewWriter(writer) + go func() { + err := writeMessageMultipart(multipartWriter, message, attachments) + if closeErr := multipartWriter.Close(); err == nil { + err = closeErr + } + _ = writer.CloseWithError(err) + }() + path := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages", accountID, conversationID) + request, err := c.newRequest(ctx, http.MethodPost, path, reader, message.SourceID) + if err != nil { + _ = reader.Close() + return ImportedMessage{}, err + } + request.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + var imported ImportedMessage + if err := c.doRequest(request, &imported); err != nil { + return ImportedMessage{}, err + } + return imported, nil +} + +func writeMessageMultipart(writer *multipart.Writer, message MessageImport, attachments []AttachmentUpload) error { + fields := map[string]string{ + "message_type": message.MessageType, "content_type": message.ContentType, "content": message.Content, + "private": strconv.FormatBool(message.Private), "source_id": message.SourceID, + "external": strconv.FormatBool(message.External), "external_created_at": message.ExternalCreatedAt.UTC().Format(time.RFC3339Nano), + } + for key, value := range map[string]map[string]any{ + "external_source_ids": message.ExternalSourceIDs, + "content_attributes": message.ContentAttributes, + "additional_attributes": message.AdditionalAttributes, + } { + if len(value) == 0 { + continue + } + encoded, err := json.Marshal(value) + if err != nil { + return err + } + fields[key] = string(encoded) + } + voice := false + for _, attachment := range attachments { + voice = voice || attachment.Voice + } + fields["is_voice_message"] = strconv.FormatBool(voice) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return err + } + } + for _, attachment := range attachments { + file, err := os.Open(attachment.Path) + if err != nil { + return err + } + name := filepath.Base(firstNonEmpty(attachment.Name, filepath.Base(attachment.Path))) + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "attachments[]", "filename": name})) + header.Set("Content-Type", firstNonEmpty(attachment.ContentType, "application/octet-stream")) + part, createErr := writer.CreatePart(header) + if createErr == nil { + _, createErr = io.Copy(part, file) + } + closeErr := file.Close() + if createErr != nil { + return createErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func (c *Client) UpdateConversationAttributes(ctx context.Context, accountID, conversationID int64, attributes map[string]any, idempotencyKey string) error { + path := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/custom_attributes", accountID, conversationID) + return c.doJSON(ctx, http.MethodPost, path, map[string]any{"custom_attributes": attributes}, nil, idempotencyKey) +} + +func (c *Client) SetConversationStatus(ctx context.Context, accountID, conversationID int64, status, idempotencyKey string) error { + if status != "open" && status != "resolved" { + return errors.New("invalid GoChat conversation status") + } + path := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_status", accountID, conversationID) + return c.doJSON(ctx, http.MethodPost, path, map[string]any{"status": status}, nil, idempotencyKey) +} + +func (c *Client) SetVisitorTyping(ctx context.Context, inboxIdentifier, sourceID string, displayID int64, typing bool) error { + status := "off" + if typing { + status = "on" + } + path := "/public/api/v1/inboxes/" + url.PathEscape(inboxIdentifier) + "/contacts/" + url.PathEscape(sourceID) + + "/conversations/" + strconv.FormatInt(displayID, 10) + "/toggle_typing" + return c.doJSON(ctx, http.MethodPost, path, map[string]any{"typing_status": status}, nil, "") +} + +func (c *Client) RetractMessage(ctx context.Context, accountID, conversationID, messageID int64, idempotencyKey string) error { + path := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/%d", accountID, conversationID, messageID) + return c.doJSON(ctx, http.MethodDelete, path, nil, nil, idempotencyKey) +} + +func contactIdentifierHash(token, identifier string) string { + mac := hmac.New(sha256.New, []byte(token)) + _, _ = mac.Write([]byte(identifier)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/channels/shangwutong/internal/gochat/messaging_test.go b/channels/shangwutong/internal/gochat/messaging_test.go new file mode 100644 index 00000000..716fc629 --- /dev/null +++ b/channels/shangwutong/internal/gochat/messaging_test.go @@ -0,0 +1,105 @@ +package gochat + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestMessagingClientEnsuresIdentityAndImportsWithStableKey(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + requests++ + switch request.URL.Path { + case "/public/api/v1/inboxes/inbox-token/contacts": + var payload ContactRequest + _ = json.NewDecoder(request.Body).Decode(&payload) + if payload.IdentifierHash != contactIdentifierHash("hmac-token", "visitor") { + t.Fatalf("identifier_hash = %q", payload.IdentifierHash) + } + _ = json.NewEncoder(response).Encode(Contact{ID: 3, SourceID: "visitor", Name: "访客"}) + case "/public/api/v1/inboxes/inbox-token/contacts/visitor/conversations": + if request.Method == http.MethodGet { + _, _ = response.Write([]byte(`[]`)) + return + } + _, _ = response.Write([]byte(`{"id":88,"internal_id":100,"inbox_id":2,"status":"open"}`)) + case "/api/v1/accounts/1/conversations/100/messages": + if request.Header.Get("Authorization") != "Bearer token" || request.Header.Get("X-GoChat-Schema-Version") != "1" { + t.Fatalf("missing connector auth headers") + } + if request.Header.Get("Idempotency-Key") != "swt:2:visitor:2:9:0" { + t.Fatalf("idempotency key = %q", request.Header.Get("Idempotency-Key")) + } + _, _ = response.Write([]byte(`{"id":9,"account_id":1,"inbox_id":2,"conversation_id":100,"source_id":"swt:2:visitor:2:9:0","external":true,"status":"sent"}`)) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + client, err := NewClient(server.URL, "token", server.Client()) + if err != nil { + t.Fatal(err) + } + contact, err := client.EnsureContact(context.Background(), "inbox-token", "hmac-token", ContactRequest{SourceID: "visitor", Name: "访客"}) + if err != nil || contact.ID != 3 { + t.Fatalf("contact=%+v err=%v", contact, err) + } + conversation, err := client.EnsureConversation(context.Background(), "inbox-token", "visitor", map[string]any{"swt_sid": "visitor"}) + if err != nil || conversation.InternalID != 100 || conversation.ID != 88 { + t.Fatalf("conversation=%+v err=%v", conversation, err) + } + message, err := client.ImportMessage(context.Background(), 1, 100, MessageImport{ + MessageType: "incoming", ContentType: "text", Content: "hello", SourceID: "swt:2:visitor:2:9:0", + External: true, ExternalCreatedAt: time.Now().UTC(), ExternalSourceIDs: map[string]any{"shangwutong": "9"}, + }) + if err != nil || message.ID != 9 || !message.External { + t.Fatalf("message=%+v err=%v", message, err) + } + if requests != 4 { + t.Fatalf("requests = %d", requests) + } +} + +func TestMessagingClientImportsMultipartAttachments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("Idempotency-Key") != "swt:2:visitor:2:10:0" { + t.Fatalf("idempotency key = %q", request.Header.Get("Idempotency-Key")) + } + if err := request.ParseMultipartForm(1 << 20); err != nil { + t.Fatal(err) + } + if request.FormValue("external") != "true" || request.FormValue("is_voice_message") != "true" || request.FormValue("external_source_ids") != `{"shangwutong":"10"}` { + t.Fatalf("form = %#v", request.MultipartForm.Value) + } + file, header, err := request.FormFile("attachments[]") + if err != nil { + t.Fatal(err) + } + defer file.Close() + payload, _ := io.ReadAll(file) + if header.Filename != "voice.amr" || string(payload) != "voice-data" { + t.Fatalf("attachment = %q %q", header.Filename, payload) + } + _, _ = response.Write([]byte(`{"id":10,"source_id":"swt:2:visitor:2:10:0","external":true}`)) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "voice.amr") + if err := os.WriteFile(path, []byte("voice-data"), 0o600); err != nil { + t.Fatal(err) + } + client, _ := NewClient(server.URL, "token", server.Client()) + message, err := client.ImportMessageWithAttachments(context.Background(), 1, 100, MessageImport{ + MessageType: "incoming", ContentType: "text", SourceID: "swt:2:visitor:2:10:0", + External: true, ExternalCreatedAt: time.Now().UTC(), ExternalSourceIDs: map[string]any{"shangwutong": "10"}, + }, []AttachmentUpload{{Path: path, Name: "voice.amr", ContentType: "audio/amr", Voice: true}}) + if err != nil || message.ID != 10 { + t.Fatalf("message=%+v err=%v", message, err) + } +} diff --git a/channels/shangwutong/internal/gochat/webhook.go b/channels/shangwutong/internal/gochat/webhook.go new file mode 100644 index 00000000..f0188d38 --- /dev/null +++ b/channels/shangwutong/internal/gochat/webhook.go @@ -0,0 +1,144 @@ +package gochat + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type WebhookEnvelope struct { + SchemaVersion int64 `json:"schema_version"` + Event string `json:"event"` + EventID string `json:"event_id"` + OccurredAt time.Time `json:"occurred_at"` + AccountID int64 `json:"account_id"` + InboxID int64 `json:"inbox_id"` + Data json.RawMessage `json:"data"` +} + +func DecodeWebhook(body []byte) (WebhookEnvelope, error) { + var envelope WebhookEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return WebhookEnvelope{}, fmt.Errorf("decode webhook: %w", err) + } + if envelope.SchemaVersion != 1 { + return WebhookEnvelope{}, fmt.Errorf("unsupported schema version %d", envelope.SchemaVersion) + } + if envelope.Event == "" || envelope.EventID == "" || envelope.OccurredAt.IsZero() || envelope.AccountID <= 0 || envelope.InboxID <= 0 || len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return WebhookEnvelope{}, errors.New("webhook required fields are missing") + } + return envelope, nil +} + +type LifecycleData struct { + ChannelType string `json:"channel_type"` + ConfigVersion int64 `json:"config_version"` +} + +type MessageWebhookData struct { + RetryVersion int64 `json:"retry_version,omitempty"` + Message WebhookMessage `json:"message"` + Conversation WebhookConversation `json:"conversation"` + Contact WebhookContact `json:"contact"` + Sender WebhookActor `json:"sender"` +} + +type ConversationStatusWebhookData struct { + Conversation WebhookConversation `json:"conversation"` + Actor WebhookActor `json:"actor"` +} + +type TypingWebhookData struct { + Conversation WebhookConversation `json:"conversation"` + Actor WebhookActor `json:"actor"` + Private bool `json:"private"` +} + +type WebhookMessage struct { + ID int64 `json:"id"` + MessageType string `json:"message_type"` + ContentType string `json:"content_type"` + Content string `json:"content"` + Private bool `json:"private"` + External bool `json:"external"` + SourceID string `json:"source_id"` + Status string `json:"status"` + ContentAttributes map[string]any `json:"content_attributes"` + AdditionalAttributes map[string]any `json:"additional_attributes"` + Attachments []WebhookAttachment `json:"attachments"` +} + +type WebhookAttachment struct { + ID int64 `json:"id"` + FileType string `json:"file_type"` + DataURL string `json:"data_url"` + FileSize int64 `json:"file_size"` + Extension string `json:"extension"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (a WebhookAttachment) Voice() bool { + voice, _ := a.Metadata["is_voice_message"].(bool) + return voice +} + +type WebhookConversation struct { + ID int64 `json:"id"` + DisplayID int64 `json:"display_id"` + Status string `json:"status"` + PreviousStatus string `json:"previous_status,omitempty"` + CustomAttributes map[string]any `json:"custom_attributes"` +} + +func (c WebhookConversation) SWTSessionID() string { + value, _ := c.CustomAttributes["swt_sid"].(string) + return strings.TrimSpace(value) +} + +type WebhookContact struct { + ID int64 `json:"id"` + SourceID string `json:"source_id"` + Name string `json:"name"` +} + +type WebhookActor struct { + ID int64 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` +} + +func VerifyWebhookSignature(secret, timestamp, signature string, body []byte, now time.Time) error { + if secret == "" { + return errors.New("webhook secret is missing") + } + seconds, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return errors.New("invalid webhook timestamp") + } + sentAt := time.Unix(seconds, 0) + if delta := now.Sub(sentAt); delta < -5*time.Minute || delta > 5*time.Minute { + return errors.New("webhook timestamp is outside the allowed window") + } + encoded, ok := strings.CutPrefix(signature, "sha256=") + if !ok { + return errors.New("invalid webhook signature format") + } + provided, err := hex.DecodeString(encoded) + if err != nil { + return errors.New("invalid webhook signature encoding") + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(timestamp)) + _, _ = mac.Write([]byte(".")) + _, _ = mac.Write(body) + if !hmac.Equal(provided, mac.Sum(nil)) { + return errors.New("webhook signature mismatch") + } + return nil +} diff --git a/channels/shangwutong/internal/gochat/webhook_test.go b/channels/shangwutong/internal/gochat/webhook_test.go new file mode 100644 index 00000000..b2e95ec3 --- /dev/null +++ b/channels/shangwutong/internal/gochat/webhook_test.go @@ -0,0 +1,24 @@ +package gochat + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" + "time" +) + +func TestVerifyWebhookSignature(t *testing.T) { + now := time.Unix(1785483001, 0) + timestamp, body := "1785483001", []byte(`{"schema_version":1}`) + mac := hmac.New(sha256.New, []byte("secret")) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + signature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if err := VerifyWebhookSignature("secret", timestamp, signature, body, now); err != nil { + t.Fatal(err) + } + if err := VerifyWebhookSignature("secret", timestamp, signature, body, now.Add(6*time.Minute)); err == nil { + t.Fatal("expected expired signature") + } +} diff --git a/channels/shangwutong/internal/httpapi/server.go b/channels/shangwutong/internal/httpapi/server.go new file mode 100644 index 00000000..43ff898d --- /dev/null +++ b/channels/shangwutong/internal/httpapi/server.go @@ -0,0 +1,563 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/account" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/observability" + "github.com/gochat/gochat/channels/shangwutong/internal/store" + "github.com/gofiber/fiber/v3" + fiberrecover "github.com/gofiber/fiber/v3/middleware/recover" + "github.com/sirupsen/logrus" +) + +const ( + maxWebhookBodyBytes = 2 << 20 + maxOutboundParts = 100 +) + +type SupervisorManager interface { + Running() int + SetTyping(int64, string, bool) error +} + +type Server struct { + app *fiber.App + store *store.Store + reconciler *account.Reconciler + manager SupervisorManager + logger *logrus.Entry + metrics *observability.Metrics + now func() time.Time + ready atomic.Bool + metricMu sync.RWMutex + metricData observability.MetricSnapshot +} + +func NewServer(database *store.Store, reconciler *account.Reconciler, manager SupervisorManager, logger *logrus.Entry, metricRegistries ...*observability.Metrics) (*Server, error) { + if database == nil || reconciler == nil { + return nil, errors.New("store and reconciler are required") + } + if logger == nil { + logger = logrus.NewEntry(logrus.New()) + } + metrics := observability.NewMetrics() + if len(metricRegistries) > 0 && metricRegistries[0] != nil { + metrics = metricRegistries[0] + } + server := &Server{store: database, reconciler: reconciler, manager: manager, logger: logger, metrics: metrics, now: time.Now} + server.app = fiber.New(fiber.Config{ + BodyLimit: maxWebhookBodyBytes + 1, + ErrorHandler: func(c fiber.Ctx, err error) error { + var fiberErr *fiber.Error + if errors.As(err, &fiberErr) { + return server.writeError(c, fiberErr.Code, "http_error", fiberErr.Message, fiberErr.Code >= 500) + } + server.logger.WithError(err).Error("unhandled HTTP request error") + return server.writeError(c, http.StatusInternalServerError, "internal_error", "internal server error", true) + }, + }) + server.registerMiddleware() + server.registerRoutes() + server.ready.Store(true) + return server, nil +} + +func (s *Server) App() *fiber.App { return s.app } + +func (s *Server) Listen(address string) error { return s.app.Listen(address) } + +func (s *Server) Shutdown(ctx context.Context) error { return s.app.ShutdownWithContext(ctx) } + +func (s *Server) SetReady(ready bool) { s.ready.Store(ready) } + +func (s *Server) RefreshMetrics(ctx context.Context) error { + snapshot, err := s.store.MetricSnapshot(ctx) + if err != nil { + return err + } + s.metricMu.Lock() + s.metricData = observability.MetricSnapshot{ + Accounts: snapshot.Accounts, InboundQueue: snapshot.InboundQueue, + OutboundQueue: snapshot.OutboundQueue, StatusSyncQueue: snapshot.StatusSyncQueue, + } + s.metricMu.Unlock() + return nil +} + +func (s *Server) StartMetrics(ctx context.Context) { + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.RefreshMetrics(ctx); err != nil && ctx.Err() == nil { + s.logger.WithFields(logrus.Fields{ + "component": "metrics", "operation": "refresh", "result": "failed", + }).WithError(err).Warn("metric snapshot refresh failed") + } + } + } + }() +} + +func (s *Server) metricSnapshot() observability.MetricSnapshot { + s.metricMu.RLock() + snapshot := s.metricData + s.metricMu.RUnlock() + if s.manager != nil { + snapshot.Supervisors = s.manager.Running() + } + return snapshot +} + +func (s *Server) registerMiddleware() { + s.app.Use(func(c fiber.Ctx) error { + requestID := strings.TrimSpace(c.Get("X-Request-ID")) + if requestID == "" { + requestID = newRequestID() + } + c.Locals("request_id", requestID) + c.Set("X-Request-ID", requestID) + return c.Next() + }) + s.app.Use(fiberrecover.New()) + s.app.Use(func(c fiber.Ctx) error { + if len(c.Body()) > maxWebhookBodyBytes { + return s.writeError(c, http.StatusRequestEntityTooLarge, "body_too_large", "request body exceeds size limit", false) + } + return c.Next() + }) + s.app.Use(func(c fiber.Ctx) error { + started := time.Now() + err := c.Next() + s.logger.WithFields(logrus.Fields{ + "component": "http", "operation": string(c.Method()) + " " + c.Path(), + "result": c.Response().StatusCode(), "request_id": requestID(c), + "duration_ms": time.Since(started).Milliseconds(), + }).Info("HTTP request") + return err + }) +} + +func (s *Server) registerRoutes() { + s.app.Get("/healthz", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "ok"}) + }) + s.app.Get("/readyz", func(c fiber.Ctx) error { + if !s.ready.Load() { + return s.writeError(c, http.StatusServiceUnavailable, "shutting_down", "server is shutting down", true) + } + if err := s.store.ReadyCheck(c.Context()); err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "SQLite is not ready", true) + } + return c.JSON(fiber.Map{"status": "ready"}) + }) + s.app.Get("/metrics", func(c fiber.Ctx) error { + c.Set(fiber.HeaderContentType, "text/plain; version=0.0.4; charset=utf-8") + return c.Send(s.metrics.Render(s.metricSnapshot())) + }) + s.app.Post("/internal/reconcile", func(c fiber.Ctx) error { + if ip := net.ParseIP(c.IP()); ip == nil || !ip.IsLoopback() { + return s.writeError(c, http.StatusForbidden, "forbidden", "loopback access required", false) + } + if err := s.reconciler.ReconcileAll(c.Context()); err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "reconcile_failed", "configuration reconcile failed", true) + } + return c.Status(http.StatusAccepted).JSON(fiber.Map{"accepted": true}) + }) + s.app.Post("/webhooks/gochat/v1", s.handleWebhook) +} + +func (s *Server) handleWebhook(c fiber.Ctx) error { + body := append([]byte(nil), c.Body()...) + envelope, err := gochat.DecodeWebhook(body) + if err != nil { + code := "invalid_webhook" + if strings.Contains(err.Error(), "unsupported schema version") { + code = "unsupported_schema_version" + } + return s.writeError(c, http.StatusUnprocessableEntity, code, err.Error(), false) + } + timestamp, signature := c.Get("X-Chatwoot-Timestamp"), c.Get("X-Chatwoot-Signature") + deliveryID := c.Get("X-Chatwoot-Delivery") + if deliveryID == "" { + return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false) + } + + local, lookupErr := s.store.Reader().GetAccountByInboxID(c.Context(), envelope.InboxID) + if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) { + return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account lookup failed", true) + } + if errors.Is(lookupErr, sql.ErrNoRows) { + local = nil + } + if local == nil { + return s.handleUnknownInbox(c, envelope, body, timestamp, signature, deliveryID) + } + if local.GochatAccountID != envelope.AccountID || gochat.VerifyWebhookSignature(local.GochatWebhookSecret, timestamp, signature, body, s.now()) != nil { + return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false) + } + return s.dispatchVerifiedWebhook(c, envelope, body, deliveryID, local) +} + +func (s *Server) handleUnknownInbox(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, timestamp, signature, deliveryID string) error { + switch envelope.Event { + case "inbox_created", "inbox_updated": + if err := s.validateLifecycle(envelope); err != nil { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_lifecycle_event", err.Error(), false) + } + err := s.reconciler.BootstrapInbox(c.Context(), envelope.InboxID, func(config gochat.InboxConfig) error { + if config.AccountID != envelope.AccountID { + return errors.New("account mismatch") + } + return gochat.VerifyWebhookSignature(config.Credentials.WebhookSecret, timestamp, signature, body, s.now()) + }) + if err != nil { + s.logger.WithError(err).WithFields(logrus.Fields{ + "component": "webhook", "operation": "bootstrap_inbox", "result": "failed", "request_id": requestID(c), + }).Warn("unknown inbox bootstrap failed") + return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false) + } + account, err := s.store.Reader().GetAccountByInboxID(c.Context(), envelope.InboxID) + if err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account persistence failed", true) + } + return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, account.ConfigVersion) + case "inbox_deleted": + return s.writeAck(c, http.StatusOK, envelope.EventID, deliveryID, nil, true, 0) + default: + return s.writeError(c, http.StatusUnauthorized, "invalid_signature", "webhook authentication failed", false) + } +} + +func (s *Server) dispatchVerifiedWebhook(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { + switch envelope.Event { + case "inbox_created", "inbox_updated": + if err := s.validateLifecycle(envelope); err != nil { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_lifecycle_event", err.Error(), false) + } + if err := s.reconciler.ReconcileInbox(c.Context(), envelope.InboxID); err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "config_sync_failed", "configuration sync failed", true) + } + updated, err := s.store.Reader().GetAccountByInboxID(c.Context(), envelope.InboxID) + if err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account persistence failed", true) + } + duplicate := updated.ConfigVersion == local.ConfigVersion + status := http.StatusAccepted + if duplicate { + status = http.StatusOK + } + return s.writeAck(c, status, envelope.EventID, deliveryID, nil, duplicate, updated.ConfigVersion) + case "inbox_deleted": + if err := s.validateLifecycle(envelope); err != nil { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_lifecycle_event", err.Error(), false) + } + if err := s.reconciler.DeleteInbox(c.Context(), envelope.InboxID); err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "delete_failed", "inbox tombstone failed", true) + } + return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, 0) + case "message_created", "message_retry_requested": + return s.acceptMessage(c, envelope, body, deliveryID, local) + case "conversation_status_changed": + return s.acceptConversationStatus(c, envelope, body, deliveryID, local) + case "conversation_typing_on", "conversation_typing_off": + return s.acceptTyping(c, envelope, deliveryID, local) + default: + return s.writeError(c, http.StatusUnprocessableEntity, "unsupported_event", "webhook event is not supported", false) + } +} + +func (s *Server) acceptConversationStatus(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { + var data gochat.ConversationStatusWebhookData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_conversation_status", "conversation status payload is invalid", false) + } + conversation, sid := data.Conversation, data.Conversation.SWTSessionID() + if conversation.ID <= 0 || conversation.DisplayID <= 0 || sid == "" || + (conversation.Status != "open" && conversation.Status != "resolved") || + (conversation.PreviousStatus != "open" && conversation.PreviousStatus != "resolved") { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_conversation_status", "conversation status transition is invalid", false) + } + if _, err := s.store.Writer().UpsertConversationMap(c.Context(), dbgen.UpsertConversationMapParams{ + AccountID: local.ID, SwtSid: sid, GochatContactSourceID: sid, + GochatConversationID: &conversation.ID, GochatDisplayID: &conversation.DisplayID, + }); err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "conversation mapping persistence failed", true) + } + if conversation.PreviousStatus != "open" || conversation.Status != "resolved" { + return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, 0) + } + queued, duplicate, err := s.store.EnqueueOutboundOperation(c.Context(), store.OutboundOperationInput{ + AccountID: local.ID, SWTSessionID: sid, EventID: envelope.EventID, + Operation: "end_conversation", Payload: string(body), OccurredAt: envelope.OccurredAt, + }) + if errors.Is(err, store.ErrOutboundConflict) { + return s.writeError(c, http.StatusConflict, "idempotency_conflict", "conversation operation conflicts with webhook", false) + } + if err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "conversation operation queue persistence failed", true) + } + status := http.StatusAccepted + if duplicate { + status = http.StatusOK + } + return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0) +} + +func (s *Server) acceptTyping(c fiber.Ctx, envelope gochat.WebhookEnvelope, deliveryID string, local *dbgen.Account) error { + var data gochat.TypingWebhookData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_typing", "typing payload is invalid", false) + } + sid := data.Conversation.SWTSessionID() + if data.Private || data.Conversation.ID <= 0 || sid == "" || s.manager == nil { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_typing", "typing payload is not eligible for Shangwutong delivery", false) + } + if err := s.manager.SetTyping(local.ID, sid, envelope.Event == "conversation_typing_on"); err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "supervisor_unavailable", "account supervisor is unavailable", true) + } + return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, 0) +} + +func (s *Server) acceptMessage(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { + var data gochat.MessageWebhookData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_message", "message payload is invalid", false) + } + message, sid := data.Message, data.Conversation.SWTSessionID() + if message.ID <= 0 || sid == "" || (message.MessageType != "outgoing" && message.MessageType != "template") || message.Private || message.External || strings.HasPrefix(message.SourceID, "swt:") { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_message", "message is not eligible for Shangwutong delivery", false) + } + retry := envelope.Event == "message_retry_requested" + if retry && data.RetryVersion <= 0 { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_retry_version", "retry_version must be positive", false) + } + content := message.Content + parts := make([]store.OutboundPartInput, 0, len(message.Attachments)+1) + contentType := strings.ToLower(strings.TrimSpace(message.ContentType)) + fallbackContent := contentType == "location" || contentType == "contact" + unsupportedContent := message.MessageType == "template" || (contentType != "" && contentType != "text" && !fallbackContent) + if unsupportedContent { + parts = append(parts, store.OutboundPartInput{Type: "unsupported"}) + } else if strings.TrimSpace(content) != "" { + parts = append(parts, store.OutboundPartInput{Type: "text", Content: &content}) + } else if fallbackContent && len(message.Attachments) == 0 { + content = map[string]string{"location": "位置消息", "contact": "联系人消息"}[contentType] + parts = append(parts, store.OutboundPartInput{Type: "text", Content: &content}) + } + for _, attachment := range message.Attachments { + if attachment.ID <= 0 { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_attachment", "attachment ID and data_url are required", false) + } + if fallback, ok := outboundAttachmentFallback(attachment); ok { + parts = append(parts, store.OutboundPartInput{Type: "text", Content: &fallback}) + continue + } + if strings.TrimSpace(attachment.DataURL) == "" { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_attachment", "attachment ID and data_url are required", false) + } + partType := outboundAttachmentType(attachment.FileType, attachment.Voice()) + dataURL, fileSize := attachment.DataURL, attachment.FileSize + name := strconv.FormatInt(attachment.ID, 10) + if extension := safeFileExtension(attachment.Extension); extension != "" { + name += "." + extension + } + parts = append(parts, store.OutboundPartInput{ + Type: partType, AttachmentID: &attachment.ID, DataURL: &dataURL, + FileName: &name, FileSize: &fileSize, Voice: attachment.Voice(), + }) + } + if len(parts) > maxOutboundParts { + return s.writeError(c, http.StatusUnprocessableEntity, "too_many_parts", "message exceeds the 100-part delivery limit", false) + } + queued, duplicate, err := s.store.EnqueueOutbound(c.Context(), store.OutboundInput{ + AccountID: local.ID, SWTSessionID: sid, EventID: envelope.EventID, OccurredAt: envelope.OccurredAt, GoChatMessageID: message.ID, + RetryVersion: data.RetryVersion, MessageType: message.ContentType, Content: &content, Payload: string(body), Parts: parts, + }, retry) + if errors.Is(err, store.ErrOutboundConflict) { + return s.writeError(c, http.StatusConflict, "idempotency_conflict", "message delivery state conflicts with webhook", false) + } + if err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "message queue persistence failed", true) + } + status := http.StatusAccepted + if duplicate { + status = http.StatusOK + } + return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0) +} + +func outboundAttachmentFallback(attachment gochat.WebhookAttachment) (string, bool) { + switch strings.ToLower(strings.TrimSpace(attachment.FileType)) { + case "location": + title := metadataString(attachment.Metadata, "fallback_title", "fallbackTitle") + text := "位置消息" + if title != "" { + text = "位置:" + title + } + lat, latOK := coordinate(attachment.Metadata, -90, 90, "coordinates_lat", "coordinatesLat") + long, longOK := coordinate(attachment.Metadata, -180, 180, "coordinates_long", "coordinatesLong") + if latOK && longOK { + text += "\nhttps://maps.google.com/?q=" + lat + "," + long + } + return text, true + case "contact": + phone := metadataString(attachment.Metadata, "fallback_title", "fallbackTitle") + meta, _ := attachment.Metadata["meta"].(map[string]any) + name := strings.TrimSpace(metadataString(meta, "firstName", "first_name") + " " + metadataString(meta, "lastName", "last_name")) + text := "联系人消息" + if name != "" { + text = "联系人:" + name + } + if phone != "" { + text += "\n电话:" + phone + } + return text, true + default: + return "", false + } +} + +func metadataString(metadata map[string]any, keys ...string) string { + for _, key := range keys { + value, exists := metadata[key] + if !exists { + continue + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + } + } + return "" +} + +func coordinate(metadata map[string]any, minimum, maximum float64, keys ...string) (string, bool) { + value := metadataString(metadata, keys...) + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed < minimum || parsed > maximum { + return "", false + } + return strconv.FormatFloat(parsed, 'f', -1, 64), true +} + +func outboundAttachmentType(fileType string, voice bool) string { + if voice { + return "audio" + } + switch strings.ToLower(strings.TrimSpace(fileType)) { + case "image": + return "image" + case "audio": + return "audio" + case "file": + return "file" + case "video": + return "video" + default: + return "unsupported" + } +} + +func safeFileExtension(value string) string { + value = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".") + if value == "" || len(value) > 16 { + return "" + } + for _, character := range value { + if (character < 'a' || character > 'z') && (character < '0' || character > '9') { + return "" + } + } + return value +} + +func (s *Server) validateLifecycle(envelope gochat.WebhookEnvelope) error { + var data gochat.LifecycleData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return err + } + if data.ChannelType != "shangwutong" || data.ConfigVersion <= 0 { + return errors.New("invalid lifecycle data") + } + return nil +} + +func (s *Server) writeAck(c fiber.Ctx, status int, eventID, deliveryID string, queueID *int64, duplicate bool, configVersion int64) error { + response := fiber.Map{ + "accepted": true, "duplicate": duplicate, "event_id": eventID, "delivery_id": deliveryID, + } + if queueID != nil { + response["queue_id"] = *queueID + } + if configVersion > 0 { + response["applied_config_version"] = configVersion + } + return c.Status(status).JSON(response) +} + +func (s *Server) writeError(c fiber.Ctx, status int, code, message string, retryable bool) error { + if strings.HasPrefix(c.Path(), "/webhooks/") { + s.metrics.ContractError("gochat_to_connector", contractMetricCode(status, code)) + } + return c.Status(status).JSON(fiber.Map{"error": fiber.Map{ + "code": code, "message": message, "retryable": retryable, "request_id": requestID(c), + }}) +} + +func contractMetricCode(status int, code string) string { + switch { + case code == "invalid_signature": + return "invalid_signature" + case code == "unsupported_schema_version": + return "unsupported_schema" + case code == "idempotency_conflict": + return "idempotency_conflict" + case status == http.StatusUnauthorized || status == http.StatusForbidden: + return "unauthorized" + case status >= 500: + return "upstream_error" + default: + return "invalid_payload" + } +} + +func requestID(c fiber.Ctx) string { + value, _ := c.Locals("request_id").(string) + return value +} + +func newRequestID() string { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value) + return fmt.Sprintf("%s-%s-%s-%s-%s", encoded[:8], encoded[8:12], encoded[12:16], encoded[16:20], encoded[20:]) +} diff --git a/channels/shangwutong/internal/httpapi/server_test.go b/channels/shangwutong/internal/httpapi/server_test.go new file mode 100644 index 00000000..2c9a974f --- /dev/null +++ b/channels/shangwutong/internal/httpapi/server_test.go @@ -0,0 +1,421 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/gochat/gochat/channels/shangwutong/internal/account" + "github.com/gochat/gochat/channels/shangwutong/internal/gochat" + "github.com/gochat/gochat/channels/shangwutong/internal/store" +) + +func TestUnknownInboxIsPersistedOnlyAfterValidBootstrapSignature(t *testing.T) { + server, database, now := newTestServer(t) + body := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + response := doWebhook(t, server, body, now, "wrong") + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("invalid signature status = %d", response.StatusCode) + } + if _, err := database.Reader().GetAccountByInboxID(context.Background(), 10); err == nil { + t.Fatal("invalid bootstrap created an account") + } + response = doWebhook(t, server, body, now, "secret") + if response.StatusCode != http.StatusAccepted { + t.Fatalf("valid signature status = %d body=%s", response.StatusCode, readBody(response)) + } + if _, err := database.Reader().GetAccountByInboxID(context.Background(), 10); err != nil { + t.Fatal(err) + } +} + +func TestMessageWebhookIsDurableAndIdempotent(t *testing.T) { + server, database, now := newTestServer(t) + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{ + Message: gochat.WebhookMessage{ + ID: 77, MessageType: "outgoing", ContentType: "text", Content: "hello", Status: "progress", + Attachments: []gochat.WebhookAttachment{{ + ID: 5, FileType: "audio", DataURL: "https://gochat.test/voice", FileSize: 100, + Extension: "amr", Metadata: map[string]any{"is_voice_message": true}, + }}, + }, + Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + Contact: gochat.WebhookContact{ID: 99, SourceID: "visitor"}, + }) + first := doWebhook(t, server, message, now, "secret") + if first.StatusCode != http.StatusAccepted { + t.Fatalf("first status = %d body=%s", first.StatusCode, readBody(first)) + } + var firstAck map[string]any + if err := json.NewDecoder(first.Body).Decode(&firstAck); err != nil { + t.Fatal(err) + } + _ = first.Body.Close() + if firstAck["accepted"] != true || firstAck["duplicate"] != false || firstAck["event_id"] != "message:77:created" || firstAck["delivery_id"] != "delivery" || firstAck["queue_id"] == nil { + t.Fatalf("first ACK = %#v", firstAck) + } + second := doWebhook(t, server, message, now, "secret") + if second.StatusCode != http.StatusOK { + t.Fatalf("duplicate status = %d body=%s", second.StatusCode, readBody(second)) + } + var duplicateAck map[string]any + if err := json.NewDecoder(second.Body).Decode(&duplicateAck); err != nil { + t.Fatal(err) + } + _ = second.Body.Close() + if duplicateAck["duplicate"] != true || duplicateAck["queue_id"] != firstAck["queue_id"] { + t.Fatalf("duplicate ACK = %#v", duplicateAck) + } + conflicting := webhookBody(t, now, "message_created", gochat.MessageWebhookData{ + Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "text", Content: "changed", Status: "progress"}, + Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + }) + conflict := doWebhook(t, server, conflicting, now, "secret") + if body := readBody(conflict); conflict.StatusCode != http.StatusConflict || !strings.Contains(body, `"code":"idempotency_conflict"`) { + t.Fatalf("conflict status=%d body=%s", conflict.StatusCode, body) + } + queued, err := database.Writer().GetOutboundByGoChatMessageID(context.Background(), 77) + if err != nil || queued.SwtSid != "visitor" { + t.Fatalf("queued = %#v, %v", queued, err) + } + parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID) + if err != nil || len(parts) != 2 || parts[0].PartType != "text" || parts[1].PartType != "audio" || parts[1].Voice != 1 { + t.Fatalf("parts = %#v, %v", parts, err) + } +} + +func TestKnownInboxRejectsBadExpiredAndUnsupportedSchemaWebhooks(t *testing.T) { + server, _, now := newTestServer(t) + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{ + Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "text", Content: "hello", Status: "progress"}, + Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + }) + for name, response := range map[string]*http.Response{ + "bad_signature": doWebhook(t, server, message, now, "wrong"), + "expired": doWebhook(t, server, message, now.Add(-6*time.Minute), "secret"), + } { + if body := readBody(response); response.StatusCode != http.StatusUnauthorized || !strings.Contains(body, `"code":"invalid_signature"`) { + t.Fatalf("%s status=%d body=%s", name, response.StatusCode, body) + } + } + var unsupported map[string]any + if err := json.Unmarshal(message, &unsupported); err != nil { + t.Fatal(err) + } + unsupported["schema_version"] = 2 + unsupportedBody, _ := json.Marshal(unsupported) + response := doWebhook(t, server, unsupportedBody, now, "secret") + if body := readBody(response); response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, `"code":"unsupported_schema_version"`) { + t.Fatalf("unsupported schema status=%d body=%s", response.StatusCode, body) + } +} + +func TestMessageWebhookRejectsMoreThanResultContractAllows(t *testing.T) { + server, _, now := newTestServer(t) + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + attachments := make([]gochat.WebhookAttachment, maxOutboundParts+1) + for index := range attachments { + attachments[index] = gochat.WebhookAttachment{ + ID: int64(index + 1), FileType: "image", DataURL: "https://gochat.test/image.png", + } + } + message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{ + Message: gochat.WebhookMessage{ + ID: 77, MessageType: "outgoing", ContentType: "text", Status: "progress", Attachments: attachments, + }, + Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + }) + response := doWebhook(t, server, message, now, "secret") + body := readBody(response) + if response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "too_many_parts") { + t.Fatalf("status = %d body=%s", response.StatusCode, body) + } +} + +func TestUnsupportedCardWebhookQueuesExplicitFailurePart(t *testing.T) { + server, database, now := newTestServer(t) + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{ + Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "cards", Content: `{"title":"card"}`, Status: "progress"}, + Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + }) + if response := doWebhook(t, server, message, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("message status = %d body=%s", response.StatusCode, readBody(response)) + } + queued, err := database.Reader().GetOutboundByGoChatMessageID(context.Background(), 77) + if err != nil { + t.Fatal(err) + } + parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID) + if err != nil || len(parts) != 1 || parts[0].PartType != "unsupported" || parts[0].Content != nil { + t.Fatalf("parts = %#v, %v", parts, err) + } +} + +func TestLocationAndContactAttachmentsBecomeReadableText(t *testing.T) { + tests := []struct { + name string + contentType string + metadata map[string]any + contains []string + }{ + {name: "location", contentType: "location", metadata: map[string]any{ + "fallback_title": "天安门", "coordinates_lat": 39.9, "coordinates_long": 116.4, + }, contains: []string{"位置:天安门", "https://maps.google.com/?q=39.9,116.4"}}, + {name: "contact", contentType: "contact", metadata: map[string]any{ + "fallback_title": "+86 13800138000", "meta": map[string]any{"firstName": "张", "lastName": "三"}, + }, contains: []string{"联系人:张 三", "电话:+86 13800138000"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server, database, now := newTestServer(t) + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{ + Message: gochat.WebhookMessage{ + ID: 77, MessageType: "outgoing", ContentType: test.contentType, Status: "progress", + Attachments: []gochat.WebhookAttachment{{ID: 5, FileType: test.contentType, Metadata: test.metadata}}, + }, + Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + }) + if response := doWebhook(t, server, message, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("message status = %d body=%s", response.StatusCode, readBody(response)) + } + queued, err := database.Reader().GetOutboundByGoChatMessageID(context.Background(), 77) + if err != nil { + t.Fatal(err) + } + parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID) + if err != nil || len(parts) != 1 || parts[0].PartType != "text" || parts[0].Content == nil { + t.Fatalf("parts = %#v, %v", parts, err) + } + for _, expected := range test.contains { + if !strings.Contains(*parts[0].Content, expected) { + t.Fatalf("fallback %q does not contain %q", *parts[0].Content, expected) + } + } + }) + } +} + +func TestTypingWebhookUpdatesNextSupervisorHeartbeatState(t *testing.T) { + server, _, now := newTestServer(t) + manager := &typingManagerRecorder{} + server.manager = manager + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + body := webhookBody(t, now, "conversation_typing_on", gochat.TypingWebhookData{ + Conversation: gochat.WebhookConversation{ID: 100, DisplayID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}}, + Actor: gochat.WebhookActor{ID: 7, Type: "user"}, + }) + response := doWebhook(t, server, body, now, "secret") + if response.StatusCode != http.StatusAccepted || manager.accountID == 0 || manager.sid != "visitor" || !manager.typing { + t.Fatalf("response=%d manager=%#v body=%s", response.StatusCode, manager, readBody(response)) + } +} + +func TestConversationResolveWebhookQueuesDurableEndOperation(t *testing.T) { + server, database, now := newTestServer(t) + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + body := webhookBody(t, now, "conversation_status_changed", gochat.ConversationStatusWebhookData{ + Conversation: gochat.WebhookConversation{ + ID: 100, DisplayID: 88, Status: "resolved", PreviousStatus: "open", + CustomAttributes: map[string]any{"swt_sid": "visitor"}, + }, + Actor: gochat.WebhookActor{ID: 7, Type: "user"}, + }) + first := doWebhook(t, server, body, now, "secret") + if first.StatusCode != http.StatusAccepted { + t.Fatalf("first status = %d body=%s", first.StatusCode, readBody(first)) + } + second := doWebhook(t, server, body, now, "secret") + if second.StatusCode != http.StatusOK { + t.Fatalf("duplicate status = %d body=%s", second.StatusCode, readBody(second)) + } + operation, err := database.Reader().GetOutboundOperationByEventID(context.Background(), "conversation_status_changed:1") + if err != nil || operation.Operation != "end_conversation" || operation.SwtSid != "visitor" { + t.Fatalf("operation = %#v, %v", operation, err) + } +} + +func TestHealthMiddlewareAddsRequestIDAndRejectsLargeBodies(t *testing.T) { + server, _, _ := newTestServer(t) + request, _ := http.NewRequest(http.MethodGet, "/healthz", nil) + response, err := server.App().Test(request) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || response.Header.Get("X-Request-ID") == "" { + t.Fatalf("health response = %d, request-id=%q", response.StatusCode, response.Header.Get("X-Request-ID")) + } + request, _ = http.NewRequest(http.MethodPost, "/webhooks/gochat/v1", bytes.NewReader(make([]byte, maxWebhookBodyBytes+1))) + response, err = server.App().Test(request) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("large body status = %d", response.StatusCode) + } +} + +func TestMetricsExposeQueueAndSupervisorGauges(t *testing.T) { + server, database, now := newTestServer(t) + server.manager = &typingManagerRecorder{} + lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1}) + if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted { + t.Fatalf("bootstrap status = %d", response.StatusCode) + } + account, err := database.Reader().GetAccountByInboxID(context.Background(), 10) + if err != nil { + t.Fatal(err) + } + content := "hello" + if _, _, err := database.EnqueueOutbound(context.Background(), store.OutboundInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(), + GoChatMessageID: 77, MessageType: "text", Content: &content, Payload: `{}`, + }, false); err != nil { + t.Fatal(err) + } + if err := server.RefreshMetrics(context.Background()); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + request, _ := http.NewRequest(http.MethodGet, "/metrics", nil) + response, err := server.App().Test(request) + if err != nil { + t.Fatal(err) + } + payload := readBody(response) + if response.StatusCode != http.StatusOK || !strings.Contains(payload, "swt_connector_supervisors 1") || + !strings.Contains(payload, `swt_connector_outbound_queue_depth{status="pending"} 1`) { + t.Fatalf("status=%d metrics=%s", response.StatusCode, payload) + } +} + +func newTestServer(t *testing.T) (*Server, *store.Store, time.Time) { + t.Helper() + database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + config := gochat.InboxConfig{ + SchemaVersion: 1, AccountID: 1, InboxID: 10, InboxIdentifier: "identifier", + Enabled: true, DesiredPresence: "online", ConfigVersion: 1, + Credentials: gochat.Credentials{ + SessionID: "BYT99917999", Username: "agent", Password: "password", + HMACToken: "hmac", WebhookSecret: "secret", + }, + } + source := &testConfigSource{config: config, configs: []gochat.InboxConfig{config}} + reconciler := account.NewReconciler(source, database, nil) + server, err := NewServer(database, reconciler, nil, nil) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1785483001, 0).UTC() + server.now = func() time.Time { return now } + return server, database, now +} + +type testConfigSource struct { + config gochat.InboxConfig + configs []gochat.InboxConfig +} + +type typingManagerRecorder struct { + accountID int64 + sid string + typing bool +} + +func (*typingManagerRecorder) Running() int { return 1 } + +func (m *typingManagerRecorder) SetTyping(accountID int64, sid string, typing bool) error { + m.accountID, m.sid, m.typing = accountID, sid, typing + return nil +} + +func (s *testConfigSource) ListInboxConfigs(context.Context) ([]gochat.InboxConfig, error) { + return s.configs, nil +} + +func (s *testConfigSource) GetInboxConfig(context.Context, int64) (gochat.InboxConfig, error) { + return s.config, nil +} + +func webhookBody(t *testing.T, now time.Time, event string, data any) []byte { + t.Helper() + eventID := event + ":1" + if event == "message_created" { + eventID = "message:77:created" + } + body, err := json.Marshal(map[string]any{ + "schema_version": 1, "event": event, "event_id": eventID, "occurred_at": now, + "account_id": 1, "inbox_id": 10, "data": data, + }) + if err != nil { + t.Fatal(err) + } + return body +} + +func doWebhook(t *testing.T, server *Server, body []byte, now time.Time, secret string) *http.Response { + t.Helper() + timestamp := strconv.FormatInt(now.Unix(), 10) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + request, _ := http.NewRequest(http.MethodPost, "/webhooks/gochat/v1", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Chatwoot-Timestamp", timestamp) + request.Header.Set("X-Chatwoot-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) + request.Header.Set("X-Chatwoot-Delivery", "delivery") + if err := gochat.VerifyWebhookSignature(secret, timestamp, request.Header.Get("X-Chatwoot-Signature"), body, now); err != nil { + t.Fatalf("test signature: %v", err) + } + response, err := server.App().Test(request) + if err != nil { + t.Fatal(err) + } + return response +} + +func readBody(response *http.Response) string { + payload, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + return string(payload) +} diff --git a/channels/shangwutong/internal/observability/logger.go b/channels/shangwutong/internal/observability/logger.go new file mode 100644 index 00000000..ede319fb --- /dev/null +++ b/channels/shangwutong/internal/observability/logger.go @@ -0,0 +1,106 @@ +package observability + +import ( + "encoding/json" + "os" + "reflect" + "strings" + + "github.com/sirupsen/logrus" +) + +const redacted = "[REDACTED]" + +type RedactionHook struct{} + +func (RedactionHook) Levels() []logrus.Level { return logrus.AllLevels } + +func (RedactionHook) Fire(entry *logrus.Entry) error { + entry.Time = entry.Time.UTC() + for key, value := range entry.Data { + if sensitiveKey(key) { + entry.Data[key] = redacted + continue + } + entry.Data[key] = redactValue(value) + } + return nil +} + +func NewLogger() *logrus.Logger { + logger := logrus.New() + logger.SetOutput(os.Stdout) + logger.SetLevel(logrus.InfoLevel) + logger.SetFormatter(&logrus.JSONFormatter{TimestampFormat: "2006-01-02T15:04:05.000000000Z07:00"}) + logger.AddHook(RedactionHook{}) + return logger +} + +func sensitiveKey(key string) bool { + key = strings.ToLower(strings.ReplaceAll(key, "-", "_")) + for _, fragment := range []string{"password", "pending_password", "authorization", "access_token", "service_token", "hmac", "secret", "cookie", "ma_token", "raw_login_body", "session_id", "username"} { + if strings.Contains(key, fragment) { + return true + } + } + return key == "ma" || key == "sn" || key == "pwd" || key == "token" +} + +func redactValue(value any) any { + return redactReflect(reflect.ValueOf(value)) +} + +func redactReflect(value reflect.Value) any { + if !value.IsValid() { + return nil + } + if (value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer) && value.IsNil() { + return nil + } + if value.CanInterface() { + if err, ok := value.Interface().(error); ok { + return err.Error() + } + } + if value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + return redactReflect(value.Elem()) + } + switch value.Kind() { + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return value.Interface() + } + output := make(map[string]any, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + key := iterator.Key().String() + if sensitiveKey(key) { + output[key] = redacted + } else { + output[key] = redactReflect(iterator.Value()) + } + } + return output + case reflect.Slice, reflect.Array: + output := make([]any, value.Len()) + for index := range value.Len() { + output[index] = redactReflect(value.Index(index)) + } + return output + case reflect.Struct: + encoded, err := json.Marshal(value.Interface()) + if err != nil { + return value.Interface() + } + var decoded any + if json.Unmarshal(encoded, &decoded) != nil { + return value.Interface() + } + return redactValue(decoded) + default: + if value.CanInterface() { + return value.Interface() + } + return nil + } +} diff --git a/channels/shangwutong/internal/observability/logger_test.go b/channels/shangwutong/internal/observability/logger_test.go new file mode 100644 index 00000000..aa187f20 --- /dev/null +++ b/channels/shangwutong/internal/observability/logger_test.go @@ -0,0 +1,57 @@ +package observability + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/sirupsen/logrus" +) + +func TestRedactionHook(t *testing.T) { + buffer := &bytes.Buffer{} + logger := logrus.New() + logger.SetOutput(buffer) + logger.SetFormatter(&logrus.JSONFormatter{}) + logger.AddHook(RedactionHook{}) + logger.WithFields(logrus.Fields{ + "password": "plain", + "headers": http.Header{"Authorization": {"Bearer token"}, "X-Request-ID": {"safe"}}, + "nested": map[string]any{ + "ma_token": "secret", + "safe": "visible", + }, + "credentials": struct { + Username string `json:"username"` + Password string `json:"password"` + }{Username: "agent", Password: "nested-secret"}, + }).WithError(errors.New("dial tcp: connection refused")).Info("test") + var payload map[string]any + if err := json.Unmarshal(buffer.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["password"] != redacted { + t.Fatalf("password leaked: %#v", payload) + } + if payload[logrus.ErrorKey] != "dial tcp: connection refused" { + t.Fatalf("error diagnostics lost: %#v", payload) + } + if timestamp, _ := payload[logrus.FieldKeyTime].(string); !strings.HasSuffix(timestamp, "Z") { + t.Fatalf("timestamp is not UTC: %#v", payload) + } + nested := payload["nested"].(map[string]any) + if nested["ma_token"] != redacted || nested["safe"] != "visible" { + t.Fatalf("nested redaction failed: %#v", nested) + } + headers := payload["headers"].(map[string]any) + if headers["Authorization"] != redacted || headers["X-Request-ID"].([]any)[0] != "safe" { + t.Fatalf("header redaction failed: %#v", headers) + } + credentials := payload["credentials"].(map[string]any) + if credentials["password"] != redacted || credentials["username"] != redacted { + t.Fatalf("struct redaction failed: %#v", credentials) + } +} diff --git a/channels/shangwutong/internal/observability/metrics.go b/channels/shangwutong/internal/observability/metrics.go new file mode 100644 index 00000000..8e8e5fb9 --- /dev/null +++ b/channels/shangwutong/internal/observability/metrics.go @@ -0,0 +1,253 @@ +package observability + +import ( + "bytes" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +var durationBuckets = [...]float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30} + +type MetricSnapshot struct { + Supervisors int + Accounts map[string]int64 + InboundQueue map[string]int64 + OutboundQueue map[string]int64 + StatusSyncQueue int64 +} + +type histogramValue struct { + Count uint64 + Sum float64 + Buckets [len(durationBuckets)]uint64 +} + +type Metrics struct { + mu sync.RWMutex + counters map[string]uint64 + histograms map[string]histogramValue +} + +func NewMetrics() *Metrics { + return &Metrics{counters: make(map[string]uint64), histograms: make(map[string]histogramValue)} +} + +func (m *Metrics) Heartbeat(result string, duration time.Duration) { + m.inc("swt_connector_heartbeat_total", labels("result", bounded(result, "success", "reset", "retryable_error", "permanent_error"))) + m.observe("swt_connector_heartbeat_duration_seconds", duration.Seconds()) +} + +func (m *Metrics) Login(result string) { + m.inc("swt_connector_login_total", labels("result", bounded(result, "success", "rejected", "verification_required", "retryable_error"))) +} + +func (m *Metrics) Presence(result string) { + m.inc("swt_connector_presence_change_total", labels("result", bounded(result, "success", "retryable_error", "permanent_error"))) +} + +func (m *Metrics) Delivery(direction, result string) { + m.inc("swt_connector_delivery_total", labels( + "direction", bounded(direction, "inbound", "outbound"), + "result", bounded(result, "delivered", "retry", "uncertain", "failed", "ambiguous"), + )) +} + +func (m *Metrics) StatusSync(result, status string) { + m.inc("swt_connector_status_sync_total", labels( + "result", bounded(result, "success", "retry"), + "status", bounded(status, "sent", "failed", "uncertain", "unknown"), + )) +} + +func (m *Metrics) Mapping(kind int64, strategy, result string) { + m.inc("swt_connector_event_mapping_total", labels( + "kind", metricKind(kind), + "strategy", bounded(strategy, "native_message", "outbound_echo", "activity", "contact_attributes", "conversation_attributes", "fallback_text", "raw_only"), + "result", bounded(result, "delivered", "retry", "failed"), + )) +} + +func (m *Metrics) Unknown(kind int64) { + m.inc("swt_connector_unknown_event_total", labels("kind_group", unknownKindGroup(kind))) +} + +func (m *Metrics) UnmappedRetraction(direction string) { + m.inc("swt_connector_unmapped_retraction_total", labels("direction", bounded(direction, "incoming", "outgoing"))) +} + +func (m *Metrics) ContractError(direction, code string) { + m.inc("swt_connector_contract_error_total", labels( + "direction", bounded(direction, "gochat_to_connector", "connector_to_gochat"), + "code", bounded(code, "invalid_signature", "invalid_payload", "unsupported_schema", "idempotency_conflict", "unauthorized", "upstream_error"), + )) +} + +func (m *Metrics) SQLiteWrite(duration time.Duration) { + m.observe("swt_connector_sqlite_write_duration_seconds", duration.Seconds()) +} + +func (m *Metrics) Render(snapshot MetricSnapshot) []byte { + if m == nil { + m = NewMetrics() + } + m.mu.RLock() + defer m.mu.RUnlock() + var output bytes.Buffer + writeGauge := func(name, labelSet string, value int64) { + _, _ = fmt.Fprintf(&output, "%s%s %d\n", name, labelSet, value) + } + output.WriteString("# HELP swt_connector_accounts Configured non-deleted accounts by runtime state.\n# TYPE swt_connector_accounts gauge\n") + for _, key := range sortedKeys(snapshot.Accounts) { + parts := strings.SplitN(key, "\x00", 2) + if len(parts) == 2 { + writeGauge("swt_connector_accounts", labels("connection_status", safeLabel(parts[0]), "presence", safeLabel(parts[1])), snapshot.Accounts[key]) + } + } + output.WriteString("# HELP swt_connector_inbound_queue_depth Persisted inbound events by state.\n# TYPE swt_connector_inbound_queue_depth gauge\n") + for _, key := range sortedKeys(snapshot.InboundQueue) { + writeGauge("swt_connector_inbound_queue_depth", labels("status", safeLabel(key)), snapshot.InboundQueue[key]) + } + output.WriteString("# HELP swt_connector_outbound_queue_depth Persisted outbound messages by state.\n# TYPE swt_connector_outbound_queue_depth gauge\n") + for _, key := range sortedKeys(snapshot.OutboundQueue) { + writeGauge("swt_connector_outbound_queue_depth", labels("status", safeLabel(key)), snapshot.OutboundQueue[key]) + } + output.WriteString("# HELP swt_connector_status_sync_queue_depth Message results waiting for GoChat acknowledgement.\n# TYPE swt_connector_status_sync_queue_depth gauge\n") + writeGauge("swt_connector_status_sync_queue_depth", "", snapshot.StatusSyncQueue) + output.WriteString("# HELP swt_connector_supervisors Running account supervisors.\n# TYPE swt_connector_supervisors gauge\n") + writeGauge("swt_connector_supervisors", "", int64(snapshot.Supervisors)) + + counterNames := make([]string, 0, len(m.counters)) + for key := range m.counters { + counterNames = append(counterNames, key) + } + sort.Strings(counterNames) + declared := "" + for _, key := range counterNames { + name, labelSet := splitMetricKey(key) + if name != declared { + _, _ = fmt.Fprintf(&output, "# TYPE %s counter\n", name) + declared = name + } + _, _ = fmt.Fprintf(&output, "%s%s %d\n", name, labelSet, m.counters[key]) + } + histogramNames := make([]string, 0, len(m.histograms)) + for name := range m.histograms { + histogramNames = append(histogramNames, name) + } + sort.Strings(histogramNames) + for _, name := range histogramNames { + value := m.histograms[name] + _, _ = fmt.Fprintf(&output, "# TYPE %s histogram\n", name) + for index, upper := range durationBuckets { + _, _ = fmt.Fprintf(&output, "%s_bucket{le=%q} %d\n", name, strconv.FormatFloat(upper, 'g', -1, 64), value.Buckets[index]) + } + _, _ = fmt.Fprintf(&output, "%s_bucket{le=\"+Inf\"} %d\n%s_sum %g\n%s_count %d\n", name, value.Count, name, value.Sum, name, value.Count) + } + return output.Bytes() +} + +func (m *Metrics) inc(name, labelSet string) { + if m == nil { + return + } + m.mu.Lock() + m.counters[name+labelSet]++ + m.mu.Unlock() +} + +func (m *Metrics) observe(name string, value float64) { + if m == nil { + return + } + m.mu.Lock() + histogram := m.histograms[name] + histogram.Count++ + histogram.Sum += value + for index, upper := range durationBuckets { + if value <= upper { + histogram.Buckets[index]++ + } + } + m.histograms[name] = histogram + m.mu.Unlock() +} + +func labels(values ...string) string { + if len(values) == 0 { + return "" + } + var output strings.Builder + output.WriteByte('{') + for index := 0; index+1 < len(values); index += 2 { + if index > 0 { + output.WriteByte(',') + } + output.WriteString(values[index]) + output.WriteString("=\"") + output.WriteString(strings.NewReplacer("\\", "\\\\", "\"", "\\\"", "\n", "\\n").Replace(values[index+1])) + output.WriteByte('"') + } + output.WriteByte('}') + return output.String() +} + +func splitMetricKey(key string) (string, string) { + if index := strings.IndexByte(key, '{'); index >= 0 { + return key[:index], key[index:] + } + return key, "" +} + +func bounded(value string, allowed ...string) string { + for _, candidate := range allowed { + if value == candidate { + return value + } + } + return "other" +} + +func safeLabel(value string) string { + if len(value) > 64 { + return "other" + } + for _, character := range value { + if (character < 'a' || character > 'z') && character != '_' && (character < '0' || character > '9') { + return "other" + } + } + return value +} + +func metricKind(kind int64) string { + switch kind { + case -8, -7, -5, -4, 0, 1, 2, 3, 5, 7, 8, 11, 12, 14, 15, 24, 26, 29, 30, 31, 34, 35, 38, 39, 41, 44, 52, 56, 58, 61, 62, 65, 66, 67, 71: + return strconv.FormatInt(kind, 10) + default: + return "unknown" + } +} + +func unknownKindGroup(kind int64) string { + switch { + case kind < 0: + return "negative" + case kind < 70: + return "0_69" + default: + return "70_plus" + } +} + +func sortedKeys(values map[string]int64) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/channels/shangwutong/internal/observability/metrics_test.go b/channels/shangwutong/internal/observability/metrics_test.go new file mode 100644 index 00000000..7d4aca36 --- /dev/null +++ b/channels/shangwutong/internal/observability/metrics_test.go @@ -0,0 +1,59 @@ +package observability + +import ( + "strconv" + "strings" + "testing" + "time" +) + +func TestMetricsRenderProductionSeriesWithoutHighCardinalityLabels(t *testing.T) { + metrics := NewMetrics() + metrics.Heartbeat("success", 25*time.Millisecond) + metrics.Login("success") + metrics.Presence("retryable_error") + metrics.Delivery("inbound", "delivered") + metrics.StatusSync("success", "sent") + metrics.Mapping(2, "native_message", "delivered") + metrics.Unknown(999) + metrics.UnmappedRetraction("incoming") + metrics.ContractError("gochat_to_connector", "invalid_signature") + metrics.SQLiteWrite(3 * time.Millisecond) + payload := string(metrics.Render(MetricSnapshot{ + Supervisors: 1, + Accounts: map[string]int64{"connected\x00online": 1}, + InboundQueue: map[string]int64{"pending": 2}, + OutboundQueue: map[string]int64{"uncertain": 1}, + StatusSyncQueue: 1, + })) + for _, name := range []string{ + "swt_connector_accounts", "swt_connector_supervisors", "swt_connector_heartbeat_total", + "swt_connector_heartbeat_duration_seconds", "swt_connector_login_total", + "swt_connector_presence_change_total", "swt_connector_inbound_queue_depth", + "swt_connector_outbound_queue_depth", "swt_connector_delivery_total", + "swt_connector_status_sync_total", "swt_connector_status_sync_queue_depth", + "swt_connector_event_mapping_total", "swt_connector_unknown_event_total", + "swt_connector_unmapped_retraction_total", "swt_connector_contract_error_total", + "swt_connector_sqlite_write_duration_seconds", + } { + if !strings.Contains(payload, name) { + t.Fatalf("metric %s missing from:\n%s", name, payload) + } + } + for _, forbidden := range []string{"account_id=", "inbox_id=", "username=", "session_id="} { + if strings.Contains(payload, forbidden) { + t.Fatalf("high-cardinality label %q found in:\n%s", forbidden, payload) + } + } +} + +func TestMetricKindKeepsEveryDocumentedKindBounded(t *testing.T) { + for _, kind := range []int64{-8, -7, -5, -4, 0, 1, 2, 3, 5, 7, 8, 11, 12, 14, 15, 24, 26, 29, 30, 31, 34, 35, 38, 39, 41, 44, 52, 56, 58, 61, 62, 65, 66, 67, 71} { + if got := metricKind(kind); got != strconv.FormatInt(kind, 10) { + t.Fatalf("metric kind %d = %q", kind, got) + } + } + if got := metricKind(999); got != "unknown" { + t.Fatalf("unknown metric kind = %q", got) + } +} diff --git a/channels/shangwutong/internal/store/accounts.go b/channels/shangwutong/internal/store/accounts.go new file mode 100644 index 00000000..5dbab116 --- /dev/null +++ b/channels/shangwutong/internal/store/accounts.go @@ -0,0 +1,134 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" +) + +var ErrIdentityChange = errors.New("session_id and username cannot be changed in place") + +type AccountConfig struct { + GoChatAccountID int64 + GoChatInboxID int64 + GoChatInboxIdentifier string + ConfigVersion int64 + SessionID string + Username string + Password string + Enabled bool + DesiredPresence string + GoChatHMACToken string + GoChatWebhookSecret string +} + +type UpsertAccountResult struct { + Account *dbgen.Account + Created bool + Changed bool +} + +func (s *Store) UpsertAccountConfig(ctx context.Context, config AccountConfig) (UpsertAccountResult, error) { + var result UpsertAccountResult + err := s.WithTx(ctx, func(queries *dbgen.Queries) error { + existing, err := queries.GetAccountByInboxID(ctx, config.GoChatInboxID) + if errors.Is(err, sql.ErrNoRows) { + created, createErr := queries.CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: config.GoChatAccountID, + GochatInboxID: config.GoChatInboxID, + GochatInboxIdentifier: config.GoChatInboxIdentifier, + ConfigVersion: config.ConfigVersion, + SessionID: config.SessionID, + Username: config.Username, + Password: config.Password, + Enabled: boolInt(config.Enabled), + DesiredPresence: config.DesiredPresence, + GochatHmacToken: config.GoChatHMACToken, + GochatWebhookSecret: config.GoChatWebhookSecret, + }) + if createErr != nil { + return createErr + } + result = UpsertAccountResult{Account: created, Created: true, Changed: true} + return nil + } + if err != nil { + return err + } + result.Account = existing + if config.ConfigVersion <= existing.ConfigVersion { + return nil + } + if config.SessionID != existing.SessionID || config.Username != existing.Username { + return ErrIdentityChange + } + + pendingPassword, credentialState := pendingCredential(existing, config.Password) + updated, err := queries.UpdateAccountConfig(ctx, dbgen.UpdateAccountConfigParams{ + GochatAccountID: config.GoChatAccountID, + GochatInboxIdentifier: config.GoChatInboxIdentifier, + ConfigVersion: config.ConfigVersion, + PendingPassword: pendingPassword, + CredentialState: credentialState, + Enabled: boolInt(config.Enabled), + DesiredPresence: config.DesiredPresence, + GochatHmacToken: config.GoChatHMACToken, + GochatWebhookSecret: config.GoChatWebhookSecret, + GochatInboxID: config.GoChatInboxID, + }) + if err != nil { + return err + } + result.Account, result.Changed = updated, true + return nil + }) + if err != nil { + return UpsertAccountResult{}, fmt.Errorf("upsert account config: %w", err) + } + return result, nil +} + +func (s *Store) MarkMissingAccountsDeleted(ctx context.Context, presentInboxIDs map[int64]struct{}) ([]int64, error) { + accounts, err := s.readerQueries.ListAccounts(ctx) + if err != nil { + return nil, err + } + deleted := make([]int64, 0) + for _, account := range accounts { + if account.DeletedAt != nil { + continue + } + if _, present := presentInboxIDs[account.GochatInboxID]; present { + continue + } + if err := s.writerQueries.MarkAccountDeleted(ctx, account.GochatInboxID); err != nil { + return nil, err + } + deleted = append(deleted, account.GochatInboxID) + } + return deleted, nil +} + +func AccountRunnable(account *dbgen.Account) bool { + return account != nil && account.DeletedAt == nil && account.Enabled == 1 && account.DesiredPresence != "offline" +} + +func pendingCredential(account *dbgen.Account, password string) (*string, string) { + if password == account.Password { + return nil, "applied" + } + if account.PendingPassword != nil && password == *account.PendingPassword { + return account.PendingPassword, account.CredentialState + } + return &password, "pending" +} + +func boolInt(value bool) int64 { + if value { + return 1 + } + return 0 +} diff --git a/channels/shangwutong/internal/store/heartbeat.go b/channels/shangwutong/internal/store/heartbeat.go new file mode 100644 index 00000000..1eb14975 --- /dev/null +++ b/channels/shangwutong/internal/store/heartbeat.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" +) + +func (s *Store) PersistHeartbeat(ctx context.Context, account *dbgen.Account, events []swt.HeartbeatEvent) (swt.Cursor, error) { + cursor := swt.Cursor{MaxWordID: account.Maxwordid, MaxOTick: account.Maxotick, MaxTmpID: account.Maxtmpid} + err := s.WithTx(ctx, func(queries *dbgen.Queries) error { + for _, event := range events { + cursor.Apply(event.Kind, event.SeqID) + status, strategy := "pending", (*string)(nil) + if ignoredKind(event.Kind) { + status = "ignored" + value := "raw_only" + strategy = &value + } + _, err := queries.InsertInboundEvent(ctx, dbgen.InsertInboundEventParams{ + AccountID: account.ID, + SwtSid: event.SessionID, + SeqID: event.SeqID, + Kind: int64(event.Kind), + SwtEventKey: fmt.Sprintf("swt:%d:%s:%d:%d", account.GochatInboxID, event.SessionID, event.Kind, event.SeqID), + OpName: optionalString(event.OpName), + Text: optionalString(event.Text), + SwtTimestamp: optionalString(event.Timestamp), + RawLine: event.RawLine, + MappingStrategy: strategy, + DeliveryStatus: status, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if event.Kind == 11 { + if presence, ok := presenceFromKind11(event.Text); ok { + if err := queries.UpdateAccountPresence(ctx, dbgen.UpdateAccountPresenceParams{ + ActualPresence: presence, ConnectionStatus: "connected", ID: account.ID, + }); err != nil { + return err + } + } + } + } + return queries.UpdateAccountCursor(ctx, dbgen.UpdateAccountCursorParams{ + Maxwordid: cursor.MaxWordID, + Maxotick: cursor.MaxOTick, + Maxtmpid: cursor.MaxTmpID, + ID: account.ID, + }) + }) + if err != nil { + return swt.Cursor{}, fmt.Errorf("persist heartbeat: %w", err) + } + return cursor, nil +} + +func ignoredKind(kind int) bool { + switch kind { + case -7, 1, 14, 24, 38, 44, 62: + return true + default: + return false + } +} + +func presenceFromKind11(text string) (string, bool) { + value, err := strconv.Atoi(text) + if err != nil { + return "", false + } + switch value { + case 0: + return "offline", true + case 1: + return "away", true + case 2: + return "busy", true + case 3: + return "online", true + default: + return "", false + } +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} diff --git a/channels/shangwutong/internal/store/heartbeat_test.go b/channels/shangwutong/internal/store/heartbeat_test.go new file mode 100644 index 00000000..b6ad7bdd --- /dev/null +++ b/channels/shangwutong/internal/store/heartbeat_test.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + "github.com/gochat/gochat/channels/shangwutong/internal/swt" +) + +func TestPersistHeartbeatStoresEventsBeforeCursorAndIsIdempotent(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 22, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + events := []swt.HeartbeatEvent{ + {SessionID: "visitor", Kind: 2, Text: "hello", SeqID: 42, Timestamp: "100", RawLine: "visitor 2 hello 42 100"}, + {SessionID: "visitor", Kind: 11, Text: "2", SeqID: 9, Timestamp: "101", RawLine: "visitor 11 2 9 101"}, + } + if _, err := store.PersistHeartbeat(ctx, account, events); err != nil { + t.Fatal(err) + } + loaded, err := store.Reader().GetAccountByID(ctx, account.ID) + if err != nil { + t.Fatal(err) + } + if loaded.Maxwordid != 42 || loaded.Maxotick != 9 || loaded.ActualPresence != "busy" { + t.Fatalf("account = %#v", loaded) + } + if _, err := store.PersistHeartbeat(ctx, loaded, events); err != nil { + t.Fatal(err) + } + claimed, err := store.Writer().ClaimInboundEvent(ctx) + if err != nil || claimed.SwtEventKey != "swt:22:visitor:2:42" { + t.Fatalf("claimed = %#v, %v", claimed, err) + } +} + +func TestIgnoredKindMatrix(t *testing.T) { + for _, kind := range []int{-7, 1, 14, 24, 38, 44, 62} { + if !ignoredKind(kind) { + t.Fatalf("kind %d should be ignored", kind) + } + } + for _, kind := range []int{-8, -5, -4, 0, 2, 3, 11, 31, 52, 67, 71} { + if ignoredKind(kind) { + t.Fatalf("kind %d must remain deliverable", kind) + } + } +} + +func TestPersistHeartbeatMarksIgnoredEventsRawOnly(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + account, err := store.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 22, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + + event := swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 14, SeqID: 42, Timestamp: "100", RawLine: "visitor 14 42 100", + } + if _, err := store.PersistHeartbeat(ctx, account, []swt.HeartbeatEvent{event}); err != nil { + t.Fatal(err) + } + + stored, err := store.Reader().GetInboundEventByKey(ctx, "swt:22:visitor:14:42") + if err != nil { + t.Fatal(err) + } + if stored.DeliveryStatus != "ignored" { + t.Fatalf("delivery_status = %q, want ignored", stored.DeliveryStatus) + } + if stored.MappingStrategy == nil || *stored.MappingStrategy != "raw_only" { + t.Fatalf("mapping_strategy = %#v, want raw_only", stored.MappingStrategy) + } +} diff --git a/channels/shangwutong/internal/store/migrate.go b/channels/shangwutong/internal/store/migrate.go new file mode 100644 index 00000000..f19c4900 --- /dev/null +++ b/channels/shangwutong/internal/store/migrate.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/gochat/gochat/channels/shangwutong/db/migrations" +) + +func Migrate(ctx context.Context, db *sql.DB) error { + connection, err := db.Conn(ctx) + if err != nil { + return fmt.Errorf("acquire migration connection: %w", err) + } + defer connection.Close() + if _, err := connection.ExecContext(ctx, "BEGIN EXCLUSIVE"); err != nil { + return fmt.Errorf("acquire exclusive migration lock: %w", err) + } + committed := false + defer func() { + if !committed { + _, _ = connection.ExecContext(context.Background(), "ROLLBACK") + } + }() + + if _, err := connection.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( +version INTEGER PRIMARY KEY, +name TEXT NOT NULL, +applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +)`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + entries, err := migrations.FS.ReadDir(".") + if err != nil { + return fmt.Errorf("read embedded migrations: %w", err) + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".up.sql") { + names = append(names, entry.Name()) + } + } + sort.Strings(names) + for _, name := range names { + version, err := migrationVersion(name) + if err != nil { + return err + } + var exists int + if err := connection.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&exists); err != nil { + return fmt.Errorf("read migration version %d: %w", version, err) + } + if exists != 0 { + continue + } + body, err := migrations.FS.ReadFile(name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + if _, err := connection.ExecContext(ctx, string(body)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err := connection.ExecContext(ctx, "INSERT INTO schema_migrations(version, name) VALUES (?, ?)", version, name); err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + } + if _, err := connection.ExecContext(ctx, "COMMIT"); err != nil { + return fmt.Errorf("commit migrations: %w", err) + } + committed = true + return nil +} + +func InspectDatabase(ctx context.Context, path string) (int64, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return 0, fmt.Errorf("resolve database path: %w", err) + } + db, err := sql.Open("sqlite", sqliteDSN(absolute, true)) + if err != nil { + return 0, err + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + return 0, err + } + if err := sqliteCheck(ctx, db, "integrity_check"); err != nil { + return 0, err + } + return CurrentMigrationVersion(ctx, db) +} + +func CurrentMigrationVersion(ctx context.Context, db *sql.DB) (int64, error) { + var version sql.NullInt64 + if err := db.QueryRowContext(ctx, "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil { + return 0, err + } + if !version.Valid { + return 0, nil + } + return version.Int64, nil +} + +func migrationVersion(name string) (int64, error) { + prefix, _, ok := strings.Cut(name, "_") + if !ok { + return 0, fmt.Errorf("invalid migration name %q", name) + } + version, err := strconv.ParseInt(prefix, 10, 64) + if err != nil || version <= 0 { + return 0, fmt.Errorf("invalid migration version in %q", name) + } + return version, nil +} diff --git a/channels/shangwutong/internal/store/outbound.go b/channels/shangwutong/internal/store/outbound.go new file mode 100644 index 00000000..2de2cc89 --- /dev/null +++ b/channels/shangwutong/internal/store/outbound.go @@ -0,0 +1,148 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" +) + +var ErrOutboundConflict = errors.New("outbound message state conflicts with webhook") + +type OutboundInput struct { + AccountID int64 + SWTSessionID string + EventID string + OccurredAt time.Time + GoChatMessageID int64 + RetryVersion int64 + MessageType string + Content *string + Payload string + Parts []OutboundPartInput +} + +type OutboundPartInput struct { + Type string + AttachmentID *int64 + Content *string + DataURL *string + FileName *string + FileSize *int64 + Voice bool +} + +func (s *Store) EnqueueOutbound(ctx context.Context, input OutboundInput, retry bool) (*dbgen.OutboundMessage, bool, error) { + if input.AccountID <= 0 || input.GoChatMessageID <= 0 || strings.TrimSpace(input.SWTSessionID) == "" || strings.TrimSpace(input.EventID) == "" || input.OccurredAt.IsZero() { + return nil, false, errors.New("outbound account, sid, event ID, occurrence time and message ID are required") + } + existing, err := s.writerQueries.GetOutboundByGoChatMessageID(ctx, input.GoChatMessageID) + if errors.Is(err, sql.ErrNoRows) { + parts := input.Parts + if len(parts) == 0 && input.Content != nil && strings.TrimSpace(*input.Content) != "" { + parts = []OutboundPartInput{{Type: "text", Content: input.Content}} + } + var created *dbgen.OutboundMessage + createErr := s.WithTx(ctx, func(queries *dbgen.Queries) error { + var err error + created, err = queries.InsertOutboundMessage(ctx, dbgen.InsertOutboundMessageParams{ + AccountID: input.AccountID, SwtSid: input.SWTSessionID, EventID: input.EventID, OccurredAt: input.OccurredAt, + GochatMessageID: input.GoChatMessageID, RetryVersion: input.RetryVersion, + MessageType: input.MessageType, Content: input.Content, Payload: input.Payload, + }) + if err != nil { + return err + } + for index, part := range parts { + voice := int64(0) + if part.Voice { + voice = 1 + } + if _, err := queries.InsertOutboundPart(ctx, dbgen.InsertOutboundPartParams{ + OutboundMessageID: created.ID, PartIndex: int64(index), PartType: part.Type, + AttachmentID: part.AttachmentID, Content: part.Content, DataUrl: part.DataURL, + FileName: part.FileName, FileSize: part.FileSize, Voice: voice, + }); err != nil { + return err + } + } + return nil + }) + if createErr != nil { + return nil, false, createErr + } + return created, false, nil + } + if err != nil { + return nil, false, err + } + if !retry { + if existing.EventID != input.EventID || existing.Payload != input.Payload || existing.AccountID != input.AccountID || existing.SwtSid != input.SWTSessionID { + return nil, false, ErrOutboundConflict + } + return existing, true, nil + } + if input.RetryVersion <= existing.RetryVersion { + if input.RetryVersion == existing.RetryVersion && existing.Payload == input.Payload { + return existing, true, nil + } + return nil, false, ErrOutboundConflict + } + var rows int64 + err = s.WithTx(ctx, func(queries *dbgen.Queries) error { + var err error + rows, err = queries.RetryOutboundMessage(ctx, dbgen.RetryOutboundMessageParams{ + EventID: input.EventID, OccurredAt: input.OccurredAt, RetryVersion: input.RetryVersion, + MessageType: input.MessageType, Content: input.Content, Payload: input.Payload, GochatMessageID: input.GoChatMessageID, + }) + if err != nil || rows != 1 { + return err + } + return queries.ResetUndeliveredOutboundParts(ctx, existing.ID) + }) + if err != nil { + return nil, false, err + } + if rows != 1 { + return nil, false, ErrOutboundConflict + } + updated, err := s.writerQueries.GetOutboundByGoChatMessageID(ctx, input.GoChatMessageID) + if err != nil { + return nil, false, fmt.Errorf("reload outbound message: %w", err) + } + return updated, false, nil +} + +type OutboundOperationInput struct { + AccountID int64 + SWTSessionID string + EventID string + Operation string + Payload string + OccurredAt time.Time +} + +func (s *Store) EnqueueOutboundOperation(ctx context.Context, input OutboundOperationInput) (*dbgen.OutboundOperation, bool, error) { + if input.AccountID <= 0 || strings.TrimSpace(input.SWTSessionID) == "" || strings.TrimSpace(input.EventID) == "" || input.Operation != "end_conversation" || input.OccurredAt.IsZero() { + return nil, false, errors.New("valid outbound operation fields are required") + } + existing, err := s.writerQueries.GetOutboundOperationByEventID(ctx, input.EventID) + if errors.Is(err, sql.ErrNoRows) { + created, createErr := s.writerQueries.InsertOutboundOperation(ctx, dbgen.InsertOutboundOperationParams{ + AccountID: input.AccountID, SwtSid: input.SWTSessionID, EventID: input.EventID, + Operation: input.Operation, Payload: input.Payload, OccurredAt: input.OccurredAt, + }) + return created, false, createErr + } + if err != nil { + return nil, false, err + } + if existing.AccountID != input.AccountID || existing.SwtSid != input.SWTSessionID || existing.Operation != input.Operation || existing.Payload != input.Payload || !existing.OccurredAt.Equal(input.OccurredAt) { + return nil, false, ErrOutboundConflict + } + return existing, true, nil +} diff --git a/channels/shangwutong/internal/store/store.go b/channels/shangwutong/internal/store/store.go new file mode 100644 index 00000000..746f2023 --- /dev/null +++ b/channels/shangwutong/internal/store/store.go @@ -0,0 +1,227 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" + _ "modernc.org/sqlite" +) + +type Store struct { + writer *sql.DB + reader *sql.DB + writerQueries *dbgen.Queries + readerQueries *dbgen.Queries + writeObserver func(time.Duration) +} + +func Open(ctx context.Context, path string) (*Store, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve database path: %w", err) + } + writer, err := sql.Open("sqlite", sqliteDSN(absolute, false)) + if err != nil { + return nil, fmt.Errorf("open sqlite writer: %w", err) + } + writer.SetMaxOpenConns(1) + writer.SetMaxIdleConns(1) + writer.SetConnMaxLifetime(0) + if err := writer.PingContext(ctx); err != nil { + _ = writer.Close() + return nil, fmt.Errorf("ping sqlite writer: %w", err) + } + if err := Migrate(ctx, writer); err != nil { + _ = writer.Close() + return nil, err + } + if err := restrictSQLiteFiles(absolute); err != nil { + _ = writer.Close() + return nil, err + } + + reader, err := sql.Open("sqlite", sqliteDSN(absolute, true)) + if err != nil { + _ = writer.Close() + return nil, fmt.Errorf("open sqlite reader: %w", err) + } + reader.SetMaxOpenConns(8) + reader.SetMaxIdleConns(8) + reader.SetConnMaxLifetime(0) + if err := reader.PingContext(ctx); err != nil { + _ = reader.Close() + _ = writer.Close() + return nil, fmt.Errorf("ping sqlite reader: %w", err) + } + + return &Store{ + writer: writer, + reader: reader, + writerQueries: dbgen.New(writer), + readerQueries: dbgen.New(reader), + }, nil +} + +func (s *Store) Writer() *dbgen.Queries { return s.writerQueries } + +func (s *Store) Reader() *dbgen.Queries { return s.readerQueries } + +func (s *Store) WithTx(ctx context.Context, fn func(*dbgen.Queries) error) error { + if fn == nil { + return errors.New("transaction callback is required") + } + started := time.Now() + defer func() { + if s.writeObserver != nil { + s.writeObserver(time.Since(started)) + } + }() + tx, err := s.writer.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := fn(s.writerQueries.WithTx(tx)); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *Store) SetWriteObserver(observer func(time.Duration)) { s.writeObserver = observer } + +func (s *Store) QuickCheck(ctx context.Context) error { + return sqliteCheck(ctx, s.reader, "quick_check") +} + +func (s *Store) IntegrityCheck(ctx context.Context) error { + return sqliteCheck(ctx, s.reader, "integrity_check") +} + +func sqliteCheck(ctx context.Context, db *sql.DB, pragma string) error { + var result string + if err := db.QueryRowContext(ctx, "PRAGMA "+pragma).Scan(&result); err != nil { + return err + } + if result != "ok" { + return fmt.Errorf("sqlite %s: %s", pragma, result) + } + return nil +} + +func (s *Store) ReadyCheck(ctx context.Context) error { + if err := s.QuickCheck(ctx); err != nil { + return err + } + connection, err := s.writer.Conn(ctx) + if err != nil { + return err + } + defer connection.Close() + if _, err := connection.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil { + return err + } + _, rollbackErr := connection.ExecContext(context.Background(), "ROLLBACK") + return rollbackErr +} + +type MetricSnapshot struct { + Accounts map[string]int64 + InboundQueue map[string]int64 + OutboundQueue map[string]int64 + StatusSyncQueue int64 +} + +func (s *Store) MetricSnapshot(ctx context.Context) (MetricSnapshot, error) { + snapshot := MetricSnapshot{ + Accounts: make(map[string]int64), InboundQueue: make(map[string]int64), OutboundQueue: make(map[string]int64), + } + accounts, err := s.readerQueries.ListAccountMetricCounts(ctx) + if err != nil { + return snapshot, err + } + for _, row := range accounts { + snapshot.Accounts[row.ConnectionStatus+"\x00"+row.ActualPresence] = row.Count + } + inbound, err := s.readerQueries.ListInboundQueueMetricCounts(ctx) + if err != nil { + return snapshot, err + } + for _, row := range inbound { + snapshot.InboundQueue[row.DeliveryStatus] = row.Count + } + outbound, err := s.readerQueries.ListOutboundQueueMetricCounts(ctx) + if err != nil { + return snapshot, err + } + for _, row := range outbound { + snapshot.OutboundQueue[row.DeliveryStatus] = row.Count + } + snapshot.StatusSyncQueue, err = s.readerQueries.CountStatusSyncQueue(ctx) + return snapshot, err +} + +func (s *Store) Close() error { + readerErr := s.reader.Close() + writerErr := s.writer.Close() + return errors.Join(readerErr, writerErr) +} + +func sqliteDSN(path string, readOnly bool) string { + uri := &url.URL{Scheme: "file", Path: path} + query := uri.Query() + query.Set("_time_format", "sqlite") + query.Set("_timezone", "UTC") + query.Add("_pragma", "busy_timeout(5000)") + query.Add("_pragma", "foreign_keys(1)") + if readOnly { + query.Set("mode", "ro") + query.Add("_pragma", "query_only(1)") + } else { + query.Add("_pragma", "journal_mode(WAL)") + query.Add("_pragma", "synchronous(NORMAL)") + query.Add("_pragma", "wal_autocheckpoint(1000)") + } + uri.RawQuery = query.Encode() + return uri.String() +} + +func (s *Store) Checkpoint(ctx context.Context) error { + checkpointCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + _, err := s.writer.ExecContext(checkpointCtx, "PRAGMA wal_checkpoint(TRUNCATE)") + return err +} + +func (s *Store) MigrationVersion(ctx context.Context) (int64, error) { + return CurrentMigrationVersion(ctx, s.reader) +} + +func (s *Store) Backup(ctx context.Context, output string) error { + absolute, err := filepath.Abs(output) + if err != nil { + return fmt.Errorf("resolve backup path: %w", err) + } + if _, err := s.writer.ExecContext(ctx, "VACUUM INTO ?", absolute); err != nil { + return fmt.Errorf("backup SQLite database: %w", err) + } + if err := os.Chmod(absolute, 0o600); err != nil { + return fmt.Errorf("restrict SQLite backup permissions: %w", err) + } + return nil +} + +func restrictSQLiteFiles(path string) error { + for _, candidate := range []string{path, path + "-wal", path + "-shm"} { + if err := os.Chmod(candidate, 0o600); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("restrict SQLite file permissions: %w", err) + } + } + return nil +} diff --git a/channels/shangwutong/internal/store/store_test.go b/channels/shangwutong/internal/store/store_test.go new file mode 100644 index 00000000..cfb117d1 --- /dev/null +++ b/channels/shangwutong/internal/store/store_test.go @@ -0,0 +1,457 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" +) + +func TestOpenMigratesAndPersistsAccount(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "connector.db") + store, err := Open(ctx, path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("database mode = %v", info.Mode().Perm()) + } + second, err := Open(ctx, path) + if err != nil { + t.Fatalf("idempotent reopen: %v", err) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + account, err := store.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, + GochatInboxID: 2, + GochatInboxIdentifier: "identifier", + ConfigVersion: 1, + SessionID: "BYT99917999", + Username: "agent", + Password: "password", + Enabled: 1, + DesiredPresence: "online", + GochatHmacToken: "hmac", + GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + loaded, err := store.Reader().GetAccountByInboxID(ctx, 2) + if err != nil { + t.Fatal(err) + } + if loaded.ID != account.ID || loaded.Maxwordid != -1 { + t.Fatalf("unexpected account: %#v", loaded) + } + if err := store.QuickCheck(ctx); err != nil { + t.Fatal(err) + } + if err := store.IntegrityCheck(ctx); err != nil { + t.Fatal(err) + } +} + +func TestWithTxRollsBack(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + wantErr := errors.New("rollback") + err = store.WithTx(ctx, func(queries *dbgen.Queries) error { + _, createErr := queries.CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if createErr != nil { + return createErr + } + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v", err) + } + if _, err := store.Reader().GetAccountByInboxID(ctx, 2); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("account should have rolled back: %v", err) + } +} + +func TestMigrationVersionParser(t *testing.T) { + if got, err := migrationVersion("001_init.up.sql"); err != nil || got != 1 { + t.Fatalf("version = %d, %v", got, err) + } + if _, err := migrationVersion("invalid.sql"); err == nil { + t.Fatal("expected invalid migration error") + } +} + +func TestOutboundClaimStopsBehindUncertainMessage(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + for messageID := int64(10); messageID <= 11; messageID++ { + if _, err := store.Writer().InsertOutboundMessage(ctx, dbgen.InsertOutboundMessageParams{ + AccountID: account.ID, SwtSid: "sid", GochatMessageID: messageID, + EventID: fmt.Sprintf("message:%d:created", messageID), OccurredAt: time.Now().Add(time.Duration(messageID) * time.Nanosecond), + MessageType: "text", Payload: `{}`, + }); err != nil { + t.Fatal(err) + } + } + first, err := store.Writer().ClaimOutboundMessage(ctx) + if err != nil || first.GochatMessageID != 10 { + t.Fatalf("first claim = %#v, %v", first, err) + } + errorCode, errorMessage := "timeout", "result uncertain" + if err := store.Writer().MarkOutboundUncertain(ctx, dbgen.MarkOutboundUncertainParams{ + ExternalErrorCode: &errorCode, LastError: &errorMessage, ID: first.ID, + }); err != nil { + t.Fatal(err) + } + if _, err := store.Writer().ClaimOutboundMessage(ctx); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("later message should be blocked, got %v", err) + } + if err := store.Writer().FailOutboundMessage(ctx, dbgen.FailOutboundMessageParams{ + ExternalErrorCode: &errorCode, LastError: &errorMessage, ID: first.ID, + }); err != nil { + t.Fatal(err) + } + second, err := store.Writer().ClaimOutboundMessage(ctx) + if err != nil || second.GochatMessageID != 11 { + t.Fatalf("second claim = %#v, %v", second, err) + } +} + +func TestOutboundOperationWaitsForEarlierMessage(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + now := time.Now() + if _, err := store.Writer().InsertOutboundMessage(ctx, dbgen.InsertOutboundMessageParams{ + AccountID: account.ID, SwtSid: "sid", EventID: "message:10:created", OccurredAt: now, + GochatMessageID: 10, MessageType: "text", Payload: `{}`, + }); err != nil { + t.Fatal(err) + } + if _, err := store.Writer().InsertOutboundOperation(ctx, dbgen.InsertOutboundOperationParams{ + AccountID: account.ID, SwtSid: "sid", EventID: "conversation:20:status:1", + Operation: "end_conversation", Payload: `{}`, OccurredAt: now.Add(time.Second), + }); err != nil { + t.Fatal(err) + } + if _, err := store.Writer().ClaimOutboundOperation(ctx); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("later operation should be blocked, got %v", err) + } + message, err := store.Writer().ClaimOutboundMessage(ctx) + if err != nil || message.GochatMessageID != 10 { + t.Fatalf("message = %#v, %v", message, err) + } + if err := store.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ID: message.ID}); err != nil { + t.Fatal(err) + } + operation, err := store.Writer().ClaimOutboundOperation(ctx) + if err != nil || operation.Operation != "end_conversation" { + t.Fatalf("operation = %#v, %v", operation, err) + } +} + +func TestQueueRetryTimesRemainClaimableOutsideUTC(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + + localZone := time.FixedZone("UTC+8", 8*60*60) + occurredAt := time.Now().In(localZone) + message, err := database.Writer().InsertOutboundMessage(ctx, dbgen.InsertOutboundMessageParams{ + AccountID: account.ID, SwtSid: "sid", EventID: "message:10:created", OccurredAt: occurredAt, + GochatMessageID: 10, MessageType: "text", Payload: `{}`, + }) + if err != nil { + t.Fatal(err) + } + if _, offset := message.OccurredAt.Zone(); offset != 0 { + t.Fatalf("stored occurred_at offset = %d, want UTC", offset) + } + message, err = database.Writer().ClaimOutboundMessage(ctx) + if err != nil { + t.Fatal(err) + } + past, detail := time.Now().Add(-time.Minute).In(localZone), "retry" + if err := database.Writer().RetryOutboundDelivery(ctx, dbgen.RetryOutboundDeliveryParams{ + NextAttemptAt: &past, LastError: &detail, ID: message.ID, + }); err != nil { + t.Fatal(err) + } + message, err = database.Writer().ClaimOutboundMessage(ctx) + if err != nil { + t.Fatalf("reclaim outbound message: %v", err) + } + if err := database.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ID: message.ID}); err != nil { + t.Fatal(err) + } + + message, err = database.Writer().ClaimStatusSync(ctx) + if err != nil { + t.Fatal(err) + } + if err := database.Writer().RetryStatusSync(ctx, dbgen.RetryStatusSyncParams{ + StatusSyncNextAt: &past, LastError: &detail, ID: message.ID, + }); err != nil { + t.Fatal(err) + } + if _, err := database.Writer().ClaimStatusSync(ctx); err != nil { + t.Fatalf("reclaim status sync: %v", err) + } + + operation, err := database.Writer().InsertOutboundOperation(ctx, dbgen.InsertOutboundOperationParams{ + AccountID: account.ID, SwtSid: "sid", EventID: "conversation:20:status:1", + Operation: "end_conversation", Payload: `{}`, OccurredAt: occurredAt.Add(time.Second), + }) + if err != nil { + t.Fatal(err) + } + operation, err = database.Writer().ClaimOutboundOperation(ctx) + if err != nil { + t.Fatal(err) + } + if err := database.Writer().RetryOutboundOperation(ctx, dbgen.RetryOutboundOperationParams{ + NextAttemptAt: &past, LastError: &detail, ID: operation.ID, + }); err != nil { + t.Fatal(err) + } + if _, err := database.Writer().ClaimOutboundOperation(ctx); err != nil { + t.Fatalf("reclaim outbound operation: %v", err) + } +} + +func TestRecoverOutboundStatusSyncMakesClaimRetryable(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + if _, err := database.Writer().InsertOutboundMessage(ctx, dbgen.InsertOutboundMessageParams{ + AccountID: account.ID, SwtSid: "sid", EventID: "message:10:created", OccurredAt: time.Now(), + GochatMessageID: 10, MessageType: "text", Payload: `{}`, + }); err != nil { + t.Fatal(err) + } + message, err := database.Writer().ClaimOutboundMessage(ctx) + if err != nil { + t.Fatal(err) + } + if err := database.Writer().CompleteOutboundMessage(ctx, dbgen.CompleteOutboundMessageParams{ID: message.ID}); err != nil { + t.Fatal(err) + } + claimed, err := database.Writer().ClaimStatusSync(ctx) + if err != nil || claimed.StatusSyncStatus != "syncing" { + t.Fatalf("status sync claim = %#v, %v", claimed, err) + } + if recovered, err := database.Writer().RecoverOutboundStatusSyncs(ctx); err != nil || recovered != 1 { + t.Fatalf("recovered = %d, %v", recovered, err) + } + reclaimed, err := database.Writer().ClaimStatusSync(ctx) + if err != nil || reclaimed.ID != claimed.ID { + t.Fatalf("status sync reclaim = %#v, %v", reclaimed, err) + } +} + +func TestRecoverOutboundDeliveringMarksOnlyPossiblePartUncertain(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + first, second := "first", "second" + message, _, err := database.EnqueueOutbound(ctx, OutboundInput{ + AccountID: account.ID, SWTSessionID: "sid", EventID: "message:10:created", OccurredAt: time.Now(), + GoChatMessageID: 10, MessageType: "text", Payload: `{}`, + Parts: []OutboundPartInput{{Type: "text", Content: &first}, {Type: "text", Content: &second}}, + }, false) + if err != nil { + t.Fatal(err) + } + if _, err := database.Writer().ClaimOutboundMessage(ctx); err != nil { + t.Fatal(err) + } + if recovered, err := database.Writer().RecoverOutboundPartDeliveriesAsUncertain(ctx); err != nil || recovered != 1 { + t.Fatalf("part recovery = %d, %v", recovered, err) + } + if recovered, err := database.Writer().RecoverOutboundDeliveriesAsUncertain(ctx); err != nil || recovered != 1 { + t.Fatalf("message recovery = %d, %v", recovered, err) + } + parts, err := database.Reader().ListOutboundParts(ctx, message.ID) + if err != nil || len(parts) != 2 || parts[0].DeliveryStatus != "uncertain" || parts[1].DeliveryStatus != "pending" { + t.Fatalf("parts = %#v, %v", parts, err) + } +} + +func TestUpsertAccountConfigKeepsWorkingPasswordUntilCandidateApplies(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + base := AccountConfig{ + GoChatAccountID: 1, GoChatInboxID: 2, GoChatInboxIdentifier: "identifier", + ConfigVersion: 1, SessionID: "BYT99917999", Username: "agent", Password: "old", + Enabled: true, DesiredPresence: "online", GoChatHMACToken: "hmac", GoChatWebhookSecret: "secret", + } + created, err := store.UpsertAccountConfig(ctx, base) + if err != nil || !created.Created { + t.Fatalf("create = %#v, %v", created, err) + } + base.ConfigVersion, base.Password = 2, "new" + updated, err := store.UpsertAccountConfig(ctx, base) + if err != nil { + t.Fatal(err) + } + if !updated.Changed || updated.Account.Password != "old" || updated.Account.PendingPassword == nil || *updated.Account.PendingPassword != "new" { + t.Fatalf("updated account = %#v", updated.Account) + } + base.ConfigVersion = 1 + replayed, err := store.UpsertAccountConfig(ctx, base) + if err != nil || replayed.Changed || replayed.Account.ConfigVersion != 2 { + t.Fatalf("old replay = %#v, %v", replayed, err) + } +} + +func TestUpsertAccountConfigRejectsIdentityChange(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + config := AccountConfig{ + GoChatAccountID: 1, GoChatInboxID: 2, GoChatInboxIdentifier: "identifier", + ConfigVersion: 1, SessionID: "BYT99917999", Username: "agent", Password: "old", + Enabled: true, DesiredPresence: "online", GoChatHMACToken: "hmac", GoChatWebhookSecret: "secret", + } + if _, err := store.UpsertAccountConfig(ctx, config); err != nil { + t.Fatal(err) + } + config.ConfigVersion, config.Username = 2, "other" + if _, err := store.UpsertAccountConfig(ctx, config); !errors.Is(err, ErrIdentityChange) { + t.Fatalf("error = %v", err) + } +} + +func TestDeletedAccountAllowsIdentityReuseForNewInbox(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + config := AccountConfig{ + GoChatAccountID: 1, GoChatInboxID: 2, GoChatInboxIdentifier: "old", + ConfigVersion: 1, SessionID: "BYT99917999", Username: "agent", Password: "secret", + Enabled: true, DesiredPresence: "offline", GoChatHMACToken: "hmac", GoChatWebhookSecret: "secret", + } + if _, err := database.UpsertAccountConfig(ctx, config); err != nil { + t.Fatal(err) + } + if err := database.Writer().MarkAccountDeleted(ctx, config.GoChatInboxID); err != nil { + t.Fatal(err) + } + config.GoChatInboxID, config.GoChatInboxIdentifier = 3, "new" + created, err := database.UpsertAccountConfig(ctx, config) + if err != nil || !created.Created { + t.Fatalf("recreate = %#v, %v", created, err) + } +} + +func TestOnlineBackupCanBeOpenedReadOnly(t *testing.T) { + ctx := context.Background() + directory := t.TempDir() + database, err := Open(ctx, filepath.Join(directory, "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + backup := filepath.Join(directory, "backup.db") + if err := database.Backup(ctx, backup); err != nil { + t.Fatal(err) + } + info, err := os.Stat(backup) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("backup mode = %v", info.Mode().Perm()) + } + version, err := InspectDatabase(ctx, backup) + if err != nil || version != 1 { + t.Fatalf("backup version = %d, %v", version, err) + } +} diff --git a/channels/shangwutong/internal/swt/client.go b/channels/shangwutong/internal/swt/client.go new file mode 100644 index 00000000..543166a5 --- /dev/null +++ b/channels/shangwutong/internal/swt/client.go @@ -0,0 +1,332 @@ +package swt + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/http/httptrace" + "net/url" + "strconv" + "strings" + "sync/atomic" + "time" +) + +const ( + defaultRequestTimeout = 30 * time.Second + maxProtocolBodyBytes = 8 << 20 +) + +type Error struct { + Operation string + Code string + Retryable bool + Uncertain bool + Err error +} + +func (e *Error) Error() string { + if e.Err == nil { + return e.Operation + ": " + e.Code + } + return e.Operation + ": " + e.Code + ": " + e.Err.Error() +} + +func (e *Error) Unwrap() error { return e.Err } + +type Client struct { + httpClient *http.Client +} + +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultRequestTimeout} + } + return &Client{httpClient: httpClient} +} + +func (c *Client) Login(ctx context.Context, credentials Credentials, presence Presence, verificationCode string) (Session, error) { + key, err := credentials.DESKey() + if err != nil { + return Session{}, err + } + baseURL, err := BuildBaseURL(credentials.SessionID) + if err != nil { + return Session{}, err + } + loginCode, err := presence.LoginCode() + if err != nil { + return Session{}, err + } + pwd, err := DESEncrypt(credentials.Password, key) + if err != nil { + return Session{}, err + } + snPlain := verificationCode + if snPlain == "" { + snPlain, err = randomDigits(6) + if err != nil { + return Session{}, fmt.Errorf("generate login nonce: %w", err) + } + } + sn, err := DESEncrypt(snPlain, key) + if err != nil { + return Session{}, err + } + cid, err := randomString(25) + if err != nil { + return Session{}, fmt.Errorf("generate client id: %w", err) + } + form := url.Values{ + "t0": {loginCode}, + "name": {credentials.Username}, + "id": {credentials.SessionID}, + "canntick": {"0"}, + "pwd": {pwd}, + "sn": {sn}, + "cid": {cid}, + "l": {"cn"}, + "update": {"7.8.2016.1202"}, + "ma": {verificationCode}, + "devicetype": {"android"}, + "tapple": {""}, + } + body, _, err := c.postForm(ctx, baseURL, "oc/login78.aspx", form) + if err != nil { + return Session{}, &Error{Operation: "login", Code: "network_error", Retryable: true, Err: err} + } + result, err := ParseLoginResponse(body) + if err != nil { + code := "login_rejected" + retryable := false + if errors.Is(err, ErrVerificationNeeded) { + code = "verification_required" + } + if errors.Is(err, ErrLoginRedirectNeeded) { + code, retryable = "login_redirect", true + } + return Session{}, &Error{Operation: "login", Code: code, Retryable: retryable, Err: err} + } + return Session{ + BaseURL: baseURL, + SiteID: credentials.SessionID[3:], + LoginName: credentials.Username, + MAToken: result.MAToken(), + }, nil +} + +type HeartbeatStatus string + +const ( + HeartbeatOK HeartbeatStatus = "ok" + HeartbeatReset HeartbeatStatus = "tickint reset" + HeartbeatServerErr HeartbeatStatus = "server connect err" +) + +type HeartbeatResult struct { + Status HeartbeatStatus + Events []HeartbeatEvent +} + +func (c *Client) Heartbeat(ctx context.Context, session Session, cursor Cursor, currentSID, typingSID string) (HeartbeatResult, error) { + if err := session.Validate(); err != nil { + return HeartbeatResult{}, err + } + form := sessionAuthForm(session) + form.Set("id", session.SiteID) + form.Set("mid", strconv.FormatInt(cursor.MaxWordID, 10)) + form.Set("t", strconv.FormatInt(cursor.MaxOTick, 10)) + form.Set("p", strconv.FormatInt(cursor.MaxTmpID, 10)) + form.Set("o", session.LoginName) + form.Set("c", currentSID) + form.Set("i", typingSID) + body, response, err := c.postForm(ctx, session.BaseURL, "oc/CheckMobile.aspx", form) + if err != nil { + return HeartbeatResult{}, &Error{Operation: "heartbeat", Code: "network_error", Retryable: true, Err: err} + } + status := HeartbeatStatus(response.Header.Get("r")) + if status == "" && response.StatusCode == http.StatusOK { + status = HeartbeatOK + } + switch status { + case HeartbeatReset: + return HeartbeatResult{Status: status}, nil + case HeartbeatServerErr: + return HeartbeatResult{Status: status}, &Error{Operation: "heartbeat", Code: "server_connect_error", Retryable: true} + case HeartbeatOK: + events, parseErr := ParseHeartbeatBody(body) + if parseErr != nil { + return HeartbeatResult{}, &Error{Operation: "heartbeat", Code: "invalid_response", Retryable: false, Err: parseErr} + } + return HeartbeatResult{Status: status, Events: events}, nil + default: + return HeartbeatResult{}, &Error{Operation: "heartbeat", Code: "unknown_status", Retryable: true, Err: fmt.Errorf("r=%q", status)} + } +} + +type SendResult struct { + Status string + Body string +} + +func (c *Client) SendText(ctx context.Context, session Session, sid, text string) (SendResult, error) { + return c.sendHTML(ctx, session, sid, wrapHTML(text), "send_text") +} + +func (c *Client) sendHTML(ctx context.Context, session Session, sid, content, operation string) (SendResult, error) { + if err := session.Validate(); err != nil { + return SendResult{}, err + } + if strings.TrimSpace(sid) == "" { + return SendResult{}, errors.New("sid is required") + } + form := sessionAuthForm(session) + form.Set("sid", sid) + form.Set("html", content) + body, response, requestWritten, err := c.postFormTracked(ctx, session.BaseURL, "oc/send.aspx", form) + if err != nil { + if response != nil && (response.StatusCode < 200 || response.StatusCode >= 300) { + return SendResult{}, &Error{Operation: operation, Code: "http_error", Retryable: response.StatusCode >= 500, Err: err} + } + if requestWritten { + return SendResult{}, &Error{Operation: operation, Code: "network_result_uncertain", Uncertain: true, Err: err} + } + return SendResult{}, &Error{Operation: operation, Code: "network_error", Retryable: true, Err: err} + } + status := response.Header.Get("r") + result := SendResult{Status: status, Body: body} + if status == "ok" { + return result, nil + } + retryable := status == "server err" + return result, &Error{Operation: operation, Code: normalizeCode(status), Retryable: retryable} +} + +func (c *Client) SetPresence(ctx context.Context, session Session, presence Presence) error { + if err := session.Validate(); err != nil { + return err + } + endpoint := "" + switch presence { + case PresenceOnline: + endpoint = "oc/online.aspx" + case PresenceBusy: + endpoint = "oc/busy.aspx" + case PresenceAway: + endpoint = "oc/away.aspx" + case PresenceOffline: + endpoint = "oc/logout.aspx" + default: + return fmt.Errorf("unsupported presence %q", presence) + } + _, response, err := c.postForm(ctx, session.BaseURL, endpoint, sessionAuthForm(session)) + if err != nil { + return &Error{Operation: "set_presence", Code: "network_error", Retryable: true, Err: err} + } + if status := response.Header.Get("r"); status != "ok" { + return &Error{Operation: "set_presence", Code: normalizeCode(status), Retryable: status == "server err"} + } + return nil +} + +func sessionAuthForm(session Session) url.Values { + return url.Values{ + "oname": {session.LoginName}, + "siteid": {session.SiteID}, + "sn": {session.MAToken}, + } +} + +func wrapHTML(text string) string { + text = html.EscapeString(text) + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + return "

" + strings.ReplaceAll(text, "\n", "
") + "

" +} + +func (c *Client) postForm(ctx context.Context, baseURL, endpoint string, form url.Values) (string, *http.Response, error) { + body, response, _, err := c.postFormTracked(ctx, baseURL, endpoint, form) + return body, response, err +} + +func (c *Client) postFormTracked(ctx context.Context, baseURL, endpoint string, form url.Values) (string, *http.Response, bool, error) { + target, err := resolveEndpoint(baseURL, endpoint) + if err != nil { + return "", nil, false, err + } + var requestWritten atomic.Bool + trace := &httptrace.ClientTrace{WroteRequest: func(info httptrace.WroteRequestInfo) { + if info.Err == nil { + requestWritten.Store(true) + } + }} + request, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodPost, target, strings.NewReader(form.Encode())) + if err != nil { + return "", nil, false, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := c.httpClient.Do(request) + if err != nil { + return "", nil, requestWritten.Load(), err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", response, requestWritten.Load(), fmt.Errorf("HTTP %s", response.Status) + } + limited := io.LimitReader(response.Body, maxProtocolBodyBytes+1) + body, err := io.ReadAll(limited) + if err != nil { + return "", response, requestWritten.Load(), err + } + if len(body) > maxProtocolBodyBytes { + return "", response, requestWritten.Load(), errors.New("protocol response exceeds size limit") + } + return strings.TrimSpace(string(body)), response, requestWritten.Load(), nil +} + +func resolveEndpoint(baseURL, endpoint string) (string, error) { + base, err := url.Parse(baseURL) + if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" { + return "", errors.New("invalid base URL") + } + base.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(endpoint, "/") + base.RawQuery = "" + base.Fragment = "" + return base.String(), nil +} + +func randomDigits(length int) (string, error) { + const digits = "0123456789" + return randomFromAlphabet(length, digits) +} + +func randomString(length int) (string, error) { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + return randomFromAlphabet(length, alphabet) +} + +func randomFromAlphabet(length int, alphabet string) (string, error) { + if length <= 0 || len(alphabet) > 256 { + return "", errors.New("invalid random string parameters") + } + random := make([]byte, length) + if _, err := rand.Read(random); err != nil { + return "", err + } + for i := range random { + random[i] = alphabet[int(random[i])%len(alphabet)] + } + return string(random), nil +} + +func normalizeCode(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "missing_status" + } + return strings.ReplaceAll(value, " ", "_") +} diff --git a/channels/shangwutong/internal/swt/client_test.go b/channels/shangwutong/internal/swt/client_test.go new file mode 100644 index 00000000..765c8965 --- /dev/null +++ b/channels/shangwutong/internal/swt/client_test.go @@ -0,0 +1,134 @@ +package swt + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestClientLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/oc/login78.aspx" { + t.Fatalf("path = %q", request.URL.Path) + } + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + if request.Form.Get("pwd") == "" || request.Form.Get("cid") == "" || request.Form.Get("t0") != "3" { + t.Fatalf("invalid login form: %#v", request.Form) + } + _, _ = response.Write([]byte("r|ok\nma|token")) + })) + defer server.Close() + + client := NewClient(rewriteTransportClient(server.URL)) + session, err := client.Login(context.Background(), Credentials{SessionID: "BYT99917999", Username: "agent", Password: "test123"}, PresenceOnline, "") + if err != nil { + t.Fatal(err) + } + if session.MAToken != "token" || session.SiteID != "99917999" { + t.Fatalf("unexpected session: %#v", session) + } +} + +func TestClientHeartbeatParsesEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("r", "ok") + _, _ = response.Write([]byte("sid 2 hello 42 100\r\n")) + })) + defer server.Close() + client := NewClient(rewriteTransportClient(server.URL)) + result, err := client.Heartbeat(context.Background(), testSession(), NewCursor(), "", "") + if err != nil { + t.Fatal(err) + } + if len(result.Events) != 1 || result.Events[0].SeqID != 42 { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestClientSendTextEscapesHTML(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + if got := request.Form.Get("html"); got != "

<b>x</b>
next

" { + t.Fatalf("html = %q", got) + } + response.Header().Set("r", "ok") + })) + defer server.Close() + client := NewClient(rewriteTransportClient(server.URL)) + if _, err := client.SendText(context.Background(), testSession(), "sid", "x\nnext"); err != nil { + t.Fatal(err) + } +} + +func TestClientSendFailureBeforeRequestWriteIsRetryable(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection lost") + })}) + _, err := client.SendText(context.Background(), testSession(), "sid", "hello") + var protocolErr *Error + if !errors.As(err, &protocolErr) || protocolErr.Uncertain || !protocolErr.Retryable || protocolErr.Code != "network_error" { + t.Fatalf("error = %#v", err) + } +} + +func TestClientSendFailureAfterRequestWriteIsUncertain(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + connection, _, err := response.(http.Hijacker).Hijack() + if err != nil { + t.Fatal(err) + } + _ = connection.Close() + })) + defer server.Close() + client := NewClient(rewriteTransportClient(server.URL)) + _, err := client.SendText(context.Background(), testSession(), "sid", "hello") + var protocolErr *Error + if !errors.As(err, &protocolErr) || !protocolErr.Uncertain || protocolErr.Retryable || protocolErr.Code != "network_result_uncertain" { + t.Fatalf("error = %#v", err) + } +} + +func TestClientSendExplicitServerErrorIsRetryableNotUncertain(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + client := NewClient(rewriteTransportClient(server.URL)) + _, err := client.SendText(context.Background(), testSession(), "sid", "hello") + var protocolErr *Error + if !errors.As(err, &protocolErr) || protocolErr.Uncertain || !protocolErr.Retryable || protocolErr.Code != "http_error" { + t.Fatalf("error = %#v", err) + } +} + +func testSession() Session { + return Session{BaseURL: "http://byt.yiaitao.com.cn/", SiteID: "99917999", LoginName: "agent", MAToken: "token"} +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func rewriteTransportClient(target string) *http.Client { + targetURL, _ := url.Parse(target) + return &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + cloned := request.Clone(request.Context()) + cloned.URL.Scheme = targetURL.Scheme + cloned.URL.Host = targetURL.Host + if !strings.HasPrefix(cloned.URL.Path, "/") { + cloned.URL.Path = "/" + cloned.URL.Path + } + return http.DefaultTransport.RoundTrip(cloned) + })} +} diff --git a/channels/shangwutong/internal/swt/crypto.go b/channels/shangwutong/internal/swt/crypto.go new file mode 100644 index 00000000..eb056e1d --- /dev/null +++ b/channels/shangwutong/internal/swt/crypto.go @@ -0,0 +1,52 @@ +package swt + +import ( + "crypto/cipher" + "crypto/des" + "encoding/hex" + "errors" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +func DESEncrypt(message, key string) (string, error) { + keyBytes, err := encodeGB2312(key) + if err != nil { + return "", fmtEncodingError("DES key", err) + } + if len(keyBytes) != des.BlockSize { + return "", errors.New("DES key must encode to exactly 8 bytes") + } + plain, err := encodeGB2312(message) + if err != nil { + return "", fmtEncodingError("DES plaintext", err) + } + block, err := des.NewCipher(keyBytes) + if err != nil { + return "", err + } + padded := pkcs5Pad(plain, block.BlockSize()) + encrypted := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, keyBytes).CryptBlocks(encrypted, padded) + return hex.EncodeToString(encrypted), nil +} + +func encodeGB2312(value string) ([]byte, error) { + encoded, _, err := transform.String(simplifiedchinese.GBK.NewEncoder(), value) + return []byte(encoded), err +} + +func fmtEncodingError(field string, err error) error { + return errors.New(field + " is not representable in GB2312: " + err.Error()) +} + +func pkcs5Pad(data []byte, blockSize int) []byte { + padLen := blockSize - len(data)%blockSize + out := make([]byte, len(data)+padLen) + copy(out, data) + for i := len(data); i < len(out); i++ { + out[i] = byte(padLen) + } + return out +} diff --git a/channels/shangwutong/internal/swt/crypto_test.go b/channels/shangwutong/internal/swt/crypto_test.go new file mode 100644 index 00000000..e4db5787 --- /dev/null +++ b/channels/shangwutong/internal/swt/crypto_test.go @@ -0,0 +1,44 @@ +package swt + +import "testing" + +func TestDESEncryptFixedVector(t *testing.T) { + got, err := DESEncrypt("test123", "69557093") + if err != nil { + t.Fatal(err) + } + if want := "8b2d06811667f1d7"; got != want { + t.Fatalf("DESEncrypt() = %q, want %q", got, want) + } +} + +func TestDESEncryptUsesGB2312(t *testing.T) { + got, err := DESEncrypt("中文", "69557093") + if err != nil { + t.Fatal(err) + } + if got == "" { + t.Fatal("expected ciphertext") + } +} + +func TestDESEncryptRejectsWrongKeyLength(t *testing.T) { + if _, err := DESEncrypt("test", "short"); err == nil { + t.Fatal("expected key length error") + } +} + +func TestDESEncryptPKCS5PaddingBoundaries(t *testing.T) { + for plaintext, expectedHexLength := range map[string]int{"": 16, "1234567": 16, "12345678": 32} { + ciphertext, err := DESEncrypt(plaintext, "69557093") + if err != nil || len(ciphertext) != expectedHexLength { + t.Fatalf("plaintext %q ciphertext=%q err=%v", plaintext, ciphertext, err) + } + } +} + +func TestDESEncryptRejectsCharactersOutsideGB2312(t *testing.T) { + if _, err := DESEncrypt("🙂", "69557093"); err == nil { + t.Fatal("expected GB2312 encoding error") + } +} diff --git a/channels/shangwutong/internal/swt/heartbeat.go b/channels/shangwutong/internal/swt/heartbeat.go new file mode 100644 index 00000000..f4057054 --- /dev/null +++ b/channels/shangwutong/internal/swt/heartbeat.go @@ -0,0 +1,157 @@ +package swt + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" +) + +type HeartbeatEvent struct { + SessionID string + Kind int + OpName string + Text string + SeqID int64 + Timestamp string + RawLine string +} + +func (e HeartbeatEvent) MessageID() (string, bool) { + if (e.Kind != 2 && e.Kind != 3) || e.SeqID < 0 { + return "", false + } + return strconv.FormatInt(e.SeqID, 10), true +} + +func (e HeartbeatEvent) RetractionTargetID() (string, bool) { + if e.Kind != -4 && e.Kind != -5 { + return "", false + } + target := strings.TrimSpace(e.Text) + if target == "" { + return "", false + } + if _, err := strconv.ParseInt(target, 10, 64); err != nil { + return "", false + } + return target, true +} + +type Cursor struct { + MaxWordID int64 + MaxOTick int64 + MaxTmpID int64 +} + +func NewCursor() Cursor { return Cursor{MaxWordID: -1, MaxOTick: -1, MaxTmpID: -1} } + +func (c *Cursor) Apply(kind int, seqID int64) { + if seqID < 0 { + return + } + switch { + case kind == 11: + if seqID > c.MaxOTick { + c.MaxOTick = seqID + } + case kind >= 70: + if seqID > c.MaxTmpID { + c.MaxTmpID = seqID + } + default: + if seqID > c.MaxWordID { + c.MaxWordID = seqID + } + } +} + +func ParseHeartbeatLine(line string) (HeartbeatEvent, error) { + raw := strings.TrimSpace(line) + if raw == "" { + return HeartbeatEvent{}, errors.New("empty heartbeat line") + } + sessionToken, rest, ok := cutToken(raw) + if !ok { + return HeartbeatEvent{}, errors.New("heartbeat line missing session_id") + } + kindToken, rest, ok := cutToken(rest) + if !ok { + return HeartbeatEvent{}, errors.New("heartbeat line missing kind") + } + timestampToken, rest, ok := cutLastToken(rest) + if !ok { + return HeartbeatEvent{}, errors.New("heartbeat line missing timestamp") + } + seqToken, textToken, ok := cutLastToken(rest) + if !ok { + return HeartbeatEvent{}, errors.New("heartbeat line missing sequence id") + } + kind, err := strconv.Atoi(kindToken) + if err != nil { + return HeartbeatEvent{}, fmt.Errorf("parse heartbeat kind: %w", err) + } + seqID, err := strconv.ParseInt(seqToken, 10, 64) + if err != nil { + return HeartbeatEvent{}, fmt.Errorf("parse heartbeat sequence id: %w", err) + } + sessionID := queryUnescape(sessionToken) + text := queryUnescape(textToken) + opName := "" + if idx := strings.IndexByte(text, '|'); idx >= 0 { + opName, text = text[:idx], text[idx+1:] + } + return HeartbeatEvent{ + SessionID: sessionID, + Kind: kind, + OpName: opName, + Text: text, + SeqID: seqID, + Timestamp: timestampToken, + RawLine: raw, + }, nil +} + +func ParseHeartbeatBody(body string) ([]HeartbeatEvent, error) { + body = strings.ReplaceAll(body, "\r\n", "\n") + lines := strings.Split(body, "\n") + events := make([]HeartbeatEvent, 0, len(lines)) + for index, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + event, err := ParseHeartbeatLine(line) + if err != nil { + return nil, fmt.Errorf("parse heartbeat line %d: %w", index+1, err) + } + events = append(events, event) + } + return events, nil +} + +func cutToken(value string) (string, string, bool) { + value = strings.TrimLeft(value, " \t") + idx := strings.IndexAny(value, " \t") + if idx < 0 { + return "", "", false + } + return value[:idx], value[idx+1:], true +} + +func cutLastToken(value string) (string, string, bool) { + value = strings.TrimRight(value, " \t") + idx := strings.LastIndexAny(value, " \t") + if idx < 0 { + return "", "", false + } + return value[idx+1:], strings.TrimRight(value[:idx], " \t"), true +} + +func queryUnescape(value string) string { + decoded, err := url.QueryUnescape(value) + if err != nil { + return value + } + return decoded +} diff --git a/channels/shangwutong/internal/swt/heartbeat_test.go b/channels/shangwutong/internal/swt/heartbeat_test.go new file mode 100644 index 00000000..b4be45c2 --- /dev/null +++ b/channels/shangwutong/internal/swt/heartbeat_test.go @@ -0,0 +1,80 @@ +package swt + +import "testing" + +func TestParseHeartbeatLineAndMessageID(t *testing.T) { + line := "13934e1d6ecf4507735968e1d9cea7f5byt99917999 2 %E4%BD%A0%E5%A5%BD+world 4360377 639183484614616556" + event, err := ParseHeartbeatLine(line) + if err != nil { + t.Fatal(err) + } + if event.Text != "你好 world" || event.Kind != 2 || event.SeqID != 4360377 { + t.Fatalf("unexpected event: %#v", event) + } + if messageID, ok := event.MessageID(); !ok || messageID != "4360377" { + t.Fatalf("message id = %q, %v", messageID, ok) + } +} + +func TestParseHeartbeatLinePreservesTextSpaces(t *testing.T) { + event, err := ParseHeartbeatLine("sid 3 operator|

hello world

4360380 639183484622475272") + if err != nil { + t.Fatal(err) + } + if event.OpName != "operator" || event.Text != "

hello world

" { + t.Fatalf("unexpected event: %#v", event) + } +} + +func TestRetractionTargetIsTextNotEventSequence(t *testing.T) { + event, err := ParseHeartbeatLine("sid -4 4360377 4360400 639183484622475272") + if err != nil { + t.Fatal(err) + } + if target, ok := event.RetractionTargetID(); !ok || target != "4360377" { + t.Fatalf("target = %q, %v", target, ok) + } + if _, ok := event.MessageID(); ok { + t.Fatal("retraction event must not expose its sequence as a message id") + } +} + +func TestParseHeartbeatBodyFailsWholeBatchOnMalformedLine(t *testing.T) { + _, err := ParseHeartbeatBody("sid 2 hello 1 2\r\ninvalid") + if err == nil { + t.Fatal("expected malformed batch error") + } +} + +func TestParseHeartbeatBodyAcceptsEmptyAndNegativeKindEvents(t *testing.T) { + events, err := ParseHeartbeatBody("\r\n") + if err != nil || len(events) != 0 { + t.Fatalf("empty events=%#v err=%v", events, err) + } + events, err = ParseHeartbeatBody("sid -5 42 43 639183484622475272\r\n") + if err != nil || len(events) != 1 || events[0].Kind != -5 || events[0].SeqID != 43 || events[0].Text != "42" { + t.Fatalf("negative events=%#v err=%v", events, err) + } +} + +func TestCursorApply(t *testing.T) { + cursor := NewCursor() + cursor.Apply(2, 10) + cursor.Apply(11, 20) + cursor.Apply(70, 30) + cursor.Apply(2, 9) + if cursor.MaxWordID != 10 || cursor.MaxOTick != 20 || cursor.MaxTmpID != 30 { + t.Fatalf("unexpected cursor: %#v", cursor) + } +} + +func TestCursorApplyIgnoresNegativeSequenceAndUsesCurrent65To67Baseline(t *testing.T) { + cursor := NewCursor() + cursor.Apply(-5, -1) + cursor.Apply(65, 65) + cursor.Apply(66, 66) + cursor.Apply(67, 67) + if cursor.MaxWordID != 67 || cursor.MaxOTick != -1 || cursor.MaxTmpID != -1 { + t.Fatalf("cursor = %#v", cursor) + } +} diff --git a/channels/shangwutong/internal/swt/login.go b/channels/shangwutong/internal/swt/login.go new file mode 100644 index 00000000..92279e42 --- /dev/null +++ b/channels/shangwutong/internal/swt/login.go @@ -0,0 +1,61 @@ +package swt + +import ( + "errors" + "net/url" + "strings" +) + +var ( + ErrLoginRejected = errors.New("login rejected") + ErrVerificationNeeded = errors.New("verification required") + ErrLoginRedirectNeeded = errors.New("login endpoint redirect required") +) + +type LoginResult struct { + Values map[string]string +} + +func (r LoginResult) MAToken() string { return r.Values["ma"] } + +func ParseLoginResponse(body string) (LoginResult, error) { + body = strings.TrimSpace(strings.ReplaceAll(body, "\r\n", "\n")) + if body == "" { + return LoginResult{}, errors.New("empty login response") + } + if strings.HasPrefix(strings.ToLower(body), "accept") { + return LoginResult{}, ErrLoginRedirectNeeded + } + + result := LoginResult{Values: make(map[string]string)} + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if decoded, err := url.QueryUnescape(value); err == nil { + value = decoded + } + result.Values[key] = value + } + + for _, code := range []string{"vcode", "vcode1", "vcode2", "ecsq"} { + if _, ok := result.Values[code]; ok || strings.HasPrefix(body, code+"|") || strings.Contains(body, "\n"+code+"|") { + result.Values["verification_code"] = code + return result, ErrVerificationNeeded + } + } + if result.Values["r"] != "ok" { + return result, ErrLoginRejected + } + if result.MAToken() == "" { + return result, errors.New("login response missing ma token") + } + return result, nil +} diff --git a/channels/shangwutong/internal/swt/login_test.go b/channels/shangwutong/internal/swt/login_test.go new file mode 100644 index 00000000..35ee9981 --- /dev/null +++ b/channels/shangwutong/internal/swt/login_test.go @@ -0,0 +1,48 @@ +package swt + +import ( + "errors" + "testing" +) + +func TestParseLoginResponse(t *testing.T) { + result, err := ParseLoginResponse("r|ok\r\nma|abc123\r\nname|%E5%BC%A0%E4%B8%BD") + if err != nil { + t.Fatal(err) + } + if result.MAToken() != "abc123" || result.Values["name"] != "张丽" { + t.Fatalf("unexpected result: %#v", result.Values) + } +} + +func TestParseLoginResponseClassifiesFailures(t *testing.T) { + tests := []struct { + body string + want error + }{ + {"r|pwd err", ErrLoginRejected}, + {"vcode|image", ErrVerificationNeeded}, + {"vcode1|image", ErrVerificationNeeded}, + {"vcode2|image", ErrVerificationNeeded}, + {"ecsq|qr", ErrVerificationNeeded}, + {"Accept http://other", ErrLoginRedirectNeeded}, + } + for _, test := range tests { + _, err := ParseLoginResponse(test.body) + if !errors.Is(err, test.want) { + t.Fatalf("ParseLoginResponse(%q) error = %v, want %v", test.body, err, test.want) + } + } +} + +func TestParseLoginResponseRejectsEmptyBody(t *testing.T) { + if _, err := ParseLoginResponse("\r\n "); err == nil { + t.Fatal("expected empty response error") + } +} + +func TestParseLoginResponseRequiresMAToken(t *testing.T) { + if _, err := ParseLoginResponse("r|ok"); err == nil { + t.Fatal("expected missing ma token error") + } +} diff --git a/channels/shangwutong/internal/swt/operations.go b/channels/shangwutong/internal/swt/operations.go new file mode 100644 index 00000000..9d96c84c --- /dev/null +++ b/channels/shangwutong/internal/swt/operations.go @@ -0,0 +1,234 @@ +package swt + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptrace" + "net/url" + "path" + "strconv" + "strings" + "sync/atomic" + "time" +) + +const voiceUploadURL = "https://lgnvoicefile.zoosnet.net/API/UploadAPI.ashx" + +type Upload struct { + Name string + ContentType string + Size int64 + Reader io.Reader +} + +func (u Upload) Validate() error { + if strings.TrimSpace(u.Name) == "" || u.Reader == nil { + return errors.New("upload name and reader are required") + } + if u.ContentType == "" { + u.ContentType = "application/octet-stream" + } + return nil +} + +func (c *Client) SendImage(ctx context.Context, session Session, sid string, upload Upload) (SendResult, error) { + if err := validateSessionUpload(session, sid, upload); err != nil { + return SendResult{}, err + } + fields := sessionAuthFields(session) + fields["sessionid"] = sid + fields["html"] = `

` + return c.sendMultipart(ctx, session.BaseURL, "oc/sendmorepics.aspx", nil, fields, "f0", "f0.jpg", upload, "send_image") +} + +func (c *Client) SendFile(ctx context.Context, session Session, sid string, upload Upload) (SendResult, error) { + if err := validateSessionUpload(session, sid, upload); err != nil { + return SendResult{}, err + } + query := url.Values{ + "uploadId": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, + "oname": {session.LoginName}, + "siteid": {session.SiteID}, + "sid": {sid}, + } + return c.sendMultipart(ctx, session.BaseURL, "oc/sendfile.aspx", query, map[string]string{"sn": session.MAToken}, "File1", upload.Name, upload, "send_file") +} + +func (c *Client) SendVoice(ctx context.Context, session Session, sid string, upload Upload) (SendResult, error) { + if err := validateSessionUpload(session, sid, upload); err != nil { + return SendResult{}, err + } + fields := map[string]string{ + "Act": "UploadFile", "ah": "1", "aB": session.SiteID, "sid": sid, "cid": sid, + "o": session.LoginName, "time": strconv.FormatInt(time.Now().UnixMilli(), 10), + "size": strconv.FormatInt(upload.Size, 10), "filetype": strings.TrimPrefix(strings.ToLower(path.Ext(upload.Name)), "."), + "Username": "myUser", "Password": "myPassword", "b": "8", + } + result, err := c.sendMultipartURL(ctx, voiceUploadURL, fields, "file", upload.Name, upload, "upload_voice") + if err != nil { + return result, err + } + var response struct { + FilePath string `json:"filepath"` + } + if err := json.Unmarshal([]byte(result.Body), &response); err != nil || response.FilePath == "" { + return result, &Error{Operation: "upload_voice", Code: "invalid_response", Err: errors.New("voice upload response missing filepath")} + } + voiceURL := "https://lgnvoicefile.zoosnet.net/" + strings.TrimLeft(response.FilePath, "/") + return c.sendHTML(ctx, session, sid, "voice_msg|1|"+voiceURL, "send_voice") +} + +func (c *Client) SetTyping(ctx context.Context, session Session, cursor Cursor, sid string, typing bool) (HeartbeatResult, error) { + if !typing { + return c.Heartbeat(ctx, session, cursor, "", "") + } + if strings.TrimSpace(sid) == "" { + return HeartbeatResult{}, errors.New("sid is required") + } + return c.Heartbeat(ctx, session, cursor, sid, sid) +} + +func (c *Client) EndConversation(ctx context.Context, session Session, sid string) error { + return c.sessionOperation(ctx, session, "oc/end.aspx", map[string]string{"sid": sid}, "end_conversation") +} + +func (c *Client) AcceptTransfer(ctx context.Context, session Session, sid string) error { + return c.sessionOperation(ctx, session, "oc/accepttransfer.aspx", map[string]string{"sid": sid}, "accept_transfer") +} + +func (c *Client) InviteVisitor(ctx context.Context, session Session, sid, words string) error { + if words == "" { + words = wrapHTML("您好,请问有什么可以帮您?") + } else if !strings.HasPrefix(words, "<") && !strings.HasPrefix(words, "openchatwin|") { + words = "openchatwin|" + wrapHTML(words) + } + return c.sessionOperation(ctx, session, "oc/invite.aspx", map[string]string{"sid": sid, "word": words}, "invite_visitor") +} + +func (c *Client) AcceptWaitingVisitor(ctx context.Context, session Session, sid, words string) error { + fields := map[string]string{"sid": sid} + if words != "" { + fields["words"] = words + } + return c.sessionOperation(ctx, session, "oc/accept.aspx", fields, "accept_waiting_visitor") +} + +func (c *Client) RefuseWaitingVisitor(ctx context.Context, session Session, sid string) error { + return c.sessionOperation(ctx, session, "oc/refuse.aspx", map[string]string{"sid": sid}, "refuse_waiting_visitor") +} + +func (c *Client) Logout(ctx context.Context, session Session) error { + return c.SetPresence(ctx, session, PresenceOffline) +} + +func (c *Client) sessionOperation(ctx context.Context, session Session, endpoint string, fields map[string]string, operation string) error { + if err := session.Validate(); err != nil { + return err + } + form := sessionAuthForm(session) + for key, value := range fields { + if strings.TrimSpace(value) == "" && key == "sid" { + return errors.New("sid is required") + } + form.Set(key, value) + } + _, response, requestWritten, err := c.postFormTracked(ctx, session.BaseURL, endpoint, form) + if err != nil { + if response != nil && (response.StatusCode < 200 || response.StatusCode >= 300) { + return &Error{Operation: operation, Code: "http_error", Retryable: response.StatusCode >= 500, Err: err} + } + if requestWritten { + return &Error{Operation: operation, Code: "network_result_uncertain", Uncertain: true, Err: err} + } + return &Error{Operation: operation, Code: "network_error", Retryable: true, Err: err} + } + if status := response.Header.Get("r"); status != "ok" { + return &Error{Operation: operation, Code: normalizeCode(status), Retryable: status == "server err"} + } + return nil +} + +func (c *Client) sendMultipart(ctx context.Context, baseURL, endpoint string, query url.Values, fields map[string]string, fileField, filename string, upload Upload, operation string) (SendResult, error) { + target, err := resolveEndpoint(baseURL, endpoint) + if err != nil { + return SendResult{}, err + } + if len(query) > 0 { + target += "?" + query.Encode() + } + return c.sendMultipartURL(ctx, target, fields, fileField, filename, upload, operation) +} + +func (c *Client) sendMultipartURL(ctx context.Context, target string, fields map[string]string, fileField, filename string, upload Upload, operation string) (SendResult, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return SendResult{}, err + } + } + part, err := writer.CreateFormFile(fileField, filename) + if err != nil { + return SendResult{}, err + } + if _, err := io.Copy(part, upload.Reader); err != nil { + return SendResult{}, err + } + if err := writer.Close(); err != nil { + return SendResult{}, err + } + var requestWritten atomic.Bool + trace := &httptrace.ClientTrace{WroteRequest: func(info httptrace.WroteRequestInfo) { + if info.Err == nil { + requestWritten.Store(true) + } + }} + request, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodPost, target, &body) + if err != nil { + return SendResult{}, err + } + request.Header.Set("Content-Type", writer.FormDataContentType()) + response, err := c.httpClient.Do(request) + if err != nil { + if requestWritten.Load() { + return SendResult{}, &Error{Operation: operation, Code: "network_result_uncertain", Uncertain: true, Err: err} + } + return SendResult{}, &Error{Operation: operation, Code: "network_error", Retryable: true, Err: err} + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, maxProtocolBodyBytes+1)) + if err != nil { + return SendResult{}, &Error{Operation: operation, Code: "network_result_uncertain", Uncertain: true, Err: err} + } + if len(payload) > maxProtocolBodyBytes { + return SendResult{}, &Error{Operation: operation, Code: "response_too_large", Err: errors.New("protocol response exceeds size limit")} + } + result := SendResult{Status: response.Header.Get("r"), Body: strings.TrimSpace(string(payload))} + if response.StatusCode < 200 || response.StatusCode >= 300 { + return result, &Error{Operation: operation, Code: "http_error", Retryable: response.StatusCode >= 500, Err: fmt.Errorf("HTTP %s", response.Status)} + } + if operation == "upload_voice" || result.Status == "ok" { + return result, nil + } + return result, &Error{Operation: operation, Code: normalizeCode(result.Status), Retryable: result.Status == "server err"} +} + +func sessionAuthFields(session Session) map[string]string { + return map[string]string{"oname": session.LoginName, "siteid": session.SiteID, "sn": session.MAToken} +} + +func validateSessionUpload(session Session, sid string, upload Upload) error { + if err := session.Validate(); err != nil { + return err + } + if strings.TrimSpace(sid) == "" { + return errors.New("sid is required") + } + return upload.Validate() +} diff --git a/channels/shangwutong/internal/swt/operations_test.go b/channels/shangwutong/internal/swt/operations_test.go new file mode 100644 index 00000000..8ed17d79 --- /dev/null +++ b/channels/shangwutong/internal/swt/operations_test.go @@ -0,0 +1,101 @@ +package swt + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestSendImageUsesProtocolMultipartFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/oc/sendmorepics.aspx" { + t.Fatalf("path = %q", request.URL.Path) + } + if err := request.ParseMultipartForm(1 << 20); err != nil { + t.Fatal(err) + } + if request.FormValue("sessionid") != "visitor" || !strings.Contains(request.FormValue("html"), "{%f0%}") { + t.Fatalf("form = %#v", request.MultipartForm.Value) + } + file, _, err := request.FormFile("f0") + if err != nil { + t.Fatal(err) + } + defer file.Close() + payload, _ := io.ReadAll(file) + if string(payload) != "image" { + t.Fatalf("file = %q", payload) + } + response.Header().Set("r", "ok") + })) + defer server.Close() + client := NewClient(rewriteTransportClient(server.URL)) + _, err := client.SendImage(context.Background(), testSession(), "visitor", Upload{ + Name: "photo.jpg", Reader: bytes.NewBufferString("image"), Size: 5, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSendVoiceUploadsThenSendsProtocolMessage(t *testing.T) { + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch requests.Add(1) { + case 1: + if request.URL.Path != "/API/UploadAPI.ashx" { + t.Fatalf("upload path = %q", request.URL.Path) + } + _, _ = response.Write([]byte(`{"filepath":"/voice/test.mp3"}`)) + case 2: + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + if request.URL.Path != "/oc/send.aspx" || request.Form.Get("html") != "voice_msg|1|https://lgnvoicefile.zoosnet.net/voice/test.mp3" { + t.Fatalf("send request = %s %#v", request.URL.Path, request.Form) + } + response.Header().Set("r", "ok") + default: + t.Fatal("unexpected request") + } + })) + defer server.Close() + client := NewClient(rewriteTransportClient(server.URL)) + _, err := client.SendVoice(context.Background(), testSession(), "visitor", Upload{ + Name: "voice.mp3", Reader: bytes.NewBufferString("audio"), Size: 5, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSessionOperationsUseDocumentedEndpoints(t *testing.T) { + want := []string{"/oc/end.aspx", "/oc/accepttransfer.aspx", "/oc/invite.aspx", "/oc/accept.aspx", "/oc/refuse.aspx"} + index := 0 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if index >= len(want) || request.URL.Path != want[index] { + t.Fatalf("request %d path = %q", index, request.URL.Path) + } + index++ + response.Header().Set("r", "ok") + })) + defer server.Close() + client := NewClient(rewriteTransportClient(server.URL)) + operations := []func() error{ + func() error { return client.EndConversation(context.Background(), testSession(), "sid") }, + func() error { return client.AcceptTransfer(context.Background(), testSession(), "sid") }, + func() error { return client.InviteVisitor(context.Background(), testSession(), "sid", "hello") }, + func() error { return client.AcceptWaitingVisitor(context.Background(), testSession(), "sid", "") }, + func() error { return client.RefuseWaitingVisitor(context.Background(), testSession(), "sid") }, + } + for _, operation := range operations { + if err := operation(); err != nil { + t.Fatal(err) + } + } +} diff --git a/channels/shangwutong/internal/swt/types.go b/channels/shangwutong/internal/swt/types.go new file mode 100644 index 00000000..41a98271 --- /dev/null +++ b/channels/shangwutong/internal/swt/types.go @@ -0,0 +1,93 @@ +package swt + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" +) + +var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{11}$`) + +type Presence string + +const ( + PresenceOffline Presence = "offline" + PresenceAway Presence = "away" + PresenceBusy Presence = "busy" + PresenceOnline Presence = "online" +) + +func (p Presence) LoginCode() (string, error) { + switch p { + case PresenceOffline: + return "0", nil + case PresenceAway: + return "1", nil + case PresenceBusy: + return "2", nil + case PresenceOnline: + return "3", nil + default: + return "", fmt.Errorf("unsupported presence %q", p) + } +} + +type Credentials struct { + SessionID string + Username string + Password string +} + +func (c Credentials) Validate() error { + if !sessionIDPattern.MatchString(c.SessionID) { + return errors.New("session_id must contain exactly 11 ASCII letters or digits") + } + if strings.TrimSpace(c.Username) == "" { + return errors.New("username is required") + } + if c.Password == "" { + return errors.New("password is required") + } + return nil +} + +func (c Credentials) DESKey() (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + return c.SessionID[3:], nil +} + +func BuildBaseURL(sessionID string) (string, error) { + if !sessionIDPattern.MatchString(sessionID) { + return "", errors.New("session_id must contain exactly 11 ASCII letters or digits") + } + if strings.EqualFold(sessionID, "LZA69557093") { + return "http://testserver.zoosnet.net/", nil + } + prefix := strings.ToLower(sessionID[:3]) + if prefix == "lzs" { + return "http://lzs.yiaitao.com.cn/lrserver/", nil + } + base := "http://" + prefix + ".yiaitao.com.cn/" + if _, err := url.ParseRequestURI(base); err != nil { + return "", fmt.Errorf("build base URL: %w", err) + } + return base, nil +} + +type Session struct { + BaseURL string + SiteID string + LoginName string + MAToken string +} + +func (s Session) Validate() error { + if strings.TrimSpace(s.BaseURL) == "" || strings.TrimSpace(s.SiteID) == "" || strings.TrimSpace(s.LoginName) == "" || s.MAToken == "" { + return errors.New("session is incomplete") + } + return nil +} diff --git a/channels/shangwutong/internal/swt/types_test.go b/channels/shangwutong/internal/swt/types_test.go new file mode 100644 index 00000000..03a12f70 --- /dev/null +++ b/channels/shangwutong/internal/swt/types_test.go @@ -0,0 +1,38 @@ +package swt + +import "testing" + +func TestBuildBaseURL(t *testing.T) { + tests := map[string]string{ + "BYT99917999": "http://byt.yiaitao.com.cn/", + "LZA69557093": "http://testserver.zoosnet.net/", + "Lzs12345678": "http://lzs.yiaitao.com.cn/lrserver/", + "DET33849584": "http://det.yiaitao.com.cn/", + } + for sessionID, want := range tests { + got, err := BuildBaseURL(sessionID) + if err != nil { + t.Fatalf("BuildBaseURL(%q): %v", sessionID, err) + } + if got != want { + t.Fatalf("BuildBaseURL(%q) = %q, want %q", sessionID, got, want) + } + } +} + +func TestBuildBaseURLRejectsMalformedSessionID(t *testing.T) { + for _, value := range []string{"", "ABC123", "ABC123456789", "ABC1234/678"} { + if _, err := BuildBaseURL(value); err == nil { + t.Fatalf("BuildBaseURL(%q) should fail", value) + } + } +} + +func TestPresenceLoginCode(t *testing.T) { + if got, err := PresenceBusy.LoginCode(); err != nil || got != "2" { + t.Fatalf("busy login code = %q, %v", got, err) + } + if _, err := Presence("invalid").LoginCode(); err == nil { + t.Fatal("invalid presence should fail") + } +} diff --git a/channels/shangwutong/sqlc.yaml b/channels/shangwutong/sqlc.yaml new file mode 100644 index 00000000..b02585b9 --- /dev/null +++ b/channels/shangwutong/sqlc.yaml @@ -0,0 +1,15 @@ +version: "2" +sql: + - engine: "sqlite" + schema: + - "db/migrations/001_init.up.sql" + queries: "db/queries" + gen: + go: + package: "dbgen" + out: "db/generated" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_result_struct_pointers: true + emit_pointers_for_null_types: true diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index 6d513eb5..c511a6e3 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -98,6 +98,40 @@ services: memory: 512M cpus: '1.0' + shangwutong: + image: gochat/shangwutong:${SHANGWUTONG_VERSION:-latest} + container_name: gochat-shangwutong + restart: always + stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s} + environment: + SWT_CONNECTOR_LISTEN: :9100 + SWT_CONNECTOR_DB_PATH: /data/connector.db + GOCHAT_BASE_URL: http://gochat:3000 + GOCHAT_CONNECTOR_SERVICE_TOKEN: ${GOCHAT_CONNECTOR_SERVICE_TOKEN:-} + SWT_MAX_INFLIGHT_HEARTBEATS: ${SWT_MAX_INFLIGHT_HEARTBEATS:-64} + SWT_INBOUND_WORKERS: ${SWT_INBOUND_WORKERS:-8} + SWT_OUTBOUND_WORKERS: ${SWT_OUTBOUND_WORKERS:-8} + SWT_SHUTDOWN_TIMEOUT: ${SWT_SHUTDOWN_TIMEOUT:-30s} + volumes: + - shangwutong_data:/data + - shangwutong_backups:/backup + healthcheck: + test: ["CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:9100/readyz"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 128M + cpus: '0.25' + volumes: postgres_data: redis_data: + shangwutong_data: + shangwutong_backups: diff --git a/deploy/quickstart/.env.example b/deploy/quickstart/.env.example index f469365a..509daa3a 100644 --- a/deploy/quickstart/.env.example +++ b/deploy/quickstart/.env.example @@ -32,3 +32,10 @@ GOCHAT_SEED_ADMIN_PASSWORD=changeme GOCHAT_SEED_ADMIN_NAME=Super Admin GOCHAT_SEED_ACCOUNT_NAME=Quickstart Account GOCHAT_SEED_INBOX_NAME=Quickstart Website Inbox + +# Required only when starting: docker compose --profile shangwutong up +GOCHAT_CONNECTOR_SERVICE_TOKEN= +SWT_MAX_INFLIGHT_HEARTBEATS=64 +SWT_INBOUND_WORKERS=8 +SWT_OUTBOUND_WORKERS=8 +SWT_SHUTDOWN_TIMEOUT=30s diff --git a/deploy/quickstart/compose.yaml b/deploy/quickstart/compose.yaml index 7ba916cf..0368f8bb 100644 --- a/deploy/quickstart/compose.yaml +++ b/deploy/quickstart/compose.yaml @@ -125,8 +125,35 @@ services: command: ["seed"] restart: "no" + shangwutong: + profiles: ["shangwutong"] + build: + context: ../.. + dockerfile: channels/shangwutong/Dockerfile + restart: unless-stopped + stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s} + environment: + SWT_CONNECTOR_LISTEN: :9100 + SWT_CONNECTOR_DB_PATH: /data/connector.db + GOCHAT_BASE_URL: http://gochat:3000 + GOCHAT_CONNECTOR_SERVICE_TOKEN: ${GOCHAT_CONNECTOR_SERVICE_TOKEN:-} + SWT_MAX_INFLIGHT_HEARTBEATS: ${SWT_MAX_INFLIGHT_HEARTBEATS:-64} + SWT_INBOUND_WORKERS: ${SWT_INBOUND_WORKERS:-8} + SWT_OUTBOUND_WORKERS: ${SWT_OUTBOUND_WORKERS:-8} + SWT_SHUTDOWN_TIMEOUT: ${SWT_SHUTDOWN_TIMEOUT:-30s} + volumes: + - shangwutong-data:/data + - shangwutong-backups:/backup + healthcheck: + test: ["CMD", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:9100/readyz"] + interval: 10s + timeout: 5s + retries: 30 + volumes: postgres-data: redis-data: meili-data: gochat-storage: + shangwutong-data: + shangwutong-backups: diff --git a/docs/plans/2026-07-31-shangwutong-connector-development-plan.md b/docs/plans/2026-07-31-shangwutong-connector-development-plan.md new file mode 100644 index 00000000..3816075e --- /dev/null +++ b/docs/plans/2026-07-31-shangwutong-connector-development-plan.md @@ -0,0 +1,2348 @@ +# 商务通多账号 Connector 生产级开发计划 + +> 日期:2026-07-31 +> 状态:功能实现与自动化验证已完成,待真实账号灰度验证 +> 实现目录:`channels/shangwutong/` +> 协议依据:`reference/shang-wu-tong/src/` 与 `reference/shang-wu-tong/docs/` +> GoChat 接入方式:独立 Connector + 商务通 Inbox(复用并扩展 API Inbox 能力) + +--- + +## 1. 目标与已确认决策 + +### 1.1 目标 + +在 `channels/shangwutong/` 中实现独立 Go 服务,脱离商务通 Android 客户端完成: + +- 500 个以上独立站点账号的登录、会话恢复、心跳轮询和自动重登。 +- 商务通访客消息可靠进入 GoChat 商务通 Inbox。 +- GoChat 坐席回复可靠发送到对应商务通会话。 +- 在线账号在 `online`、`busy`、`away`、`offline` 之间切换。 +- 商务通登录字段只在 GoChat Inbox 中录入,Connector 自动拉取、持久化、在线更新并保留最后一次可用凭据。 +- GoChat 升级期间保持商务通账号心跳,并在 GoChat 恢复后补投消息。 +- Connector 重启后从持久化 cursor 恢复,不因进程升级主动调用商务通 logout。 +- 对账号、消息、cursor 和投递队列提供可查询状态、指标和错误诊断。 +- 对参考资料中全部已知 kind、kind=0 状态、kind=31 子类型和 kind=2/3 内容格式给出可执行映射;无法无损映射的类型必须有明确降级、扩展方案和验证状态。 + +### 1.2 已确认的架构决策 + +| 决策项 | 选择 | +|---|---| +| 商务通账号关系 | 独立站点账号 | +| 账号与 GoChat 映射 | 一个商务通账号对应一个商务通 Inbox | +| Connector 进程模型 | 一个 GoChat 部署对应一个 Connector 服务,统一管理全部商务通 Inbox | +| 账号并发模型 | 每账号一个长期 supervisor goroutine | +| 通用 worker pool 用途 | 只处理无状态的入站/出站投递,不持有账号登录态 | +| Connector 存储 | 一个 SQLite 数据库文件,按 `account_id` 逻辑隔离 | +| SQLite 表模型 | 统一表,不为每个账号动态创建表 | +| 配置唯一来源 | GoChat 商务通 Inbox;不建设独立账号管理后台 | +| Connector 账号创建 | 按 `gochat_inbox_id` 自动幂等 upsert,不接受人工创建账号 | +| GoChat 接入 | 新增 `shangwutong` Inbox 类型,复用 API Inbox 的 contact/conversation/message/webhook 能力 | +| 配置同步 | 生命周期 webhook 只作失效通知;Connector 同步拉取并持久化配置后才 ACK | +| 稳定标识 | 跨系统幂等键只使用 `gochat_inbox_id`,SQLite `account_id` 只作本地外键 | +| 在线状态 | GoChat 保存 `desired_presence`;心跳 `r` 决定连接状态,成功的 login/status/logout 或明确 kind=11 决定 `actual_presence` | +| 多客服协作 | 复用 AccountUser、InboxMember 和 Conversation.AssigneeID;全部回复共享该 Inbox 的商务通身份 | +| 出站状态 | Connector 入库后 GoChat message 保持 progress(发送中),商务通实际结果通过状态回写更新 sent/failed | +| 第一版 UI | 只扩展现有 Inbox 创建/设置页,不新增独立商务通账号后台 | +| GoChat 升级 | Connector 独立运行并缓存待投消息 | +| Connector 升级 | 接受全部账号短时恢复/重连,不实现首版多实例接管 | + +### 1.3 首版明确不做 + +- 不实现一个账号一个进程、容器或 SQLite 文件。 +- 不为每个账号动态建表;Connector 使用统一 `accounts` 表和统一 cursor 字段。 +- 不以 JSON session 文件作为生产状态源。 +- 不实现 Connector 多实例租约、主从接管或零感知 Connector 升级。 +- 不支持多个 Connector 同时管理同一个 GoChat 部署;需要分片时再引入 `connector_id`。 +- 不建设 Connector 账号 CRUD API、账号管理 UI 或第二套配置真相源。 +- 不为内部配置链路增加 AES、envelope、AAD、fingerprint 或额外 credential key;账号身份和变更判断只使用 Inbox ID 与 `config_version`。 +- 不新增 GoChat/Connector 周期性 PING 协议;配置正确性由生命周期 webhook、启动全量 reconcile 和持久重试保证。 +- 不把参考 go-bridge 原样复制为生产代码。 + +后续只有在 Connector 多实例高可用成为真实需求时才增加分片租约;账号配置和状态仍留在现有 Inbox 页面,不再引入独立后台。 + +--- + +## 2. 现状与实施前缺口 + +### 2.1 可复用协议成果 + +以下参考实现已经给出协议事实和可移植逻辑: + +- `reference/shang-wu-tong/src/login.py` + - `session_id` 到 BASE URL 的映射。 + - DES/CBC/PKCS5Padding,key 与 IV 均为 `session_id[3:]`。 + - 登录端点 `oc/login78.aspx` 和登录响应解析。 +- `reference/shang-wu-tong/src/heartbeat.py` + - `oc/CheckMobile.aspx` 每 2.5 秒轮询。 + - `mid/t/p` 三类 cursor 的更新规则。 + - `kind` 事件解析和 `tickint reset`、`server connect err` 分类。 +- `reference/shang-wu-tong/src/chat_ops.py` + - 文本、图片、文件、语音发送。 + - 输入状态、结束会话、接收/拒绝访客、接受转接。 + - `online`、`busy`、`away` 和 logout。 +- `reference/shang-wu-tong/go-bridge/internal/swt/` + - Go 版 DES、登录响应、心跳行和发送参数的测试样例。 + +这些内容作为协议和测试向量来源;修改生产逻辑前必须先与 Python 原型及对应逆向文档核对。 + +### 2.2 参考 go-bridge 不能直接投产的原因 + +当前 `reference/shang-wu-tong/go-bridge/` 仍存在以下生产阻塞项: + +- `cmd/serve.go` 创建 `AccountManager` 时传入的 `ChatwootClient` 为 `nil`,商务通入站消息不会进入 GoChat。 +- 入站事件在 cursor 事务提交后直接调用 Chatwoot,失败时没有独立的持久投递状态和补偿 worker。 +- Chatwoot webhook parser 期望消息字段位于 JSON 顶层,而当前 GoChat API Inbox webhook 使用 `data.message` 等嵌套字段。 +- SQLite 中密码和 `ma` token 虽为内部明文字段,但参考实现没有文件权限、备份边界和日志脱敏约束。 +- Account API 形成第二套账号配置源,且没有认证、字段级校验、密码安全更新或在线状态切换闭环;生产实现将删除该入口,改为 GoChat Inbox 驱动。 +- outbox 只覆盖文本,未从 GoChat 重新加载附件与会话数据。 +- 网络超时后的“商务通可能已经发送成功”没有 uncertain 状态,直接重试可能重复发送。 + +因此实施时只移植已验证的协议函数和测试样例,重新实现运行时、存储、Inbox 配置同步与 GoChat adapter。 + +### 2.3 GoChat 当前能力与需要补齐的部分 + +可直接复用: + +- API Inbox 已有 `Identifier`、`HMACToken`、`WebhookURL` 和 `Secret`。 +- Public API 已支持 contact、conversation、message 的创建与查询。 +- API Inbox webhook 已支持 `X-Chatwoot-Timestamp`、`X-Chatwoot-Signature` 和 `X-Chatwoot-Delivery`。 +- GoChat 已有持久化 `background_jobs` worker、重试和幂等键。 +- 事件类型和 webhook listener 已识别 `inbox.created/inbox.updated/inbox.deleted`,可复用现有签名 envelope。 +- GoChat 已有渠道配置脱敏 helper,可复用于普通 Inbox serializer 和日志。 + +必须补齐: + +- API Inbox 的出站 webhook 目前同步发送,失败后没有持久自动重试。 +- Inbox 创建/更新流程目前没有完整接通 `EventInboxCreated/EventInboxUpdated` dispatch;必须在事务提交后产生生命周期事件。 +- 没有 Connector 专用、account-scoped 且逐 Inbox 校验的配置列表、配置详情和运行状态回写端点。 +- 普通 Inbox serializer 会暴露 API channel 的 `additional_attributes`,商务通密码不得放入该字段;需要独立配置存储、专用配置接口和脱敏 serializer。 +- Connector 重放同一 `echo_id/source_id` 时,Public/Application message create 都缺少明确的幂等返回。 +- API Inbox webhook payload 需要固定版本,不能让 Connector 依赖当前内部 `ChannelEvent.Data` 的偶然结构。 +- API Inbox 出站消息必须只走 webhook,不应同时进入不存在的 API `ChannelProvider.SendMessage` 路径。 +- Dashboard `sendMessageWithData` 当前把 create/retry 响应强制覆盖为 sent;商务通消息必须以服务端返回的 progress/sent/failed 为准,不能在 Connector ACK 前制造已发送假象。 +- `MessageService.RetryInConversation` 当前直接把消息设为 sent、清空全部 content attributes 并进入旧 `SendReply` worker;商务通失败消息需要独立的 durable retry event,保留业务属性和 result version。 +- Application message create 需要允许受限的 `external`/`additional_attributes`/`external_source_ids`,并保证导入的 incoming/outgoing/activity 不触发外发或 webhook 回环。 +- 当前短期 JWT 不适合作为 7×24 Connector 机器凭据,需要 account-scoped service token 和轮换机制。 + +--- + +## 3. 总体架构 + +```text +┌──────────────────────────── GoChat ─────────────────────────────┐ +│ │ +│ SWT Inbox A ─┐ │ +│ SWT Inbox B ─┼─ message/config events ─ durable webhook ───┐ │ +│ ... │ │ │ +│ SWT Inbox N ─┘ │ │ +│ ▲ │ │ +│ │ Public/Application API + Connector config/status API │ │ +└───────┼──────────────────────────────────────────────────────┼───┘ + │ │ + │ inbound relay + status signed invalidation/message webhook + │ │ +┌───────┼──────────── Shangwutong Connector ───────────────────▼───┐ +│ │ │ +│ GoChat Client GoChat Webhook Handler │ +│ config reconcile ◄──── inbox_created/inbox_updated │ +│ ▲ │ transaction │ +│ │ ▼ │ +│ inbound_events outbound_messages │ +│ ▲ │ │ +│ │ ▼ │ +│ Account Supervisor × N ─ SWT Protocol Client │ +│ │ login/heartbeat/status/send │ +│ ▼ │ +│ one SQLite database │ +└───────┬───────────────────────────────────────────────────────────┘ + │ + │ 2.5s heartbeat + message/session APIs + ▼ +┌──────────────────────── 商务通服务器 ───────────────────────────┐ +│ 独立站点账号 A / B / ... / N │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.1 进程与 goroutine 模型 + +- 一个 Connector 进程管理全部账号;启动时先从 SQLite 恢复已有 supervisor,再异步从 GoChat 分页拉取全部有权访问的商务通 Inbox 并幂等同步。 +- webhook 只唤醒指定 Inbox 的配置拉取;创建、更新和重复通知都走同一个 upsert 路径。 +- `desired_presence=offline` 的账号只保留配置,不启动 supervisor。 +- 其他账号各启动一个 supervisor;supervisor 是该账号登录、心跳、状态切换和密码更新的唯一执行者。 +- supervisor 使用 `context.Context` 和 `sync.WaitGroup` 管理生命周期,不引入通用 goroutine pool。 +- 入站 GoChat relay 与出站商务通发送使用有限 worker;任务状态保存在 SQLite,不依赖内存 channel 保证可靠性。 +- 所有账号共享一个调优后的 `http.Transport`,按主机复用连接。 +- 启动时在一个心跳周期内随机错峰,避免 500 个账号同时请求。 +- 首版使用一个 Connector 实例;不预埋租约表和分片配置。 + +### 3.2 账号隔离边界 + +- 每个账号拥有独立的登录凭据、`ma` token、三组 cursor、presence 和错误状态。 +- 一个账号 panic、认证失败或商务通服务异常不能停止其他账号 supervisor。 +- 所有共享表以 `account_id` 作为第一查询条件,并建立对应索引。 +- 商务通 Inbox ID 在 Connector 内唯一映射到一个商务通账号。 +- 本地 `account_id` 只用于 SQLite 外键;跨系统身份以不可变的 `gochat_inbox_id` 为准,不根据用户名、密码或其 HASH 判断账号是否存在。 + +--- + +## 4. 代码与模块结构 + +```text +channels/shangwutong/ +├── go.mod +├── go.sum +├── sqlc.yaml +├── Dockerfile +├── README.md +├── cmd/shangwutong/main.go # Cobra root command +├── db/ +│ ├── migrations/ # //go:embed 的版本化 SQL +│ ├── queries/ # sqlc 输入 SQL +│ └── generated/ # sqlc 生成代码,提交到 Git +└── internal/ + ├── account/ # supervisor、状态机、manager + ├── command/ # serve/migrate/reconcile/backup/doctor + ├── config/ # 环境变量解析和启动校验 + ├── gochat/ # Public/Application/Connector API client、Schema 与 webhook contract + ├── httpapi/ # Fiber v3 webhook、health、ready、metrics + ├── observability/ # Logrus、redaction hook、metrics + ├── store/ # database/sql + sqlc、事务和队列调度 + └── swt/ # 登录、心跳、消息、状态和会话协议 +``` + +实现约束: + +- Connector 是独立 Go module,固定 `go 1.26.0`;Fiber `v3.4.0` 最低要求 Go 1.25,而锁定的 sqlc `v1.31.1` 要求 Go 1.26,因此 Connector 取两者的最高最低版本。GoChat 主 module 仍可保持 `go 1.24.0`,两者分别构建。 +- CLI 使用 Cobra;HTTP server 使用 Fiber v3;数据访问只使用 `database/sql + sqlc`;日志统一使用 Logrus。 +- SQLite 驱动使用 `modernc.org/sqlite`,避免 CGO 部署差异;不引入 GORM 或其他 ORM。 +- SQL migration 使用 `//go:embed`,启动时在事务内按版本顺序执行。 +- 协议请求统一经过一个可注入 `*http.Client`,测试使用 `httptest.Server`。 +- 禁止引入 DI 容器;在 Cobra `serve` command 中显式完成 config → logger → DB → repositories → services → Fiber 的装配。 + +依赖和工具版本首版固定如下,升级必须单独提交并跑完整 contract/规模测试: + +| 依赖/工具 | 固定版本 | 用途 | +|---|---:|---| +| Go toolchain | `1.26.x` | Connector 编译、测试和 sqlc 生成 | +| `github.com/spf13/cobra` | `v1.10.2` | CLI 与运维命令 | +| `github.com/gofiber/fiber/v3` | `v3.4.0` | GoChat webhook、health/ready/metrics | +| `github.com/sqlc-dev/sqlc` | `v1.31.1` | SQLite 类型安全查询代码生成,仅构建期使用 | +| `github.com/sirupsen/logrus` | `v1.9.4` | JSON 结构化日志 | +| `modernc.org/sqlite` | `v1.53.0` | 无 CGO SQLite driver | + +### 4.1 Cobra 命令边界 + +```text +shangwutong serve +shangwutong migrate up|status +shangwutong reconcile +shangwutong backup --output +shangwutong doctor +``` + +- `serve` 是唯一启动 Fiber 和账号 supervisor 的命令。 +- 不提供 `account create/update/delete/presence` 命令;这些操作统一通过 GoChat Inbox 创建/设置完成。 +- `reconcile` 调用运行中 Connector 的本机运维端点,触发一次 GoChat 全量配置同步,不直接写 SQLite。 +- `migrate` 由部署 init step 或 `serve` 启动前执行;迁移使用独占锁,发现另一个 Connector 正在写入时拒绝运行。 +- `backup` 走 SQLite 在线备份/VACUUM INTO,不复制活动中的 DB/WAL 文件。 +- `doctor` 只读检查配置、DB quick_check、迁移版本、GoChat Connector API 连通性与目录权限,不发起商务通登录。 + +### 4.2 Fiber v3 约束 + +- Fiber 只承载 HTTP 边界,不把 `*fiber.Ctx` 传入 service/store/swt 包。 +- 全局 middleware 顺序固定为 request ID → recover → body limit → access log/redaction → route auth → handler。 +- webhook v1 为支持同版本可选字段扩展而忽略未知字段,但严格校验 required/type/enum;本机 reconcile 运维端点无业务 body,仅允许 loopback 或受运维 token 保护。 +- `GET /metrics` 可使用 Fiber adapter,但指标采集不得在请求路径中扫描全部账号。 +- 关闭时先停止接收管理写请求和新 webhook,再 drain 已接收请求,最后取消 supervisor;使用 Fiber `ShutdownWithContext`。 + +### 4.3 sqlc 生成约束 + +`sqlc.yaml` 使用 `engine: sqlite`、`sql_package: database/sql`,生成目录为 `db/generated`。所有业务 SQL 放在 `db/queries/*.sql`,禁止在 handler/service 中拼接 SQL;只有迁移执行器、动态批量占位符和 SQLite PRAGMA 可保留少量手写 SQL。 + +生成代码必须提交,CI 执行: + +```bash +go tool sqlc generate +git diff --exit-code -- db/generated +go test ./... +go vet ./... +``` + +在 `go.mod` 中使用 Go tool dependency 固定 `github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1`,避免依赖开发机全局安装。事务中通过 `queries.WithTx(tx)` 执行,禁止混用非事务 query handle。 + +### 4.4 Logrus 约束 + +- 生产环境固定 `JSONFormatter`,时间使用 UTC RFC3339Nano;本地可切换 text formatter。 +- 每条业务日志至少包含 `component`、`operation`、`result` 和 `request_id`;账号相关日志再加 `connector_account_id`。 +- 实现 redaction hook,递归清除 `password`、`new_password`、`ma`、`token`、`secret`、`authorization`、cookie 和原始登录 body。 +- 除 `cmd/shangwutong/main.go` 最外层外禁止使用 `logrus.Fatal`/`Panic`;库代码返回 error,由边界统一记录。 +- 消息正文、完整 `swt_sid`、IP、User-Agent 默认不写 info 日志;debug 日志也必须经过脱敏并受显式开关控制。 + +--- + +## 5. 配置与敏感字段 + +### 5.1 Connector 环境变量 + +| 变量 | 必填 | 默认值 | 说明 | +|---|---:|---|---| +| `SWT_CONNECTOR_LISTEN` | 否 | `:9100` | webhook、health、ready、metrics 监听地址 | +| `SWT_CONNECTOR_DB_PATH` | 是 | — | SQLite 路径,如 `/data/connector.db` | +| `GOCHAT_BASE_URL` | 是 | — | GoChat 内部地址 | +| `GOCHAT_CONNECTOR_SERVICE_TOKEN` | 是 | — | 仅获显式 GoChat account grant 的机器令牌,拉取配置、回写状态、导入消息和更新会话时使用 | +| `SWT_MAX_INFLIGHT_HEARTBEATS` | 否 | `64` | 同时在途心跳上限 | +| `SWT_INBOUND_WORKERS` | 否 | `8` | 推送 GoChat 的 worker 数 | +| `SWT_OUTBOUND_WORKERS` | 否 | `8` | 发送商务通的 worker 数 | +| `SWT_SHUTDOWN_TIMEOUT` | 否 | `30s` | 优雅退出最长等待时间 | + +启动时缺少必填项、DB 位于不可写目录或 service token 为空时直接失败,不带降级默认值启动。 + +### 5.2 GoChat 配置 + +GoChat 不新增任何 `GOCHAT_SWT_*` 全局环境变量或启动参数。Connector webhook URL 属于 Inbox 级配置,创建/更新商务通 Inbox 时写入现有 `Inbox.WebhookURL`;首版各 Inbox 均指向同一个逻辑 Connector,字段保留在 Inbox 仅为避免增加全局启动配置。 + +### 5.3 明文交互与边界保护 + +这是内部系统,GoChat 与 Connector 之间不增加应用层二次加密: + +- GoChat 配置表和 Connector SQLite 直接保存 `password`、pending password、`ma`、HMAC token 和 webhook secret。 +- Connector config API 使用普通 JSON 返回登录字段;不生成 AES envelope、不配置共享 credential key、不做 AAD/key ID/fingerprint。 +- 账号身份使用 `gochat_inbox_id`,配置变化使用单调递增的 `config_version`,不对用户名和密码计算 HASH。 +- `GOCHAT_CONNECTOR_SERVICE_TOKEN` 仍由部署 secret manager 注入,不写入 Connector SQLite;它负责接口授权,不参与凭据加密。 +- config API 只暴露在受控内部网络,并由 account-scoped service token 鉴权;每次访问仍校验 Inbox 归属和渠道类型。是否启用 TLS 由内部网络部署规范决定,协议本身不再强制额外加密层。 +- 普通 Inbox API、生命周期 webhook、错误响应、metrics 和日志不得输出密码、token、secret、完整 Authorization header 或原始登录 body;config API 的请求/响应 body 禁止进入 access log。 +- SQLite 文件及数据库备份限制为 Connector 运行用户读写;GoChat 数据库访问继续服从现有生产权限和备份规范。 + +普通 Inbox GET 仍只返回 `password_configured`、`config_version`、`desired_presence` 和脱敏运行状态;只有 Connector config API 可以返回明文登录字段。 + +--- + +## 6. SQLite 设计 + +### 6.1 连接方式 + +一个物理数据库文件使用两个 `database/sql` 句柄: + +- writer:`MaxOpenConns(1)`、`MaxIdleConns(1)`,所有写事务串行。 +- reader:只读、`MaxOpenConns(8)`,供状态查询和 worker 扫描。 + +统一 PRAGMA: + +```text +journal_mode=WAL +synchronous=NORMAL +busy_timeout=5000 +foreign_keys=ON +wal_autocheckpoint=1000 +``` + +禁止直接复制正在运行的 DB/WAL 文件;在线备份使用 `VACUUM INTO`。禁止将 SQLite 放在 NFS、SMB、ExFAT 或 tmpfs。 + +### 6.2 accounts + +```sql +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gochat_account_id INTEGER NOT NULL, + gochat_inbox_id INTEGER NOT NULL UNIQUE, + gochat_inbox_identifier TEXT NOT NULL, + config_version INTEGER NOT NULL, + applied_config_version INTEGER NOT NULL DEFAULT 0, + config_sync_status TEXT NOT NULL DEFAULT 'pending', + + session_id TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + pending_password TEXT, + credential_state TEXT NOT NULL DEFAULT 'ready', + enabled INTEGER NOT NULL DEFAULT 1, + + desired_presence TEXT NOT NULL DEFAULT 'online', + actual_presence TEXT NOT NULL DEFAULT 'offline', + connection_status TEXT NOT NULL DEFAULT 'offline', + + base_url TEXT, + site_id TEXT, + login_name TEXT, + ma_token TEXT, + maxwordid INTEGER NOT NULL DEFAULT -1, + maxotick INTEGER NOT NULL DEFAULT -1, + maxtmpid INTEGER NOT NULL DEFAULT -1, + + gochat_hmac_token TEXT NOT NULL, + gochat_webhook_secret TEXT NOT NULL, + + failure_count INTEGER NOT NULL DEFAULT 0, + last_error_code TEXT, + last_error_message TEXT, + last_login_at DATETIME, + last_heartbeat_at DATETIME, + last_config_sync_at DATETIME, + deleted_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CHECK(config_sync_status IN ('pending', 'applied', 'rejected', 'deleted')), + CHECK(desired_presence IN ('online', 'busy', 'away', 'offline')), + CHECK(actual_presence IN ('online', 'busy', 'away', 'offline')) +); + +CREATE UNIQUE INDEX idx_accounts_active_swt_identity + ON accounts(session_id, username) + WHERE deleted_at IS NULL; +``` + +说明: + +- `session_id` 指商务通登录使用的 11 位站点 ID。 +- `gochat_inbox_id` 是账号存在性与 upsert 的唯一跨系统键;`session_id + username` 只做业务重复校验,密码不参与身份判断。 +- `config_version` 是最近成功拉取的期望版本,`applied_config_version` 是已完整应用版本;密码校验失败时前者前进、后者保留,防止同一错误版本被启动 reconcile 反复登录。 +- 重复或更旧版本直接幂等 ACK;GoChat 必须保证任何 Connector 可见字段变化都先递增 `config_version`。 +- 心跳响应中的访客会话 ID 在其他表中统一命名为 `swt_sid`,避免概念混淆。 +- cursor 与账号放在同一行,保证恢复时只需一次读取。 +- `enabled=0` 表示 GoChat Inbox 被显式禁用;`desired_presence=offline` 表示配置有效但用户要求离线。GoChat 暂时不可达不改变这两个字段。 +- `deleted_at` 只由已验证的 `inbox_deleted` 或全量 reconcile 缺失确认写入;删除先 best-effort logout 再停止 supervisor,历史 cursor、映射和队列按保留策略清理,不级联误删。 + +### 6.3 conversation_maps + +```sql +CREATE TABLE conversation_maps ( + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + gochat_contact_source_id TEXT NOT NULL, + gochat_contact_id INTEGER, + gochat_conversation_id INTEGER, + gochat_display_id INTEGER, + swt_assignee_name TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(account_id, swt_sid) +); +``` + +### 6.4 inbound_events + +```sql +CREATE TABLE inbound_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + seq_id INTEGER NOT NULL, + kind INTEGER NOT NULL, + swt_event_key TEXT NOT NULL UNIQUE, + event_subtype TEXT, + op_name TEXT, + text TEXT, + swt_timestamp TEXT, + raw_line TEXT NOT NULL, + normalized_payload TEXT, + mapping_strategy TEXT, + delivery_status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + gochat_message_id INTEGER, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(account_id, swt_sid, kind, seq_id) +); + +CREATE INDEX idx_inbound_events_ready + ON inbound_events(delivery_status, next_attempt_at, account_id, id); +``` + +允许的 `delivery_status`:`pending`、`delivering`、`delivered`、`ignored`、`failed`。 + +inbound worker 同样使用 writer 事务条件更新原子领取 `pending → delivering` 并检查 affected rows;stale delivering 可恢复 pending,因为 Connector → GoChat 请求始终携带稳定 Idempotency-Key。 + +`swt_event_key` 使用第 10.3 节定义的稳定值。唯一约束必须包含 `kind`,因为不同事件流可能出现相同 `seq_id`;本地递增 `id` 只用于保留心跳响应原始行顺序,不进入任何跨系统 key。`normalized_payload` 保存解析后的稳定 JSON,`mapping_strategy` 保存实际采用的 `native_message`、`activity`、`contact_attributes`、`conversation_attributes`、`fallback_text` 或 `raw_only`。原始 `raw_line` 始终保留,用于未来补解析,但任何 HTTP 接口都不直接返回其中的访客隐私数据。 + +### 6.5 message_maps + +撤回、历史批次、客服回显和幂等重放不能只依赖 `inbound_events.gochat_message_id`,因为一个 kind=52 事件可能展开为多条消息。增加独立消息映射表: + +```sql +CREATE TABLE message_maps ( + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + swt_message_id TEXT, + swt_seq_id INTEGER NOT NULL, + kind INTEGER NOT NULL, + child_index INTEGER NOT NULL DEFAULT 0, + direction TEXT NOT NULL, + gochat_message_id INTEGER NOT NULL, + gochat_source_id TEXT, + content_fingerprint TEXT, + retracted_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(account_id, swt_sid, kind, swt_seq_id, child_index), + CHECK(direction IN ('incoming', 'outgoing')) +); + +CREATE INDEX idx_message_maps_gochat_message_id + ON message_maps(gochat_message_id); + +CREATE UNIQUE INDEX idx_message_maps_swt_message_id + ON message_maps(account_id, swt_sid, swt_message_id) + WHERE swt_message_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_message_maps_gochat_source_id + ON message_maps(gochat_source_id) + WHERE gochat_source_id IS NOT NULL; +``` + +- kind=2/3 的心跳第 4 字段 `id` 同时是 `seq_id`、`maxwordid` cursor 和官方客户端写入 `ChatMsgEntity.msgId` 的商务通消息 ID,因此固定 `swt_message_id=decimal(seq_id)`,禁止再计算或 HASH 一个伪消息 ID。 +- kind=-4/-5 中撤回事件自己的第 4 字段仍是该事件的 `seq_id`,其 `text` 是目标 `swt_message_id`;Connector 必须按 `account_id+swt_sid+swt_message_id` 精确命中后再撤回,缺失、格式非法或未命中只告警,不使用内容或“最后一条消息”猜测。 +- kind=52 一个批次可能展开多个 child;child 有协议内消息 ID 时写入 `swt_message_id`,没有时保持 NULL,仅使用 `kind+batch_seq_id+child_index` 做本地映射和跨系统幂等,不把该组合冒充商务通消息 ID。 +- `gochat_source_id` 只在 Connector 导入消息时填写;GoChat 原生 outgoing message 的 `source_id` 为空,不能用空字符串参与 UNIQUE 约束,必须依靠 `gochat_message_id` 映射。 +- `gochat_message_id` 不能 UNIQUE:一条 GoChat 消息中的文本和多个附件会按顺序拆成多个商务通子操作,每个子操作可能得到不同的原始 `swt_message_id`,它们必须允许映射回同一个 GoChat message。单 ID 时 `external_source_ids.shangwutong` 保持字符串;多个 ID 时升级为按确认顺序去重的字符串数组,数组中每一项仍只能是商务通原始 ID。 +- `content_fingerprint` 仅用于客服 kind=3 回显和 uncertain 对账,不能单独作为撤回依据。只有 account、swt_sid、内容/媒体摘要、发送顺序和受限时间窗口共同得到唯一候选时才能确认 uncertain;多个相同候选保持 uncertain 并告警,不能猜测标 sent。 + +### 6.6 outbound_messages + +```sql +CREATE TABLE outbound_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + gochat_message_id INTEGER NOT NULL UNIQUE, + retry_version INTEGER NOT NULL DEFAULT 0, + message_type TEXT NOT NULL, + content TEXT, + payload TEXT NOT NULL, + delivery_status TEXT NOT NULL DEFAULT 'pending', + result_version INTEGER NOT NULL DEFAULT 0, + external_id TEXT, + external_error_code TEXT, + claimed_at DATETIME, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + status_sync_status TEXT NOT NULL DEFAULT 'not_required', + status_sync_attempts INTEGER NOT NULL DEFAULT 0, + status_sync_next_at DATETIME, + status_reported_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')), + CHECK(status_sync_status IN ('not_required', 'pending', 'syncing', 'synced')) +); + +CREATE INDEX idx_outbound_messages_ready + ON outbound_messages(delivery_status, next_attempt_at, account_id, id); + +CREATE UNIQUE INDEX idx_outbound_one_delivering_per_account + ON outbound_messages(account_id) + WHERE delivery_status = 'delivering'; + +CREATE INDEX idx_outbound_status_sync_ready + ON outbound_messages(status_sync_status, status_sync_next_at, account_id, id); + +CREATE TABLE outbound_parts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + outbound_message_id INTEGER NOT NULL REFERENCES outbound_messages(id) ON DELETE CASCADE, + part_index INTEGER NOT NULL, + part_type TEXT NOT NULL, + attachment_id INTEGER, + content TEXT, + data_url TEXT, + file_name TEXT, + file_size INTEGER, + voice INTEGER NOT NULL DEFAULT 0, + delivery_status TEXT NOT NULL DEFAULT 'pending', + external_id TEXT, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(outbound_message_id, part_index), + CHECK(part_type IN ('text', 'image', 'file', 'audio', 'video', 'unsupported')), + CHECK(delivery_status IN ('pending', 'delivered', 'uncertain', 'failed')) +); + +CREATE TABLE outbound_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + swt_sid TEXT NOT NULL, + event_id TEXT NOT NULL UNIQUE, + operation TEXT NOT NULL, + payload TEXT NOT NULL, + occurred_at DATETIME NOT NULL, + delivery_status TEXT NOT NULL DEFAULT 'pending', + claimed_at DATETIME, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(operation IN ('end_conversation')), + CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')) +); +``` + +允许的 `delivery_status`:`pending`、`delivering`、`delivered`、`uncertain`、`failed`。 + +允许的 `status_sync_status`:`not_required`、`pending`、`syncing`、`synced`。外部状态变为 delivered/uncertain/failed 时递增 `result_version`,并将 `status_sync_status` 重置为 pending;GoChat 状态回写成功后改为 synced。回写 Idempotency-Key 使用该 version,status sync worker 也必须用条件更新原子领取 `pending → syncing`。 + +worker 领取必须在 writer 事务内完成条件更新,并检查 affected rows: + +1. 先选取该账号最早的未终结记录,即 `pending|delivering|uncertain` 中 ID 最小的一条;只有它为 pending 时才允许领取。 +2. 账号存在 delivering 或更早的 uncertain 记录时不得领取后续消息;uncertain 在 kind=3 对账成功或观察窗口转 failed 后才能放行。 +3. 原子执行 `pending → delivering` 并写 `claimed_at`;只有更新一行才取得任务。 +4. 不同账号可并行,同一账号首版严格串行,保证多个客服连续回复顺序。 +5. 超时停留在 delivering 的记录按 uncertain 规则恢复,不得无条件重发。 + +`retry_version` 对应 GoChat 对同一 message 的显式重试次数,只接受单调增加的值。`payload` 中的多附件/文本操作按固定顺序保存 `operation_id/type/status/external_id`;每个子操作确认后立即持久化,进程恢复或手动 retry 只执行未成功的子操作,不能重发已经 delivered 的文本或附件。只有全部子操作成功时整条消息才是 delivered;任一子操作 uncertain 时整条消息进入 uncertain 并触发账号顺序屏障。 + +### 6.7 cursor 事务规则 + +每次成功解析心跳响应后,在同一 writer 事务内: + +1. `INSERT OR IGNORE` 所有新事件。 +2. 将明确无需推送 GoChat 的事件标为 `ignored`。 +3. 更新该账号 `maxwordid/maxotick/maxtmpid`。 +4. 更新 `last_heartbeat_at` 和清空连续协议错误。 +5. commit 后才允许下一轮心跳使用新 cursor。 + +禁止先更新 cursor 再写事件;禁止通过 session JSON 单独保存 cursor。 + +空心跳不每 2.5 秒写 DB,只在内存更新指标,并按较低频率刷新 `last_heartbeat_at`,避免制造无意义写负载。 + +--- + +## 7. GoChat Inbox 配置与 Connector 同步协议 + +本节替代独立账号管理 API。GoChat 商务通 Inbox 是账号配置的唯一写入口;Connector 只保存运行副本、cursor、映射和队列,不接受人工账号 CRUD。 + +### 7.1 商务通 Inbox 创建与更新 + +复用现有 Inbox API: + +```http +POST /api/v1/accounts/{account_id}/inboxes +PATCH /api/v1/accounts/{account_id}/inboxes/{inbox_id} +``` + +创建请求的渠道字段固定为: + +```json +{ + "name": "商务通-站点A", + "channel": { + "type": "shangwutong", + "session_id": "LZA69557093", + "username": "operator01", + "password": "write-only-secret", + "desired_presence": "online", + "webhook_url": "http://shangwutong-connector:9100/webhooks/gochat/v1" + } +} +``` + +- `session_id` 必须满足协议长度且 `[3:]` 可作为 8 字节 DES key;`username/password` 非空并限制长度。 +- `desired_presence` 只允许 `online|busy|away|offline`,默认 `online`。 +- `webhook_url` 必填,允许内部 `http|https` 绝对地址,禁止 userinfo、fragment 和非 HTTP scheme;保存到该 Inbox 的现有 `WebhookURL` 字段,不进入全局配置。创建/更新时查询当前启用的其他商务通 Inbox,规范化后的 URL 必须完全一致;只有目标 Inbox 已 `enabled=false + desired_presence=offline` 且不存在其他启用商务通 Inbox 时才能更换,首版不支持按 URL 分片。 +- 更新时省略 `password` 表示保持不变;空字符串和 `null` 均拒绝,避免误清空。 +- `session_id` 或 `username` 改变身份边界,首版拒绝原地修改。迁移必须先将旧 Inbox 切到 offline 并删除,再创建新 Inbox;不支持新旧账号重叠登录验证。 +- 密码是 write-only。普通创建/查询/更新响应只返回 `password_configured=true`,不得回显密码。 +- 后端内部 `ChannelType` 使用 `shangwutong`,Chatwoot-facing serializer 输出 `Channel::Shangwutong`;contact、conversation、message 和 webhook 行为复用 API Inbox。 +- 在 GoChat 集中增加一个 `isAPIInboxLike` 判断覆盖 `api|shangwutong`,只用于 ChannelAPI 创建、Public API/HMAC 和 webhook 分发;禁止在各 handler 散落重复字符串判断。商务通不注册直接发送的 `ChannelProvider.SendMessage`,坐席出站只走 durable webhook。 + +GoChat PostgreSQL 新增一张一对一配置表,避免把秘密放入可序列化的 `additional_attributes`: + +```sql +CREATE TABLE channel_shangwutong_configs ( + inbox_id BIGINT PRIMARY KEY REFERENCES inboxes(id) ON DELETE CASCADE, + session_id TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + desired_presence TEXT NOT NULL DEFAULT 'online', + config_version BIGINT NOT NULL DEFAULT 1, + actual_presence TEXT NOT NULL DEFAULT 'offline', + connection_status TEXT NOT NULL DEFAULT 'pending', + credential_status TEXT NOT NULL DEFAULT 'pending', + last_heartbeat_at TIMESTAMPTZ, + last_error_code TEXT, + status_updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CHECK(desired_presence IN ('online', 'busy', 'away', 'offline')) +); + +CREATE UNIQUE INDEX uq_channel_shangwutong_active_identity + ON channel_shangwutong_configs(session_id, username) + WHERE deleted_at IS NULL; +``` + +同一事务内创建 `Inbox + ChannelAPI + channel_shangwutong_configs`:`ChannelAPI` 继续提供 identifier/HMAC 能力,配置表保存商务通字段和 Connector 回报的脱敏状态。删除 Inbox 时同一事务软删除配置行,使 partial unique index 允许旧身份按 offline → delete → recreate 流程复用。密码、`ChannelAPI.HMACToken` 和 `Inbox.Secret` 按内部系统约定明文存储,但商务通 serializer 不返回这些字段,日志也不得记录。 + +#### 7.1.1 Inbox 成员与客服协作 + +- URL 中的 `account_id` 是 GoChat tenant/workspace,不是客服用户 ID。 +- 复用现有 `AccountUser`、`InboxMember` 和 `Conversation.AssigneeID/TeamID`,不新增商务通人员表。 +- Inbox 创建后通过现有成员管理 UI/API 添加客服;非管理员必须是 InboxMember 才能查看和回复,管理员沿用现有全局可见规则。 +- `AssigneeID` 只表示主要负责人,不改变其他有权限 InboxMember 的协作能力。 +- 多个 GoChat 客服的消息都使用该 Inbox 的同一组商务通 `oname/siteid/sn` 发送;首版不伪造不同商务通客服身份,也不修改客户可见正文。 +- webhook 中保留真实 GoChat sender ID/name 供审计;商务通侧只显示共享登录身份。需要一人一个商务通身份时使用多个 Inbox,不在首版增加 operator→agent 映射。 + +### 7.2 配置版本与密码变更 + +- `config_version` 在影响 Connector 的字段变化时单调加一:密码、`desired_presence`、Inbox enabled、webhook URL、identifier/HMAC/webhook secret 轮换均算变化;仅修改 Inbox 名称不触发账号重配。 +- Connector 以 `gochat_inbox_id` 判断账号存在:存在则按较新版本更新,不存在则创建本地运行记录。重复版本幂等,旧版本忽略。 +- 密码更新后,Connector 先写 `pending_password` 并尝试新登录;成功后原子替换正式密码和 `ma`,cursor 不变。 +- 新密码认证失败时保留 Connector 的最后一次可用密码/session,回写 `credential_status=rejected`。GoChat 保留管理员最新输入并在 Inbox 设置页显示失败,等待再次修改,不自动把错误密码改回旧值。 +- 若 `desired_presence=offline`,Connector 只保存 pending 密码并回写 `credential_status=pending`,不为验证密码主动登录;下一次切到在线状态时再验证。 +- `vcode/ecsq` 时进入 `verification_required` 并停止自动登录重试;人工验证流程必须在真实抓包确认后再扩展现有 Inbox 设置页,首版不另建验证后台。 + +### 7.3 生命周期失效通知 + +Inbox 事务提交后必须实际 dispatch `EventInboxCreated/EventInboxUpdated/EventInboxDeleted`,并复用现有签名头发送到 Connector: + +```http +POST /webhooks/gochat/v1 +X-Chatwoot-Timestamp: 1785483001 +X-Chatwoot-Signature: sha256= +X-Chatwoot-Delivery: +Content-Type: application/json +``` + +```json +{ + "schema_version": 1, + "event": "inbox_updated", + "event_id": "inbox:123:config:7", + "occurred_at": "2026-07-31T08:30:01Z", + "account_id": 1, + "inbox_id": 123, + "data": { + "channel_type": "shangwutong", + "config_version": 7 + } +} +``` + +- 通知不携带密码、用户名、session、token、secret 或完整配置;它只表示本地缓存可能失效。 +- `event_id` 在重试中保持不变,`delivery` 每次尝试可变化。GoChat 使用现有 `background_jobs` 持久重试;Connector 完成配置 upsert/tombstone 后返回 202,重复版本返回 200,拉取或持久化失败返回 503。 +- 已知 Inbox 先用本地 webhook secret 验签再拉取。收到未知 Inbox 的 `inbox_created|inbox_updated` 时,Connector 只解析受限大小的 `account_id/inbox_id`,使用 service token 拉取配置,取得 secret 后再对保留的原始 body 验签;验签失败不得创建本地账号。未知 `inbox_deleted` 对本地没有可执行状态,直接返回 200 ignored,避免因资源已删除而形成不可验证的重试死循环。 +- 普通更新使用当前 secret 签名;secret 轮换事件必须使用旧 secret 签名,Connector 验证后拉取并应用新 secret。 +- `inbox_deleted` job 在删除事务提交前持久化 `webhook_url/current_secret/account_id/inbox_id/tombstone_version`,资源删除后仍可按快照投递和签名;job payload 不进入日志。 +- 在线修改 `webhook_url` 不支持把账号迁移到另一个 Connector。迁移按 offline → 删除旧 Inbox → 搬迁 SQLite/部署新 Connector → 创建新 Inbox 的运维流程执行。 +- 通知投递失败不改变 Inbox 保存结果;持久重试负责在线修复,Connector 启动全量 reconcile 负责最终兜底。 + +### 7.4 Connector 配置拉取 API + +新增两个只读 GoChat 端点: + +```http +GET /api/v1/connector/shangwutong/inboxes?cursor=&limit=100 +GET /api/v1/connector/shangwutong/inboxes/{inbox_id} +Authorization: Bearer +``` + +复用现有 PlatformApp `AccessToken` 哈希存储和 `PermissibleTypeAccount` 记录:为 Connector 创建专用 PlatformApp,每条 Account permissible 就是一项显式 tenant/workspace grant。一个 token 可获准一个或多个 account,但不是全局 SuperAdmin token;它不预绑定具体 Inbox,因此获准 account 内新建 Inbox 无需二次授权即可完成首次拉取。首版不新增 scope 表,最小权限由“专用 PlatformApp + account permissible + 仅在本节 Connector API 和第 10.2 节列出的必要 Application API 路由接受该 token”共同实现。列表只能返回 grant 集合内的商务通 Inbox,详情和每次写请求仍逐项校验 account、Inbox 归属与渠道类型,禁止使用 SuperAdmin 或人工坐席 JWT。 + +现有 PlatformApp middleware 默认读取 `api_access_token`,普通 Bearer 又会进入用户 JWT 解析。实现时复用其 token 哈希查询和 permissible 校验,增加仅挂载于上述 allowlist 路由的 Connector service-auth middleware:只接受 `Authorization: Bearer `,验证 owner type 为 PlatformApp 后写入独立 service principal context,不伪造成用户;这些路由不得再先经过普通 JWT middleware。现有 Platform API 的 `api_access_token` 行为保持不变。 + +列表与详情使用相同 item Schema: + +```json +{ + "schema_version": 1, + "account_id": 1, + "inbox_id": 123, + "inbox_identifier": "api-inbox-token", + "enabled": true, + "desired_presence": "busy", + "config_version": 7, + "credentials": { + "session_id": "LZA69557093", + "username": "operator01", + "password": "secret", + "hmac_token": "public-api-hmac", + "webhook_secret": "webhook-signing-secret" + }, + "updated_at": "2026-07-31T08:30:00Z" +} +``` + +- `credentials` 按内部系统约定直接返回明文,不再包装 envelope;只有该 Connector config API 可以返回这些字段。 +- 响应必须带 `Cache-Control: no-store`,访问日志不记录 body;接口只暴露在 Connector 可达的内部网络。 +- 列表按稳定的 `(inbox_id)` cursor 分页,最多 100 条;只有完整读完所有页才算一次成功全量快照。 +- 401/403 不区分 token 无效与资源越权;详情 404 不泄露其他 account 的 Inbox 是否存在。 + +### 7.5 启动 reconcile 与幂等 upsert + +Connector 启动先用 SQLite 中最后一次成功配置启动已有 supervisor,保证 GoChat 升级或暂时不可达时账号继续心跳;随后异步执行一次全量 reconcile: + +1. 分页拉取全部授权商务通 Inbox,并逐条验证 Schema 和必填字段。 +2. 以 `gochat_inbox_id` 查找本地账号;不存在则创建,存在且版本较新则更新,版本相同则直接幂等跳过。 +3. 新账号在 0~2.5 秒内随机启动 supervisor;更新账号通过非阻塞 wakeup 让唯一 supervisor 重读 DB。 +4. 只有完整快照成功后,才能把本地存在但快照缺失的账号标记 deleted;任一分页失败不得批量停号。 +5. reconcile 失败保留本地配置继续运行并告警,不把 GoChat 不可达解释成禁用或离线。 + +收到生命周期通知时只对单个 Inbox 执行同一 upsert;不维护第二套分支逻辑。`inbox_deleted` 验签后停止 supervisor,并保留 cursor、映射和未完成队列供审计/回滚;按运维保留期再清理。 + +### 7.6 Presence 与运行状态回写 + +GoChat 的 `desired_presence` 是期望值;空心跳或仅有 `r=ok` 只能确认连接/session 状态,`actual_presence` 取最近一次成功的 login/status/logout,或协议明确返回的 kind=11 在线状态事件: + +| GoChat 值 | 登录 `t0` | 在线切换端点 | +|---|---:|---| +| `online` | `3` | `oc/online.aspx` | +| `busy` | `2` | `oc/busy.aspx` | +| `away` | `1` | `oc/away.aspx` | +| `offline` | `0` | `oc/logout.aspx` | + +- 前三种状态间切换不重新登录;从 offline 切回时按对应 `t0` 登录。 +- 显式切到 offline 时调用 logout,成功或 session 明确失效后停止心跳。 +- Connector 使用保存的 `ma+cursor` 恢复且首次心跳 `r=ok` 后,必须重新调用一次 desired presence 对应端点;成功后才能把 actual presence 更新为目标值。 +- 心跳仅有 `r=ok` 时只更新 `connection_status=connected/last_heartbeat_at`,不得据此猜测 actual presence;收到已验证的 kind=11 `text=0/1/2/3` 时可分别更新 offline/away/busy/online。`tickint reset` 将 actual presence 置为 offline 并进入重登。 +- `enabled=false` 和 `inbox_deleted` 都执行 best-effort logout 后停止 supervisor;logout 失败记录错误但不阻止显式停用/删除完成。 +- Connector SIGTERM、GoChat API 不可达或 GoChat 升级均不改 `desired_presence`,也不调用 logout。 +- 不新增 GoChat/Connector PING。WebSocket ping 和 Chatwoot Hub ping 与渠道账号无关;商务通心跳负责连接检测和收消息。 + +Connector 使用受限 service token 回写脱敏状态: + +```http +PUT /api/v1/connector/shangwutong/inboxes/{inbox_id}/status +``` + +```json +{ + "config_version": 7, + "actual_presence": "busy", + "connection_status": "connected", + "credential_status": "applied", + "last_heartbeat_at": "2026-07-31T08:31:02Z", + "last_error_code": null +} +``` + +状态枚举固定;GoChat 拒绝比当前 `config_version` 更新的未来版本,旧版本状态只记审计不覆盖当前 credential 状态。Connector 仅在状态变化或每 30 秒刷新一次 heartbeat 时间,禁止每 2.5 秒写 GoChat DB。待回写的最新状态以 SQLite `accounts` 行为准,重试调度只在内存中合并唤醒;进程重启后仍从持久状态继续上报,不阻塞商务通心跳。现有 `GET /api/v1/accounts/{account_id}/inboxes/{inbox_id}/health` 扩展为读取该状态表,前端复用 Inbox 设置页展示。 + +固定枚举如下:`actual_presence=online|busy|away|offline`;`connection_status=pending|logging_in|connected|degraded|relogin_required|verification_required|auth_failed|disabled|offline`;`credential_status=pending|verifying|applied|rejected|verification_required`。未知值返回 422,禁止把错误字符串直接存入状态字段。 + +### 7.7 Connector 本机运维端点 + +```http +GET /healthz +GET /readyz +GET /metrics +POST /internal/reconcile +``` + +- `/healthz` 只表示进程存活。 +- `/readyz` 在 SQLite 可读写、迁移完成且 HTTP server 可接收 webhook 时返回 200;GoChat 暂时不可达或个别账号离线不影响 readiness。 +- `/metrics` 返回 Prometheus text format,不包含用户名、session_id、inbox_id 或 account_id 标签。 +- `/internal/reconcile` 只允许 loopback/Unix socket 或运维层鉴权,作用仅是唤醒一次全量拉取,不接受任何账号字段。 + +--- + +## 8. Account Supervisor 与状态机 + +### 8.1 两类状态分离 + +Presence 表示商务通坐席状态: + +```text +online / busy / away / offline +``` + +Connection status 表示 Connector 与商务通的连接健康: + +```text +pending +logging_in +connected +degraded +relogin_required +verification_required +auth_failed +disabled +offline +``` + +禁止用一个 `status` 字段同时表达二者。 + +### 8.2 启动与恢复 + +启动流程: + +1. 从 SQLite 查询最后一次已应用且 `enabled=1 AND desired_presence<>'offline'` 的账号,不等待 GoChat 网络。 +2. 为每个账号注册唯一 supervisor;重复 StartAccount 必须幂等。 +3. 在 0~2.5 秒内随机延迟第一次请求。 +4. 若存在 `ma` token,先带已保存 cursor 发一次心跳恢复 session。 +5. 心跳 `r=ok` 后重新调用一次 desired presence 端点并以其成功结果恢复 actual presence。 +6. 只有收到 `tickint reset` 或缺少 token 时才执行登录;登录成功后写入 token/actual presence,再进入心跳循环。 +7. 与 supervisor 恢复并行执行第 7.5 节全量 reconcile;新配置通过同一 wakeup 路径应用。 + +这样 Connector 重启时不会因 GoChat 暂时不可达让全部账号下线,也不会无条件让 500 个账号同时登录。 + +### 8.3 单账号串行命令 + +supervisor 每轮同时监听: + +```text +context cancel +heartbeat timer +configuration wakeup +presence change +credential verification +typing state change +``` + +配置 reconcile 不直接调用商务通;它只提交持久化 desired state/pending credential 并发送一次非阻塞 wakeup。supervisor 每次被唤醒后重新读取账号行,以数据库最新版本为准,因此重复 webhook 和连续状态切换自然合并为最后一次期望值。 + +账号运行态并发规则: + +- supervisor 是 `ma/base_url/login_name/actual_presence` 的唯一写入者,并在更新后发布带 version 的不可变 session snapshot。 +- 每个账号只有一个 `sync.RWMutex` operation gate:heartbeat 和单条 outbound send 持读锁并使用同一 version 的 snapshot;login、密码替换、presence 切换和 logout 持写锁,必须等待当前发送结束后才能发布新 snapshot。媒体网络请求不在 supervisor goroutine 内执行,因此其他账号和调度循环不被阻塞。 +- outbound worker 取得读锁后只读取一次 snapshot 执行当前发送;收到 token reset/session 失效时不自行登录,只释放锁并唤醒 supervisor,等待新 version 后重试。 +- 第 6.6 节数据库约束保证同一账号最多一个在途发送,operation gate 保证发送不会与密码替换/login/logout 并发使用两组 token;两者共同保证多客服连续回复的顺序和 session 一致性。 +- 心跳仍由 supervisor 独立执行;媒体发送不得占用 heartbeat loop。不同账号可由通用 worker 并行处理。 + +### 8.4 心跳错误分类 + +| 错误 | 状态与动作 | +|---|---| +| `r=ok` | 重置网络失败计数,解析并持久化事件 | +| `tickint reset` | `relogin_required`,立即按 desired presence 登录 | +| `server connect err` | `degraded`,带 jitter 重试,不立即反复登录 | +| HTTP timeout/连接失败 | `degraded`,指数退避,最大 30 秒 | +| 登录密码错误 | `auth_failed`,停止自动重试 | +| `vcode/vcode1/vcode2/ecsq` | `verification_required`,回写 GoChat Inbox 状态并停止自动登录重试 | +| 未知协议响应 | 保存截断后的非敏感响应,继续退避并告警 | + +网络故障不能按“连续三次失败”直接重登,否则商务通不可达时会形成登录风暴。 + +### 8.5 panic 与退出 + +- supervisor 顶层 defer 记录非敏感 panic 信息并将该账号标为 degraded;manager 在退避后重启该账号 supervisor。 +- SIGTERM 时先停止接收新 webhook/reconcile,等待正在进行的心跳事务完成,然后 cancel 所有 supervisor。 +- cancel 后等待全部 done channel 或 shutdown timeout。 +- 不调用商务通 logout,不改变 desired presence;重启后先用心跳确认连接,再重新应用 desired presence,以状态端点结果校正 actual presence。 + +--- + +## 9. 商务通协议客户端 + +### 9.1 核心类型 + +```go +type Credentials struct { + SessionID string + Username string + Password string +} + +type Session struct { + BaseURL string + SiteID string + LoginName string + MAToken string +} + +type Cursor struct { + MaxWordID int64 + MaxOTick int64 + MaxTmpID int64 +} +``` + +协议层不依赖 SQLite、GoChat client 或 Fiber HTTP 边界。 + +### 9.2 必须实现的方法 + +```text +BuildBaseURL +EncryptPasswordDES +Login +Heartbeat +SendText +SendImage +SendFile +SendVoice +SetPresence +Logout +SetTyping +EndConversation +AcceptTransfer +InviteVisitor +AcceptWaitingVisitor +RefuseWaitingVisitor +``` + +每个方法接收 context,使用明确的请求超时,并返回分类错误;禁止在协议包内直接重试。 + +### 9.3 登录 + +- 端点:`POST {base_url}oc/login78.aspx`。 +- charset、padding、hex 大小写必须与 Python fixed vector 一致。 +- `cid` 和随机 `sn` 使用 `crypto/rand`,不用 `math/rand` 生成认证随机数。 +- 登录响应按行解析 `key|value`,保存 `ma`。 +- `Accept`、`vcode*`、`ecsq`、密码错误和 HTTP 错误分别返回可判断的 typed error。 + +### 9.4 心跳与 cursor + +- 端点:`POST {base_url}oc/CheckMobile.aspx`。 +- 默认间隔严格为前一请求完成后 2.5 秒,不允许多个心跳重叠。 +- 请求携带 `id/mid/t/p/o/sn/c/i`。 +- 每行按“从左右固定字段、中间保留空格”解析,不能简单 `strings.Fields` 丢失占位内容。 +- cursor 基线规则:`kind=11` 更新 t,`kind>=70` 更新 p,其他适用事件更新 mid。参考字典对 kind=65/66/67 标注为 p,但 Python/Go parser 和范围规则标注为 mid;生产实现先按 parser 的 mid 规则并保留专项指标,真实抓包确认后才能定稿。 +- 负 kind 的 cursor 语义必须以真实抓包验证;验证前保留原始事件并使用现有 maxwordid 规则,同时设置专项指标。 + +### 9.5 消息和会话操作 + +| 功能 | 端点/方式 | +|---|---| +| 文本/表情 | `oc/send.aspx`,HTML 内容 | +| 图片 | `oc/sendmorepics.aspx` multipart | +| 文件 | `oc/sendfile.aspx` multipart | +| 语音 | 上传到 voice API 后以 `voice_msg` 调 `send.aspx` | +| 输入中 | 下一次心跳同时设置 `c=sid&i=sid` | +| 停止输入 | 下一次心跳清空 `c/i` | +| 结束 | `oc/end.aspx` | +| 接受转接 | `oc/accepttransfer.aspx` | +| 邀请 | `oc/invite.aspx` | +| 接受/拒绝等待访客 | `oc/accept.aspx` / `oc/refuse.aspx` | + +发送响应至少分类:成功、session 过期、会话结束、sid 错误、状态错误、服务端错误、网络结果不确定。 + +`oc/send.aspx` 当前已确认的响应只有 `r` 结果,不返回商务通消息 ID。`r=ok` 可将 GoChat message 标 sent,但 `external_id` 暂为空;后续 kind=3 心跳回显的 `seq_id` 才是该消息的 `swt_message_id`,唯一对账成功后再补写 GoChat `external_source_ids.shangwutong`。禁止在发送时根据时间、内容或 GoChat ID 伪造商务通消息 ID。 + +--- + +## 10. GoChat 对接契约与消息场景映射 + +本节是 Connector 与 GoChat 的实现合同。字段、方向、鉴权、幂等和错误语义变更时必须提升 Schema 版本并保留 contract fixture,不能只修改一侧代码。 + +### 10.1 对接边界与 GoChat 前置改造 + +每个商务通账号上线前: + +1. 管理员在 GoChat 现有 Inbox 创建页选择“商务通”,录入登录字段和初始 `desired_presence`。 +2. GoChat 在同一事务创建商务通 Inbox、底层 ChannelAPI 能力和商务通配置,并使用该 Inbox 提交的 `webhook_url`。 +3. GoChat 提交后投递 `inbox_created`;Connector 拉取配置、按 Inbox ID 自动创建本地运行账号并开始登录。 +4. Connector 使用部署时创建的 account-scoped service token,不使用人工坐席或 SuperAdmin 登录态。 + +Connector 不持有 SuperAdmin 凭据,不调用 Connector 账号创建接口,也不维护独立账号后台。`Channel::Shangwutong` 是专用 Inbox 类型,但下列消息能力直接复用和扩展 API Inbox: + +| 能力 | 当前 GoChat 事实 | 本项目要求 | +|---|---|---| +| Public contact/conversation | 已支持创建、查询、更新 | 直接复用 | +| Public message | 只创建 `incoming/text`,附件依赖 staged upload | 不作为 v1 canonical import;所有商务通消息统一走 Application API,Public 路径仅保留兼容测试 | +| Application message | 已支持 incoming/outgoing/activity、content type 和 multipart 附件,Message model 已有 `external_source_ids` | 增加受限 `external`、`additional_attributes`、`external_source_ids` 输入和幂等键;`external=true` 时禁止触发渠道外发 worker | +| Attachment metadata | model 可存 Metadata,但当前通用 serializer 主要返回 URL/大小/宽高 | 返回受控 `metadata`、location coordinates、contact fallback title,供已有前端 bubble 使用 | +| 消息撤回 | Application DELETE 已生成 `content_attributes.deleted=true` tombstone | 直接复用,但必须先有 `message_maps` 精确定位 | +| 会话状态/属性 | Application API 已支持显式 status 和 custom attributes | 直接复用 | +| API Inbox webhook | 当前是同步投递,payload 嵌套在 `data`,无持久重试 | 改为本节定义的 durable webhook v1 | +| Dashboard 消息状态 | create/retry 响应目前被前端强制覆盖为 sent | 统一信任服务端响应 status;商务通保持 progress,等待真实结果事件 | +| 失败消息 retry | 当前直接设 sent、清空 attributes 并入 `SendReply` worker | 商务通分支设 progress、保留属性并发 durable `message_retry_requested` | +| Inbox 生命周期 | 已定义 created/updated/deleted 事件,但创建/更新未完整 dispatch | 事务提交后 dispatch,作为无秘密的配置失效通知;Schema 见第 7.3 节 | +| 商务通配置 | 普通 API additional_attributes 会被序列化 | 独立配置表、专用 config/status API 与脱敏 serializer;Schema 见第 7 节 | +| 机器鉴权 | 已有哈希存储的 PlatformApp access token 与 permissible | 增加仅限路由 allowlist 的 Connector Bearer middleware 并复用 account permissible;不再新建第二套 token 系统 | +| 消息幂等 | 当前 `source_id` 可写但没有明确幂等返回 | 增加 `(inbox_id, source_id)` 查询与原消息返回语义 | + +GoChat 消息等待外部发送结果时统一使用现有前端已识别的 `status=progress`,不再引入含义重复的 message `pending` 枚举;Connector SQLite 队列仍使用 `delivery_status=pending`。实现时扩展 GoChat Message model 注释、状态校验、serializer 和状态事件测试,使 progress 可安全持久化;conversation 的 `pending` 工作流状态与此无关。 + +### 10.2 交互方向、端点与鉴权总表 + +| 方向 | 目的 | 方法与端点 | 鉴权 | 重试级别 | +|---|---|---|---|---| +| Connector → GoChat | 启动全量配置 reconcile | `GET /api/v1/connector/shangwutong/inboxes` | Connector service token | 分页幂等重试 | +| Connector → GoChat | 拉取单 Inbox 配置 | `GET /api/v1/connector/shangwutong/inboxes/{inbox_id}` | Connector service token | 幂等重试 | +| Connector → GoChat | 回写心跳/连接/凭据状态 | `PUT /api/v1/connector/shangwutong/inboxes/{inbox_id}/status` | Connector service token | 最新状态覆盖重试 | +| Connector → GoChat | 回写坐席消息真实发送结果 | `PUT /api/v1/connector/shangwutong/inboxes/{inbox_id}/messages/{message_id}/status` | Connector service token | 幂等重试直到 synced | +| Connector → GoChat | upsert Contact | `POST /public/api/v1/inboxes/{inbox_identifier}/contacts` | body 内 identifier HMAC | 幂等重试 | +| Connector → GoChat | 更新 Contact | `PATCH /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}` | body 内 identifier HMAC | 幂等重试 | +| Connector → GoChat | 查询/创建 Conversation | `GET|POST /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}/conversations` | Contact source ID;HMAC 已在 contact 创建时验证 | 幂等 ensure | +| Connector → GoChat | 导入消息/附件/activity | `POST /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/messages` | Connector service token | 幂等重试 | +| Connector → GoChat | 更新会话属性 | `POST /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/custom_attributes` | Connector service token | 幂等重试 | +| Connector → GoChat | 显式切换会话状态 | `POST /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/toggle_status` | Connector service token | 幂等重试 | +| Connector → GoChat | 访客 typing | `POST /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}/conversations/{display_id}/toggle_typing` | Contact source ID | best-effort | +| Connector → GoChat | 撤回消息 | `DELETE /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/messages/{gochat_message_id}` | Connector service token | 幂等重试,404 需核对 | +| GoChat → Connector | 坐席消息创建/失败重试/会话状态 | `POST /webhooks/gochat/v1` | webhook HMAC | GoChat durable job | +| GoChat → Connector | 坐席 typing | `POST /webhooks/gochat/v1` | webhook HMAC | best-effort,不入 durable 队列 | +| GoChat → Connector | Inbox 创建/配置/删除通知 | `POST /webhooks/gochat/v1` | webhook HMAC | GoChat durable job;只触发配置拉取 | + +受保护的 GoChat API 统一发送: + +```http +Authorization: Bearer +X-GoChat-Schema-Version: 1 +X-Request-ID: +Idempotency-Key: +``` + +GoChat 当前也接受 `access-token` 头中的用户 JWT,但 Connector 固定使用标准 Bearer 头和第 7.4 节的 service principal,不把 PlatformApp token 交给用户 JWT parser。生产 token 只在路由 allowlist 内被接受,并按 Account permissible 限制;每个请求仍校验 account grant,涉及 Inbox 的请求再校验目标 Inbox 归属和 `channel_type=shangwutong`。Contact Public API 继续使用现有 identifier HMAC,不扩大 service token 路由面。 + +### 10.3 标识符、时间与幂等规范 + +| 字段 | 含义 | 格式/来源 | +|---|---|---| +| `connector_account_id` | Connector SQLite 运行记录主键 | 整数,只作本地外键,不用于跨系统识别账号 | +| `gochat_account_id` | GoChat tenant/account ID | `accounts.gochat_account_id` | +| `gochat_inbox_id` | 商务通 Inbox 数字 ID | Connector 账号存在性和 upsert 的唯一跨系统键 | +| `inbox_identifier` | Public API 路径 token | 不是 inbox ID,也不参与 contact HMAC | +| `swt_sid` | 商务通访客会话 ID | Contact `source_id` 与 `identifier` | +| `seq_id` | 心跳事件第 4 字段 `id` | 对应 cursor;kind=2/3 时也就是商务通消息 ID | +| `swt_message_id` | 商务通原始消息 ID | kind=2/3 为 `decimal(seq_id)`;kind=-4/-5 的 `text` 为目标消息 ID;不计算伪 ID | +| `swt_event_key` | 商务通事件全局幂等键 | `swt:{gochat_inbox_id}:{swt_sid}:{kind}:{seq_id}` | +| `gochat_message_id` | GoChat 内部消息 ID | webhook 与 Application API response 返回 | +| `delivery_id` | 某次 webhook 投递 ID | UUID;同一 event 的重试可变化 | +| `event_id` | webhook 业务事件 ID | `message::created` 或 `conversation::status:`,重试不变 | + +Connector 导入消息时: + +```text +source_id = swt::::: +Idempotency-Key = 同 source_id +``` + +- `source_id/swt_event_key` 是命名空间化的幂等键,不是商务通消息 ID;普通 kind=2/3 禁止以任何组合串覆盖原始 `swt_message_id`。 +- GoChat 使用现有 `messages.external_source_ids.shangwutong` 保存原始 `swt_message_id`:入站 kind=2/3 创建时直接写入字符串;GoChat 出站消息在 kind=3 回显唯一匹配后补写。单子操作保持字符串,多子操作产生多个原始 ID 时升级为有序去重字符串数组。kind=52 child 没有协议消息 ID 时不写该字段。 +- SQLite `connector_account_id` 禁止出现在 GoChat payload、source ID 或 Idempotency-Key 中;Connector 重建数据库后本地主键变化不得改变外部标识。 +- GoChat 收到重复 key 必须返回首次创建的资源和 `idempotent_replay=true`,不能再创建消息。 +- 同一 key 但 canonical request hash 不同返回 `409 idempotency_conflict`,禁止静默覆盖。 +- GoChat PostgreSQL 增加 Connector 范围的 partial unique index:`UNIQUE(inbox_id, source_id) WHERE source_id LIKE 'swt:%'`;上线前扫描并清理已有冲突,数据库约束负责封闭并发竞态。 +- GoChat 按稳定字段顺序 canonicalize `message_type/content_type/content/private/source_id/external/external_created_at/external_source_ids/content_attributes/additional_attributes/attachment SHA-256`,将 request SHA-256 持久化;命中 unique index 后用该 hash 区分安全重放与 409 冲突。 +- 所有 API 时间使用 UTC RFC3339Nano;商务通原始时间另存 `content_attributes.swt.raw_timestamp`。 +- GoChat 内部 ID 与 display ID 不混用:Public Conversation API 路径使用 `display_id`,Application API 路径使用内部 conversation ID。 + +### 10.4 鉴权与签名算法 + +Contact identity HMAC: + +```text +identifier = swt_sid +identifier_hash = lowercase_hex(HMAC-SHA256(gochat_hmac_token, UTF8(swt_sid))) +``` + +Webhook HMAC: + +```text +signed_payload = ASCII(unix_timestamp) + "." + raw_request_body +signature = "sha256=" + lowercase_hex(HMAC-SHA256(webhook_secret, signed_payload)) +``` + +- Connector 必须在读取/解析 JSON 前保留原始 body;已知 Inbox 先查本地 secret 验签再完整 decode。仅未知 Inbox 的 `inbox_created|inbox_updated` 允许按第 7.3 节先拉配置、后验原始 body;未知 delete 只做无副作用 ignored,其他未知事件直接拒绝。 +- 时间戳与 Connector 当前时间偏差超过 300 秒返回 401。 +- HMAC 用 constant-time compare;拉取失败或验签失败统一返回 401,不泄露 inbox 是否存在。 +- 本协议不额外强制 TLS/mTLS;在受控内部网络可按部署约定使用 HTTP,并依靠 service token、网络隔离和访问控制。只要链路经过不受信网络,就必须由部署层启用 HTTPS、mTLS 或等价安全隧道,业务 Schema 不随传输方式变化。 + +### 10.5 Connector → GoChat 请求与响应 Schema + +账号配置列表、详情和运行状态回写 Schema 以第 7.4~7.6 节为准。本节只定义商务通事件进入 GoChat contact/conversation/message 的交互;Connector 不再通过 Public Inbox GET 做账号绑定检查。 + +#### 10.5.1 Contact ensure/update + +```http +POST /public/api/v1/inboxes/{inbox_identifier}/contacts +Content-Type: application/json +``` + +```json +{ + "source_id": "visitor-session-id", + "identifier": "visitor-session-id", + "identifier_hash": "4f7c...lowercase-hex", + "name": "北京市客人12", + "email": "", + "phone_number": "+8613800138000", + "avatar_url": "", + "custom_attributes": { + "swt_inbox_id": 123, + "swt_ip_location": "北京市", + "swt_os": "Windows 10", + "swt_browser": "Chrome 149", + "swt_source_type": "friendlink" + } +} +``` + +必填字段为 `source_id`、`identifier`、`identifier_hash`、`name`。电话只在 kind=26 明确解析且通过 E.164/业务校验后写入,不把未知数字猜成手机号。成功或已存在均返回 200: + +```json +{ + "source_id": "visitor-session-id", + "pubsub_token": "opaque-token", + "id": 456, + "name": "北京市客人12", + "email": "", + "phone_number": "+8613800138000" +} +``` + +后续 kind=7/8/26/35/41/61/65/66 使用同结构 `PATCH .../contacts/{swt_sid}`。GoChat 对 custom attributes 采用 merge 语义;缺失字段不删除已有值,显式 `null` 是否删除必须在 contract test 中固定。 + +#### 10.5.2 Conversation ensure + +```http +GET /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}/conversations +POST /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}/conversations +``` + +```json +{ + "custom_attributes": { + "swt_sid": "visitor-session-id", + "swt_inbox_id": 123, + "swt_state": "waiting", + "swt_source_url": "https://example.com/landing", + "swt_source_type": "friendlink", + "swt_assignee_name": "张丽" + } +} +``` + +创建成功返回: + +```json +{ + "id": 88, + "uuid": "b6516b0b-33a0-44e2-9155-164801bc77fd", + "inbox_id": 123, + "contact_last_seen_at": 0, + "status": "open", + "agent_last_seen_at": 0, + "contact": {"id": 456}, + "messages": [] +} +``` + +这里的 `id=88` 是 Public API display ID。Connector 必须从 Application API 查询或创建响应扩展中同时得到内部 conversation ID,写入 `conversation_maps`。为消除二次猜测,本项目要求 Public create response 增加只读 `internal_id`;版本 1 Connector 不得把 display ID 当内部 ID 调 Application API。 + +#### 10.5.3 Message/activity import + +文本、system activity 和外部商务通客服消息使用 JSON: + +```http +POST /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/messages +Content-Type: application/json +``` + +```json +{ + "message_type": "incoming", + "content_type": "text", + "content": "您好,我想咨询", + "private": false, + "source_id": "swt:123:visitor-session-id:2:98765:0", + "external_source_ids": {"shangwutong": "98765"}, + "external": true, + "external_created_at": "2026-07-31T07:30:01.123Z", + "content_attributes": { + "swt": { + "kind": 2, + "subtype": "text", + "seq_id": 98765, + "raw_timestamp": "2026-07-31 15:30:01", + "historical": false, + "unparsed": false + } + }, + "additional_attributes": { + "senderName": "商务通访客" + } +} +``` + +枚举约束: + +- `message_type`: `incoming`、`outgoing`、`activity`;Connector 不创建 `template` 或 `private_note`。 +- `content_type`: 首版使用 `text`、`image`、`audio`、`video`、`file`;`cards` 只有在第 10.12 节的 GoChat 扩展完成后才允许使用。 +- `external` 对所有商务通导入消息固定为 true。GoChat 必须持久化该字段,并在 `external=true` 时跳过 `EnqueueSendReply` 和 API Inbox 出站 webhook。 +- kind=2/3 必须写 `external_source_ids.shangwutong=decimal(seq_id)`;该值是商务通原始消息 ID。出站多子操作可累积为原始 ID 数组。kind=52 child 只有协议明确提供独立 ID 时才写,禁止使用组合幂等键填充。 +- 外部客服/机器人消息使用 `message_type=outgoing`,并将实际名称放入 `additional_attributes.senderName` 与 `content_attributes.swt.operator_name`;未配置 agent 映射时不伪造 GoChat user ID。 +- `activity` 在 GoChat 中居中展示,内容仅允许 Connector 生成的受控 HTML/纯文本模板,原始商务通 HTML 不直接传入。 + +成功或幂等重放返回 200: + +```json +{ + "id": 9001, + "account_id": 1, + "inbox_id": 123, + "conversation_id": 100, + "message_type": "incoming", + "content_type": "text", + "content": "您好,我想咨询", + "source_id": "swt:123:visitor-session-id:2:98765:0", + "external_source_ids": {"shangwutong": "98765"}, + "external": true, + "status": "sent", + "content_attributes": {"swt": {"kind": 2, "seq_id": 98765}}, + "additional_attributes": {"senderName": "商务通访客"}, + "attachments": [], + "created_at": 1785483001, + "idempotent_replay": false +} +``` + +图片、音频、视频和文件使用同一路径的 `multipart/form-data`: + +```text +message_type=incoming|outgoing +content_type=text +content= +source_id=<稳定 source_id> +external_source_ids= +external=true +external_created_at= +content_attributes= +additional_attributes= +attachments[]= +is_voice_message=true|false +``` + +Connector 先下载商务通远程媒体到受限临时文件,完成 SSRF、DNS rebinding、大小、MIME 和超时校验后再上传 GoChat。GoChat 最终 attachment Schema 为: + +```json +{ + "id": 3001, + "message_id": 9001, + "file_type": "image", + "data_url": "https://gochat.example/files/3001", + "thumb_url": "https://gochat.example/files/3001/thumb", + "file_size": 123456, + "extension": "jpg", + "width": 1280, + "height": 720, + "metadata": {"is_voice_message": false} +} +``` + +#### 10.5.4 Conversation attributes/status/typing + +属性 merge: + +```http +POST /api/v1/accounts/{gochat_account_id}/conversations/{internal_id}/custom_attributes +``` + +```json +{ + "custom_attributes": { + "swt_state": "transferring", + "swt_assignee_name": "张丽", + "swt_visitor_left_at": "2026-07-31T07:35:00Z" + } +} +``` + +显式状态切换: + +```http +POST /api/v1/accounts/{gochat_account_id}/conversations/{internal_id}/toggle_status +``` + +```json +{"status":"open"} +``` + +```json +{ + "meta": {}, + "payload": { + "success": true, + "conversation_id": 88, + "current_status": "open", + "snoozed_until": null + } +} +``` + +只使用 `open` 和 `resolved` 作为商务通同步状态;`pending`/`snoozed` 是 GoChat 工作流概念,不用来伪装“等待应答/邀请中/转接中”。同状态重复请求返回 200,不生成重复 activity。 + +访客 typing: + +```http +POST /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}/conversations/{display_id}/toggle_typing +Content-Type: application/json + +{"typing_status":"on"} +``` + +`typing_status` 为 `on|off`,成功返回空 body 200。typing 不进入 SQLite durable 队列,超时即丢弃;下一次状态变化覆盖前值。 + +#### 10.5.5 Message retraction + +```http +DELETE /api/v1/accounts/{gochat_account_id}/conversations/{internal_id}/messages/{gochat_message_id} +``` + +GoChat 不物理删除消息,而是更新为: + +```json +{ + "content": "This message was deleted", + "content_type": "text", + "content_attributes": {"deleted": true} +} +``` + +前端已有 deleted tombstone 展示并隐藏消息状态。kind=-4/-5 的 `text` 按官方客户端静态证据解析为目标 `swt_message_id`,Connector 仅在 `message_maps(account_id,swt_sid,swt_message_id)` 精确命中时调用;重复撤回视为成功,目标字段缺失/非法或不存在映射时转 `unmapped_retraction` 告警,不创建猜测性 tombstone。 + +成功与已撤回重复请求均返回 `204 No Content`;仅当 GoChat 中确实无该 message 且 reconcile 也找不到 `source_id` 时返回 404。 + +#### 10.5.6 坐席消息发送结果回写 + +Connector webhook 入库成功只代表 durable accepted,GoChat message 保持 `progress`。商务通实际发送结束后调用: + +```http +PUT /api/v1/connector/shangwutong/inboxes/{inbox_id}/messages/{gochat_message_id}/status +Authorization: Bearer +Idempotency-Key: swt-delivery::: +``` + +```json +{ + "result_version": 1, + "status": "sent", + "external_id": null, + "external_ids": [], + "error_code": null, + "error_message": null, + "occurred_at": "2026-08-01T08:30:00Z" +} +``` + +- `result_version` 是从 1 开始单调递增的必填整数,必须与 Idempotency-Key 尾部一致;同一外部结果重试使用相同 version 和相同 body。 +- `status` 只允许 `sent|failed|uncertain`。sent/failed 复用现有 `MessageService.UpdateStatus` 和 message status event;不得新建平行状态表。 +- `external_id` 每次只允许填写本次确认的一个商务通原始消息 ID。`oc/send.aspx` 的 `r=ok` 不返回 ID,因此首次 sent 回写为 null;每个 kind=3 回显唯一匹配后以更高 `result_version` 再次回写 `status=sent, external_id=decimal(seq_id)`,GoChat 将一个值保存为字符串,将同一 GoChat message 的多个不同值合并为有序去重数组。组合 `source_id`、时间戳、内容 HASH 和 GoChat ID 都不得写入该字段。 +- `external_ids` 是多子操作的原子快照,最多 100 项并按 part 顺序排列;一个 ID 时 Connector 只发 `external_id` 保持兼容,两个及以上时发 `external_ids`,避免多个回显在一次 status sync 前到达时只保留最后一个。数组内仍只允许十进制商务通原始 ID,禁止组合幂等键。 +- uncertain 不改变 GoChat message 的 progress 状态,只写 `content_attributes.external_delivery_state=uncertain` 和诊断字段;kind=3 回显确认后再回写 sent,观察窗口超时回写 failed。 +- failed 将 `error_code/error_message` 写入标准 `externalError`,供前端失败状态和手动 retry 使用。 +- GoChat 校验 message 属于路径中的 Inbox,且为 `external=false` 的 outgoing/template;越权返回 403,资源不匹配返回 404。 +- GoChat 将最新 `result_version` 及 canonical body hash 保存到 message content attributes;更小版本直接幂等忽略,相同版本同 body 返回成功,相同版本不同 body 返回 409。明确业务拒绝产生的 failed 为终态;`uncertain_timeout` failed 可被更高版本的 kind=3 sent 证据纠正。 +- Connector 在 result 尚未回写成功时保持 `status_sync_status=pending` 并退避重试;只有收到 2xx 才标记 synced。 + +### 10.6 GoChat → Connector webhook v1 Schema + +请求头: + +```http +Content-Type: application/json +X-GoChat-Webhook-Version: 1 +X-Chatwoot-Timestamp: +X-Chatwoot-Signature: sha256= +X-Chatwoot-Delivery: +``` + +公共 envelope: + +```json +{ + "schema_version": 1, + "event": "message_created", + "event_id": "message:9001:created", + "occurred_at": "2026-07-31T07:30:01.123Z", + "account_id": 1, + "inbox_id": 123, + "data": {} +} +``` + +必填字段为 `schema_version`、`event`、`event_id`、`occurred_at`、`account_id`、`inbox_id`、`data`。未知顶层字段可忽略;未知 `schema_version` 返回 422,不能按 v1 猜测解析。 + +#### 10.6.1 `message_created` + +```json +{ + "schema_version": 1, + "event": "message_created", + "event_id": "message:9001:created", + "occurred_at": "2026-07-31T07:30:01.123Z", + "account_id": 1, + "inbox_id": 123, + "data": { + "message": { + "id": 9001, + "message_type": "outgoing", + "content_type": "text", + "content": "您好", + "private": false, + "external": false, + "source_id": "", + "status": "progress", + "content_attributes": {}, + "additional_attributes": {}, + "attachments": [] + }, + "conversation": { + "id": 100, + "display_id": 88, + "status": "open", + "custom_attributes": { + "swt_sid": "visitor-session-id", + "swt_inbox_id": 123 + } + }, + "contact": { + "id": 456, + "source_id": "visitor-session-id", + "name": "北京市客人12" + }, + "sender": { + "id": 77, + "type": "user", + "name": "GoChat 坐席" + } + } +} +``` + +投递筛选必须在 GoChat job 入队前完成: + +- 只投递 `Channel::Shangwutong` Inbox 的 `message_type=outgoing|template`、`private=false`、`external=false` 消息;普通 API Inbox 保持自身 webhook 行为。 +- `source_id` 以 `swt:` 开头或 `content_attributes.swt.imported=true` 的消息不投递。 +- activity、incoming、private note、已 deleted 的消息不投递。 +- 消息附件数组每项至少包含 `id`、`file_type`、`data_url`、`file_size`、`extension`;Connector 按商务通能力决定发送或返回永久不支持错误。 + +##### 10.6.1.1 `message_retry_requested` + +现有失败消息 retry API 对 `Channel::Shangwutong` 使用专用分支:只允许 `status=failed`、`external=false` 的 outgoing/template;在同一事务将消息改回 progress、递增并持久化 `content_attributes.external_retry_version`、清除 `externalError` 与 `external_delivery_state`,同时写入 durable job。不得清空其他 content attributes,也不得进入旧 `EnqueueSendReply`。非商务通渠道保持现有行为。 + +Webhook 使用与 `message_created` 完全相同的 `data.message/conversation/contact/sender` Schema,并额外增加: + +```json +{ + "schema_version": 1, + "event": "message_retry_requested", + "event_id": "message:9001:retry:1", + "occurred_at": "2026-08-01T09:00:00Z", + "account_id": 1, + "inbox_id": 123, + "data": { + "retry_version": 1, + "message": {"id": 9001, "status": "progress"}, + "conversation": {"id": 100, "custom_attributes": {"swt_sid": "visitor-session-id"}}, + "contact": {"id": 456, "source_id": "visitor-session-id"}, + "sender": {"id": 77, "type": "user", "name": "GoChat 坐席"} + } +} +``` + +示例为节省篇幅省略了公共 message 字段,真实请求不得省略第 10.6.1 节要求的正文、属性和 attachments。Connector 对相同/更旧 `retry_version` 幂等返回原 `queue_id`;较新版本仅在本地记录为 failed 时持久化新 retry version,将整行 delivery status 和未 delivered 子操作重置为 pending、`status_sync_status` 重置为 `not_required`,同时清理本轮 claim/退避错误但保留历史 `result_version` 和已 delivered 子操作。本地记录缺失时可由完整 payload 重建。若本地仍为 delivering/uncertain/delivered 则返回 409,禁止在结果不确定或已成功时重复发送。每次重试产生的新外部结果继续递增 `result_version`,不把 retry version 当作 result version。 + +#### 10.6.2 `conversation_status_changed` + +```json +{ + "schema_version": 1, + "event": "conversation_status_changed", + "event_id": "conversation:100:status:42", + "occurred_at": "2026-07-31T07:35:00Z", + "account_id": 1, + "inbox_id": 123, + "data": { + "conversation": { + "id": 100, + "display_id": 88, + "status": "resolved", + "previous_status": "open", + "custom_attributes": {"swt_sid": "visitor-session-id"} + }, + "actor": {"id": 77, "type": "user", "name": "GoChat 坐席"} + } +} +``` + +- 只有 `open → resolved` 生成 durable `oc/end.aspx` outbox operation。 +- `resolved → open` 只更新 Connector mapping;商务通会话已结束时不能保证恢复原 sid,需等待新访客事件建立新会话。 +- 同状态变化和由 Connector 导入触发的状态变化必须通过 event ID/来源标记去重,防止双向循环。 + +#### 10.6.3 `conversation_typing_on|conversation_typing_off` + +```json +{ + "schema_version": 1, + "event": "conversation_typing_on", + "event_id": "typing:100:77:1785483001123", + "occurred_at": "2026-07-31T07:30:01.123Z", + "account_id": 1, + "inbox_id": 123, + "data": { + "conversation": { + "id": 100, + "display_id": 88, + "custom_attributes": {"swt_sid": "visitor-session-id"} + }, + "actor": {"id": 77, "type": "user"}, + "private": false + } +} +``` + +typing 采用短超时同步投递,Connector 只更新该账号 supervisor 的下一次 heartbeat `c/i` 字段;不为 typing 创建 outbox 记录,不允许它阻塞消息 webhook。 + +#### 10.6.4 `inbox_created|inbox_updated|inbox_deleted` + +生命周期事件使用相同公共 envelope,`data` 固定为: + +```json +{ + "channel_type": "shangwutong", + "config_version": 7 +} +``` + +`inbox_created/inbox_updated` 只触发第 7.4 节单 Inbox 配置拉取,`inbox_deleted` 只触发本地 tombstone;任何事件都不携带登录字段或完整配置。只有 `channel_type=shangwutong` 才由 Connector 接受。未知 Inbox 的 bootstrap 验签和失败语义以第 7.3 节为准。 + +#### 10.6.5 Webhook ACK 与错误 Schema + +新事件在 SQLite 事务提交后返回 202: + +```json +{ + "accepted": true, + "duplicate": false, + "event_id": "message:9001:created", + "delivery_id": "d8fa884d-f229-47a9-a9a8-6d909ddb15e9", + "queue_id": 12345 +} +``` + +`queue_id` 只用于已写入 outbound_messages 的 message/retry/status 操作。生命周期事件完成配置 upsert/tombstone 后返回同一 envelope,但省略 `queue_id`,增加 `applied_config_version`;重复版本返回 200、`duplicate=true`。重复 message/retry event 返回首次 `queue_id`。验签失败不返回这些标识。错误统一为: + +```json +{ + "error": { + "code": "unsupported_schema_version", + "message": "schema_version 2 is not supported", + "retryable": false, + "request_id": "9fef2cd6-4cef-4a53-93ba-40f152797506" + } +} +``` + +#### 10.6.6 GoChat durable delivery job + +新增任务类型 `api_inbox:webhook_delivery:v1`: + +1. 坐席消息创建后以 `api-inbox-message::created` 入队;失败消息重试以 `api-inbox-message::retry:` 入队;明确的会话状态变化以 `api-inbox-conversation::status:` 入队;Inbox 配置提交后以 `swt-inbox::config:` 入队,删除事件使用稳定 tombstone version。 +2. message/retry/status/create/普通 update job 执行时重新加载所需 Inbox、ChannelAPI、Message、Conversation、Contact、Sender 和 Attachments,按本节生成 payload;不得把创建事件时的临时 `ChannelEvent.Data` 直接持久化为外部合同。secret 轮换 update 必须额外保存 old signing secret;`inbox_deleted` 必须在删除事务提交前持久化 `webhook_url/current_secret/account_id/inbox_id/channel_type/tombstone_version` 快照。 +3. Connector 返回 2xx 后完成 job。message webhook 2xx 只表示 Connector outbox 已可靠入库,GoChat message 保持 progress;实际 sent/failed 由第 10.5.6 节状态回写驱动。 +4. 429/5xx/网络错误由现有 `background_jobs` worker 退避重试;4xx 按第 10.7 节分类。只有 message/retry job 的永久错误才将对应 message 标 failed 并写 `externalError.code`;conversation/lifecycle job 记录告警并保留可重试任务,不得误改无关消息。 +5. 最大尝试次数为 10。message/retry job 耗尽后把 GoChat message 标 failed 并写 `externalError.code=connector_delivery_exhausted`;conversation/lifecycle job 只进入告警和运维重试,不伪造 message 状态。webhook delivery job 的运维手动 retry 复用相同 `event_id`、生成新 `delivery_id`。坐席对 failed message 的业务重试则按第 10.6.1.1 节生成递增 retry version 和新的业务 `event_id`,两者不得混用。 +6. 商务通 Inbox message/status/lifecycle 不再经过同步 WebhookListener 重复投递;普通 API Inbox 和 account webhook 保持原行为。 +7. typing 不进入 durable job,使用短超时 best-effort 投递,失败只记低频 metric。 + +### 10.7 状态码、重试与失败语义 + +| HTTP 状态 | 语义 | 调用方动作 | +|---:|---|---| +| 200 | 成功或幂等重放 | 完成 | +| 202 | webhook/异步命令已可靠入库 | 完成,后续查状态 | +| 400 | JSON/字段错误 | 永久失败,人工或代码修复 | +| 401 | token/HMAC/时间戳无效 | 不自动高频重试;告警并等待凭据修复 | +| 403 | token 无 account/inbox 权限 | 永久失败并告警 | +| 404 | 映射资源不存在 | 先执行一次 ensure/reconcile,再失败 | +| 409 | 幂等 key 对应不同 payload 或状态冲突 | 不重试,进入人工诊断 | +| 413 | body/附件过大 | 永久失败;可生成 GoChat fallback text | +| 415 | MIME/content type 不支持 | 永久失败;按映射策略降级 | +| 422 | Schema 版本或业务语义不支持 | 永久失败 | +| 429 | 限流 | 按 `Retry-After` 重试并加 jitter | +| 500/502/503/504 | 临时服务错误 | 指数退避重试 | + +- Connector 与 GoChat 本项目新增/扩展端点统一使用第 10.6.5 节的 `{ "error": {code,message,retryable,request_id} }` envelope。Connector 在兼容现有 GoChat 端点期间可以解析历史顶层 `{"error":"..."}`,但 contract fixture 和新代码只生成标准 envelope。 +- 网络错误发生在请求尚未写出前可安全重试;不确定是否提交时必须用同一 `Idempotency-Key` 重试。 +- Connector → GoChat 的超时不推进该事件 delivery 状态,但商务通 cursor 已因原始事件落盘而可推进。 +- GoChat → Connector 只有收到 2xx 才完成 durable job;非 2xx 和网络错误由 GoChat worker 重试。 + +### 10.8 GoChat 展示能力基线 + +| GoChat 场景 | 当前展示能力 | 商务通使用方式 | +|---|---|---| +| 普通文本/换行/链接 | 原生 text bubble | 直接映射,先清洗 HTML | +| 图片/音频/视频/文件 | 原生 attachment bubble | 上传为 attachment;语音设置 `is_voice_message` | +| 居中系统事件 | 原生 activity bubble | 状态、分配、转接、邀请等受控文案 | +| 消息撤回 | 原生 deleted tombstone | 精确 message map 后 DELETE | +| Contact 名称/电话/自定义字段 | Contact 详情原生支持 | kind=7/26/35/41/61/65/66 更新 | +| Conversation custom attributes | 可持久化并在属性区域展示 | 状态、来源、外部客服、机器人信息 | +| open/resolved | 原生状态 | 对话开始/明确结束 | +| location/contact attachment | 前端已有 bubble,当前后端 serializer/ingress 元数据不完整 | 完成 metadata contract 后启用 | +| cards/form | 有部分 content type/旧组件基础,但没有商务通商品 Schema | 首版不用作无损映射 | +| 动态 label color | GoChat label 是全局 tag 语义 | 不能直接等价映射 | + +### 10.9 商务通 kind → GoChat 场景总映射 + +分类:`E`=原生近似无损;`L`=可展示但有语义损失;`A`=只更新属性/状态;`R`=只保留原始事件;`X`=需要 GoChat 扩展。一个事件可以同时采用多种策略。 + +| kind | 商务通含义 | GoChat 映射 | 分类 | 处理细节 | +|---:|---|---|---|---| +| -8 | 首次响应统计 | Connector metric + raw;未来 reporting event ingest | R/X | 不生成聊天气泡,解析 `chat_firstresponsetime` 和 operator | +| -7 | 内部跳过 | raw + ignored | R | 推进 mid,不展示 | +| -5 | 客服撤回 | DELETE 已映射 outgoing message | E/R | `text` 是目标 `swt_message_id`;无目标映射时 `unmapped_retraction` | +| -4 | 访客撤回 | DELETE 已映射 incoming message | E/R | 同上 | +| 0 | 访客状态 | status/custom attributes/activity | E/L/A | 按 10.9.1 子表处理 | +| 1 | 内部跳过 | raw + ignored | R | 不展示 | +| 2 | 访客消息 | incoming message/attachment | E/L/X | `swt_message_id=decimal(seq_id)`,按 10.10 内容子表处理 | +| 3 | 客服消息 | local echo 对账或 external outgoing | E/L | `swt_message_id=decimal(seq_id)`;本 Connector 回显补写 GoChat external source ID,其他客服导入 GoChat | +| 5 | 旧文档误标“对话中” | raw + quarantine | R | 源码无独立 kind=5 分支;对话中只能按 kind=0/text=5 处理 | +| 7 | 访客环境信息 | Contact custom attributes | A | IP/地区/ISP/分辨率/语言/时区/OS/浏览器/UA;敏感字段受权限控制 | +| 8 | 访客来源页面 | Conversation/Contact custom attributes | A | URL、来源描述、source type;注意这不是转接状态 | +| 11 | 商务通操作员在线状态 | Connector `actual_presence`/诊断 | A/R | 不映射 GoChat agent presence,避免误导 | +| 12 | 客服别名 | conversation `swt_operator_alias` | A | 有静态 agent mapping 时可辅助匹配 | +| 14 | 内部跳过 | raw + ignored | R | 不展示 | +| 15 | 文件传输 | file attachment | E/L | URL 不可下载时 fallback text + 原链接 | +| 24 | 内部跳过 | raw + ignored | R | 不展示 | +| 26 | 电话回拨请求 | 更新 Contact name/phone + incoming activity/text | E/L/X | 首版显示“访客请求回拨”;富回拨卡片需扩展 | +| 29 | 标签颜色 | `swt_label_color` custom attribute | A/X | 可配置映射到预创建 GoChat label,不能动态改全局 label 色 | +| 30 | 系统提示 | activity message | E | 清洗为受控纯文本 | +| 31 | 系统消息 | activity/outgoing/attributes/raw | E/L/A/R/X | 按 10.11 子表处理 | +| 34 | 机器人状态 | conversation `swt_robot_state` | A/X | GoChat 无等价机器人控制状态 | +| 35 | 第三方访客来源 | Contact/Conversation custom attributes | A/L | 已知字段展开,未知字段留 raw | +| 38 | 内部跳过 | raw + ignored | R | 不展示 | +| 39 | 旧文档标“分配客服”但无源码分支 | raw + quarantine | R | 不等同 kind=31/distribute_chat,收到真实样本后再增加 parser | +| 41 | 访客显示名 | PATCH Contact name | E | 空名不覆盖已有名 | +| 44 | 内部跳过 | raw + ignored | R | 不展示 | +| 52 | 历史消息批次 | 拆为多条 message,保留 external time | L/X/R | 每个 child 独立 message key;格式不完整时整批 raw 并告警 | +| 56 | 会话类型 | conversation `swt_conversation_type` | A | 可选映射预创建 label | +| 58 | 访客离开 | attribute + activity | L/A | 不立即 resolve;等待明确 ended 或可配置 grace period | +| 61 | 访客名更新 | PATCH Contact name | E | 与 kind=41 同一 merge 规则 | +| 62 | 内部跳过 | raw + ignored | R | 不展示 | +| 65 | XST 访客画像 | Contact/Conversation custom attributes | A/R/X | char(26) 字段仅对已验证键展开,未知字段 raw | +| 66 | 搜索关键词/来源 | Contact/Conversation custom attributes | E/A | `swt_search_keyword`、`swt_referrer`、`swt_search_engine` | +| 67 | 机器人自动回复 | external outgoing message | E/L | senderName=商务通机器人;富内容再按 10.10 解析 | +| 71 | 访客关闭页面 | attribute + activity | L/A | 不等同对话结束,不自动 resolve | +| unknown | 未知 kind | raw + metric + quarantine | R | 推进对应 cursor,不能丢弃;规则上线后可 replay | + +kind=11 的 `text=0/1/2/3` 分别更新 Connector `actual_presence=offline/away/busy/online`;它表示商务通操作员状态,不广播成 GoChat 坐席 presence。 + +#### 10.9.1 kind=0 状态子表 + +| text | 商务通状态 | GoChat status | 额外展示/属性 | +|---:|---|---|---| +| 0 | 新访客 | 不主动创建会话或保持现状 | Contact ensure,`swt_state=new_visitor` | +| 1 | 邀请中 | 不改 status | activity“已发起邀请”,`swt_state=inviting` | +| 3 | 等待应答 | `open` | activity“访客等待接待”,记录 waiting_since | +| 5 | 对话中 | `open` | `swt_state=chatting`,不重复发 activity | +| 6 | 内部转接 | `open` | activity + `swt_state=internal_transfer` | +| 7 | 转接中 | `open` | activity + `swt_state=transferring`;注意这里是 kind=0/text=7 | +| 8 | 转接接受 | `open` | activity + `swt_state=transfer_accepted` | +| 10 | 已离开 | 保持 open | activity + left_at;明确 ended 后再 resolve | + +GoChat `pending` 不代表商务通“等待应答”,因此不做该映射。状态 activity 以 `(swt_sid,state,seq_id)` 幂等,连续重复状态只更新属性。 + +### 10.10 kind=2/3/67 消息内容映射 + +| 商务通内容 | GoChat 展示 | 分类 | 解析与降级 | +|---|---|---|---| +| 纯文本 | text bubble | E | URL decode + HTML entity decode,保留换行 | +| `

/
` HTML | text bubble | L | 白名单转 Markdown/纯文本;脚本、style、事件属性全部移除 | +| HTML 内 `` | image attachment + caption | E/L | 多图支持;无法下载时用 alt/URL fallback | +| HTML 表情图片 | emoji/小 image 或 Unicode 文本 | L | 有已知表情字典则转 Unicode,否则保留 alt 文本 | +| `voice_msg|source|url` | audio attachment | E | 校验来源与 MIME,设置 voice metadata | +| `filemsg|...` | file attachment | E/L | 保留文件名、大小、MIME;字段缺失时 fallback link | +| `baidunmdata_msg|1|url` | 商品卡片 | L/X | 首版 text 摘要 + link;需 `swt_rich_card` 才能无损 | +| `baidunmdata_msg|3|url` | 案例卡片 | L/X | 同上 | +| `baidunmdata_msg|other|url` | 优惠券卡片 | L/X | 同上 | +| 微信/企业微信 JSON `text` | text bubble | E | 保留渠道名在 content attributes | +| 微信/企业微信 JSON `voice` | audio attachment | E/L | 下载地址/token 失效时 fallback text | +| 微信/企业微信 JSON `image` | image attachment | E/L | 同上 | +| `0|好友进入对话窗口` | activity | E | 不当作访客发言 | +| 字节跳动咨询签名 URL | text/link 或 image attachment | L/R | 需真实样本确认签名资源类型与有效期 | +| 可识别 location JSON | location attachment | X/L | 后端 metadata serializer 补齐后原生展示;之前转文本地址 | +| 可识别 contact JSON | contact attachment | X/L | 后端 metadata serializer 补齐后原生展示;之前转姓名/电话文本 | +| 未识别 JSON/前缀 | text fallback + raw | L/R | `content_attributes.swt.unparsed=true`,正文限制长度,原始数据只在 Connector 保存 | + +出站 GoChat → 商务通能力矩阵: + +| GoChat 内容 | 商务通发送 | 策略 | +|---|---|---| +| text | `oc/send.aspx` | 原生支持 | +| image attachment | `oc/sendmorepics.aspx` | 原生支持;逐项发送并保序 | +| file attachment | `oc/sendfile.aspx` | 原生支持 | +| audio/voice | voice upload + `voice_msg` | 真实账号验证后启用 | +| video | 无已确认原生协议 | 首版阻止并在 GoChat 标 failed;可配置转文件或链接需用户确认 | +| location/contact | 无已确认原生协议 | 转明确文本,不伪装为原生卡片 | +| cards/form/template | 无等价协议 | 拒绝并返回 `unsupported_outbound_content`,不得只发送不可理解 JSON | +| 多附件 + 文本 | 文本后逐附件串行 | 每个子操作持久化进度,重启不从头重复已确认子项 | + +### 10.11 kind=31 系统消息子类型映射 + +| 子类型 | GoChat 映射 | 分类 | 备注 | +|---|---|---|---| +| `distribute_chat|name` | activity + `swt_assignee_name` | E/A | 首版不调用 assign API;未来明确配置 operator→agent 映射后才允许分配 | +| `distribute_lastoname|name` | attribute | A | 默认不生成气泡 | +| `guest_direct_chat|name` | activity + assignee attribute | E/A | 不自动创建同名 GoChat agent | +| `guest_open_chat` | status=open + activity | E | ensure conversation | +| `guest_continue_chat` | status=open + activity | E | 已 resolved 时 reopen | +| `cdcheck_chat_ended` | status=resolved + activity | E | 明确结束信号 | +| `away_timeout_chat` | activity;可配置 resolve | L | 默认等待真实业务确认 | +| `robot_chat` | `swt_robot_state=active` + activity | A/L/X | 无 GoChat 等价控制状态 | +| `robot_chat_fenpei|...` | robot/assignee attributes + activity | A/L | 字段按真实样本解析 | +| `invite0|name` | invitation activity | L/X | 快速邀请;无访客侧动作 UI | +| `invite1|name` | invitation activity | L/X | 强制对话 | +| `invite2|...` | invitation activity + raw | L/R/X | 自定义邀请正文需清洗 | +| `invite3|name` | invitation activity | L/X | 请求直接对话 | +| `inviteyuyue|name` | invitation activity | L/X | GoChat form 不能代表商务通原预约单 | +| `invitepingjia|name` | invitation activity | L/X | 不映射成 GoChat CSAT,避免评价回错系统 | +| `guest_refuse_invite|...` | activity | E/L | 保留拒绝原因文本 | +| `distribute1_chat|...` | raw + 可选 activity | R/L | 对话列表结构需真实包确认 | +| `lastoname_is|...` | assignee attribute | A | 不展示内部标识 | +| `ACT_CL|wx_{id}` | Contact `swt_wechat_id` + activity | E/A | 属于敏感字段,受权限控制 | +| `ACT_CL|num_{phone}` | 校验后更新 phone + activity | E/A | 不合法号码只保存 raw | +| `ACT_CL|{dial}` | activity | L | 仅表示进入拨号,不等于呼叫成功 | +| `ACT_XST|RobotAutoMsg|text` | external outgoing | E | senderName=商务通机器人 | +| `ACT_XST|SWTSettingAutoMsg|text` | external outgoing | E | senderName=商务通自动回复 | +| `ACT_XST|SystemAutoMsg|text` | external outgoing/activity | E/L | 有明确对话内容用 outgoing,否则 activity | +| `ACT_XST|BaiduAutoMsg|text` | external outgoing | E | 去掉稳定前缀但保留来源属性 | +| `ACT_XST|` | activity | L | 仅白名单可见文本 | +| `ACT_XST|NotShow|QuDaoVisitorInfo|...` | attributes + raw,不建气泡 | A/R | 与名称语义一致,避免污染会话 | +| `ACT_XST|NotShow|QuDaoInfoWhenReceiveVisitorMsg|json` | attributes + raw | A/R | 展开 SWTID/SWTCID/QueryWord 等已知键 | +| `ACT_XST|NotShow|BaiduRobotReleasedNoticeMsg|...` | robot state + raw | A/R | 不展示内部控制消息 | +| `ACT_XST|NotShow|BaiduRobotRecvKFMsgState|...` | outbound delivery hint + raw | A/R | 可用于对账,不能当新消息 | +| `ACT_XST|NotShow|SWTRobotChatWindowNotice|...` | robot state + raw | A/R | 需样本确认字段 | +| `ACT_XST|NotShow|BaiduLingYinCallBackUrlInfoMsg|...` | raw;可提取受控 callback link | R/X | 链接权限/有效期未确认 | +| `ACT_XST|ByteDanceLeaveMsg|...` | 解析成功则 incoming,否则 activity+raw | L/R/X | 需真实包确认留言正文、访客 ID、媒体结构 | +| `ACT_XST|ByteDanceSysMsg|...` | activity + raw | L/R | 不可见控制字段不展示 | +| `ACT_XST|ByteDanceCSFenPeiMsg|...` | assignee attribute + activity | L/A | 不自动匹配 agent | +| 其他 `ACT_XST` | 白名单文本 activity,否则 raw | L/R | 未知结构不能直接渲染 HTML | + +### 10.12 无法直接映射的类型与解决评估 + +| 类型/场景 | 无法直接映射原因 | 首版降级 | 推荐解决方案 | 决策/优先级 | +|---|---|---|---|---| +| 商品/案例/优惠券卡片 | GoChat 没有商务通卡片字段和稳定 bubble contract | text 摘要 + 原链接 | 新增通用 `external_rich_card` content type、JSON Schema 和前端 bubble | P1,样本齐全后做 | +| 邀请/预约/评价动作 | GoChat conversation status/form/CSAT 会触发不同业务,不等价 | activity + attributes | 若需要在 GoChat 操作商务通邀请,增加 Connector command API 与专用 action component | P2,不阻塞收发消息 | +| 等待/内部转接/转接中 | GoChat 只有 open/pending/resolved/snoozed,语义粒度不足 | status=open + `swt_state` + activity | 增加 channel-neutral `external_state` 展示 badge,不扩展核心 status enum | P1 | +| 商务通 label color | GoChat label color 属于全局 tag,不是会话临时颜色 | `swt_label_color` attribute | 管理端配置 color→预创建 label ID 映射 | P2 | +| 机器人状态/控制通知 | GoChat agent bot 状态与商务通机器人状态机不同 | attributes/raw,用户可读自动回复仍导入 | 增加 external bot state badge;控制操作仍留 Connector | P2 | +| 外部客服身份/在线状态 | 商务通名称/别名不能可靠对应 GoChat user ID | senderName attribute;presence 只在 Connector 显示 | 可配置 operator→GoChat agent 映射表,禁止按姓名自动创建/匹配 | P1 | +| kind=-8 首响统计 | GoChat reporting_events 没有外部幂等 ingest contract | Connector metric/raw | 增加受保护 reporting event ingest,并标 source=swt | P2 | +| kind=7/8/35/65/66 画像与来源 | 可存 custom attributes,但默认 UI 不一定注册字段,且含 PII | 保存白名单字段,敏感值限制权限 | 部署时创建 GoChat custom attribute definitions;增加数据保留/脱敏策略 | P0 合规项 | +| 撤回目标 ID 缺失/未命中 | 基线协议已确认 kind=-4/-5 的 `text` 是目标消息 ID,但异常版本、历史缺口或映射丢失时仍无法定位 | 记录 unmapped 告警,不猜测 | 用真实抓包覆盖不同渠道/版本;必要时按 GoChat `external_source_ids` 补建 Connector 映射 | P1,不阻塞基线精确撤回 | +| kind=52 历史批次缺 ID/方向 | 一个事件可含多条消息,格式可能跨版本 | 可验证 child 才导入,其余整批 raw | 为每种版本做 fixture parser;增加 history replay 工具 | P1 | +| ByteDance/NotShow/XST 私有结构 | 文档只有部分样本,字段和可见性不稳定 | human-readable 白名单 activity,其余 raw | 真实账号采包、版本化 subtype parser,未知字段 quarantine | 持续验证 | +| video/location/contact/cards 出站 | 商务通发送协议没有已验证等价端点 | 明确失败或经配置转文本/文件 | 抓包确认端点;没有端点则保持不支持 | 不伪造支持 | +| 复杂 HTML/动态表情 | GoChat 会清洗 HTML,远端资源可能失效 | 纯文本/附件/alt 降级 | 建立表情字典和安全 HTML 转换 golden tests | P1 | + +“最大可能复现”不等于把未知 payload 直接显示给坐席。所有事件都必须做到:原始数据可追溯、已知字段尽量结构化、可见内容安全、未知类型可统计并可在 parser 升级后 replay;无法证明语义的状态和动作宁可标记为 raw/unsupported,也不能伪造成错误的 GoChat 业务状态。 + +--- + +## 11. 可靠性、幂等与重试 + +### 11.1 入站可靠性 + +```text +商务通心跳响应 +→ SQLite 事务:事件去重 + cursor 推进 +→ inbound worker 调 GoChat +→ 2xx 后标记 delivered +``` + +GoChat 不可用时 cursor 仍可推进,因为原始事件已持久化;恢复后按账号和 seq_id 顺序补投。单个失败事件不能永久阻塞后续账号,但同一 `swt_sid` 的会话创建与首条消息必须保持先后顺序。 + +### 11.2 出站可靠性 + +```text +GoChat durable webhook +→ Connector SQLite outbox commit +→ 2xx ack GoChat +→ outbound worker 调商务通 +→ 成功/失败/uncertain 持久化 +→ 幂等回写 GoChat message status +``` + +重试规则: + +- 明确未建立连接、DNS 失败等未发送错误:指数退避自动重试。 +- `tickint reset`:先触发 supervisor 重登,再重试。 +- `state err`、`sid err`、`ended`:业务失败,不自动无限重试。 +- 请求已写出后发生 timeout/EOF:标记 `uncertain`,等待 kind=3 回显对账;未确认前不自动重复发送。 +- `uncertain` 固定观察 5 分钟;超过窗口仍无法确认时标记 `failed/uncertain_timeout`,之后才允许坐席或管理员通过第 10.6.1.1 节显式重试;progress/uncertain 状态不显示普通 retry 操作。晚到 kind=3 在 10 分钟受限匹配窗口内仍可用更高 result version 纠正为 sent,但已出现多个候选时不得猜测。 +- 多子操作消息每完成一个文本/附件就持久化进度;进程恢复和显式 retry 只发送未成功子项,避免部分成功后整条重发。 +- 外部结果每次变化都将 `status_sync_status` 重置为 pending;GoChat 暂时不可达不改变已经确认的商务通结果,恢复后继续回写。 + +### 11.3 重试参数 + +- 初始退避 1 秒,指数增长,最大 5 分钟,并加入 jitter。 +- 登录认证错误和 verification_required 不进入自动重试。 +- inbound/outbound 默认最多 10 次;GoChat 出站 webhook 使用现有 worker 默认退避并配置 10 次。 +- failed 和 uncertain 记录保留,不能自动删除以掩盖故障。 + +### 11.4 顺序保证 + +- 心跳响应事件按原始行顺序写入。 +- 同一账号、同一 `swt_sid` 的入站事件串行投递。 +- 不同账号可并行。 +- 同一 `gochat_message_id` 只允许一条 outbound 记录。 +- outbound worker 使用第 6.6 节原子 claim;同一账号最多一条 delivering,并严格按 outbox ID 发送。更早的 uncertain 会阻塞该账号后续消息,直到 kind=3 确认或观察窗口转 failed,不能为了吞吐绕过不确定结果。首版用账号级串行换取正确顺序,只有规模测试证明单账号吞吐不足且新模型仍能处理 session 变更与 uncertain 屏障时,才放宽为按 `swt_sid` 串行。 + +--- + +## 12. 升级、停机与恢复 + +### 12.1 GoChat 升级 + +- Connector 不随 GoChat Web/API 容器重启。 +- 商务通 supervisor 继续心跳并把事件写入 SQLite。 +- GoChat 返回连接失败/503 时 inbound worker 退避,不能阻塞心跳。 +- 配置拉取和状态回写失败时,Connector 继续使用 SQLite 中最后一次已应用配置;不得将 API 不可达解释为 `offline/disabled/deleted`。 +- GoChat 恢复后按顺序补投。 +- GoChat 发往 Connector 的消息已经由 durable webhook job 保存,Connector 暂时不可达时不会静默丢失。 +- GoChat 升级期间继续生成 webhook Schema v1;v1 可增加可选字段但不能删除/改类型。破坏性 v2 必须使用新 version header/fixture,先升级 Connector 接受 v1+v2,再切 GoChat emit v2。 +- GoChat API 暂时不可用只会增加 Connector inbound queue,不会让 500+ 商务通账号 logout;恢复后用相同 Idempotency-Key 补投。 +- GoChat 恢复后,持久 lifecycle webhook 与手动/下次启动 reconcile 会修正配置;GoChat 升级本身不会停止任何商务通 supervisor。 + +### 12.2 Connector 升级 + +首版允许全部账号短时恢复,流程固定: + +1. readiness 置为失败,负载均衡停止向该实例发送新 webhook。 +2. 停止启动新的 reconcile;webhook 在 HTTP shutdown 前继续接收。 +3. cancel supervisor,等待在途心跳完成 SQLite 事务。 +4. 停止 relay worker;inbound delivering 恢复 pending,outbound 只有确认请求未写出才恢复 pending,否则标 uncertain。 +5. HTTP server graceful shutdown。 +6. 执行 WAL checkpoint,关闭 DB。 +7. 不调用商务通 logout,不改 desired presence。 +8. 新进程优先使用保存的 token+cursor 恢复;只有 token reset 才重新登录。 + +### 12.3 崩溃恢复 + +- 启动时将超时停留的 inbound delivering 恢复为 pending;崩溃后遗留的 outbound delivering 一律恢复为 uncertain,因为 `claimed_at` 不能证明 HTTP 请求尚未写出。只有优雅停机时仍存活的 worker 能明确证明尚未调用协议 client,才可在退出前主动恢复 pending。 +- SQLite 事务保证 cursor 不会领先于已持久化事件。 +- outbox 的 GoChat message ID 唯一约束阻止 webhook 重放。 +- 定期执行 `PRAGMA quick_check`;完整备份周期执行 `integrity_check`。 + +--- + +## 13. 可观测性与运维 + +### 13.1 日志 + +统一结构化字段: + +```text +component +connector_account_id +gochat_inbox_id +swt_sid(仅会话日志需要) +operation +result +error_code +duration_ms +``` + +禁止记录密码、`ma`、GoChat token、HMAC secret、完整登录 body 和完整 Authorization header。访客消息正文默认不进入 info 日志。 + +### 13.2 指标 + +至少提供: + +```text +swt_connector_accounts{connection_status,presence} +swt_connector_heartbeat_total{result} +swt_connector_heartbeat_duration_seconds +swt_connector_login_total{result} +swt_connector_presence_change_total{result} +swt_connector_inbound_queue_depth{status} +swt_connector_outbound_queue_depth{status} +swt_connector_delivery_total{direction,result} +swt_connector_status_sync_total{result,status} +swt_connector_status_sync_queue_depth +swt_connector_event_mapping_total{kind,strategy,result} +swt_connector_unknown_event_total{kind_group} +swt_connector_unmapped_retraction_total{direction} +swt_connector_contract_error_total{direction,code} +swt_connector_sqlite_write_duration_seconds +swt_connector_supervisors +``` + +不添加 account_id、username、session_id 等高基数或敏感标签;`kind` 只允许第 10.9 节的已知有限集合,未知值只按 `negative/0_69/70_plus/parse_error` 分组,精确值仅进入受控诊断日志和 SQLite 审计记录。 + +### 13.3 告警建议 + +- `auth_failed`、`credential_status=rejected` 或 `verification_required` 持续超过 5 分钟。 +- connected 账号比例低于配置账号的 95%。 +- inbound/outbound pending 最老记录超过 2 分钟。 +- uncertain 出站消息大于 0。 +- 已完成商务通发送但 GoChat status sync pending 最老记录超过 2 分钟。 +- unknown kind/subtype、raw-only fallback 或 unmapped retraction 在 5 分钟内持续增长。 +- webhook Schema/签名/幂等冲突错误大于 0。 +- lifecycle 通知持续失败、配置版本落后或全量 reconcile 连续失败。 +- SQLite quick_check 非 ok、写事务超过 1 秒或 DB volume 空间低于 20%。 +- GoChat 连续不可达超过 1 分钟。 + +--- + +## 14. 分阶段实施计划 + +### Phase 0:协议基线与工程骨架 + +实施: + +- 建立独立 Go 1.26 module,固定 Cobra、Fiber v3、sqlc、Logrus 和 modernc SQLite 版本。 +- 建立 Cobra `serve/migrate/reconcile/backup/doctor` 命令、Fiber v3 webhook/health 路由、Logrus redaction hook、配置加载和优雅退出。 +- 建立 `sqlc.yaml`、migration/query 目录、生成代码提交规则和 CI drift check。 +- 移植 DES、BASE URL、登录响应、心跳行和 cursor 纯函数。 +- 移植 Python/Go 参考 fixed vectors,纠正参考实现中的端点或参数差异。 +- 建立 typed protocol errors 和无敏感信息日志规范。 + +验收: + +- `go test ./...`、`go vet ./...` 通过。 +- `go tool sqlc generate` 后工作树无差异,所有 store query 均有生成方法或明确的手写例外。 +- Fiber recover/body-limit/auth middleware 顺序有集成测试,Cobra 每个命令有参数和退出码测试。 +- DES fixed vector 与 Python 输出逐字节一致。 +- 登录、心跳和 kind parser 对参考样本全部通过。 +- 协议包不依赖 store、GoChat 或 HTTP API 包。 + +### Phase 1:商务通 Inbox、配置同步、SQLite 与 supervisor + +实施: + +- 完成 embedded migration、sqlc reader/writer queries、`WithTx` 事务 helper 和队列查询。 +- 在 GoChat 增加 `Channel::Shangwutong` Inbox 类型和 `channel_shangwutong_configs` 迁移,复用 ChannelAPI identifier/HMAC/webhook 行为。 +- 扩展现有 Inbox 创建/设置页录入 `session_id/username/password/desired_presence/webhook_url`;密码 write-only,普通 serializer 只返回脱敏状态,webhook URL 写入 Inbox 自身字段。 +- 实现明文 config list/detail/status API;敏感字段只出现在受 service token 保护的 config API,不进入普通 serializer、webhook 或日志。 +- 实现专用 PlatformApp、Account permissible、Connector Bearer service-auth 路由 allowlist,以及第 7.4~7.6 节配置 list/detail/status API;每个资源请求继续校验 account grant、Inbox 归属与渠道类型。 +- 在 Inbox 事务提交后 dispatch 生命周期事件,并使用现有 background job 做签名持久投递。 +- Connector 完成本地明文 credential 持久化、启动全量 reconcile、单 Inbox 拉取和按 `gochat_inbox_id` 幂等 upsert。 +- 完成 supervisor 唯一性、错峰、session 恢复、重登和状态机。 +- 完成在线密码验证后原子替换与失败回滚。 + +验收: + +- GoChat 创建 Inbox 后无需调用 Connector 账号 API,即可自动出现本地账号并登录;重复 lifecycle webhook 不创建重复记录。 +- GoChat 启动不要求任何 `GOCHAT_SWT_*` 参数;全部商务通 Inbox 的 Inbox 级 `webhook_url` 指向同一个逻辑 Connector。 +- 500 个 Inbox 启动时每个账号恰好一个 supervisor;重复 StartAccount、重复 reconcile 和重复 presence 更新不创建额外 goroutine。 +- Connector 在 GoChat 不可达时从 SQLite 恢复 supervisor;全量分页中途失败不会把任何现有账号标记 deleted。 +- online/busy/away 使用正确端点,offline 使用 logout;心跳只更新 connection status,恢复 session 后重新应用 desired presence。 +- 新密码错误不覆盖旧密码,成功后 cursor 不变。 +- Connector 重启后 desired presence 和 cursor 保持;GoChat health 接口可见真实心跳状态和 credential 错误。 +- Inbox 普通 API、日志和 lifecycle webhook 不包含密码、HMAC token 或 webhook secret;Connector config API 按 Schema 明文返回。 + +### Phase 2:商务通入站 → GoChat + +实施: + +- 完成 heartbeat HTTP、事件事务和 inbound worker。 +- 完成 GoChat Public/Application API client、service token 鉴权、统一错误分类和 Idempotency-Key。 +- 完成 contact/conversation/message ensure 和 conversation_maps。 +- 完成 `message_maps`、文本、kind=0 状态、外部客服消息和基础附件解析。 +- 为 GoChat message create 增加 `(inbox_id, source_id)` 幂等查询、request hash conflict 和原资源返回。 +- 所有 `source_id/swt_event_key/Idempotency-Key` 使用稳定 `gochat_inbox_id`,Connector SQLite 重建前后保持不变。 +- 为 Public conversation response 增加 `internal_id`,禁止混用 display ID。 +- 为 Application message 增加受限 `external`/`additional_attributes`/`external_source_ids` 输入;kind=2/3 将原始 `seq_id` 写入 `external_source_ids.shangwutong`,确保导入消息不触发渠道外发。 + +验收: + +- 访客首条消息自动创建 contact、conversation 和 incoming message。 +- 同一心跳响应重复 10 次只产生一条 GoChat 消息。 +- 同一 source ID 不同 body 返回 409,同一 body 重放返回同一 message ID。 +- kind=2/3 的 GoChat message 同时保留组合 `source_id` 和原始 `external_source_ids.shangwutong`;两者用途不同且不互相冒充。 +- external incoming/outgoing/activity 均不进入 API Inbox 出站 webhook,不形成回环。 +- GoChat 停机 5 分钟后恢复,期间事件全部按顺序补投。 +- Connector 在 GoChat API 超时期间仍持续心跳并推进已落盘 cursor。 + +### Phase 3:GoChat 出站 → 商务通 + +实施: + +- 在 GoChat 注册 `api_inbox:webhook_delivery` durable job。 +- 按第 10.6 节固定 webhook v1 envelope、消息/会话/typing/lifecycle payload、签名头、ACK/error Schema 和 fixtures。 +- 让 `Channel::Shangwutong` 的 outgoing/template 初始持久化为 progress,并补齐 GoChat Message 合法状态校验、serializer 和状态事件支持。 +- 修改 GoChat 商务通 retry 分支:failed → progress、递增 retry version、保留业务 attributes,并投递 `message_retry_requested`;普通渠道原行为不变。 +- 修改 Dashboard 发送 action 信任服务端返回的 message status,不再把 create/retry 响应无条件覆盖为 sent。 +- Connector webhook 验签、时间窗口、inbox 路由和 outbox 幂等入库。 +- 完成文本发送、账号级原子 claim/串行、operation gate、uncertain 顺序屏障、token reset 重登后重试、结果分类和 kind=3 对账。 +- 实现第 10.5.6 节消息发送结果回写,复用 GoChat `MessageService.UpdateStatus`,Connector 持久重试直至 synced。 +- 移除 API Inbox message 的同步重复 webhook 路径。 + +验收: + +- GoChat 坐席文本回复到达正确商务通账号和 swt_sid。 +- Connector 202 后 GoChat message 仍为 progress;商务通实际成功后变 sent,永久失败后变 failed,uncertain 不误标 sent。 +- Dashboard 创建、实时事件和页面刷新三条路径都显示同一服务端状态;failed 消息显式 retry 后回到 progress,Connector 只重发未成功子操作。 +- 两名 InboxMember 连续回复十条消息,商务通按 GoChat message/outbox ID 顺序收到,且只使用共享商务通身份。 +- 8 个 outbound worker 并发领取时,同一账号最多一条 delivering,重复 claim 不会重复发送。 +- 第一条发送进入 uncertain 时,同账号后续消息不会越过;kind=3 确认或观察窗口转 failed 后才按序继续。 +- 同一 GoChat delivery 重放不会重复发送。 +- Connector 停机时 GoChat job 自动重试;恢复后消息送达。 +- 模拟发送后 timeout 时消息进入 uncertain,不自动重复发送。 +- 外部商务通客服消息同步到 GoChat 后不会形成 webhook 回环。 +- message/status webhook 的 event ID 在全部重试中稳定,delivery ID 重放不会重复入队。 +- 密码或 presence 更新只发送无秘密的 `inbox_updated`,Connector 拉取较新版本后应用并回写状态。 +- 未知 Schema 版本返回 422,错误签名/过期时间戳返回 401 且不泄露 inbox 信息。 + +### Phase 4:媒体、typing 与会话操作 + +实施: + +- 完成图片、文件、语音出站和入站映射,以及 HTML/微信 JSON/表情/多附件解析。 +- 完成 typing 的心跳 `c/i` 合并。 +- 完成 end/accept/refuse/transfer/invite。 +- 完成 kind=-4/-5 `text` 目标消息 ID 与 GoChat message update 的精确映射。 +- 实现第 10.9 至 10.11 节全部 kind、状态、消息格式和 kind=31 子类型 dispatcher;每条规则写入 `mapping_strategy`。 +- 实现 rich card、历史批次、ByteDance/XST 未知结构的降级/quarantine/replay 机制。 +- 对媒体下载增加大小、Content-Type、URL 和超时限制。 + +验收: + +- 每类媒体至少一条真实账号端到端用例。 +- typing 不额外启动心跳,不破坏 cursor。 +- 会话结束在两侧状态一致且重复操作幂等。 +- 超大、非法 MIME、超时附件不会阻塞账号 supervisor。 +- 已知事件表无未分类条目;未知 kind/subtype 会留 raw、计数并可 replay,不会误显示原始 HTML。 +- 撤回只命中精确 message map;缺目标 ID 的用例产生告警且不删除错误消息。 + +### Phase 5:规模、升级与生产交付 + +实施: + +- 使用本地 fake SWT server 模拟 500+ 账号。 +- 增加 Dockerfile、持久卷、healthcheck 和 Compose 服务。 +- 完成备份/恢复、quick_check、指标、告警说明和 runbook。 +- 使用真实商务通测试账号验证 token 恢复、密码更新、presence、异常响应和负 kind cursor。 +- 使用真实账号补齐 kind=7/8、kind=52、撤回、富卡片、NotShow/XST/ByteDance fixture,并在映射矩阵标记验证证据。 +- 完成 GoChat 升级与 Connector 升级演练。 + +验收: + +- 500 账号连续运行至少 1 小时,无重复 supervisor、SQLite BUSY、goroutine 泄漏或事件丢失。 +- 500 账号每 2.5 秒轮询时,调度延迟不持续跨越下一心跳周期。 +- GoChat 停机/恢复演练事件总数完全一致。 +- GoChat 滚动升级期间全部商务通 supervisor 持续心跳,账号不会因配置 API 暂时不可达被批量停用或 logout。 +- Connector SIGTERM 在 30 秒内退出,重启后 cursor 连续且不主动 logout。 +- `PRAGMA integrity_check` 为 `ok`。 +- 日志和 API 响应扫描不包含测试密码、token 或 webhook secret。 + +--- + +## 15. 测试矩阵 + +### 15.1 单元测试 + +- DES:中文/ASCII 密码、padding 边界、非法 session ID。 +- 登录解析:ok、Accept、vcode/vcode1/vcode2、ecsq、密码错误、空响应。 +- 心跳解析:空 body、多行、连续空格、URL 编码、负 kind、未知 kind、非法 seq。 +- 消息 ID:kind=2/3 的 `swt_message_id=decimal(seq_id)`;kind=-4/-5 区分事件 seq 与 `text` 目标 ID;非消息事件不得生成伪消息 ID。 +- cursor:mid/t/p 分类、重复事件、同一响应最大值更新。 +- 消息:HTML、emoji、voice_msg、filemsg、百度卡片、微信/企业微信 JSON、字节签名、未知格式。 +- 事件 dispatcher:第 10.9 表中每个 kind、kind=0 的每个状态、kind=31 的每个已知 subtype 至少一个 golden fixture。 +- 历史/撤回:kind=52 多 child 独立幂等且无 child ID 时不伪造商务通消息 ID;-4/-5 从 `text` 精确命中、重复撤回、目标 ID 缺失/未命中不误删。 +- store:迁移幂等、sqlc query、事务回滚、相同 seq 不同 kind 的事件去重、原始行顺序、message map、outbox 唯一键、retry version、多子操作断点、原子 claim、账号级 delivering 唯一约束、uncertain 顺序屏障、stale delivering 恢复和状态回写重试。 +- config:`config_version` 单调更新、同版本幂等、旧版本忽略、密码变更 pending/应用/拒绝和 serializer 脱敏。 +- supervisor:状态变化、credential 串行、session snapshot version、operation gate 读写互斥、token reset 唤醒、presence 恢复、取消、panic 恢复和无重复启动。 +- Logrus:嵌套 password/token/header/raw login body 均被 redaction hook 清除。 + +### 15.2 HTTP 与安全测试 + +- Connector config/status/message-result API 无 token、错误 token、正确 token、越权 account/inbox 和最小权限;account-scoped token 可读取新建 Inbox,但在 allowlist 之外不能替代用户 JWT。 +- service token 显式 grant 一个/多个 account 时,列表和资源接口只返回/修改获准范围,不能跨 tenant 越权。 +- Inbox 创建/更新的 JSON 超限、未知字段、非法枚举、重复 `session_id+username`、密码省略/空值语义,以及 webhook URL 缺失、非法 scheme、userinfo/fragment、与现有启用 Inbox URL 不一致校验。 +- webhook 正确签名、错误签名、过期时间戳、未知 inbox bootstrap、未知普通事件、重放 delivery、旧 secret 签名轮换通知和删除快照投递。 +- webhook 202 新入队、200 重放、409 幂等冲突、422 未知 Schema 的响应逐字段校验。 +- message-result 缺失/非法/与 Idempotency-Key 不一致的 `result_version` 被拒绝;同 version 同 body 幂等成功,同 version 不同 body 返回 409。 +- message-result 首次 sent 允许 `external_id=null`;非空值必须是商务通原始消息 ID,kind=3 更高版本回写后准确合并到 GoChat `external_source_ids.shangwutong`。 +- config list 分页完整后才 tombstone 缺失账号;分页中断、401、503 均保留本地账号。 +- config API 缺少必填登录字段、非法版本或错误类型时不创建/更新账号;同版本直接幂等跳过。 +- password/token/header 明文不进入日志、普通 Inbox 响应或 lifecycle webhook;只允许 config API 返回合同内的明文字段。 +- 附件 URL 拒绝 loopback、link-local 和私有网段 SSRF 目标,除非明确配置为 GoChat 内部可信主机。 + +### 15.3 GoChat contract 测试 + +- `Channel::Shangwutong` Inbox 创建/查询/更新、底层 ChannelAPI identifier/HMAC mandatory 和专用配置表事务一致性。 +- 普通 Inbox serializer 只返回 `password_configured/config_version/desired_presence` 与脱敏 health,不返回配置表中的敏感字段。 +- `inbox_created/inbox_updated/inbox_deleted` 在事务提交后真实 dispatch;delete job 保存 URL/secret tombstone 快照,secret 轮换通知使用旧 secret,Connector upsert/tombstone 成功后才 ACK。 +- config list/detail/status 与 webhook v1 message/status/typing/lifecycle payload、附件 Schema 和签名使用固定跨模块 fixture。 +- Public conversation 同时返回 display ID 与 internal ID,Connector 路由使用正确 ID。 +- Application message 同 source ID + 同 body 返回同一 message;不同 body 返回 409。 +- kind=2/3 Application message 将原始 `seq_id` 持久化到 `external_source_ids.shangwutong`;出站 kind=3 回显通过结果回写补写同字段,多子操作可形成原始 ID 数组,普通组合 source ID 不进入该字段。 +- `external=true` incoming/outgoing/activity 不触发 `EnqueueSendReply` 或 API Inbox webhook。 +- Application DELETE 产生 deleted tombstone,前端 Message 组件显示撤回状态。 +- durable job 非 2xx 重试、最终失败、手动 retry。 +- message webhook 202 不提前标 sent;结果回写的 sent/failed/uncertain 转换、幂等重放和错误字段逐项验证。 +- Dashboard 不覆盖服务端 progress/failed;商务通 retry 不清空普通 content attributes、不进入旧 `SendReply` worker,并用递增 retry version 投递 durable event。 +- 两个 AccountUser 同时作为 InboxMember 时均能查看/回复;AssigneeID 只改变主要负责人,其他成员权限保持现有 GoChat 语义。 +- external outgoing `source_id=swt:*` 不重新入商务通 outbox。 +- Contact/Conversation custom attribute definitions 创建后,kind=7/8/35/65/66 字段可在 GoChat 详情区域查看。 + +### 15.4 端到端场景 + +1. 在 GoChat 创建商务通 Inbox → lifecycle 通知 → Connector 自动 upsert → 登录 → online → 心跳。 +2. GoChat Inbox 中 online → busy → away → online,Connector 拉取配置后切换且不重新登录。 +3. 在 GoChat 在线修改正确密码,Connector 应用新版本,session 更新且 cursor 不变,health 显示 applied。 +4. 在 GoChat 在线修改错误密码,Connector 保留旧凭据/session,health 显示 rejected;再次提交正确密码后恢复。 +5. 访客发文本 → GoChat 收件箱出现 → 坐席回复 → 商务通收到。 +6. GoChat 停机期间访客连续发消息 → GoChat 恢复后无丢失/重复。 +7. Connector webhook 接收后进程崩溃 → 重启后出站继续发送。 +8. Connector 心跳事务前后分别 kill -9 → cursor 与事件保持一致。 +9. token reset → 自动重登;密码错误 → auth_failed,不形成登录风暴。 +10. Connector 升级不调用 logout,重启优先恢复 token。 +11. 商品/案例/优惠券卡片按首版 fallback 展示链接,不显示原始协议串。 +12. kind=31 分配/邀请/机器人/NotShow/ByteDance 各走预期 activity、attributes、message 或 raw-only 策略。 +13. kind=-4/-5 使用 `text` 中目标消息 ID 精确撤回;目标字段缺失、非法或未命中时 GoChat 原消息保持不变,Connector 记录 `unmapped_retraction`。 +14. GoChat 坐席发送不支持的 video/card/form 时得到明确 failed 状态和 `unsupported_outbound_content`,不静默丢失。 +15. Connector 在 GoChat 停机时重启,从 SQLite 恢复全部 supervisor 并持续心跳;GoChat 恢复后 reconcile 不产生重复账号。 +16. 配置列表第二页返回 503 时,本地所有账号继续运行;完整快照恢复后才处理已删除 Inbox。 +17. 删除或禁用一个商务通 Inbox 只停止对应 supervisor,不影响其他账号。 +18. Connector SQLite 重建后同一商务通事件仍生成相同 source ID,不产生重复 GoChat 消息。 +19. 两名客服并发回复十条消息,8 个 worker 下仍按 outbox ID 串行送达且没有重复领取。 +20. Connector 已 ACK outbox 后模拟商务通永久失败,GoChat message 最终为 failed;模拟 uncertain 时保持 progress,kind=3 对账后转 sent。 +21. webhook secret 轮换使用旧 secret 完成通知,Connector 应用新 secret 后继续接收消息。 +22. 删除 Inbox 后即使数据库记录已删除,durable job 仍使用快照完成签名通知,Connector best-effort logout 并 tombstone。 +23. 心跳只有 `r=ok`、没有 kind=11,且 busy 端点失败时,connection 为 connected,actual presence 不错误更新为 busy。 +24. 旧 Inbox offline 并删除后,可用相同 `session_id+username` 创建新 Inbox;Connector tombstone 不阻塞 partial unique index。 +25. 同账号第一条消息发送结果 uncertain 时第二条保持 pending;第一条被 kind=3 确认或观察窗口转 failed 后,第二条才允许领取。 +26. 多附件消息第一项成功、第二项失败后点击 retry,只发送第二项;GoChat 原消息状态按 progress → sent/failed 流转,第一项不重复发送。 +27. 入站 kind=2/3 将原始 seq 同时作为商务通消息 ID 存入 `external_source_ids`;出站 `r=ok` 时不伪造 ID,kind=3 回显后补写,撤回可按该 ID 精确定位。 + +--- + +## 16. 上线顺序与回滚 + +### 16.1 上线顺序 + +1. 先部署 GoChat `Channel::Shangwutong`、独立配置表、Connector service token 权限与 config/status API;默认不改变其他 Inbox。 +2. 部署生命周期事件 dispatch、API-semantics durable webhook v1、Public conversation `internal_id`、message external/additional attributes/external source IDs/幂等改动和 contract fixtures。 +3. 部署 Connector,完成 health/ready/SQLite 备份验证。 +4. 在 GoChat 创建一个测试商务通 Inbox 灰度,验证自动 upsert,不手工写 Connector DB。 +5. 验证至少一个工作日的登录、心跳、入站、出站、presence 和密码更新。 +6. 分批在 GoChat 创建剩余 Inbox,观察 reconcile、队列深度和心跳延迟。 +7. 500+ 账号稳定后再关闭旧接入方式。 + +### 16.2 回滚 + +- Connector 回滚使用旧镜像读取同一向后兼容 schema;新迁移首期只允许加表、加列、加索引。 +- 回滚 Connector 前先暂停 GoChat 商务通 Inbox 配置修改并备份 SQLite。 +- GoChat durable webhook job 可通过停止注册 job handler 回退,但已排队 job 必须保留,恢复新版本后继续处理。 +- 回滚不调用商务通 logout,不删除商务通 Inbox,不清空 cursor 和队列表。 + +--- + +## 17. 生产上线阻塞项 + +以下事项未验证前不得宣称生产交付完成: + +- GoChat 完成 `Channel::Shangwutong`、独立配置表、脱敏 serializer,以及基于 PlatformApp AccessToken/Account permissible/路由 allowlist 的 Connector service principal;短期人工 JWT 不能作为生产凭据。 +- GoChat 完成 Inbox 生命周期事件的事务后 dispatch、delete URL/secret 快照、旧 secret 轮换通知、明文 config list/detail/status API 与 startup reconcile contract。 +- GoChat 完成 Application message 的 `external`/`additional_attributes`/`external_source_ids`/幂等语义、Public conversation `internal_id` 和 API-semantics durable webhook v1。 +- GoChat 完成真实发送结果回写;Connector outbox ACK 不得提前将 message 标 sent。 +- GoChat 完成 Dashboard 服务端状态优先和商务通 failed-message durable retry;不得把 create/retry 响应前端覆盖为 sent,也不得进入旧 `SendReply` worker。 +- Connector 完成稳定 `gochat_inbox_id` 幂等键、账号级原子 claim/串行发送、uncertain 顺序屏障和 result status 持久回写。 +- Connector 完成 retry version 幂等和多子操作进度持久化;部分成功后的恢复/重试不得重复发送已成功子项。 +- 多客服 InboxMember 权限、AssigneeID 协作和共享商务通发送身份通过端到端验证。 +- 真实账号验证登录、心跳、三种 presence 和 logout。 +- 验证进程短时停止心跳后,商务通保留事件的时长足以支持升级恢复。 +- 验证 `tickint reset` 后用保存密码重登不会丢失 cursor 窗口内事件。 +- 验证负 kind 事件是否与普通消息共享 maxwordid 序列。 +- 解决 kind=65/66/67 在参考字典(标 p)与 Python/Go cursor parser(标 mid)之间的冲突,并用连续心跳抓包证明不会重放或漏事件。 +- 验证发送请求 timeout 后是否必定产生 kind=3 回显,并覆盖同一会话连续发送相同文本/附件的歧义样本,以决定 uncertain 对账窗口和唯一匹配条件。 +- 验证 vcode/ecsq 的完整处理流程;无法自动处理时必须提供明确人工恢复步骤。 +- 验证 500+ 账号是否触发商务通单 IP、单域名或站点侧限流。 +- 用真实包再次确认 kind=7 是环境信息、kind=8 是来源信息,避免沿用 Python `KIND_LABELS` 中“转接”误标;转接只使用 kind=0 的 text=6/7/8。 +- 用真实账号验证不同会话来源和客户端版本的 kind=-4/-5 均以 `text` 携带目标 message ID;静态源码已确认的基线精确撤回可实施,异常样本必须降级为 unmapped,不得猜测。 +- 验证 kind=52 历史批次各版本的 child 分隔、方向、时间与 ID,未验证版本不得自动展开。 +- 验证商品/案例/优惠券卡片、ByteDanceLeaveMsg/SysMsg/CSFenPeiMsg、NotShow/XST 各 subtype 的真实 payload 与可见性。 +- 验证图片、文件、语音的下载 URL 有效期与认证方式,以及商务通是否存在 video/location/contact/card 的真实发送端点。 + +功能实现完成、自动化测试通过与真实商务通生产可用是三个独立里程碑,验收报告必须分别记录。 + +--- + +## 18. 完成定义 + +本计划完成需同时满足: + +- `channels/shangwutong` 独立构建、测试、镜像和运行文档齐全。 +- Connector 使用 Go 1.26、Cobra、Fiber v3、sqlc、Logrus 和 modernc SQLite 的固定版本;sqlc 生成代码无 drift。 +- GoChat 商务通 Inbox 是唯一账号配置入口;不存在 Connector 账号 CRUD API、独立账号管理 UI 或第二配置源。 +- Inbox 生命周期通知、配置拉取、按 Inbox ID 幂等 upsert、状态回写和启动全量 reconcile 均有持久化闭环。 +- 账号、明文密码、presence、session、cursor 和队列均有持久化闭环;账号识别与配置同步只依赖 Inbox ID 和 `config_version`。 +- GoChat 双向消息均经过持久队列和幂等保护;所有跨系统 key 使用稳定 `gochat_inbox_id`,不使用 SQLite 自增 ID。 +- 商务通原始消息 ID 与组合幂等键严格分离:kind=2/3 直接使用 `seq_id`,GoChat 持久化到 `external_source_ids.shangwutong`,多子操作保存原始 ID 数组;只有 kind=52 无 ID child 使用 batch seq + child index 防重且不冒充外部 ID。 +- Connector ACK、商务通实际发送和 GoChat message status 三个阶段严格分离;sent/failed/uncertain 均可恢复并最终同步。 +- Dashboard 以服务端 message status 为准;failed message retry 使用独立递增版本和同一 outbox 记录,保留已完成子操作进度。 +- 同一账号出站消息原子领取且严格串行,多客服并发回复不会乱序或重复发送。 +- GoChat AccountUser/InboxMember/AssigneeID 协作路径保持现有权限语义,首版明确共享一个商务通客服身份。 +- 第 10 节交互端点、请求/响应、签名、ACK/error Schema 均有跨 GoChat/Connector contract fixtures。 +- 参考资料中的全部已知 kind、kind=0 状态、kind=2/3 内容和 kind=31 subtype 都有 `native/activity/attributes/fallback/raw/extension` 明确分类和自动化测试。 +- 无法直接映射项均有产品可见的降级行为、GoChat 扩展建议或真实抓包阻塞结论;不存在静默丢弃。 +- GoChat 升级或配置 API 暂时不可达不停止商务通账号心跳,也不会把账号误判为 disabled/deleted。 +- Connector 重启不主动 logout,能够恢复 token/cursor 或明确重登。 +- 心跳 `r` 只决定 connection status;actual presence 由成功的 login/status/logout 或明确 kind=11 更新,session 恢复后仍需主动重新应用 desired presence。 +- 500+ 账号规模测试和 SQLite 完整性检查通过。 +- 真实账号覆盖登录、收发、状态、密码更新和主要异常恢复。 +- 明文凭据只存在 GoChat 专用配置表和 Connector SQLite;普通 serializer、lifecycle webhook、日志和未授权 API 均不可访问。 +- 所有生产阻塞项均有验证证据或明确的不上线结论。 + +--- + +## 19. 2026-08-01~2026-08-03 实施与验证状态 + +### 19.1 已实现并由自动化验证 + +- GoChat:`Channel::Shangwutong` Inbox、专用配置表与脱敏 serializer、PlatformApp service principal/account grant/路由 allowlist、配置分页 API、状态与版本化消息结果回写、Application message 幂等导入、Public conversation `internal_id`、durable lifecycle/message/status webhook、failed retry 和 Dashboard 服务端状态优先均已落地。 +- Connector:单进程多账号 supervisor、统一 SQLite/sqlc schema、启动 reconcile、明文配置同步、pending password 回滚、在线 presence、session/cursor 恢复、心跳与 typing、有限入站/出站 worker、账号级严格串行、多子操作断点、5 分钟 uncertain 屏障、10 分钟唯一回显匹配、晚到纠正、撤回精确映射和结果持久回写均已落地。启动时先恢复 SQLite 中的 supervisor,不等待 GoChat;首次全量 reconcile 失败后按 1 秒至 5 分钟指数退避持续重试,并尊重服务端 `Retry-After`。 +- 崩溃恢复:重启时将领取后未完成的入站任务恢复为 pending,将 delivering 的出站父消息/操作恢复为 uncertain,将 syncing 的状态回写恢复为 pending;父消息仍为 delivering 且不存在 uncertain part 时,保守地把第一个 pending part 恢复为 uncertain,避免 kind=3 回显失去匹配对象。恢复逻辑均有进程重启回归测试。 +- 时间与队列:modernc SQLite 连接统一使用 UTC 和 SQLite 可解析时间格式;所有重试领取、status sync、消息/操作先后关系和 uncertain 过期比较使用 `julianday`,旧的不可解析重试时间保守视为已到期。UTC+8 回归覆盖入站、出站、状态回写和会话操作,修复了本地时区下 1 秒重试可能被延后近 8 小时、uncertain 可能提前过期的问题。 +- Supervisor 恢复:首次恢复心跳会原样携带持久化的 `maxwordid/maxotick/maxtmpid`;`tickint_reset` 会清理 session 并保持消息 pending 重试。panic 恢复测试确认 manager 会重启唯一 supervisor,Heartbeat panic 也会释放 operation gate 读锁,不会在 session 恢复时死锁。 +- 消息映射:全部已知 kind、kind=0 状态和已知 kind=31 subtype 均有分类测试;文本/HTML/多图/voice/file/JSON image/audio/video/card fallback、`.NET ticks`、kind=2/3 原始 ID、kind=-4/-5 目标 ID 已覆盖。kind=52 在没有真实 fixture 前按设计保持 raw-only。 +- 出站边界:location 降级为可读标题与坐标链接,contact 降级为姓名与电话文本;video/card/form/template 仍在发送任何 part 前明确失败。单条 GoChat 消息最多允许 100 个 part,与 `external_ids` 最大 100 项契约一致,101 个及以上会整体拒绝而不是部分发送。 +- 重试协议:GoChat API 错误支持秒数和 HTTP-date 两种 `Retry-After`(解析上限 24 小时);入站投递和消息状态回写等待时间不得早于服务端要求,并只增加向上 jitter。`429`/`5xx` 即使错误 envelope 漏写 `retryable` 仍保持可重试。 +- 媒体:入站/出站图片、文件、语音协议路径、32 MiB 限制、响应头与实际内容联合 MIME 校验、临时文件 0600/清理、私网和 DNS rebinding/redirect SSRF 防护、永久与临时错误分类已覆盖;空响应和伪报 `image/png` 的 HTML 响应会被拒绝。 +- 可观测性:Logrus `WithError` 保留可诊断错误字符串,同时递归脱敏字段并统一 UTC 时间;已知 kind 使用有限指标标签,`-8`、`5`、`39` 不再误计 unknown。指标快照启动时加载、后台每 10 秒刷新,`/metrics` 请求只读内存,不逐请求扫描 SQLite。 +- 数据安全:SQLite 主库、WAL、SHM 和在线备份统一为 `0600`,镜像内 `/data`、`/backup` 为 `0700`;生产与 Quickstart Compose 使用独立备份卷,避免数据库和备份处于同一故障域。embedded migration 使用单连接 `BEGIN EXCLUSIVE` 整体事务,避免多进程同时判定版本并执行 DDL;readiness 使用轻量 `quick_check`,`doctor`、备份检查和生产验收使用完整 `integrity_check`。 +- 运维:Cobra 命令、Fiber health/ready/metrics、在线备份、非 root Docker 镜像、生产/Quickstart Compose、`stop_grace_period`、README 和运行手册已完成。CI 新增独立 `shangwutong` job,固定 Go 1.26.4 并执行 sqlc drift、race、vet 和 build,security/build job 均依赖该门禁。 +- HTTP 合同:Connector 端到端 server 测试直接验证首次 `202` ACK 字段、同 body 重放 `200 duplicate=true` 与稳定 queue ID、同 event/message ID 不同 body 的 `409 idempotency_conflict`、已知 Inbox 错签/过期签名的 `401`,以及未知 Schema 版本的 `422`。 +- 规模长时回归:`SWT_SOAK_DURATION=1h` 使用 fake protocol 连续运行 500 个账号 `3601.59s` 并通过。测试确认每个账号至少持续完成两轮心跳、只有一个 supervisor、最大并发心跳不超过 64、最大心跳间隔不超过两个周期、每秒 SQLite quick check 成功、生成事件数与 ignored 持久化总数完全一致、停止后 supervisor 清零且最终 integrity check 为 `ok`。该证据覆盖本地调度/SQLite/事件持久化,不替代真实商务通单 IP/域名限流和真实端到端事件对账。 +- 故障恢复回归:GoChat 返回 503 后,同一会话的首条事件回到 pending;GoChat 恢复后 3 条事件按原顺序完整排空且状态均为 delivered。该测试证明本地队列恢复语义,不冒充真实 5 分钟停机演练。 + +已通过的本地命令: + +```bash +cd channels/shangwutong +go tool sqlc generate +go test ./... +go test -race ./... +go vet ./... +go build -o /tmp/shangwutong-build-check ./cmd/shangwutong +SWT_SOAK_DURATION=1h go test ./internal/account -run TestManagerMaintainsOneSupervisorPer500Accounts -count=1 -timeout 70m + +cd backend +GOCHAT_TEST_DB=sqlite go test ./internal/... ./pkg/... ./cmd/... +go test ./... +go vet ./... + +cd frontend +pnpm exec vitest run app/javascript/dashboard/helper/specs/inbox.spec.js app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js --no-coverage +pnpm build + +cd .. +git diff --check +git ls-files -m -o --exclude-standard -- '*.go' | xargs -r gofmt -l +docker compose -f deploy/docker/docker-compose.prod.yml config --quiet +docker compose -f deploy/quickstart/compose.yaml config --quiet +yq '.' .github/workflows/ci.yml >/dev/null +``` + +上述命令在同步到 `origin/main@96f5c796` 后重新全量执行通过;sqlc 生成前后 7 个生成文件哈希一致,无 drift。同步引入的 Instagram/TikTok 每 Inbox OAuth 凭据协议也完成一次读取 JSON body、query `return_to` 优先的兼容修复,并通过相关后端全量测试。2026-08-03 远端新增的 Captain 文档 backend、Widget SDK 和 Vite 变更合并后,再次通过 GoChat SQLite 全量、默认全量、vet、前端 83 个定向测试和生产构建;Captain 三个 backend 与商务通 listener/job/handler/router 注册均保留。 + +本次代码交付按确认后的边界以 fake/fixture 模拟端到端和自动化回归通过为验收条件;真实商务通账号证据延期到灰度阶段补齐,不阻塞当前代码提交与推送,也不因此宣称已通过生产上线验收。 + +Compose 配置解析通过;生产 Compose 仅报告现存的未注入密码变量和顶层 `version` 过时警告。使用 `docker build --network host` 完整构建最新镜像成功。实际容器验证结果如下: + +- 镜像和运行进程均为 `connector:connector`(uid/gid 10001),不是 root。 +- `/data`、`/backup` 为 `0700`;`connector.db`、WAL、SHM 和在线生成的 `/backup/check.db` 均为 `0600`。 +- `/healthz`、`/readyz`、`/metrics` 均返回成功,且 metrics 请求路径在数据库关闭后的缓存测试仍可工作。 +- `migrate status` 返回 migration 1;`doctor` 通过配置、migration 和完整 SQLite integrity check 后,按预期在测试中报告 GoChat API 不可达。 +- GoChat 不可达时日志持续记录 startup reconcile 诊断和 connection refused 根因,测试 service token 未出现在日志中。 +- SIGTERM 后容器状态为 `exited`、退出码 `0`、`OOMKilled=false`;测试容器及匿名卷已清理。 + +### 19.2 仍阻塞生产上线的真实证据 + +- 尚无可用真实商务通账号,因此登录、session 保留、三种在线状态、密码更新、收发、真实媒体 URL、撤回和 `tickint reset` 只能确认实现与 fake/fixture 自动化,不能标记真实端到端通过。 +- kind=65/66/67 当前按静态基线推进 `maxwordid`,但 `p` 与 `mid` 的参考冲突仍需连续真实抓包裁决;负 kind 是否共享 cursor 也需要真实证据。 +- kind=52 各服务器版本 child 结构、方向、时间和独立 ID 尚无真实 fixture,继续 raw-only 是明确的不上线保护策略,不得为追求展示率伪造解析。 +- vcode/vcode1/vcode2/ecsq 已能识别并进入 `verification_required`,但人工解除流程仍需真实账号演练。 +- 发送 timeout 后 kind=3 是否必达、相同文本/同类型多附件的真实歧义概率、商务通单 IP/单域名限流、真实 video/location/contact/card 端点均未验证。 +- 已完成 500 账号 fake protocol 一小时 soak,但尚未执行 500+ 真实商务通账号的服务端限流验证;GoChat 真实停机恢复事件总数对账、真实 Connector 升级窗口和全部账号日志泄露扫描也仍需灰度环境证据。 + +因此当前结论是:功能实现和自动化验证已完成到可进入真实账号灰度的状态;第 17 节与本节列出的真实协议证据未补齐前,生产上线结论仍为“不通过”。 diff --git a/docs/runbooks/shangwutong-connector.md b/docs/runbooks/shangwutong-connector.md new file mode 100644 index 00000000..1553303f --- /dev/null +++ b/docs/runbooks/shangwutong-connector.md @@ -0,0 +1,124 @@ +# 商务通 Connector 生产运行手册 + +## 1. 上线前检查 + +1. GoChat 已部署 `Channel::Shangwutong` migration、Connector service principal、配置/状态 API 和 durable webhook worker。 +2. 为 Connector 创建 account-scoped service token,只 grant 要接入的 Account,不使用人工坐席 JWT。 +3. Connector 与 GoChat 位于受控内部网络;链路跨越不受信网络时由部署层启用 HTTPS、mTLS 或等价隧道。 +4. `/data` 和备份目录只允许 Connector 运行用户及备份任务访问。Connector 将数据库、WAL/SHM 和在线备份设为 `0600`,镜像内 `/data` 和 `/backup` 为 `0700`;宿主机挂载目录仍需按相同边界配置。数据库和备份包含明文商务通登录凭据。 +5. 为每个商务通 Inbox 配置相同逻辑 Connector webhook URL;不在 Connector 环境变量中配置账号字段。 +6. 验证主机时钟同步。webhook 签名存在时间窗口,明显时钟漂移会造成全量 401。 + +## 2. 首次启动 + +```bash +docker compose up -d postgres redis gochat worker +docker compose run --rm shangwutong migrate up +docker compose up -d shangwutong +docker compose exec shangwutong shangwutong doctor +``` + +依次确认: + +```bash +curl -fsS http://127.0.0.1:9100/healthz +curl -fsS http://127.0.0.1:9100/readyz +curl -fsS http://127.0.0.1:9100/metrics +``` + +若端口未发布到宿主机,在容器网络内执行检查。Connector 不以 GoChat health 作为启动前置条件:先恢复 SQLite 中已有 supervisor,首次全量配置快照失败后按 1 秒到 5 分钟指数退避重试;GoChat 恢复后自动完成同步。 + +## 3. 灰度顺序 + +1. 只创建一个测试 Inbox,确认 lifecycle webhook 返回 202/200,Connector 自动创建本地账号。 +2. 在 GoChat 配置页确认 `connection_status=connected`、`credential_status=applied`、actual presence 与 desired presence 一致。 +3. 验证访客文本入站、坐席文本出站、撤回、图片、文件、语音、会话结束和 typing。 +4. 在线切换 online/busy/away/offline;确认只影响目标 Inbox。 +5. 提交错误新密码,确认旧 session 仍工作且配置状态为 rejected;再提交正确密码恢复。 +6. 运行至少一个工作日灰度后分批创建剩余 Inbox,持续观察队列、心跳和认证错误。 + +## 4. 日常监控 + +重点指标: + +```text +swt_connector_accounts +swt_connector_supervisors +swt_connector_heartbeat_total +swt_connector_heartbeat_duration_seconds +swt_connector_inbound_queue_depth +swt_connector_outbound_queue_depth +swt_connector_delivery_total +swt_connector_status_sync_total +swt_connector_status_sync_queue_depth +swt_connector_event_mapping_total +swt_connector_unknown_event_total +swt_connector_unmapped_retraction_total +swt_connector_contract_error_total +swt_connector_sqlite_write_duration_seconds +``` + +建议告警:connected 比例低于 95%;pending 最老记录超过 2 分钟;uncertain 大于 0;status sync 超过 2 分钟;未知 kind、raw-only 或 unmapped retraction 持续增长;SQLite 检查失败或卷空间低于 20%。指标标签不包含账号 ID、用户名、session ID 或 Inbox ID。 + +日志禁止输出 password、pending password、`ma`、HMAC/webhook secret、Authorization、完整登录 body 和访客正文。发现泄露时先轮换相应 token/secret,再保全并限制日志访问,最后修复 redaction 后重新部署。 + +## 5. 备份与恢复 + +在线备份: + +```bash +docker compose exec shangwutong shangwutong backup --output /backup/connector-2026-08-01.db +``` + +生产与 Quickstart Compose 都将 `/backup` 挂载为独立持久卷,不与 `/data` 共用故障域;外部备份系统仍需定期把该卷复制到受控异地存储。 + +备份完成后在隔离环境执行: + +```bash +SWT_CONNECTOR_DB_PATH=/restore/connector.db shangwutong migrate status +sqlite3 /restore/connector.db 'PRAGMA integrity_check;' +``` + +恢复步骤:停止 Connector;保存当前 DB/WAL/SHM 作为故障证据;将验证通过的备份放回明确的 DB 路径并设为运行用户 `0600`;启动 Connector;检查 ready、migration、账号数、cursor 和队列深度。恢复不会主动 logout,保存的 session 可优先复用;失效 session 会自动重登。 + +禁止在运行中用 `cp connector.db` 作为一致性备份,也不要删除 failed/uncertain 记录来“清空告警”。 + +## 6. 升级与回滚 + +升级前: + +```bash +docker compose exec shangwutong shangwutong backup --output /backup/pre-upgrade.db +docker compose exec shangwutong shangwutong doctor +``` + +滚动步骤:拉取新镜像;执行 `migrate up`;向旧进程发送 SIGTERM;确认 30 秒内退出;启动新进程;确认 ready、supervisor 数、cursor 和 queue;抽样真实收发。停止期间 GoChat durable webhook 会重试,商务通 session 不执行主动 logout。 + +崩溃恢复时,遗留 inbound delivering 回到 pending;遗留 outbound delivering 转 uncertain,禁止无证据重发。uncertain 在唯一 kind=3 回显确认或 5 分钟观察超时后解除屏障。 + +回滚只允许使用能读取当前 schema 的镜像。回滚前暂停 Inbox 配置修改并备份;不要清空 SQLite、cursor 或 GoChat durable jobs。 + +## 7. 故障处理 + +- `auth_failed`:核对 Inbox 的 session ID、username、最近密码版本;不要直接改 SQLite。通过 GoChat 配置页提交新密码。 +- `verification_required`:当前不自动绕过 vcode/ecsq。保留账号离线,按真实商务通客户端完成人工验证并记录流程证据。 +- `relogin_required` / `tickint_reset`:Connector 会清理 token 并用保存密码重登;观察是否形成登录风暴。 +- inbound 堆积:检查 GoChat Application/Public API、service token grant 和附件下载;cursor 已在原始事件持久化后推进,不要回退 cursor 猜测重放。 +- outbound uncertain:等待观察窗口和 kind=3;不要重复点击发送。超时 failed 后才允许显式 retry。 +- `invalid_signature`:检查 Inbox webhook secret、系统时钟和 lifecycle secret 轮换顺序。 +- SQLite BUSY/损坏:停止流量,保全 DB/WAL/SHM,运行只读检查并从验证过的在线备份恢复。 + +## 8. 生产证据清单 + +以下结果必须分别记录,不能用单元测试替代: + +- 真实账号登录、session 恢复、online/busy/away/offline、logout 和密码正确/错误更新。 +- 文本、图片、文件、语音双向收发,撤回及连续相同内容的 uncertain 歧义样本。 +- 负 kind 与 65/66/67 cursor 连续抓包;kind=52 不同版本真实 fixture。 +- vcode/ecsq 人工恢复流程。 +- 500+ 账号连续至少 1 小时:无重复 supervisor、SQLite BUSY、goroutine 泄漏、事件丢失,心跳调度不持续跨周期。 +- GoChat 停机恢复事件总数一致;GoChat 升级期间 supervisor 持续心跳。 +- Connector SIGTERM 30 秒内退出,重启后 cursor 连续且未批量 logout。 +- `PRAGMA integrity_check` 为 `ok`,日志和响应扫描不含密码、token 或 secret。 + +任一真实协议项没有证据时,结论应写“功能已实现/自动化已通过,但该项阻塞生产上线”,不得标记为生产验证完成。 diff --git a/frontend/app/javascript/dashboard/components-next/icon/provider.js b/frontend/app/javascript/dashboard/components-next/icon/provider.js index d7a9c93a..2846c4a2 100644 --- a/frontend/app/javascript/dashboard/components-next/icon/provider.js +++ b/frontend/app/javascript/dashboard/components-next/icon/provider.js @@ -4,6 +4,7 @@ import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; export function useChannelIcon(inbox) { const channelTypeIconMap = { 'Channel::Api': 'i-woot-api', + 'Channel::Shangwutong': 'i-ri-customer-service-2-line', 'Channel::Email': 'i-woot-mail', 'Channel::FacebookPage': 'i-woot-messenger', 'Channel::Line': 'i-woot-line', diff --git a/frontend/app/javascript/dashboard/helper/inbox.js b/frontend/app/javascript/dashboard/helper/inbox.js index 4039a07d..23aa2ff6 100644 --- a/frontend/app/javascript/dashboard/helper/inbox.js +++ b/frontend/app/javascript/dashboard/helper/inbox.js @@ -5,6 +5,7 @@ export const INBOX_TYPES = { TWILIO: 'Channel::TwilioSms', WHATSAPP: 'Channel::Whatsapp', API: 'Channel::Api', + SHANGWUTONG: 'Channel::Shangwutong', EMAIL: 'Channel::Email', TELEGRAM: 'Channel::Telegram', LINE: 'Channel::Line', @@ -45,6 +46,7 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill', [INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-fill', [INBOX_TYPES.API]: 'i-ri-cloudy-fill', + [INBOX_TYPES.SHANGWUTONG]: 'i-ri-customer-service-2-fill', [INBOX_TYPES.EMAIL]: 'i-ri-mail-fill', [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill', [INBOX_TYPES.LINE]: 'i-ri-line-fill', @@ -60,6 +62,7 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.TWITTER]: 'i-woot-x', [INBOX_TYPES.WHATSAPP]: 'i-woot-whatsapp', [INBOX_TYPES.API]: 'i-woot-api', + [INBOX_TYPES.SHANGWUTONG]: 'i-ri-customer-service-2-line', [INBOX_TYPES.EMAIL]: 'i-woot-mail', [INBOX_TYPES.TELEGRAM]: 'i-woot-telegram', [INBOX_TYPES.LINE]: 'i-woot-line', @@ -105,6 +108,9 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.API: return 'api'; + case INBOX_TYPES.SHANGWUTONG: + return 'shangwutong'; + case INBOX_TYPES.EMAIL: return 'email'; @@ -141,6 +147,9 @@ export const getInboxClassByType = (type, phoneNumber) => { case INBOX_TYPES.API: return 'cloud'; + case INBOX_TYPES.SHANGWUTONG: + return 'customer-service-2'; + case INBOX_TYPES.EMAIL: return 'mail'; diff --git a/frontend/app/javascript/dashboard/helper/specs/inbox.spec.js b/frontend/app/javascript/dashboard/helper/specs/inbox.spec.js index 775f05d3..ee5dc383 100644 --- a/frontend/app/javascript/dashboard/helper/specs/inbox.spec.js +++ b/frontend/app/javascript/dashboard/helper/specs/inbox.spec.js @@ -35,6 +35,11 @@ describe('#Inbox Helpers', () => { it('should return correct class for Api', () => { expect(getInboxClassByType('Channel::Api')).toEqual('cloud'); }); + it('should return correct class for Shangwutong', () => { + expect(getInboxClassByType(INBOX_TYPES.SHANGWUTONG)).toEqual( + 'customer-service-2' + ); + }); it('should return correct class for Email', () => { expect(getInboxClassByType('Channel::Email')).toEqual('mail'); }); @@ -69,6 +74,12 @@ describe('#Inbox Helpers', () => { expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill'); }); + it('returns correct icon for Shangwutong', () => { + expect(getInboxIconByType(INBOX_TYPES.SHANGWUTONG)).toBe( + 'i-ri-customer-service-2-fill' + ); + }); + it('returns correct icon for Email', () => { expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill'); }); diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index bce64494..a07b6a96 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -384,6 +384,21 @@ "ERROR_MESSAGE": "We were not able to save the api channel" } }, + "SHANGWUTONG_CHANNEL": { + "TITLE": "Shangwutong channel", + "DESC": "Connect a Shangwutong site account to a shared GoChat inbox.", + "CHANNEL_NAME": "Inbox name", + "SESSION_ID": "Site session ID", + "SESSION_ID_ERROR": "Enter the 11-letter or digit Shangwutong session ID.", + "USERNAME": "Login username", + "PASSWORD": "Login password", + "DESIRED_PRESENCE": "Initial presence", + "WEBHOOK_URL": "Connector webhook URL", + "SUBMIT_BUTTON": "Create Shangwutong channel", + "API": { + "ERROR_MESSAGE": "We were not able to save the Shangwutong channel" + } + }, "EMAIL_CHANNEL": { "TITLE": "Email Channel", "DESC": "Integrate your email inbox.", @@ -479,6 +494,10 @@ "TITLE": "API", "DESCRIPTION": "Make a custom channel using our API" }, + "SHANGWUTONG": { + "TITLE": "Shangwutong", + "DESCRIPTION": "Connect a Shangwutong site account" + }, "TELEGRAM": { "TITLE": "Telegram", "DESCRIPTION": "Configure Telegram channel using Bot token" @@ -1155,6 +1174,17 @@ "DESCRIPTION": "Connect with Other Providers" } }, + "SHANGWUTONG_SETTINGS": { + "RUNTIME_STATUS": "Connector status", + "RUNTIME_STATUS_HELP": "The latest connection and credential state reported by the Connector.", + "REFRESH": "Refresh status", + "DESIRED_PRESENCE": "Desired presence", + "DESIRED_PRESENCE_HELP": "Changes are applied online. Offline logs out only this Shangwutong account.", + "PASSWORD": "Replace password", + "PASSWORD_HELP": "Leave blank to keep the current password. A new password is verified online before replacing the working credential.", + "WEBHOOK_URL": "Connector webhook URL", + "WEBHOOK_URL_HELP": "All enabled Shangwutong inboxes must use the same Connector URL." + }, "CHANNELS": { "MESSENGER": "Messenger", "WEB_WIDGET": "Website", @@ -1166,6 +1196,7 @@ "TELEGRAM": "Telegram", "LINE": "Line", "API": "API Channel", + "SHANGWUTONG": "Shangwutong", "INSTAGRAM": "Instagram", "TIKTOK": "TikTok", "VOICE": "Voice" diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json index 44d5b1f1..030353a8 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -384,6 +384,21 @@ "ERROR_MESSAGE": "我们无法保存 api 频道" } }, + "SHANGWUTONG_CHANNEL": { + "TITLE": "商务通渠道", + "DESC": "将一个商务通站点账号接入共享的 GoChat 收件箱。", + "CHANNEL_NAME": "收件箱名称", + "SESSION_ID": "站点 Session ID", + "SESSION_ID_ERROR": "请输入由 11 位字母或数字组成的商务通 Session ID。", + "USERNAME": "登录账号", + "PASSWORD": "登录密码", + "DESIRED_PRESENCE": "初始在线状态", + "WEBHOOK_URL": "Connector Webhook 地址", + "SUBMIT_BUTTON": "创建商务通渠道", + "API": { + "ERROR_MESSAGE": "无法保存商务通渠道" + } + }, "EMAIL_CHANNEL": { "TITLE": "电子邮件频道", "DESC": "集成您的电子邮件收件箱。", @@ -479,6 +494,10 @@ "TITLE": "API", "DESCRIPTION": "使用我们的 API 创建一个自定义频道" }, + "SHANGWUTONG": { + "TITLE": "商务通", + "DESCRIPTION": "连接一个商务通站点账号" + }, "TELEGRAM": { "TITLE": "Telegram", "DESCRIPTION": "使用 Bot 令牌配置 Telegram 频道" @@ -1155,6 +1174,17 @@ "DESCRIPTION": "与其他提供商关联" } }, + "SHANGWUTONG_SETTINGS": { + "RUNTIME_STATUS": "Connector 运行状态", + "RUNTIME_STATUS_HELP": "显示 Connector 最近回报的连接、在线与凭据状态。", + "REFRESH": "刷新状态", + "DESIRED_PRESENCE": "期望在线状态", + "DESIRED_PRESENCE_HELP": "状态在线切换;选择 offline 只会退出当前商务通账号。", + "PASSWORD": "修改密码", + "PASSWORD_HELP": "留空表示保持原密码。新密码在线验证成功后才替换当前可用凭据。", + "WEBHOOK_URL": "Connector Webhook 地址", + "WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个 Connector 地址。" + }, "CHANNELS": { "MESSENGER": "Messenger", "WEB_WIDGET": "网站", @@ -1166,6 +1196,7 @@ "TELEGRAM": "Telegram", "LINE": "Line", "API": "API 频道", + "SHANGWUTONG": "商务通", "INSTAGRAM": "Instagram", "TIKTOK": "TikTok", "VOICE": "语音" diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index 7d1d5885..88d31934 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -4,6 +4,7 @@ import Facebook from './channels/Facebook.vue'; import Website from './channels/Website.vue'; import Twitter from './channels/Twitter.vue'; import Api from './channels/Api.vue'; +import Shangwutong from './channels/Shangwutong.vue'; import Email from './channels/Email.vue'; import Sms from './channels/Sms.vue'; import Whatsapp from './channels/Whatsapp.vue'; @@ -18,6 +19,7 @@ const channelViewList = { website: Website, twitter: Twitter, api: Api, + shangwutong: Shangwutong, email: Email, sms: Sms, whatsapp: Whatsapp, diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index 133f4a79..e395e1b0 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -55,6 +55,14 @@ const channelList = computed(() => { description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.API.DESCRIPTION'), icon: 'i-woot-api', }, + { + key: 'shangwutong', + title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.SHANGWUTONG.TITLE'), + description: t( + 'INBOX_MGMT.ADD.AUTH.CHANNEL.SHANGWUTONG.DESCRIPTION' + ), + icon: 'i-ri-customer-service-2-line', + }, { key: 'telegram', title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE'), diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 393ccbb1..ed4ab4fe 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -199,6 +199,7 @@ export default { this.isATwilioChannel || this.isALineChannel || this.isAPIInbox || + this.isShangwutongInbox || (this.isAnEmailChannel && !this.inbox.provider) || this.shouldShowWhatsAppConfiguration || this.isAWebWidgetInbox @@ -292,6 +293,7 @@ export default { this.isAWhatsAppChannel || this.isAFacebookInbox || this.isAPIInbox || + this.isShangwutongInbox || this.isAnInstagramChannel || this.isALineChannel || this.isATiktokChannel || @@ -591,6 +593,9 @@ export default { this.isInboundEmailEnabled && this.continuityViaEmail, }, }; + if (this.isShangwutongInbox) { + payload.channel = {}; + } if (this.avatarFile) { payload.avatar = this.avatarFile; } diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Shangwutong.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Shangwutong.vue new file mode 100644 index 00000000..904764f5 --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Shangwutong.vue @@ -0,0 +1,176 @@ + + + diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue new file mode 100644 index 00000000..2915ceb7 --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue @@ -0,0 +1,194 @@ + + + diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 711217f3..b265ea60 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -32,6 +32,7 @@ const i18nMap = { 'Channel::Telegram': 'TELEGRAM', 'Channel::Line': 'LINE', 'Channel::Api': 'API', + 'Channel::Shangwutong': 'SHANGWUTONG', 'Channel::Instagram': 'INSTAGRAM', 'Channel::Tiktok': 'TIKTOK', }; diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 8beaea48..c4eca9a0 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -11,6 +11,7 @@ import { required } from '@vuelidate/validators'; import NextButton from 'dashboard/components-next/button/Button.vue'; import TextArea from 'next/textarea/TextArea.vue'; import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue'; +import ShangwutongConfiguration from '../channels/ShangwutongConfiguration.vue'; import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper'; export default { @@ -23,6 +24,7 @@ export default { NextButton, TextArea, WhatsappReauthorize, + ShangwutongConfiguration, }, mixins: [inboxMixin], props: { @@ -189,6 +191,7 @@ export default {