diff --git a/config.go b/config.go index 9c60efc..9e18bba 100644 --- a/config.go +++ b/config.go @@ -14,6 +14,7 @@ import ( var emailEnabled = false var messagesEnabled = false var telegramEnabled = false +var discordEnabled = false func (app *appContext) GetPath(sect, key string) (fs.FS, string) { val := app.config.Section(sect).Key(key).MustString("") @@ -42,7 +43,7 @@ func (app *appContext) loadConfig() error { key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) } } - for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users"} { + for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users"} { app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) } app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") @@ -87,15 +88,17 @@ func (app *appContext) loadConfig() error { app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false) telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) + discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false) if !messagesEnabled { emailEnabled = false telegramEnabled = false + discordEnabled = false } else if app.config.Section("email").Key("method").MustString("") == "" { emailEnabled = false } else { emailEnabled = true } - if !emailEnabled && !telegramEnabled { + if !emailEnabled && !telegramEnabled && !discordEnabled { messagesEnabled = false } diff --git a/config/config-base.json b/config/config-base.json index 12f6861..5756d1c 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -546,6 +546,62 @@ } } }, + "discord": { + "order": [], + "meta": { + "name": "Discord", + "description": "Settings for Discord invites/signup/notifications" + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Enable signup verification through Discord and the sending of notifications through it.\nSee the jfa-go wiki for setting up a bot." + }, + "required": { + "name": "Require on sign-up", + "required": false, + "required_restart": true, + "depends_true": "enabled", + "type": "bool", + "value": false, + "description": "Require Discord connection on sign-up." + }, + "token": { + "name": "API Token", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Discord Bot API Token." + }, + "start_command": { + "name": "Start command", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "!start", + "description": "Command to start the user verification process." + }, + "language": { + "name": "Language", + "required": false, + "requires_restart": false, + "depends_true": "enabled", + "type": "select", + "options": [ + ["en-us", "English (US)"] + ], + "value": "en-us", + "description": "Default Discord message language. Visit weblate if you'd like to translate." + } + } + }, "telegram": { "order": [], "meta": { @@ -565,6 +621,7 @@ "name": "Require on sign-up", "required": false, "required_restart": true, + "depends_true": "enabled", "type": "bool", "value": false, "description": "Require telegram connection on sign-up." @@ -1140,6 +1197,14 @@ "type": "text", "value": "", "description": "Stores telegram user IDs and language preferences." + }, + "discord_users": { + "name": "Discord users", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Stores discord user IDs and language preferences." } } } diff --git a/discord.go b/discord.go new file mode 100644 index 0000000..7d6843a --- /dev/null +++ b/discord.go @@ -0,0 +1,146 @@ +package main + +import ( + "fmt" + "strings" + + dg "github.com/bwmarrin/discordgo" +) + +type DiscordToken struct { + Token string + ChannelID string + UserID string + Username string +} + +type DiscordDaemon struct { + Stopped bool + ShutdownChannel chan string + bot *dg.Session + username string + tokens map[string]DiscordToken // map of user IDs to tokens. + verifiedTokens []DiscordToken + languages map[string]string // Store of languages for user channelIDs. Added to on first interaction, and loaded from app.storage.discord on start. + app *appContext +} + +func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) { + token := app.config.Section("discord").Key("token").String() + if token == "" { + return nil, fmt.Errorf("token was blank") + } + bot, err := dg.New("Bot " + token) + if err != nil { + return nil, err + } + dd := &DiscordDaemon{ + Stopped: false, + ShutdownChannel: make(chan string), + bot: bot, + tokens: map[string]DiscordToken{}, + verifiedTokens: []DiscordToken{}, + languages: map[string]string{}, + app: app, + } + for _, user := range app.storage.discord { + if user.Lang != "" { + dd.languages[user.ChannelID] = user.Lang + } + } + return dd, nil +} + +func (d *DiscordDaemon) NewAuthToken(channelID, userID, username string) DiscordToken { + pin := genAuthToken() + token := DiscordToken{ + Token: pin, + ChannelID: channelID, + UserID: userID, + Username: username, + } + return token +} + +func (d *DiscordDaemon) run() { + d.bot.AddHandler(d.messageHandler) + d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages + if err := d.bot.Open(); err != nil { + d.app.err.Printf("Discord: Failed to start daemon: %v", err) + return + } + d.username = d.bot.State.User.Username + defer d.bot.Close() + <-d.ShutdownChannel + d.ShutdownChannel <- "Down" + return +} + +func (d *DiscordDaemon) Shutdown() { + d.Stopped = true + d.ShutdownChannel <- "Down" + <-d.ShutdownChannel + close(d.ShutdownChannel) +} + +func (d *DiscordDaemon) messageHandler(s *dg.Session, m *dg.MessageCreate) { + if m.Author.ID == s.State.User.ID { + return + } + sects := strings.Split(m.Content, " ") + if len(sects) == 0 { + return + } + lang := d.app.storage.lang.chosenTelegramLang + if storedLang, ok := d.languages[m.Author.ID]; ok { + lang = storedLang + } + switch msg := sects[0]; msg { + case d.app.config.Section("telegram").Key("start_command").MustString("!start"): + d.commandStart(s, m, lang) + default: + d.commandPIN(s, m, lang) + } +} + +func (d *DiscordDaemon) commandStart(s *dg.Session, m *dg.MessageCreate, lang string) { + channel, err := s.UserChannelCreate(m.Author.ID) + if err != nil { + d.app.err.Printf("Discord: Failed to create private channel with \"%s\": %v", m.Author.Username, err) + return + } + token := d.NewAuthToken(channel.ID, m.Author.ID, m.Author.Username) + d.tokens[m.Author.ID] = token + content := d.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n" + content += d.app.storage.lang.Telegram[lang].Strings.get("languageMessage") + _, err = s.ChannelMessageSend(channel.ID, content) + if err != nil { + d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) + return + } +} + +func (d *DiscordDaemon) commandPIN(s *dg.Session, m *dg.MessageCreate, lang string) { + token, ok := d.tokens[m.Author.ID] + if !ok || token.Token != m.Content { + _, err := s.ChannelMessageSendReply( + m.ChannelID, + d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"), + m.Reference(), + ) + if err != nil { + d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) + } + return + } + _, err := s.ChannelMessageSendReply( + m.ChannelID, + d.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"), + m.Reference(), + ) + if err != nil { + d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) + } + d.verifiedTokens = append(d.verifiedTokens, token) + delete(d.tokens, m.Author.ID) +} diff --git a/go.mod b/go.mod index 2fe4e0e..1ca27fc 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ replace github.com/hrfee/jfa-go/ombi => ./ombi replace github.com/hrfee/jfa-go/logger => ./logger require ( + github.com/bwmarrin/discordgo v0.23.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a // indirect diff --git a/go.sum b/go.sum index 610d893..8c9ae47 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4= +github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= @@ -148,6 +150,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/hrfee/mediabrowser v0.3.3 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I= github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs= @@ -265,6 +269,7 @@ github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/dl v0.0.0-20190829154251-82a15e2f2ead h1:jeP6FgaSLNTMP+Yri3qjlACywQLye+huGLmNGhBzm6k= golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/main.go b/main.go index e233a30..a16d561 100644 --- a/main.go +++ b/main.go @@ -96,6 +96,7 @@ type appContext struct { validator Validator email *Emailer telegram *TelegramDaemon + discord *DiscordDaemon info, debug, err logger.Logger host string port int @@ -341,6 +342,10 @@ func start(asDaemon, firstCall bool) { if err := app.storage.loadTelegramUsers(); err != nil { app.err.Printf("Failed to load Telegram users: %v", err) } + app.storage.discord_path = app.config.Section("files").Key("discord_users").String() + if err := app.storage.loadDiscordUsers(); err != nil { + app.err.Printf("Failed to load Discord users: %v", err) + } app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String() app.storage.loadProfiles() @@ -567,6 +572,15 @@ func start(asDaemon, firstCall bool) { defer app.telegram.Shutdown() } } + if discordEnabled { + app.discord, err = newDiscordDaemon(app) + if err != nil { + app.err.Printf("Failed to authenticate with Discord: %v", err) + } else { + go app.discord.run() + defer app.discord.Shutdown() + } + } } else { debugMode = false address = "0.0.0.0:8056" diff --git a/storage.go b/storage.go index 0fe7333..add1a9a 100644 --- a/storage.go +++ b/storage.go @@ -15,19 +15,20 @@ import ( ) type Storage struct { - timePattern string - invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path string - users map[string]time.Time - invites Invites - profiles map[string]Profile - defaultProfile string - emails, displayprefs, ombi_template map[string]interface{} - telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. - customEmails customEmails - policy mediabrowser.Policy - configuration mediabrowser.Configuration - lang Lang - invitesLock, usersLock sync.Mutex + timePattern string + invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path string + users map[string]time.Time + invites Invites + profiles map[string]Profile + defaultProfile string + emails, displayprefs, ombi_template map[string]interface{} + telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. + discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users. + customEmails customEmails + policy mediabrowser.Policy + configuration mediabrowser.Configuration + lang Lang + invitesLock, usersLock sync.Mutex } type TelegramUser struct { @@ -37,6 +38,13 @@ type TelegramUser struct { Contact bool // Whether to contact through telegram or not } +type DiscordUser struct { + ChannelID string + Username string + Lang string + Contact bool +} + type customEmails struct { UserCreated customEmail `json:"userCreated"` InviteExpiry customEmail `json:"inviteExpiry"` @@ -765,6 +773,14 @@ func (st *Storage) storeTelegramUsers() error { return storeJSON(st.telegram_path, st.telegram) } +func (st *Storage) loadDiscordUsers() error { + return loadJSON(st.discord_path, &st.discord) +} + +func (st *Storage) storeDiscordUsers() error { + return storeJSON(st.discord_path, st.discord) +} + func (st *Storage) loadCustomEmails() error { return loadJSON(st.customEmails_path, &st.customEmails) } diff --git a/telegram.go b/telegram.go index 7897413..1d8acc9 100644 --- a/telegram.go +++ b/telegram.go @@ -9,7 +9,7 @@ import ( tg "github.com/go-telegram-bot-api/telegram-bot-api" ) -type VerifiedToken struct { +type TelegramVerifiedToken struct { Token string ChatID int64 Username string @@ -21,7 +21,7 @@ type TelegramDaemon struct { bot *tg.BotAPI username string tokens []string - verifiedTokens []VerifiedToken + verifiedTokens []TelegramVerifiedToken languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start. link string app *appContext @@ -37,12 +37,11 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { return nil, err } td := &TelegramDaemon{ - Stopped: false, ShutdownChannel: make(chan string), bot: bot, username: bot.Self.UserName, tokens: []string{}, - verifiedTokens: []VerifiedToken{}, + verifiedTokens: []TelegramVerifiedToken{}, languages: map[int64]string{}, link: "https://t.me/" + bot.Self.UserName, app: app, @@ -55,10 +54,7 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) { return td, nil } -var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". -func (t *TelegramDaemon) NewAuthToken() string { +func genAuthToken() string { rand.Seed(time.Now().UnixNano()) pin := make([]rune, 8) for i := range pin { @@ -68,10 +64,18 @@ func (t *TelegramDaemon) NewAuthToken() string { pin[i] = runes[rand.Intn(len(runes))] } } - t.tokens = append(t.tokens, string(pin)) return string(pin) } +var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". +func (t *TelegramDaemon) NewAuthToken() string { + pin := genAuthToken() + t.tokens = append(t.tokens, pin) + return pin +} + func (t *TelegramDaemon) run() { t.app.info.Println("Starting Telegram bot daemon") u := tg.NewUpdate(0) @@ -222,7 +226,7 @@ func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) if err != nil { t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) } - t.verifiedTokens = append(t.verifiedTokens, VerifiedToken{ + t.verifiedTokens = append(t.verifiedTokens, TelegramVerifiedToken{ Token: upd.Message.Text, ChatID: upd.Message.Chat.ID, Username: upd.Message.Chat.UserName,