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})
|
||||
|
||||
Reference in New Issue
Block a user