From 9fac79b1f0c6ba96e277b19c2f57c8268dd21748 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 22 May 2021 21:42:15 +0100 Subject: [PATCH] Discord: Add users via accounts tab Doesn't require a PIN like Telegram, as we can access a list of guild users with the GuildMembers intent set. This has to be enabled under Bot > Priviliged Gateway intents on the developer portal. --- api.go | 59 ++++++++++++++++++++++++++++++-- css/base.css | 23 +++++++++++++ discord.go | 56 +++++++++++++++++++++++++++++- html/admin.html | 12 +++++++ lang/admin/en-us.json | 5 ++- models.go | 15 ++++++++ router.go | 6 ++-- ts/admin.ts | 4 +++ ts/modules/accounts.ts | 78 +++++++++++++++++++++++++++++++++++++++++- ts/typings/d.ts | 1 + 10 files changed, 252 insertions(+), 7 deletions(-) diff --git a/api.go b/api.go index f42f715..b3d0879 100644 --- a/api.go +++ b/api.go @@ -2048,7 +2048,7 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) { // @Summary Sets whether to notify a user through telegram or not. // @Produce json -// @Param SetContactMethodDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram." +// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram." // @Success 200 {object} boolResponse // @Success 400 {object} boolResponse // @Success 500 {object} boolResponse @@ -2164,7 +2164,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { // @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. // @Produce json // @Success 200 {object} boolResponse -// @Success 401 {object} boolResponse +// @Failure 401 {object} boolResponse // @Param pin path string true "PIN code to check" // @Param invCode path string true "invite Code" // @Router /invite/{invCode}/discord/verified/{pin} [get] @@ -2180,6 +2180,61 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { respondBool(200, ok, gc) } +// @Summary Returns a list of matching users from a Discord guild, given a username (discriminator optional). +// @Produce json +// @Success 200 {object} DiscordUsersDTO +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param username path string true "username to search." +// @Router /users/discord/{username} [get] +// @tags Other +func (app *appContext) DiscordGetUsers(gc *gin.Context) { + name := gc.Param("username") + if name == "" { + respondBool(400, false, gc) + return + } + users := app.discord.GetUsers(name) + resp := DiscordUsersDTO{Users: make([]DiscordUserDTO, len(users))} + for i, u := range users { + resp.Users[i] = DiscordUserDTO{ + Name: u.User.Username + "#" + u.User.Discriminator, + ID: u.User.ID, + AvatarURL: u.User.AvatarURL("32"), + } + } + gc.JSON(200, resp) +} + +// @Summary Links a Discord account to a Jellyfin account via user IDs. Notifications are turned on by default. +// @Produce json +// @Success 200 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID." +// @Router /users/discord [post] +// @tags Other +func (app *appContext) DiscordConnect(gc *gin.Context) { + var req DiscordConnectUserDTO + gc.BindJSON(&req) + if req.JellyfinID == "" || req.DiscordID == "" { + respondBool(400, false, gc) + return + } + user, ok := app.discord.NewUser(req.DiscordID) + if !ok { + respondBool(500, false, gc) + return + } + app.storage.discord[req.JellyfinID] = user + if err := app.storage.storeDiscordUsers(); err != nil { + app.err.Printf("Failed to store Discord users: %v", err) + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + // @Summary Restarts the program. No response means success. // @Router /restart [post] // @Security Bearer diff --git a/css/base.css b/css/base.css index 3b9626d..c4889b6 100644 --- a/css/base.css +++ b/css/base.css @@ -130,6 +130,10 @@ div.card:contains(section.banner.footer) { text-align: center; } +.w-100 { + width: 100%; +} + .inline-block { display: inline-block; } @@ -483,3 +487,22 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) { max-width: 15rem; min-width: 10rem; } + +td.img-circle { + width: 32px; + height: 32px; +} + +span.shield.img-circle { + padding: 0.2rem; +} + +img.img-circle { + border-radius: 50%; + vertical-align: middle; +} + +.table td.sm { + padding-top: 0.1rem; + padding-bottom: 0.1rem; +} diff --git a/discord.go b/discord.go index 4c44931..3d92ead 100644 --- a/discord.go +++ b/discord.go @@ -71,7 +71,7 @@ func (d *DiscordDaemon) MustGetUser(channelID, userID, discrim, username string) func (d *DiscordDaemon) run() { d.bot.AddHandler(d.messageHandler) - d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages + d.bot.Identify.Intents = dg.IntentsGuildMessages | dg.IntentsDirectMessages | dg.IntentsGuildMembers if err := d.bot.Open(); err != nil { d.app.err.Printf("Discord: Failed to start daemon: %v", err) return @@ -92,6 +92,60 @@ func (d *DiscordDaemon) run() { return } +// Returns the user(s) roughly corresponding to the username (if they are in the guild). +// if no discriminator (#xxxx) is given in the username and there are multiple corresponding users, a list of all matching users is returned. +func (d *DiscordDaemon) GetUsers(username string) []*dg.Member { + members, err := d.bot.GuildMembers( + d.bot.State.Guilds[len(d.bot.State.Guilds)-1].ID, + "", + 1000, + ) + if err != nil { + d.app.err.Printf("Discord: Failed to get members: %v", err) + return nil + } + hasDiscriminator := strings.Contains(username, "#") + var users []*dg.Member + for _, member := range members { + if !hasDiscriminator { + userSplit := strings.Split(member.User.Username, "#") + if strings.Contains(userSplit[0], username) { + users = append(users, member) + } + } else if strings.Contains(member.User.Username, username) { + return nil + } + } + return users +} + +func (d *DiscordDaemon) NewUser(ID string) (user DiscordUser, ok bool) { + u, err := d.bot.User(ID) + if err != nil { + d.app.err.Printf("Discord: Failed to get user: %v", err) + return + } + user.ID = ID + user.Username = u.Username + user.Contact = true + user.Discriminator = u.Discriminator + channel, err := d.bot.UserChannelCreate(ID) + if err != nil { + d.app.err.Printf("Discord: Failed to create DM channel: %v", err) + return + } + user.ChannelID = channel.ID + ok = true + 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.GuildID != "" && d.channelName != "" { if d.channelID == "" { diff --git a/html/admin.html b/html/admin.html index 6286144..bc0f80d 100644 --- a/html/admin.html +++ b/html/admin.html @@ -328,6 +328,18 @@ {{ end }} + {{ if .discord_enabled }} + + {{ end }}
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 8fb42d1..51d809c 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -20,6 +20,7 @@ "create": "Create", "apply": "Apply", "delete": "Delete", + "add": "Add", "name": "Name", "date": "Date", "enabled": "Enabled", @@ -94,7 +95,8 @@ "notifyEvent": "Notify on:", "notifyInviteExpiry": "On expiry", "notifyUserCreation": "On user creation", - "sendPIN": "Ask the user to send the PIN below to the bot." + "sendPIN": "Ask the user to send the PIN below to the bot.", + "searchDiscordUser": "Start typing the Discord username to link it." }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", @@ -107,6 +109,7 @@ "updateApplied": "Update applied, please restart.", "updateAppliedRefresh": "Update applied, please refresh.", "telegramVerified": "Telegram account verified.", + "accountConnected": "Account connected.", "errorConnection": "Couldn't connect to jfa-go.", "error401Unauthorized": "Unauthorized. Try refreshing the page.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", diff --git a/models.go b/models.go index d018738..3641eb1 100644 --- a/models.go +++ b/models.go @@ -261,3 +261,18 @@ type SetContactMethodsDTO struct { Discord bool `json:"discord"` Telegram bool `json:"telegram"` } + +type DiscordUserDTO struct { + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + ID string `json:"id"` +} + +type DiscordUsersDTO struct { + Users []DiscordUserDTO `json:"users"` +} + +type DiscordConnectUserDTO struct { + JellyfinID string `json:"jf_id"` + DiscordID string `json:"discord_id"` +} diff --git a/router.go b/router.go index 8dcf793..38e3c97 100644 --- a/router.go +++ b/router.go @@ -165,10 +165,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/telegram/pin", app.TelegramGetPin) api.GET(p+"/telegram/verified/:pin", app.TelegramVerified) api.POST(p+"/users/telegram", app.TelegramAddUser) - } - if discordEnabled || telegramEnabled { api.POST(p+"/users/contact", app.SetContactMethods) } + if discordEnabled { + api.GET(p+"/users/discord/:username", app.DiscordGetUsers) + api.POST(p+"/users/discord", app.DiscordConnect) + } if app.config.Section("ombi").Key("enabled").MustBool(false) { api.GET(p+"/ombi/users", app.OmbiUsers) api.POST(p+"/ombi/defaults", app.SetOmbiDefaults) diff --git a/ts/admin.ts b/ts/admin.ts index 6c14be7..edfb009 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -66,6 +66,10 @@ window.availableProfiles = window.availableProfiles || []; if (window.telegramEnabled) { window.modals.telegram = new Modal(document.getElementById("modal-telegram")); } + + if (window.discordEnabled) { + window.modals.discord = new Modal(document.getElementById("modal-discord")); + } })(); var inviteCreator = new createInvite(); diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index b8473e2..d969777 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -24,6 +24,12 @@ interface getPinResponse { username: string; } +interface DiscordUser { + name: string; + avatar_url: string; + id: string; +} + class user implements User { private _row: HTMLTableRowElement; private _check: HTMLInputElement; @@ -218,7 +224,7 @@ class user implements User { this._discordUsername = u; if (u == "") { this._discord.innerHTML = `Add`; - // (this._discord.querySelector("span") as HTMLSpanElement).onclick = this._addDiscord; + (this._discord.querySelector("span") as HTMLSpanElement).onclick = this._addDiscord; } else { let innerHTML = ` ${u} @@ -401,6 +407,76 @@ class user implements User { } }); } + + private _timer: NodeJS.Timer; + + private _discordKbListener = () => { + clearTimeout(this._timer); + const list = document.getElementById("discord-list") as HTMLTableElement; + const input = document.getElementById("discord-search") as HTMLInputElement; + if (input.value.length < 2) { + return; + } + list.innerHTML = ``; + addLoader(list); + list.parentElement.classList.add("mb-1", "mt-1"); + this._timer = setTimeout(() => { + _get("/users/discord/" + input.value, null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status != 200) { + removeLoader(list); + list.parentElement.classList.remove("mb-1", "mt-1"); + return; + } + const users = req.response["users"] as Array; + let innerHTML = ``; + for (let i = 0; i < users.length; i++) { + innerHTML += ` + + + + + +

