feat(channels): 社交渠道 OAuth 改进 + 集成应用重命名 + AI 流程文档
- Facebook/Instagram/TikTok 渠道 OAuth 授权流程改进 - 新增 oauth/credentialstore 包 - 集成应用 OpenAI 重命名为 OpenAI 兼容 (migration 061) - 消息生命周期与 AI 流程架构文档 - inbox 管理界面 i18n 更新
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 进行视频会议'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Revert "OpenAI 兼容" back to "OpenAI"
|
||||
UPDATE integration_apps SET
|
||||
name = 'OpenAI',
|
||||
description = '连接 OpenAI 以获取标签建议和 AI 辅助工作流'
|
||||
WHERE hook_type = 'openai';
|
||||
@@ -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';
|
||||
@@ -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 侧边栏 |
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 中继续",
|
||||
|
||||
@@ -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',
|
||||
|
||||
+61
-11
@@ -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"
|
||||
>
|
||||
<a href="#" @click="startLogin()">
|
||||
<img
|
||||
class="w-auto h-10 rounded-md"
|
||||
src="~dashboard/assets/images/channels/facebook_login.png"
|
||||
alt="Facebook-logo"
|
||||
<div class="w-full max-w-md space-y-4">
|
||||
<div class="flex flex-col items-center mb-4">
|
||||
<a href="#" @click.prevent>
|
||||
<img
|
||||
class="w-auto h-10 rounded-md"
|
||||
src="~dashboard/assets/images/channels/facebook_login.png"
|
||||
alt="Facebook-logo"
|
||||
/>
|
||||
</a>
|
||||
<p class="py-4">
|
||||
{{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-left space-y-3">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
Meta App ID
|
||||
</span>
|
||||
<input
|
||||
v-model="fbAppId"
|
||||
type="text"
|
||||
placeholder="1234567890123456"
|
||||
class="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
Meta App Secret
|
||||
</span>
|
||||
<input
|
||||
v-model="fbAppSecret"
|
||||
type="password"
|
||||
placeholder="App Secret"
|
||||
class="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
Graph API Version
|
||||
</span>
|
||||
<input
|
||||
v-model="fbApiVersion"
|
||||
type="text"
|
||||
placeholder="v18.0"
|
||||
class="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.ADD.FB.LOGIN_WITH_FB')"
|
||||
:disabled="!fbAppId || !fbAppSecret"
|
||||
@click="startLogin()"
|
||||
/>
|
||||
</a>
|
||||
<p class="py-6">
|
||||
{{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="hasError" class="max-w-lg mx-auto text-center">
|
||||
|
||||
+33
-2
@@ -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 () => {
|
||||
<p class="py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
|
||||
</p>
|
||||
<div class="w-full max-w-sm space-y-3 text-left mb-6">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
Instagram App ID
|
||||
</span>
|
||||
<input
|
||||
v-model="igAppId"
|
||||
type="text"
|
||||
placeholder="1234567890123456"
|
||||
class="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
Instagram App Secret
|
||||
</span>
|
||||
<input
|
||||
v-model="igAppSecret"
|
||||
type="password"
|
||||
placeholder="App Secret"
|
||||
class="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
|
||||
lg
|
||||
icon="i-ri-instagram-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:disabled="isRequestingAuthorization || !igAppId || !igAppSecret"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
|
||||
@click="requestAuthorization()"
|
||||
|
||||
+33
-2
@@ -11,6 +11,10 @@ const errorStateMessage = ref('');
|
||||
const errorStateDescription = ref('');
|
||||
const isRequestingAuthorization = ref(false);
|
||||
|
||||
// Per-inbox TikTok App credentials (collected during channel setup)
|
||||
const ttAppId = ref('');
|
||||
const ttAppSecret = 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 tiktokClient.generateAuthorization();
|
||||
const response = await tiktokClient.generateAuthorization({
|
||||
app_id: ttAppId.value,
|
||||
app_secret: ttAppSecret.value,
|
||||
});
|
||||
const {
|
||||
data: { url },
|
||||
} = response;
|
||||
@@ -65,11 +72,35 @@ const requestAuthorization = async () => {
|
||||
<p class="py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||
</p>
|
||||
<div class="w-full max-w-sm space-y-3 text-left mb-6">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
TikTok App ID
|
||||
</span>
|
||||
<input
|
||||
v-model="ttAppId"
|
||||
type="text"
|
||||
placeholder="1234567890123456"
|
||||
class="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
TikTok App Secret
|
||||
</span>
|
||||
<input
|
||||
v-model="ttAppSecret"
|
||||
type="password"
|
||||
placeholder="App Secret"
|
||||
class="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||
lg
|
||||
icon="i-ri-tiktok-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:disabled="isRequestingAuthorization || !ttAppId || !ttAppSecret"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||
@click="requestAuthorization()"
|
||||
|
||||
+8
-2
@@ -20,6 +20,12 @@ export default {
|
||||
inboxId() {
|
||||
return this.inbox.id;
|
||||
},
|
||||
fbAppId() {
|
||||
return this.inbox.fb_app_id || '';
|
||||
},
|
||||
fbApiVersion() {
|
||||
return this.inbox.fb_api_version || 'v18.0';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
@@ -27,9 +33,9 @@ export default {
|
||||
methods: {
|
||||
runFBInit() {
|
||||
FB.init({
|
||||
appId: window.chatwootConfig.fbAppId,
|
||||
appId: this.fbAppId,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig.fbApiVersion,
|
||||
version: this.fbApiVersion,
|
||||
status: true,
|
||||
});
|
||||
window.fbSDKLoaded = true;
|
||||
|
||||
Reference in New Issue
Block a user