feat(channels): 社交渠道 OAuth 改进 + 集成应用重命名 + AI 流程文档

- Facebook/Instagram/TikTok 渠道 OAuth 授权流程改进
- 新增 oauth/credentialstore 包
- 集成应用 OpenAI 重命名为 OpenAI 兼容 (migration 061)
- 消息生命周期与 AI 流程架构文档
- inbox 管理界面 i18n 更新
This commit is contained in:
Rogee
2026-08-01 19:38:27 +08:00
parent 63df5b8b91
commit 1446cfe701
21 changed files with 897 additions and 99 deletions
@@ -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})