${users[i].name}

+ + + ${window.lang.strings("add")} + + + `; + } + list.innerHTML = innerHTML; + removeLoader(list); + list.parentElement.classList.remove("mb-1", "mt-1"); + for (let i = 0; i < users.length; i++) { + const button = document.getElementById(`discord-user-${users[i].id}`) as HTMLInputElement; + button.onclick = () => _post("/users/discord", {jf_id: this.id, discord_id: users[i].id}, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + document.dispatchEvent(new CustomEvent("accounts-reload")); + if (req.status != 200) { + window.notifications.customError("errorConnectDiscord", window.lang.notif("errorFailureCheckLogs")); + return + } + window.notifications.customSuccess("discordConnected", window.lang.notif("accountConnected")); + window.modals.discord.close() + } + }); + } + } + }); + }, 750); + } + + private _addDiscord = () => { + if (!window.discordEnabled) { return; } + const input = document.getElementById("discord-search") as HTMLInputElement; + const list = document.getElementById("discord-list") as HTMLDivElement; + list.innerHTML = ``; + input.value = ""; + input.removeEventListener("keyup", this._discordKbListener); + input.addEventListener("keyup", this._discordKbListener); + window.modals.discord.show(); + } private _addTelegram = () => _get("/telegram/pin", null, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 200) { diff --git a/ts/typings/d.ts b/ts/typings/d.ts index f768063..ba40de0 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -102,6 +102,7 @@ declare interface Modals { extendExpiry: Modal; updateInfo: Modal; telegram: Modal; + discord: Modal; } interface Invite {