Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func googleEmailCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return emailOAuthCallback(db, emailCallbackConfig{
|
||||
Provider: "google",
|
||||
ClientIDEnv: "GOOGLE_OAUTH_CLIENT_ID",
|
||||
SecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
TokenURLEnv: "GOOGLE_OAUTH_TOKEN_URL",
|
||||
DefaultURL: "https://oauth2.googleapis.com/token",
|
||||
IMAPAddress: "imap.gmail.com",
|
||||
StateSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
})
|
||||
}
|
||||
|
||||
func microsoftEmailCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return emailOAuthCallback(db, emailCallbackConfig{
|
||||
Provider: "microsoft",
|
||||
ClientIDEnv: "AZURE_APP_ID",
|
||||
SecretEnv: "AZURE_APP_SECRET",
|
||||
TokenURLEnv: "MICROSOFT_OAUTH_TOKEN_URL",
|
||||
DefaultURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
IMAPAddress: "outlook.office365.com",
|
||||
StateSecretEnv: "AZURE_APP_SECRET",
|
||||
})
|
||||
}
|
||||
|
||||
type emailCallbackConfig struct {
|
||||
Provider string
|
||||
ClientIDEnv string
|
||||
SecretEnv string
|
||||
TokenURLEnv string
|
||||
DefaultURL string
|
||||
IMAPAddress string
|
||||
StateSecretEnv string
|
||||
}
|
||||
|
||||
func emailOAuthCallback(db *gorm.DB, cfg emailCallbackConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv(cfg.StateSecretEnv))
|
||||
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: envOrDefault(cfg.TokenURLEnv, cfg.DefaultURL),
|
||||
ClientID: os.Getenv(cfg.ClientIDEnv),
|
||||
ClientSecret: os.Getenv(cfg.SecretEnv),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/" + cfg.Provider + "/callback",
|
||||
})
|
||||
claims, claimErr := parseJWTClaimsUnverified(body["id_token"])
|
||||
if err != nil || claimErr != nil || strings.TrimSpace(claimString(claims, "email")) == "" {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
email := claimString(claims, "email")
|
||||
login := email
|
||||
if cfg.Provider == "microsoft" {
|
||||
if value := firstNonBlank(claimString(claims, "preferred_username"), claimString(claims, "upn")); value != "" {
|
||||
login = value
|
||||
}
|
||||
}
|
||||
name := firstNonBlank(claimString(claims, "name"), strings.Split(email, "@")[0])
|
||||
inbox, existed, err := upsertEmailOAuthInbox(c, db, accountID, email, login, name, cfg.IMAPAddress, cfg.Provider, body)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
if existed {
|
||||
c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func instagramChannelCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("INSTAGRAM_APP_SECRET"))
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
if c.Query("error") != "" {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{
|
||||
"error_type": firstNonBlank(c.Query("error"), "authorization_error"),
|
||||
"code": "400",
|
||||
"error_message": firstNonBlank(c.Query("error_description"), "Authorization was denied"),
|
||||
}))
|
||||
return
|
||||
}
|
||||
if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", nil))
|
||||
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"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/instagram/callback",
|
||||
})
|
||||
instagramID := firstNonBlank(body["instagram_account_id"], body["user_id"], body["id"])
|
||||
username := firstNonBlank(body["username"], body["instagram_account_name"], "Instagram")
|
||||
if err != nil || body["access_token"] == "" || instagramID == "" {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "OAuthException", "code": "400", "error_message": "failed to exchange OAuth token"}))
|
||||
return
|
||||
}
|
||||
|
||||
inbox, existed, err := upsertInstagramInbox(c, db, accountID, instagramID, username, body)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()}))
|
||||
return
|
||||
}
|
||||
if existed {
|
||||
c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func tiktokChannelCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("TIKTOK_APP_SECRET"))
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
if c.Query("error") != "" {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{
|
||||
"error_type": firstNonBlank(c.Query("error"), "access_denied"),
|
||||
"code": c.Query("error_code"),
|
||||
"error_message": firstNonBlank(c.Query("error_description"), "User cancelled the Authorization"),
|
||||
}))
|
||||
return
|
||||
}
|
||||
if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", nil))
|
||||
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"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/tiktok/callback",
|
||||
})
|
||||
businessID := firstNonBlank(body["business_id"], body["tiktok_business_id"], body["advertiser_id"])
|
||||
if err != nil || body["access_token"] == "" || businessID == "" {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "failed to exchange OAuth token"}))
|
||||
return
|
||||
}
|
||||
if !tiktokScopesGranted(body["scope"]) {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "ungranted_scopes", "code": "400", "error_message": "User did not grant all the required scopes"}))
|
||||
return
|
||||
}
|
||||
|
||||
name := firstNonBlank(body["display_name"], body["username"], "TikTok")
|
||||
inbox, existed, err := upsertTikTokInbox(c, db, accountID, businessID, name, body)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()}))
|
||||
return
|
||||
}
|
||||
if existed {
|
||||
c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func twitterChannelCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(firstNonBlank(c.Query("state"), c.Query("account_id")), os.Getenv("TWITTER_CONSUMER_SECRET"))
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
if c.Query("denied") != "" || db == nil || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil))
|
||||
return
|
||||
}
|
||||
body, err := exchangeTwitterAccessToken(c)
|
||||
if err != nil || body.Get("oauth_token") == "" || body.Get("user_id") == "" {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil))
|
||||
return
|
||||
}
|
||||
inbox, existed, err := upsertTwitterInbox(c, db, accountID, body)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil))
|
||||
return
|
||||
}
|
||||
if existed {
|
||||
c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func upsertEmailOAuthInbox(c *gin.Context, db *gorm.DB, accountID uint, email, login, name, imapAddress, provider string, tokenBody map[string]string) (*model.Inbox, bool, error) {
|
||||
var channel channelmodel.ChannelEmail
|
||||
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND (imap_login = ? OR email = ?)", accountID, login, email).First(&channel).Error == nil
|
||||
if existed {
|
||||
channel.IMAPLogin = login
|
||||
channel.IMAPAddress = imapAddress
|
||||
channel.IMAPPort = 993
|
||||
channel.IMAPEnabled = true
|
||||
channel.MailboxName = name
|
||||
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var inbox model.Inbox
|
||||
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox.Name = name
|
||||
inbox.ChannelConfig = oauthInboxConfig(provider, tokenBody)
|
||||
return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error
|
||||
}
|
||||
|
||||
channel = channelmodel.ChannelEmail{AccountID: accountID, Email: email, MailboxName: name, Domain: emailDomain(email), IMAPEnabled: true, IMAPAddress: imapAddress, IMAPPort: 993, IMAPLogin: login, IMAPSSLMode: "ssl"}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "email", ChannelID: channel.ID, Enabled: true, ChannelConfig: oauthInboxConfig(provider, tokenBody)}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
channel.InboxID = inbox.ID
|
||||
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
|
||||
}
|
||||
|
||||
func upsertInstagramInbox(c *gin.Context, db *gorm.DB, accountID uint, instagramID, username string, body map[string]string) (*model.Inbox, bool, error) {
|
||||
var channel channelmodel.ChannelInstagram
|
||||
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND instagram_account_id = ?", accountID, instagramID).First(&channel).Error == nil
|
||||
if existed {
|
||||
channel.PageAccessToken = body["access_token"]
|
||||
channel.InstagramAccountName = username
|
||||
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var inbox model.Inbox
|
||||
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox.Name = username
|
||||
inbox.ChannelConfig = instagramInboxConfig(channel)
|
||||
return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error
|
||||
}
|
||||
channel = channelmodel.ChannelInstagram{AccountID: accountID, InstagramAccountID: instagramID, InstagramBusinessAccountID: body["instagram_business_account_id"], PageAccessToken: body["access_token"], ConnectedFBPageID: firstNonBlank(body["connected_fb_page_id"], body["page_id"]), InstagramAccountName: username}
|
||||
if channel.ConnectedFBPageID == "" {
|
||||
channel.ConnectedFBPageID = instagramID
|
||||
}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox := model.Inbox{AccountID: accountID, Name: username, ChannelType: "instagram", ChannelID: channel.ID, Enabled: true, ChannelConfig: instagramInboxConfig(channel)}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
channel.InboxID = inbox.ID
|
||||
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
|
||||
}
|
||||
|
||||
func upsertTikTokInbox(c *gin.Context, db *gorm.DB, accountID uint, businessID, name string, body map[string]string) (*model.Inbox, bool, error) {
|
||||
var channel channelmodel.ChannelTikTok
|
||||
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND tiktok_business_id = ?", accountID, businessID).First(&channel).Error == nil
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(parseIntDefault(body["expires_in"], 3600)) * time.Second)
|
||||
if existed {
|
||||
channel.AccessToken = body["access_token"]
|
||||
channel.RefreshToken = body["refresh_token"]
|
||||
channel.TokenExpiresAt = expiresAt
|
||||
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var inbox model.Inbox
|
||||
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox.Name = name
|
||||
inbox.ChannelConfig = tiktokInboxConfig(channel)
|
||||
return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error
|
||||
}
|
||||
channel = channelmodel.ChannelTikTok{AccountID: accountID, TikTokBusinessID: businessID, AccessToken: body["access_token"], RefreshToken: body["refresh_token"], TokenExpiresAt: expiresAt}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "tiktok", ChannelID: channel.ID, Enabled: true, ChannelConfig: tiktokInboxConfig(channel)}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
channel.InboxID = inbox.ID
|
||||
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
|
||||
}
|
||||
|
||||
func upsertTwitterInbox(c *gin.Context, db *gorm.DB, accountID uint, values url.Values) (*model.Inbox, bool, error) {
|
||||
var channel channelmodel.ChannelTwitter
|
||||
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND twitter_user_id = ?", accountID, values.Get("user_id")).First(&channel).Error == nil
|
||||
name := firstNonBlank(values.Get("screen_name"), values.Get("name"), "Twitter")
|
||||
if existed {
|
||||
channel.TwitterAccessToken = values.Get("oauth_token")
|
||||
channel.TwitterAccessTokenSecret = values.Get("oauth_token_secret")
|
||||
channel.ScreenName = values.Get("screen_name")
|
||||
channel.Name = name
|
||||
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var inbox model.Inbox
|
||||
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox.Name = name
|
||||
inbox.ChannelConfig = twitterInboxConfig(channel)
|
||||
if err := db.WithContext(c.Request.Context()).Save(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &inbox, true, nil
|
||||
}
|
||||
channel = channelmodel.ChannelTwitter{AccountID: accountID, TwitterUserID: values.Get("user_id"), TwitterAccessToken: values.Get("oauth_token"), TwitterAccessTokenSecret: values.Get("oauth_token_secret"), ScreenName: values.Get("screen_name"), Name: name, AccessToken: values.Get("oauth_token")}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "twitter", ChannelID: channel.ID, Enabled: true, ChannelConfig: twitterInboxConfig(channel)}
|
||||
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
channel.InboxID = inbox.ID
|
||||
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
|
||||
}
|
||||
|
||||
func parseJWTClaimsUnverified(tokenString string) (jwt.MapClaims, error) {
|
||||
claims := jwt.MapClaims{}
|
||||
_, _, err := jwt.NewParser().ParseUnverified(tokenString, claims)
|
||||
return claims, err
|
||||
}
|
||||
|
||||
func claimString(claims jwt.MapClaims, key string) string {
|
||||
if value, ok := claims[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func oauthInboxConfig(provider string, tokenBody map[string]string) string {
|
||||
encoded, _ := json.Marshal(map[string]any{"provider": provider, "provider_config": compactStringMap(tokenBody)})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func instagramInboxConfig(channel channelmodel.ChannelInstagram) string {
|
||||
encoded, _ := json.Marshal(map[string]any{
|
||||
"instagram_id": channel.InstagramAccountID,
|
||||
"instagram_account_id": channel.InstagramAccountID,
|
||||
"instagram_business_account_id": channel.InstagramBusinessAccountID,
|
||||
"instagram_account_name": channel.InstagramAccountName,
|
||||
"connected_fb_page_id": channel.ConnectedFBPageID,
|
||||
"page_access_token": channel.PageAccessToken,
|
||||
"reauthorization_required": channel.ReauthorizationRequired,
|
||||
})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func tiktokInboxConfig(channel channelmodel.ChannelTikTok) string {
|
||||
encoded, _ := json.Marshal(map[string]any{
|
||||
"tiktok_business_id": channel.TikTokBusinessID,
|
||||
"access_token": channel.AccessToken,
|
||||
"refresh_token": channel.RefreshToken,
|
||||
"webhook_verify_token": channel.WebhookVerifyToken,
|
||||
"reauthorization_required": channel.ReauthorizationRequired,
|
||||
})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func twitterInboxConfig(channel channelmodel.ChannelTwitter) string {
|
||||
encoded, _ := json.Marshal(map[string]any{
|
||||
"twitter_user_id": channel.TwitterUserID,
|
||||
"screen_name": channel.ScreenName,
|
||||
"twitter_access_token": channel.TwitterAccessToken,
|
||||
"twitter_access_token_secret": channel.TwitterAccessTokenSecret,
|
||||
"tweets_enabled": true,
|
||||
})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func compactStringMap(values map[string]string) map[string]string {
|
||||
result := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
if value != "" {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func exchangeTwitterAccessToken(c *gin.Context) (url.Values, error) {
|
||||
form := url.Values{}
|
||||
form.Set("oauth_token", c.Query("oauth_token"))
|
||||
form.Set("oauth_verifier", c.Query("oauth_verifier"))
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, envOrDefault("TWITTER_OAUTH_TOKEN_URL", "https://api.twitter.com/oauth/access_token"), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("twitter access token failed: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return url.ParseQuery(string(body))
|
||||
}
|
||||
|
||||
func tiktokScopesGranted(scope string) bool {
|
||||
if strings.TrimSpace(scope) == "" {
|
||||
return true
|
||||
}
|
||||
granted := map[string]bool{}
|
||||
for _, item := range strings.Split(scope, ",") {
|
||||
granted[strings.TrimSpace(item)] = true
|
||||
}
|
||||
for _, required := range []string{"user.info.basic", "user.info.username", "user.info.stats", "user.info.profile", "user.account.type", "user.insights", "message.list.read", "message.list.send", "message.list.manage"} {
|
||||
if !granted[required] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func inboxAgentsURL(accountID uint, inboxID uint) string {
|
||||
return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%d/agents", frontendBaseURL(), accountID, inboxID)
|
||||
}
|
||||
|
||||
func inboxSettingsURL(accountID uint, inboxID uint) string {
|
||||
return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/%d", frontendBaseURL(), accountID, inboxID)
|
||||
}
|
||||
|
||||
func newInboxURL(accountID uint, channel string, query map[string]string) string {
|
||||
base := fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%s", frontendBaseURL(), accountID, channel)
|
||||
if len(query) == 0 {
|
||||
return base
|
||||
}
|
||||
values := url.Values{}
|
||||
for key, value := range query {
|
||||
if value != "" {
|
||||
values.Set(key, value)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return base
|
||||
}
|
||||
return base + "?" + values.Encode()
|
||||
}
|
||||
|
||||
func emailDomain(email string) string {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseIntDefault(value string, fallback int) int {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -0,0 +1,311 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func linearIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("LINEAR_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("linear", accountID)
|
||||
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: envOrDefault("LINEAR_OAUTH_TOKEN_URL", "https://api.linear.app/oauth/token"),
|
||||
ClientID: os.Getenv("LINEAR_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/linear/callback",
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"token_type": body["token_type"],
|
||||
"scope": body["scope"],
|
||||
"refresh_token": body["refresh_token"],
|
||||
}
|
||||
if body["expires_in"] != "" {
|
||||
settings["expires_in"] = body["expires_in"]
|
||||
if seconds, err := strconv.Atoi(body["expires_in"]); err == nil {
|
||||
settings["expires_on"] = time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if err := upsertIntegrationHook(c, db, accountID, model.HookTypeLinear, "", body["access_token"], settings); err != nil {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func shopifyIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("SHOPIFY_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("shopify", accountID)
|
||||
shop := strings.TrimSpace(c.Query("shop"))
|
||||
if !ok || db == nil || c.Query("code") == "" || shop == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
|
||||
tokenURL := os.Getenv("SHOPIFY_OAUTH_TOKEN_URL")
|
||||
if tokenURL == "" {
|
||||
tokenURL = fmt.Sprintf("https://%s/admin/oauth/access_token", shop)
|
||||
}
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: tokenURL,
|
||||
ClientID: os.Getenv("SHOPIFY_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("SHOPIFY_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/shopify/callback",
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
|
||||
if err := createIntegrationHook(c, db, accountID, model.HookTypeShopify, shop, body["access_token"], map[string]any{"scope": body["scope"]}); err != nil {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func notionIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("NOTION_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("notion", accountID)
|
||||
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: envOrDefault("NOTION_OAUTH_TOKEN_URL", "https://api.notion.com/v1/oauth/token"),
|
||||
ClientID: os.Getenv("NOTION_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("NOTION_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/notion/callback",
|
||||
BasicAuth: true,
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"token_type": body["token_type"],
|
||||
"workspace_name": body["workspace_name"],
|
||||
"workspace_id": body["workspace_id"],
|
||||
"workspace_icon": body["workspace_icon"],
|
||||
"bot_id": body["bot_id"],
|
||||
}
|
||||
if owner := strings.TrimSpace(body["owner"]); owner != "" {
|
||||
settings["owner"] = owner
|
||||
}
|
||||
if err := createIntegrationHook(c, db, accountID, model.HookTypeNotion, "", body["access_token"], settings); err != nil {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
type oauthTokenExchangeRequest struct {
|
||||
TokenURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Code string
|
||||
RedirectURI string
|
||||
BasicAuth bool
|
||||
}
|
||||
|
||||
func exchangeOAuthToken(c *gin.Context, req oauthTokenExchangeRequest) (map[string]string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", req.Code)
|
||||
form.Set("redirect_uri", req.RedirectURI)
|
||||
if !req.BasicAuth {
|
||||
form.Set("client_id", req.ClientID)
|
||||
form.Set("client_secret", req.ClientSecret)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, req.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if req.BasicAuth {
|
||||
httpReq.SetBasicAuth(req.ClientID, req.ClientSecret)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("oauth token exchange failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(raw))
|
||||
for key, value := range raw {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
result[key] = typed
|
||||
case float64:
|
||||
result[key] = strconv.FormatInt(int64(typed), 10)
|
||||
case nil:
|
||||
result[key] = ""
|
||||
default:
|
||||
encoded, _ := json.Marshal(typed)
|
||||
result[key] = string(encoded)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func callbackAccountID(state string, secret string) (uint, bool) {
|
||||
state = strings.TrimSpace(state)
|
||||
if state == "" {
|
||||
return 0, false
|
||||
}
|
||||
if id, err := strconv.ParseUint(state, 10, 64); err == nil && id > 0 {
|
||||
return uint(id), true
|
||||
}
|
||||
if secret == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
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(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return 0, false
|
||||
}
|
||||
if sub, ok := claims["sub"].(float64); ok && sub > 0 {
|
||||
return uint(sub), true
|
||||
}
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
id, err := strconv.ParseUint(sub, 10, 64)
|
||||
return uint(id), err == nil && id > 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func accountExists(c *gin.Context, db *gorm.DB, accountID uint) bool {
|
||||
var count int64
|
||||
if err := db.WithContext(c.Request.Context()).Model(&model.Account{}).Where("id = ?", accountID).Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func upsertIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
|
||||
settingsJSON, err := json.Marshal(compactMap(settings))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hook model.IntegrationHook
|
||||
err = db.WithContext(c.Request.Context()).
|
||||
Where("account_id = ? AND (app_id = ? OR hook_type = ?)", accountID, string(hookType), hookType).
|
||||
First(&hook).Error
|
||||
if err == nil {
|
||||
hook.AppID = string(hookType)
|
||||
hook.HookType = hookType
|
||||
hook.Status = model.HookStatusActive
|
||||
hook.ReferenceID = referenceID
|
||||
hook.AccessToken = accessToken
|
||||
hook.Settings = datatypes.JSON(settingsJSON)
|
||||
return db.WithContext(c.Request.Context()).Save(&hook).Error
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return createIntegrationHook(c, db, accountID, hookType, referenceID, accessToken, settings)
|
||||
}
|
||||
|
||||
func createIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
|
||||
settingsJSON, err := json.Marshal(compactMap(settings))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hook := &model.IntegrationHook{
|
||||
AccountID: accountID,
|
||||
AppID: string(hookType),
|
||||
HookType: hookType,
|
||||
Status: model.HookStatusActive,
|
||||
AccessToken: accessToken,
|
||||
ReferenceID: referenceID,
|
||||
Settings: datatypes.JSON(settingsJSON),
|
||||
}
|
||||
return db.WithContext(c.Request.Context()).Create(hook).Error
|
||||
}
|
||||
|
||||
func compactMap(values map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if value == nil || value == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func integrationRedirectURL(app string, accountID uint) string {
|
||||
if accountID == 0 {
|
||||
return frontendBaseURL()
|
||||
}
|
||||
return fmt.Sprintf("%s/app/accounts/%d/settings/integrations/%s", frontendBaseURL(), accountID, app)
|
||||
}
|
||||
|
||||
func redirectWithError(location string) string {
|
||||
if strings.Contains(location, "?") {
|
||||
return location + "&error=true"
|
||||
}
|
||||
return location + "?error=true"
|
||||
}
|
||||
|
||||
func frontendBaseURL() string {
|
||||
return strings.TrimRight(envOrDefault("FRONTEND_URL", "http://localhost:3000"), "/")
|
||||
}
|
||||
|
||||
func envOrDefault(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
package router
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
package router
|
||||
|
||||
// placeholder
|
||||
@@ -0,0 +1 @@
|
||||
test3
|
||||
Reference in New Issue
Block a user