507 lines
20 KiB
Go
507 lines
20 KiB
Go
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 ""
|
|
}
|