From 1446cfe701acf399001bb88dd6b38780064dca2a Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 1 Aug 2026 19:38:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E7=A4=BE=E4=BA=A4=E6=B8=A0?= =?UTF-8?q?=E9=81=93=20OAuth=20=E6=94=B9=E8=BF=9B=20+=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E9=87=8D=E5=91=BD=E5=90=8D=20+=20AI=20?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Facebook/Instagram/TikTok 渠道 OAuth 授权流程改进 - 新增 oauth/credentialstore 包 - 集成应用 OpenAI 重命名为 OpenAI 兼容 (migration 061) - 消息生命周期与 AI 流程架构文档 - inbox 管理界面 i18n 更新 --- .../api/v1/facebook_channel_handler.go | 5 + .../handler/api/v1/inbox_serializer.go | 3 + .../api/v1/instagram_channel_handler.go | 11 +- .../api/v1/integration_hook_handler.go | 6 +- .../handler/api/v1/social_authorization.go | 65 ++- .../handler/api/v1/tiktok_channel_handler.go | 7 +- .../oauth/credentialstore/credentialstore.go | 73 +++ backend/internal/router/channel_callbacks.go | 62 ++- backend/internal/service/inbox_service.go | 8 + .../000059_translate_integration_apps.up.sql | 2 +- ...00061_rename_openai_to_compatible.down.sql | 5 + .../000061_rename_openai_to_compatible.up.sql | 5 + .../message-lifecycle-and-ai-flow.md | 523 ++++++++++++++++++ .../components/widgets/ChannelItem.vue | 43 +- .../dashboard/i18n/locale/en/inboxMgmt.json | 4 +- .../i18n/locale/zh_CN/inboxMgmt.json | 4 +- .../dashboard/settings/inbox/ChannelList.vue | 18 +- .../settings/inbox/channels/Facebook.vue | 72 ++- .../settings/inbox/channels/Instagram.vue | 35 +- .../settings/inbox/channels/Tiktok.vue | 35 +- .../settings/inbox/facebook/Reauthorize.vue | 10 +- 21 files changed, 897 insertions(+), 99 deletions(-) create mode 100644 backend/internal/oauth/credentialstore/credentialstore.go create mode 100644 backend/migrations/000061_rename_openai_to_compatible.down.sql create mode 100644 backend/migrations/000061_rename_openai_to_compatible.up.sql create mode 100644 docs/architecture/message-lifecycle-and-ai-flow.md diff --git a/backend/internal/handler/api/v1/facebook_channel_handler.go b/backend/internal/handler/api/v1/facebook_channel_handler.go index 5a262e69..09d7576b 100644 --- a/backend/internal/handler/api/v1/facebook_channel_handler.go +++ b/backend/internal/handler/api/v1/facebook_channel_handler.go @@ -122,6 +122,9 @@ type FacebookCallbackRegisterRequest struct { InboxName string `json:"inbox_name" form:"inbox_name"` PageName string `json:"page_name" form:"page_name"` EnableAutoAssignment bool `json:"enable_auto_assignment" form:"enable_auto_assignment"` + // Per-inbox Meta App credentials (collected during channel setup, not global config) + FBAppID string `json:"fb_app_id" form:"fb_app_id"` + FBAppSecret string `json:"fb_app_secret" form:"fb_app_secret"` } type FacebookCallbackPagesRequest struct { @@ -167,6 +170,8 @@ func (h *FacebookChannelHandler) RegisterFacebookPage(c *gin.Context) { UserAccessToken: req.UserAccessToken, PageName: req.PageName, EnableAutoAssignment: req.EnableAutoAssignment, + FBAppID: req.FBAppID, + FBAppSecret: req.FBAppSecret, }, h.fbRepo) if err != nil { if renderInboxLimitExceeded(c, err) { diff --git a/backend/internal/handler/api/v1/inbox_serializer.go b/backend/internal/handler/api/v1/inbox_serializer.go index 067e4164..e2b63214 100644 --- a/backend/internal/handler/api/v1/inbox_serializer.go +++ b/backend/internal/handler/api/v1/inbox_serializer.go @@ -76,6 +76,9 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an case "Channel::FacebookPage": payload["page_id"] = configValue(config, "page_id") payload["reauthorization_required"] = configValue(config, "reauthorization_required") + if isAdmin { + payload["fb_app_id"] = configValue(config, "fb_app_id") + } case "Channel::Instagram": payload["instagram_id"] = configValue(config, "instagram_id") payload["reauthorization_required"] = configValue(config, "reauthorization_required") diff --git a/backend/internal/handler/api/v1/instagram_channel_handler.go b/backend/internal/handler/api/v1/instagram_channel_handler.go index 875d65b8..ceb687a4 100644 --- a/backend/internal/handler/api/v1/instagram_channel_handler.go +++ b/backend/internal/handler/api/v1/instagram_channel_handler.go @@ -62,7 +62,9 @@ func NewInstagramChannelHandler( // InstagramAuthorizationRequest is the DTO for initiating IG OAuth flow. // Reference: Chatwoot instagram_controller#authorization → redirects to Meta login type InstagramAuthorizationRequest struct { - RedirectURL string `json:"redirect_url" validate:"required,url"` + RedirectURL string `json:"redirect_url" validate:"omitempty,url"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` } // Authorization generates a Meta OAuth authorize URL for Instagram. @@ -113,10 +115,13 @@ func (h *InstagramChannelHandler) ChatwootAuthorization(c *gin.Context) { return } - redirectURL, err := buildInstagramChatwootAuthorizationURL(accountID, authorizationReturnTo(c)) + var req InstagramAuthorizationRequest + _ = c.ShouldBindJSON(&req) + + redirectURL, err := buildInstagramChatwootAuthorizationURL(accountID, authorizationReturnTo(c), 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}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL}) diff --git a/backend/internal/handler/api/v1/integration_hook_handler.go b/backend/internal/handler/api/v1/integration_hook_handler.go index dfca3b5c..109d5493 100644 --- a/backend/internal/handler/api/v1/integration_hook_handler.go +++ b/backend/internal/handler/api/v1/integration_hook_handler.go @@ -266,7 +266,9 @@ func integrationAppSettingsFormSchema(appID string) []gin.H { schemas := map[string][]gin.H{ "openai": { {"label": "API Key", "type": "text", "name": "api_key", "validation": "required"}, - {"label": "Show label suggestions", "type": "checkbox", "name": "label_suggestion", "validation": ""}, + {"label": "Base URL", "type": "text", "name": "base_url", "validation": "required", "help": "OpenAI 兼容 API 地址,例如 https://api.openai.com/v1"}, + {"label": "模型", "type": "text", "name": "model", "validation": "required", "help": "自定义模型名称,例如 gpt-4o-mini、deepseek-chat 等"}, + {"label": "启用标签建议", "type": "checkbox", "name": "label_suggestion", "validation": "", "help": "勾选后,在对话视图中显示 AI 生成的标签建议"}, }, "dialogflow": { {"label": "Dialogflow Project ID", "type": "text", "name": "project_id", "validation": "required", "validationName": "Project Id"}, @@ -297,7 +299,7 @@ func integrationAppSettingsFormSchema(appID string) []gin.H { func integrationAppVisibleProperties(appID string) []string { properties := map[string][]string{ - "openai": {"api_key", "label_suggestion"}, + "openai": {"api_key", "base_url", "model", "label_suggestion"}, "dialogflow": {"project_id", "region", "language_code"}, "google_translate": {"project_id"}, "dyte": {"organization_id"}, diff --git a/backend/internal/handler/api/v1/social_authorization.go b/backend/internal/handler/api/v1/social_authorization.go index 89052fa7..e86b0efc 100644 --- a/backend/internal/handler/api/v1/social_authorization.go +++ b/backend/internal/handler/api/v1/social_authorization.go @@ -3,10 +3,13 @@ package v1 import ( "fmt" "net/url" - "os" "strings" + "time" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "github.com/gochat/gochat/internal/oauth/credentialstore" ) const instagramAuthorizationScope = "instagram_business_basic,instagram_business_manage_messages" @@ -24,20 +27,31 @@ func authorizationReturnTo(c *gin.Context) string { return strings.TrimSpace(payload.ReturnTo) } -func buildInstagramChatwootAuthorizationURL(accountID uint, returnTo string) (string, error) { - clientID := strings.TrimSpace(os.Getenv("INSTAGRAM_APP_ID")) - clientSecret := strings.TrimSpace(os.Getenv("INSTAGRAM_APP_SECRET")) - if clientID == "" || clientSecret == "" { - return "", fmt.Errorf("Instagram OAuth is not configured") +// TikTokAuthorizationRequest is the DTO for initiating TikTok OAuth flow +// with per-inbox credentials. +type TikTokAuthorizationRequest struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` +} + +func buildInstagramChatwootAuthorizationURL(accountID uint, returnTo string, appID, appSecret string) (string, error) { + if strings.TrimSpace(appID) == "" || strings.TrimSpace(appSecret) == "" { + return "", fmt.Errorf("Instagram App ID and Secret are required") } - state, err := signedChatwootOAuthStateWithReturnTo(accountID, clientSecret, returnTo) + // Store credentials temporarily for the callback to retrieve + nonce, err := credentialstore.Store(appID, appSecret) + if err != nil { + return "", fmt.Errorf("failed to store OAuth credentials: %w", err) + } + + state, err := signedChatwootOAuthStateWithReturnToAndNonce(accountID, appSecret, returnTo, nonce) if err != nil { return "", err } frontendURL := strings.TrimRight(envOrDefaultV1("FRONTEND_URL", "http://localhost:3000"), "/") params := url.Values{} - params.Set("client_id", clientID) + params.Set("client_id", appID) params.Set("redirect_uri", frontendURL+"/instagram/callback") params.Set("scope", instagramAuthorizationScope) params.Set("enable_fb_login", "0") @@ -47,24 +61,43 @@ func buildInstagramChatwootAuthorizationURL(accountID uint, returnTo string) (st return "https://api.instagram.com/oauth/authorize?" + params.Encode(), nil } -func buildTikTokChatwootAuthorizationURL(accountID uint, returnTo string) (string, error) { - clientID := strings.TrimSpace(os.Getenv("TIKTOK_APP_ID")) - clientSecret := strings.TrimSpace(os.Getenv("TIKTOK_APP_SECRET")) - if clientID == "" || clientSecret == "" { - return "", fmt.Errorf("TikTok OAuth is not configured") +func buildTikTokChatwootAuthorizationURL(accountID uint, returnTo string, appID, appSecret string) (string, error) { + if strings.TrimSpace(appID) == "" || strings.TrimSpace(appSecret) == "" { + return "", fmt.Errorf("TikTok App ID and Secret are required") } - state, err := signedChatwootOAuthStateWithReturnTo(accountID, clientSecret, returnTo) + // Store credentials temporarily for the callback to retrieve + nonce, err := credentialstore.Store(appID, appSecret) + if err != nil { + return "", fmt.Errorf("failed to store OAuth credentials: %w", err) + } + + state, err := signedChatwootOAuthStateWithReturnToAndNonce(accountID, appSecret, returnTo, nonce) if err != nil { return "", err } frontendURL := strings.TrimRight(envOrDefaultV1("FRONTEND_URL", "http://localhost:3000"), "/") params := url.Values{} - params.Set("client_id", clientID) - params.Set("client_key", clientID) + params.Set("client_id", appID) + params.Set("client_key", appID) params.Set("redirect_uri", frontendURL+"/tiktok/callback") params.Set("response_type", "code") params.Set("scope", tiktokAuthorizationScope) params.Set("state", state) return "https://www.tiktok.com/v2/auth/authorize?" + params.Encode(), nil } + +// signedChatwootOAuthStateWithReturnToAndNonce creates a JWT state token +// that includes a credential nonce for the callback to retrieve stored credentials. +func signedChatwootOAuthStateWithReturnToAndNonce(accountID uint, secret string, returnTo string, nonce string) (string, error) { + claims := jwt.MapClaims{ + "sub": accountID, + "iat": time.Now().Unix(), + "nonce": nonce, + } + if strings.TrimSpace(returnTo) != "" { + claims["return_to"] = strings.TrimSpace(returnTo) + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} diff --git a/backend/internal/handler/api/v1/tiktok_channel_handler.go b/backend/internal/handler/api/v1/tiktok_channel_handler.go index 01787a94..9d2a5cdd 100644 --- a/backend/internal/handler/api/v1/tiktok_channel_handler.go +++ b/backend/internal/handler/api/v1/tiktok_channel_handler.go @@ -62,10 +62,13 @@ func (h *TikTokChannelHandler) ChatwootAuthorization(c *gin.Context) { return } - redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c)) + var req TikTokAuthorizationRequest + _ = c.ShouldBindJSON(&req) + + redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c), 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}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL}) diff --git a/backend/internal/oauth/credentialstore/credentialstore.go b/backend/internal/oauth/credentialstore/credentialstore.go new file mode 100644 index 00000000..6d38ce4b --- /dev/null +++ b/backend/internal/oauth/credentialstore/credentialstore.go @@ -0,0 +1,73 @@ +// Package credentialstore provides a short-lived in-memory cache for OAuth +// credentials that need to be passed from the authorization request to the +// OAuth callback handler. +// +// Usage: +// 1. Authorization handler stores {app_id, app_secret} keyed by a random nonce +// 2. The nonce is embedded in the OAuth state JWT +// 3. Callback handler extracts the nonce from state, retrieves credentials, and the entry auto-expires +// +// Entries expire after 10 minutes. This is a single-instance solution; +// for multi-instance deployments, replace with Redis-backed implementation. +package credentialstore + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +const defaultTTL = 10 * time.Minute + +// Entry holds OAuth credentials temporarily for the callback flow. +type Entry struct { + AppID string + AppSecret string + CreatedAt time.Time +} + +var ( + mu sync.RWMutex + entries = make(map[string]Entry) +) + +// Store saves credentials and returns a nonce key for retrieval. +func Store(appID, appSecret string) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + nonce := hex.EncodeToString(b) + + mu.Lock() + defer mu.Unlock() + // Prune expired entries opportunistically + now := time.Now() + for k, v := range entries { + if now.Sub(v.CreatedAt) > defaultTTL { + delete(entries, k) + } + } + entries[nonce] = Entry{ + AppID: appID, + AppSecret: appSecret, + CreatedAt: now, + } + return nonce, nil +} + +// Retrieve fetches and deletes credentials by nonce. Returns ok=false if not found or expired. +func Retrieve(nonce string) (Entry, bool) { + mu.Lock() + defer mu.Unlock() + entry, ok := entries[nonce] + if !ok { + return Entry{}, false + } + delete(entries, nonce) // one-time use + if time.Since(entry.CreatedAt) > defaultTTL { + return Entry{}, false + } + return entry, true +} diff --git a/backend/internal/router/channel_callbacks.go b/backend/internal/router/channel_callbacks.go index 5b562387..72265aad 100644 --- a/backend/internal/router/channel_callbacks.go +++ b/backend/internal/router/channel_callbacks.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/oauth/credentialstore" "github.com/golang-jwt/jwt/v5" "gorm.io/gorm" ) @@ -96,7 +97,10 @@ func emailOAuthCallback(db *gorm.DB, cfg emailCallbackConfig) gin.HandlerFunc { func instagramChannelCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("INSTAGRAM_APP_SECRET")) + // First try to verify state with per-inbox credentials (nonce-based) + // If that fails, fall back to env-based verification + stateSecret := os.Getenv("INSTAGRAM_APP_SECRET") + accountID, ok := callbackAccountID(c.Query("state"), stateSecret) if !ok { c.Redirect(http.StatusFound, frontendBaseURL()) return @@ -114,10 +118,17 @@ func instagramChannelCallback(db *gorm.DB) gin.HandlerFunc { return } + // Resolve per-inbox credentials: try nonce from state first, fall back to env + appID, appSecret, credOK := resolveChannelOAuthCredentials(c.Query("state"), stateSecret, "INSTAGRAM_APP_ID", "INSTAGRAM_APP_SECRET") + if !credOK { + c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "Instagram OAuth credentials not configured"})) + return + } + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: envOrDefault("INSTAGRAM_OAUTH_TOKEN_URL", "https://api.instagram.com/oauth/access_token"), - ClientID: os.Getenv("INSTAGRAM_APP_ID"), - ClientSecret: os.Getenv("INSTAGRAM_APP_SECRET"), + ClientID: appID, + ClientSecret: appSecret, Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/instagram/callback", }) @@ -143,7 +154,8 @@ func instagramChannelCallback(db *gorm.DB) gin.HandlerFunc { func tiktokChannelCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("TIKTOK_APP_SECRET")) + stateSecret := os.Getenv("TIKTOK_APP_SECRET") + accountID, ok := callbackAccountID(c.Query("state"), stateSecret) if !ok { c.Redirect(http.StatusFound, frontendBaseURL()) return @@ -161,10 +173,17 @@ func tiktokChannelCallback(db *gorm.DB) gin.HandlerFunc { return } + // Resolve per-inbox credentials: try nonce from state first, fall back to env + appID, appSecret, credOK := resolveChannelOAuthCredentials(c.Query("state"), stateSecret, "TIKTOK_APP_ID", "TIKTOK_APP_SECRET") + if !credOK { + c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "TikTok OAuth credentials not configured"})) + return + } + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: envOrDefault("TIKTOK_OAUTH_TOKEN_URL", "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/"), - ClientID: os.Getenv("TIKTOK_APP_ID"), - ClientSecret: os.Getenv("TIKTOK_APP_SECRET"), + ClientID: appID, + ClientSecret: appSecret, Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/tiktok/callback", }) @@ -504,3 +523,34 @@ func firstNonBlank(values ...string) string { } return "" } + +// resolveChannelOAuthCredentials resolves per-inbox OAuth credentials from the +// state JWT's nonce claim. If no nonce is present (legacy/env-based flow), it +// falls back to environment variables. +// Returns (appID, appSecret, ok). +func resolveChannelOAuthCredentials(state string, stateSecret string, envAppID, envAppSecret string) (string, string, bool) { + // Try per-inbox credentials via nonce in the JWT state + if stateSecret != "" { + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(stateSecret), nil + }) + if err == nil && token.Valid { + if nonce, ok := claims["nonce"].(string); ok && nonce != "" { + if entry, found := credentialstore.Retrieve(nonce); found { + return entry.AppID, entry.AppSecret, true + } + } + } + } + // Fallback to environment variables (backward compatibility) + appID := strings.TrimSpace(os.Getenv(envAppID)) + appSecret := strings.TrimSpace(os.Getenv(envAppSecret)) + if appID != "" && appSecret != "" { + return appID, appSecret, true + } + return "", "", false +} diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index 9acb2778..2462b36a 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -1545,6 +1545,8 @@ type FacebookInboxConfig struct { PageAccessToken string `json:"page_access_token"` PageName string `json:"page_name,omitempty"` WebhookVerifyToken string `json:"verify_token"` + FBAppID string `json:"fb_app_id,omitempty"` + FBAppSecret string `json:"fb_app_secret,omitempty"` } // CreateFacebookInboxRequest is the DTO for creating a Facebook Messenger inbox. @@ -1557,6 +1559,9 @@ type CreateFacebookInboxRequest struct { PageName string `json:"page_name,omitempty"` WebhookVerifyToken string `json:"webhook_verify_token,omitempty"` // auto-generated if empty EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"` + // Per-inbox Meta App credentials + FBAppID string `json:"fb_app_id,omitempty"` + FBAppSecret string `json:"fb_app_secret,omitempty"` } // CreateFacebookInbox creates a new Facebook Messenger channel inbox. @@ -1587,6 +1592,7 @@ func (s *InboxService) CreateFacebookInbox(ctx context.Context, accountID uint, PageAccessToken: req.PageAccessToken, UserAccessToken: req.UserAccessToken, PageName: req.PageName, + AppID: req.FBAppID, WebhookVerifyToken: verifyToken, ReauthorizationRequired: false, } @@ -1597,6 +1603,8 @@ func (s *InboxService) CreateFacebookInbox(ctx context.Context, accountID uint, PageAccessToken: req.PageAccessToken, PageName: req.PageName, WebhookVerifyToken: verifyToken, + FBAppID: req.FBAppID, + FBAppSecret: req.FBAppSecret, } configJSON, err := json.Marshal(config) if err != nil { diff --git a/backend/migrations/000059_translate_integration_apps.up.sql b/backend/migrations/000059_translate_integration_apps.up.sql index bf945af5..cda2f37a 100644 --- a/backend/migrations/000059_translate_integration_apps.up.sql +++ b/backend/migrations/000059_translate_integration_apps.up.sql @@ -6,7 +6,7 @@ UPDATE integration_apps SET END, description = CASE WHEN name = 'Dashboard Apps' THEN '在对话侧边栏中嵌入自定义仪表板应用' - WHEN name = 'OpenAI' THEN '连接 OpenAI 以获取标签建议和 AI 辅助工作流' + WHEN name = 'OpenAI 兼容' THEN '连接 OpenAI 兼容 API(支持自定义 BaseURL 和模型,可对接 DeepSeek、通义千问等)' WHEN name = 'Dialogflow' THEN '将 Dialogflow 代理连接到收件箱' WHEN name = 'Google Translate' THEN '连接 Google 翻译以进行消息翻译' WHEN name = 'Dyte' THEN '连接 Dyte 进行视频会议' diff --git a/backend/migrations/000061_rename_openai_to_compatible.down.sql b/backend/migrations/000061_rename_openai_to_compatible.down.sql new file mode 100644 index 00000000..b15cd0f4 --- /dev/null +++ b/backend/migrations/000061_rename_openai_to_compatible.down.sql @@ -0,0 +1,5 @@ +-- Revert "OpenAI 兼容" back to "OpenAI" +UPDATE integration_apps SET + name = 'OpenAI', + description = '连接 OpenAI 以获取标签建议和 AI 辅助工作流' +WHERE hook_type = 'openai'; diff --git a/backend/migrations/000061_rename_openai_to_compatible.up.sql b/backend/migrations/000061_rename_openai_to_compatible.up.sql new file mode 100644 index 00000000..376a92a4 --- /dev/null +++ b/backend/migrations/000061_rename_openai_to_compatible.up.sql @@ -0,0 +1,5 @@ +-- Rename OpenAI integration to "OpenAI 兼容" and update description +UPDATE integration_apps SET + name = 'OpenAI 兼容', + description = '连接 OpenAI 兼容 API(支持自定义 BaseURL 和模型,可对接 DeepSeek、通义千问等)' +WHERE hook_type = 'openai'; diff --git a/docs/architecture/message-lifecycle-and-ai-flow.md b/docs/architecture/message-lifecycle-and-ai-flow.md new file mode 100644 index 00000000..f937f38f --- /dev/null +++ b/docs/architecture/message-lifecycle-and-ai-flow.md @@ -0,0 +1,523 @@ +# GoChat 消息生命周期与 AI 自动回复流程 + +> 本文档描述从渠道消息入站、人工/AI 回复、消息打标签到 AI 自动触发的完整运行流程。 +> 所有文件路径相对于仓库根目录,行号截至 2026-07-30。 + +--- + +## 一、整体架构概览 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 外部渠道 │ +│ Facebook │ Instagram │ TikTok │ Telegram │ LINE │ Email │ Web Widget │ API +└─────┬──────────┬──────────┬──────────┬─────────┬───────┬──────┬──────┘ + │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Webhook 路由层 (router.go) │ +│ /webhooks/facebook /webhooks/instagram /webhooks/tiktok ... │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Channel Provider 层 (插件接口) │ +│ FacebookProvider InstagramProvider TikTokProvider TelegramProvider│ +│ IncomingMessage() 解析渠道原始 payload → channel.IncomingMessage │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ IncomingPersister (持久化层) │ +│ 1. resolveOrCreateContactInbox — 查找/创建 Contact + ContactInbox │ +│ 2. resolveOrCreateConversation — 查找/创建 Conversation │ +│ 3. createMessage — 持久化 Message │ +│ 4. dispatchIncomingEvents — 广播 channel events │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ DispatchAsync + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Dispatcher (事件分发总线) │ +│ channel.Dispatcher → 广播 ChannelEvent 给所有注册的 EventListener │ +│ │ +│ 注册的 Listener: │ +│ ├── AutoReplyListener — AI 自动回复 │ +│ ├── AutomationRuleListener— 自动化规则(标签/分配/状态变更) │ +│ ├── ActionCableListener — WebSocket 推送到前端 │ +│ ├── NotificationListener — 创建通知 │ +│ ├── AgentBotRuleListener — Agent Bot 规则 │ +│ └── AutoAssignmentListener— 自动分配 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、入站消息流(Inbound) + +### 2.1 Webhook 路由注册 + +路由在 `backend/internal/router/router.go` 中注册: + +- **Facebook**: `POST /webhooks/facebook` → `FacebookWebhookHandler.HandleWebhook` +- **Instagram**: `POST /webhooks/instagram` → `InstagramWebhookHandler` +- **TikTok**: `POST /webhooks/tiktok` → `TikTokWebhookHandler` +- **Telegram**: `POST /webhooks/telegram/:inbox_id` → `TelegramWebhookHandler` +- **LINE**: `POST /webhooks/line/:inbox_id` → `LineWebhookHandler` +- **Email**: IMAP 轮询 + ActionMailbox 端点 +- **Web Widget**: WebSocket 实时消息(不走 webhook) +- **API Channel**: `POST /webhooks/api/:inbox_id` → 外部系统主动推送 + +### 2.2 Webhook Handler → Provider → IncomingMessage + +以 Facebook 为例: + +``` +FacebookWebhookHandler.HandleWebhook (handler/webhook/facebook_webhook.go) + → 解析 Facebook webhook payload (entry[].messaging[]) + → 遍历每条 messaging event + → 调用 FacebookProvider.IncomingMessage() (channel/facebook/provider.go) + → 解析 sender_id, recipient_id, message text, attachments + → 返回 channel.IncomingMessage{ + SourceID: "facebook_msg_xxx", + ConversationID: senderPSID, + Content: messageText, + ContentType: channel.ContentText, + Attachments: []Attachment{...}, + ChannelType: channel.ChannelFacebook, + } +``` + +其他渠道同理,每个 Provider 实现 `IncomingMessage()` 方法将渠道原始数据归一化为 `channel.IncomingMessage` 结构。 + +### 2.3 IncomingPersister 持久化 + +`backend/internal/handler/webhook/incoming_persister.go:74` + +``` +IncomingPersister.PersistIncoming(ctx, inbox, msg) + │ + ├── 若有 WorkerPool → enqueueIncomingMessagePersist (异步队列) + │ └── providerIncomingMessagePersistJob (incoming_persister_jobs.go:24) + │ └── performPersistIncoming (异步执行) + │ + └── performPersistIncoming(ctx, inbox, msg) — 同步路径 + │ + ├── 1. resolveOrCreateContactInbox (L451) + │ ├── 按 inbox_id + source_id 查找 ContactInbox + │ ├── 找到 → 返回关联的 Contact(更新 name 等) + │ └── 未找到 → 创建 Contact + ContactInbox + │ + ├── 2. resolveOrCreateConversation (L504) + │ ├── 查找 account_id+inbox_id+contact_id+status=open 的最近会话 + │ ├── 找到 → 更新 last_activity_at, last_message_at + │ └── 未找到 → 创建新 Conversation (status=open) + │ + ├── 3. createMessage (L545) + │ ├── 构建 model.Message{SenderType:"Contact", MessageType:"incoming"} + │ ├── 处理 in_reply_to(按 source_id 查找被回复的消息) + │ ├── 处理 attachments + │ └── messageRepo.Create() 持久化到 messages 表 + │ + └── 4. dispatchIncomingEvents (L383) + ├── EventContactCreated (仅新联系人) + ├── EventConversationCreated + EventConversationOpened (仅新会话) + ├── EventConversationUpdated (已有会话) + ├── EventMessageCreated + └── EventMessageIncoming + └── dispatcher.DispatchAsync() → 广播给所有 Listener +``` + +### 2.4 WebSocket 实时推送 + +``` +ActionCableListener.OnEvent (internal/wsevent/bridge_listener.go:51) + ├── 接收 EventMessageCreated / EventMessageIncoming + ├── 构建 WebSocket payload (message JSON + conversation meta) + └── wsHub.Broadcast() → 推送到该 account 所有在线 agent 前端 + └── 前端 Vue SPA WebSocket 连接接收 → Vuex/Pinia store 更新 → UI 刷新 +``` + +### 2.5 通知创建 + +``` +NotificationListener.OnEvent + ├── 接收 EventMessageIncoming + ├── 检查 inbox 的通知设置 + ├── 创建 Notification 记录 (notification 表) + ├── 推送 Push notification (PushDeliveryService) + │ └── SendPushNotification (push_delivery_service.go:63) + └── 发送邮件通知(如配置) +``` + +--- + +## 三、出站回复流(Outbound) + +### 3.1 前端发起 + +``` +Agent 在 ReplyBox 输入消息 → 点击发送 + │ + ├── frontend/app/javascript/dashboard/api/inbox/message.js:56 + │ MessageApi.create({ conversationId, message, ... }) + │ → POST /api/v1/accounts/:id/conversations/:conversation_id/messages + │ + └── 前端 Vuex store: conversations/actions.js + createPendingMessageAndSend → MessageApi.create() +``` + +### 3.2 后端处理 + +``` +MessageHandler.Create (handler/api/v1/message_handler.go) + → 解析请求 body (content, private, files, echo_id, ...) + → 调用 MessageService.Create() + │ + │ backend/internal/service/message_service.go + │ + ├── 构建 model.Message{MessageType:"outgoing", SenderType:"User"} + ├── messageRepo.Create() — 持久化消息 + ├── 更新 conversation.last_activity_at, last_message_at + ├── dispatchMessageEvent(EventMessageCreated + EventMessageOutgoing) + │ └── dispatcher.Dispatch() → 广播给 Listener + │ ├── ActionCableListener → WebSocket 推送 (agent 端即时看到) + │ ├── AutomationRuleListener → 触发自动化规则 + │ └── 其他 Listener + │ + └── 投递到渠道 (MessageDeliveryWorker) + │ + ├── 若有 WorkerPool → EnqueueSendReply (异步队列) + │ └── message_delivery_worker.go: CreateOutgoing → SendReply + │ + └── SendReply(messageID) + ├── 加载 message + conversation + inbox + contact + ├── 根据 inbox.ChannelType 获取对应 Provider + ├── 调用 Provider.SendMessage(ctx, inbox, message, contact) + │ ├── Facebook: POST graph.facebook.com/me/messages + │ ├── Instagram: POST graph.facebook.com/me/messages (IGID) + │ ├── TikTok: POST business-api.tiktok.com/... + │ ├── Telegram: POST api.telegram.org/bot.../sendMessage + │ ├── Email: SMTP 发送 + │ ├── Web Widget: WebSocket 推送到 widget 端 + │ └── API: POST 到 inbox.webhook_url + ├── 更新 message.Status = "sent" / "delivered" / "failed" + └── dispatchMessageEvent(EventMessageStatusUpdated) +``` + +### 3.3 消息状态回执 + +渠道 webhook 回调消息已读/已送达状态: +``` +FacebookWebhookHandler → 解析 delivery/read event + → IncomingPersister.dispatchMessageStatusEvent() + → 更新 message.Status + → dispatcher.Dispatch(EventMessageStatusUpdated) + → ActionCableListener → WebSocket 推送状态更新到前端 +``` + +--- + +## 四、标签系统 + +### 4.1 数据模型(双路径并存) + +GoChat 标签系统存在两条并行路径: + +**路径 A — Tag 关联表(结构化)** +- **Tag** (`backend/internal/model/tag.go:12`): 表名 `tags`,字段 `ID, AccountID, Name(账号内唯一), Color, Description, ShowOnSidebar` + - 唯一索引:`idx_tag_account_name`(AccountID + Name) +- **ConversationLabel** (`backend/internal/model/conversation_label.go:11`): 表名 `conversation_labels`,字段 `ID, ConversationID, TagID, AccountID` + - 唯一索引:`idx_conv_label_tag`(ConversationID + TagID),防止重复关联 + - Repository: `TagRepo` (`repository/tag_repo.go`) — `DeleteWithAssociations` 删除 Tag 时同步清理关联行和 `conversations.labels` 文本字段 + +**路径 B — Conversation.Labels 文本字段(遗留)** +- `Conversation.Labels string` (`model/conversation.go:30`) — 逗号分隔字符串,如 `"billing,urgent"` +- 辅助函数: `mergeConversationLabels()` / `splitConversationLabels()` (`conversation_maintenance_worker.go:726,755`) +- `TagRepo.RenameConversationLabelText` (`repository/tag_repo.go:70`) — Tag 重命名时同步更新 `conversations.labels` 中的文本 + +两条路径在自动化规则和手动操作中同时维护。 + +### 4.2 标签 CRUD API + +``` +LabelHandler (handler/api/v1/label_handler.go) + ├── GET /api/v1/accounts/:id/labels — 列出标签 + ├── POST /api/v1/accounts/:id/labels — 创建标签 + ├── PATCH /api/v1/accounts/:id/labels/:id — 更新标签 + └── DELETE /api/v1/accounts/:id/labels/:id — 删除标签 + │ + └── LabelService (service/label_service.go) + └── LabelRepo (repository/label_repo.go) +``` + +### 4.3 会话标签操作 + +``` +ConversationHandler (handler/api/v1/conversation_handler.go) + ├── POST /conversations/:id/labels — 给会话打标签 + └── POST /conversations/:id/labels — 移除标签 + │ + └── ConversationService.ToggleLabels() + ├── 更新 conversation.labels 字段 (mergeConversationLabels) + ├── dispatchConversationEventWithData(EventConversationLabelsUpdated) + └── ActionCableListener → WebSocket 推送标签变更到前端 +``` + +### 4.4 自动化规则中的标签 + +``` +AutomationRule (model/automation_rule.go) + └── AutomationAction.ActionType = "add_label" + └── ActionParams = {"labels": ["billing", "urgent"]} + +AutomationRuleListener.OnEvent (automation/listener.go) + ├── 接收 EventMessageCreated / EventConversationCreated + ├── ConditionsFilterService 检查规则条件是否匹配 + ├── 匹配 → ActionService 执行动作 + │ └── add_label → 更新 conversation.labels + └── dispatch EventConversationLabelsUpdated +``` + +### 4.5 前端标签管理 + +``` +LabelBox.vue (dashboard/routes/dashboard/conversation/labels/LabelBox.vue) + ├── 显示当前会话所有标签 + ├── 添加/移除标签 → API 调用 + └── useConversationLabels composable +``` + +--- + +## 五、AI 自动回复 + +### 5.1 架构概览 + +``` + 入站消息 EventMessageCreated + │ + ▼ + ┌────────────────────────────────┐ + │ AutoReplyListener │ + │ (service/auto_reply_listener.go)│ + └───────────────┬────────────────┘ + │ + ┌─────────▼──────────┐ + │ EvaluateRules() │ + │ (auto_reply_rule │ + │ _service.go) │ + └─────────┬──────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ▼ ▼ ▼ + Static Mode LLM Mode Mixed Mode + (固定文本回复) (LLM 生成回复) (LLM + 固定文本) + │ │ │ + │ ┌──────▼──────┐ │ + │ │ LLM Provider │ │ + │ │ (RAG 知识库) │ │ + │ └──────┬──────┘ │ + │ │ │ + └───────────────┼───────────────┘ + ▼ + MessageService.Create() + (outgoing message) + │ + ▼ + MessageDeliveryWorker + → 渠道发送 +``` + +### 5.2 AutoReplyListener 触发链路 + +`backend/internal/service/auto_reply_listener.go` + +``` +AutoReplyListener.OnEvent(ctx, event) (L62) + │ + ├── 过滤:仅处理 EventMessageCreated / EventMessageIncoming + ├── 过滤:仅处理 sender_type = "Contact" 的入站消息 + ├── 加载 Conversation(检查是否已 resolved → 跳过) + ├── fetchRecentMessages — 取最近 10 条消息作为上下文 (L205) + │ + ├── 构建 AutoReplyEvaluationContext { + │ AccountID, InboxID, ConversationID, + │ MessageContent, PreviousMessages, + │ ConversationStatus, Language + │ } + │ + ├── AutoReplyRuleService.EvaluateRules(ctx, evalCtx) (auto_reply_rule_service.go) + │ ├── 按 inbox_id 查找 active 状态的 auto-reply rules + │ ├── 逐条评估 conditions(关键词匹配 / 正则 / 消息长度 / 时间窗口) + │ ├── 按 priority 排序,返回第一个匹配的 rule + │ └── 根据 rule.Mode 生成回复内容: + │ ├── Static: 直接返回 rule.ResponseText + │ ├── LLM: 调用 LLM Provider 生成回复(可带 RAG 检索) + │ └── Mixed: LLM 生成 + 固定文本拼接 + │ + ├── 检查 OneTimeOnly — 该规则是否已对此会话触发过 (L225) + │ + ├── 若有 DelaySeconds → 异步 goroutine 延迟发送 (L137) + │ + └── sendAutoReply(ctx, event, conversation, result) (L151) + ├── 解析 inbox 关联的 AgentBot 作为 sender + ├── 构建 CreateMessageRequest{ + │ Content: replyContent, + │ MessageType: "outgoing", + │ SenderType: "agent_bot", + │ } + └── MessageService.Create() → 消息持久化 + 事件分发 + └── → MessageDeliveryWorker → 渠道发送 +``` + +### 5.3 Auto-Reply Rule 数据模型 + +`backend/internal/model/captain_auto_reply_rule.go` + +``` +CaptainAutoReplyRule { + AccountID + AssistantID — 关联 Captain Assistant + InboxID — 绑定到特定 inbox + Name, Description + Status — draft / active / archived + Mode — static / llm / mixed + Priority — 数字越小优先级越高 + Conditions — JSON: 关键词/正则/消息属性条件 + ResponseText — static/mixed 模式的固定回复文本 + LLMPromptOverride — LLM 模式的自定义 prompt + DelaySeconds — 延迟发送秒数 + OneTimeOnly — 是否只触发一次 +} +``` + +### 5.4 Captain Assistant + +Captain 是 GoChat 的 AI 助手框架,提供 LLM 能力: + +``` +CaptainAssistant (model/captain_assistant.go) + ├── 关联一个 LLM Provider (OpenAI / DeepSeek / 自定义) + ├── 关联 RAG 文档库 (CaptainDocument) + ├── 关联 Scenario(场景化 prompt 模板) + └── 关联 Auto-Reply Rules + +CaptainAssistantHandler (handler/api/v1/captain_assistant_handler.go) + ├── CRUD assistant + ├── 测试 assistant (发送测试消息) + └── 生成回复 (直接调用 LLM) + +CaptainConversationHandler (handler/api/v1/captain_conversation_handler.go) + ├── AI 参与会话 — Captain 读取会话上下文 + RAG 检索 → 生成建议回复 + └── 也可直接发送 AI 回复到会话 +``` + +### 5.5 Copilot(Agent 辅助) + +Copilot 是给人工 agent 用的 AI 辅助工具,不会自动发送回复: + +``` +CopilotHandler (handler/api/v1/copilot_handler.go) + ├── POST /copilot/suggest — 根据会话上下文生成建议回复 + ├── POST /copilot/rephrase — 改写 agent 输入的文本 + └── POST /copilot/summarize — 总结会话 + +CopilotContainer.vue (前端侧边栏) + ├── Agent 在侧边栏与 Copilot 交互 + ├── Copilot 调用 LLM + RAG 生成建议 + └── Agent 确认后手动发送(不自动发送) +``` + +### 5.6 RAG 知识检索 + +``` +RAGHandler (handler/api/v1/rag_handler.go) + ├── 上传文档 → 向量化 → 存入 pgvector + ├── 检索:query 向量化 → pgvector 相似度搜索 → 返回 top-k 文档片段 + └── 在 LLM 调用时注入检索到的上下文 + +流程: + 用户消息 → 向量化 → pgvector 检索相关文档 → 注入 LLM prompt → 生成回复 +``` + +--- + +## 六、完整消息生命周期 + +``` + 客户 GoChat 后端 Agent 前端 + │ │ │ + │ 1. 发送消息 │ │ + │ ──────────────────────► │ │ + │ 2. Webhook 接收 │ + │ 3. Provider 解析 │ + │ 4. IncomingPersister 持久化 │ + │ 5. Dispatcher 广播事件 │ + │ │ 6. WebSocket 推送 │ + │ │ ────────────────────────────► │ + │ │ 7. Agent 看到消息 │ + │ │ │ + │ 8. AutoReplyListener 检查 │ + │ 9. 匹配规则 → AI 生成回复 │ + │ 10. MessageService.Create() │ + │ 11. MessageDeliveryWorker │ + │ 12. 渠道发送回复 │ │ + │ ◄────────────────────── │ │ + │ │ 13. WebSocket 推送 AI 回复 │ + │ │ ────────────────────────────► │ + │ │ 14. Agent 看到 AI 回复 │ + │ │ │ + │ │ 15. (可选) Agent 手动回复 │ + │ │ ◄──────────────────────────── │ + │ 16. MessageService.Create() │ + │ 17. Provider.SendMessage() │ + │ 18. 渠道发送回复 │ │ + │ ◄────────────────────── │ │ + │ │ │ + │ │ 19. AutomationRuleListener │ + │ │ → 匹配规则 → 自动打标签 │ + │ │ → 自动分配 agent │ + │ │ → 自动变更状态 │ +``` + +--- + +## 七、关键文件索引 + +| 组件 | 文件路径 | 关键函数/行号 | +|---|---|---| +| Webhook 路由 | `backend/internal/router/router.go` | webhook 路由注册 | +| Facebook Webhook | `backend/internal/handler/webhook/facebook_webhook.go` | `HandleWebhook` | +| TikTok Webhook | `backend/internal/handler/webhook/tiktok_webhook.go` | `HandleWebhook` | +| LINE Webhook | `backend/internal/handler/webhook/line_webhook.go` | `HandleWebhook` | +| IncomingPersister | `backend/internal/handler/webhook/incoming_persister.go` | `PersistIncoming` L74 | +| 持久化 Job | `backend/internal/handler/webhook/incoming_persister_jobs.go` | `providerIncomingMessagePersistJob` L24 | +| Dispatcher | `backend/internal/channel/dispatcher.go` | `Dispatch` L85, `DispatchAsync` L110 | +| Channel Event | `backend/internal/channel/event.go` | `ChannelEvent` 结构体, EventType 常量 | +| Facebook Provider | `backend/internal/channel/facebook/provider.go` | `IncomingMessage`, `SendMessage` L352 | +| Instagram Provider | `backend/internal/channel/facebook/instagram_provider.go` | `IncomingMessage`, `SendMessage` L364 | +| Message Service | `backend/internal/service/message_service.go` | `Create`, `dispatchMessageEvent` L68 | +| Message Handler | `backend/internal/handler/api/v1/message_handler.go` | `Create` | +| Message Delivery Worker | `backend/internal/service/message_delivery_worker.go` | `EnqueueSendReply`, `SendReply` | +| AutoReply Listener | `backend/internal/service/auto_reply_listener.go` | `OnEvent` L62, `sendAutoReply` L151 | +| AutoReply Rule Service | `backend/internal/service/auto_reply_rule_service.go` | `EvaluateRules` | +| AutoReply Rule Model | `backend/internal/model/captain_auto_reply_rule.go` | `CaptainAutoReplyRule` | +| Captain Assistant Handler | `backend/internal/handler/api/v1/captain_assistant_handler.go` | CRUD + 测试 | +| Captain Conversation Handler | `backend/internal/handler/api/v1/captain_conversation_handler.go` | AI 会话参与 | +| Copilot Handler | `backend/internal/handler/api/v1/copilot_handler.go` | suggest/rephrase/summarize | +| RAG Handler | `backend/internal/handler/api/v1/rag_handler.go` | 文档上传/检索 | +| Automation Rule Listener | `backend/internal/automation/listener.go` | `OnEvent` | +| Automation Action | `backend/internal/model/automation_action.go` | `AutomationAction` L10 | +| Tag Model | `backend/internal/model/tag.go` | `Tag` L12 | +| ConversationLabel Model | `backend/internal/model/conversation_label.go` | `ConversationLabel` L11 | +| Tag Repo | `backend/internal/repository/tag_repo.go` | `DeleteWithAssociations` L54, `RenameConversationLabelText` L70 | +| ConversationLabel Repo | `backend/internal/repository/conversation_label_repo.go` | CRUD | +| WebSocket Hub | `backend/internal/ws/hub.go` | `Broadcast` | +| WS Event Bridge | `backend/internal/wsevent/bridge_listener.go` | `ActionCableListener.OnEvent` L51 | +| Inbox Serializer | `backend/internal/handler/api/v1/inbox_serializer.go` | `serializeInbox` L21 | +| Notification Service | `backend/internal/service/notification_delivery_service.go` | 通知投递 | +| Push Delivery | `backend/internal/service/push_delivery_service.go` | `SendPushNotification` L63 | +| 前端 Message API | `frontend/app/javascript/dashboard/api/inbox/message.js` | `create` L56 | +| 前端 ReplyBox | `frontend/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue` | 消息输入框 | +| 前端 Copilot | `frontend/app/javascript/dashboard/components/copilot/CopilotContainer.vue` | Copilot 侧边栏 | diff --git a/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue b/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue index 2cbad72c..4dded6d6 100644 --- a/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/frontend/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -15,18 +15,6 @@ const props = defineProps({ const emit = defineEmits(['channelItemClick']); -const hasFbConfigured = computed(() => { - return window.chatwootConfig?.fbAppId; -}); - -const hasInstagramConfigured = computed(() => { - return window.chatwootConfig?.instagramAppId; -}); - -const hasTiktokConfigured = computed(() => { - return window.chatwootConfig?.tiktokAppId; -}); - const isActive = computed(() => { const { key } = props.channel; if (Object.keys(props.enabledFeatures).length === 0) { @@ -35,39 +23,18 @@ const isActive = computed(() => { if (key === 'website') { return props.enabledFeatures.channel_website; } - if (key === 'facebook') { - return props.enabledFeatures.channel_facebook && hasFbConfigured.value; - } if (key === 'email') { return props.enabledFeatures.channel_email; } - - if (key === 'instagram') { - return ( - props.enabledFeatures.channel_instagram && hasInstagramConfigured.value - ); - } - - if (key === 'tiktok') { - return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value; - } - if (key === 'voice') { return props.enabledFeatures.channel_voice; } - return [ - 'website', - 'twilio', - 'api', - 'whatsapp', - 'sms', - 'telegram', - 'line', - 'instagram', - 'tiktok', - 'voice', - ].includes(key); + // All other channels (facebook, instagram, tiktok, whatsapp, sms, api, + // telegram, line, etc.) are always clickable. OAuth credentials (App ID, + // API tokens, etc.) are collected per-inbox during the channel setup flow, + // not gated by global config. + return true; }); const isComingSoon = computed(() => { diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 5825f31f..bce64494 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -49,7 +49,9 @@ "ADD_NAME": "Add a name for your inbox", "PICK_NAME": "Pick a Name for your Inbox", "PICK_A_VALUE": "Pick a value", - "CREATE_INBOX": "Create Inbox" + "CREATE_INBOX": "Create Inbox", + "LOGIN_WITH_FB": "Login with Facebook", + "APP_CREDENTIALS_HELP": "Enter your Meta App credentials to connect. Each inbox can use a different Meta App." }, "INSTAGRAM": { "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", 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 d7535979..44d5b1f1 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -49,7 +49,9 @@ "ADD_NAME": "为收件箱添加名称", "PICK_NAME": "为收件箱选择一个名称", "PICK_A_VALUE": "选择一个数值", - "CREATE_INBOX": "新增收件箱" + "CREATE_INBOX": "新增收件箱", + "LOGIN_WITH_FB": "使用 Facebook 登录", + "APP_CREDENTIALS_HELP": "输入您的 Meta App 凭据以进行连接。每个收件箱可以使用不同的 Meta App。" }, "INSTAGRAM": { "CONTINUE_WITH_INSTAGRAM": "在 Instagram 中继续", 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 e2ebd27c..133f4a79 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -16,10 +16,6 @@ const globalConfig = useMapGetter('globalConfig/get'); const enabledFeatures = ref({}); -const hasTiktokConfigured = computed(() => { - return window.chatwootConfig?.tiktokAppId; -}); - const channelList = computed(() => { const { apiChannelName } = globalConfig.value; const channels = [ @@ -79,14 +75,12 @@ const channelList = computed(() => { }, ]; - if (hasTiktokConfigured.value) { - channels.push({ - key: 'tiktok', - title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE'), - description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.DESCRIPTION'), - icon: 'i-woot-tiktok', - }); - } + channels.push({ + key: 'tiktok', + title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE'), + description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.DESCRIPTION'), + icon: 'i-woot-tiktok', + }); channels.push({ key: 'voice', diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 39e0df81..f8cc3aea 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -47,6 +47,10 @@ export default { errorStateMessage: '', errorStateDescription: '', hasLoginStarted: false, + // Per-inbox Meta App credentials (collected during channel setup) + fbAppId: '', + fbAppSecret: '', + fbApiVersion: 'v18.0', }; }, @@ -122,9 +126,9 @@ export default { runFBInit() { FB.init({ - appId: window.chatwootConfig.fbAppId, + appId: this.fbAppId, xfbml: true, - version: window.chatwootConfig.fbApiVersion, + version: this.fbApiVersion, status: true, }); window.fbSDKLoaded = true; @@ -195,6 +199,8 @@ export default { page_access_token: this.selectedPage.access_token, page_id: this.selectedPage.id, inbox_name: this.selectedPage.name?.trim(), + fb_app_id: this.fbAppId, + fb_app_secret: this.fbAppSecret, }; }, @@ -226,16 +232,60 @@ export default { v-if="!hasLoginStarted" class="flex flex-col items-center justify-center h-full text-center" > - - Facebook-logo +
+ + Facebook-logo + +

+ {{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }} +

+
+
+ + + +
+ - -

- {{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }} -

+
diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue index 4819ad0d..b1b34cb1 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue @@ -11,6 +11,10 @@ const errorStateMessage = ref(''); const errorStateDescription = ref(''); const isRequestingAuthorization = ref(false); +// Per-inbox Meta App credentials (collected during channel setup) +const igAppId = ref(''); +const igAppSecret = ref(''); + onMounted(() => { const urlParams = new URLSearchParams(window.location.search); // TODO: Handle error type @@ -36,7 +40,10 @@ onMounted(() => { const requestAuthorization = async () => { isRequestingAuthorization.value = true; - const response = await instagramClient.generateAuthorization(); + const response = await instagramClient.generateAuthorization({ + app_id: igAppId.value, + app_secret: igAppSecret.value, + }); const { data: { url }, } = response; @@ -65,11 +72,35 @@ const requestAuthorization = async () => {

{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}

+
+ + +