|
|
@ -1,6 +1,39 @@ |
|
|
|
import Application from "../Application"; |
|
|
|
import Service from "./Service"; |
|
|
|
import { Client } from "discord.js"; |
|
|
|
import { Client, Collection, Message, TextChannel, User as DiscordUser } from "discord.js"; |
|
|
|
import { env, formatImdbId, generateToken, secret } from "@server/util"; |
|
|
|
import { DiscordChannel } from "@server/database/entities/DiscordChannel"; |
|
|
|
import { DiscordAccount, MovieTicket } from "@server/database/entities"; |
|
|
|
import MovieSearch from "./MovieSearch"; |
|
|
|
import { DiscordLinkRequest } from "@server/database/entities/DiscordLinkRequest"; |
|
|
|
|
|
|
|
/** |
|
|
|
* The required role to perfrom administrative commands on the bot |
|
|
|
*/ |
|
|
|
const ROLE_ADMIN = "Autoplex Admin"; |
|
|
|
|
|
|
|
/** |
|
|
|
* The message prefix for commands |
|
|
|
*/ |
|
|
|
const COMMAND_PREFIX = "!plex"; |
|
|
|
|
|
|
|
/** |
|
|
|
* How frequently to update Discord status messages |
|
|
|
*/ |
|
|
|
const UPDATE_STATUS_INTERVAL = 1500; |
|
|
|
|
|
|
|
interface IChannelMap { |
|
|
|
[channelId: string]: Message |
|
|
|
} |
|
|
|
|
|
|
|
interface ICommandMap { |
|
|
|
[key: string]: (message: Message) => void|Promise<void> |
|
|
|
} |
|
|
|
|
|
|
|
interface IAuthCommandmap { |
|
|
|
[key: string]: (account: DiscordAccount, message: Message) => void|Promise<void> |
|
|
|
} |
|
|
|
|
|
|
|
export default class DiscordBot extends Service |
|
|
|
{ |
|
|
|
/** |
|
|
@ -8,20 +41,73 @@ export default class DiscordBot extends Service |
|
|
|
*/ |
|
|
|
protected bot: Client; |
|
|
|
|
|
|
|
/** |
|
|
|
* Maintain a set of request channels |
|
|
|
*/ |
|
|
|
protected channels: IChannelMap = {}; |
|
|
|
|
|
|
|
/** |
|
|
|
* Admin commands available |
|
|
|
*/ |
|
|
|
protected adminCommands: ICommandMap; |
|
|
|
|
|
|
|
/** |
|
|
|
* Guest commands available |
|
|
|
*/ |
|
|
|
protected guestCommands: ICommandMap; |
|
|
|
|
|
|
|
/** |
|
|
|
* Authorized commands available |
|
|
|
*/ |
|
|
|
protected authCommands: IAuthCommandmap; |
|
|
|
|
|
|
|
/** |
|
|
|
* The status update interval |
|
|
|
*/ |
|
|
|
protected updateStatusTimeout: NodeJS.Timeout | null = null; |
|
|
|
|
|
|
|
/** |
|
|
|
* Timestamp of the most recent status update |
|
|
|
*/ |
|
|
|
protected lastStatusUpdate: number = 0; |
|
|
|
|
|
|
|
/** |
|
|
|
* The current status of the bot |
|
|
|
*/ |
|
|
|
protected status: string = ""; |
|
|
|
|
|
|
|
/** |
|
|
|
* Create a new Discord bot instance |
|
|
|
*/ |
|
|
|
public constructor(app: Application) { |
|
|
|
super("Discord Bot", app); |
|
|
|
this.bot = new Client(); |
|
|
|
this.adminCommands = { |
|
|
|
"install": this.cmdInstallChannel.bind(this) |
|
|
|
}; |
|
|
|
this.guestCommands = { |
|
|
|
"link": this.cmdLinkAccount.bind(this) |
|
|
|
} |
|
|
|
this.authCommands = {}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Boot the discord bot |
|
|
|
*/ |
|
|
|
public async boot() { |
|
|
|
await this.bot.login(process.env["DISCORD_BOT_TOKEN"]); |
|
|
|
this.log("Discord bot ready"); |
|
|
|
let token = await secret(env("DISCORD_BOT_KEY_FILE")); |
|
|
|
await this.bot.login(token); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Invoked when all other services are booted and ready |
|
|
|
*/ |
|
|
|
public async start() { |
|
|
|
await this.loadChannels(); |
|
|
|
this.bot.on("message", message => this.onMessage(message)); |
|
|
|
setInterval(() => { |
|
|
|
this.updateStatus(Date.now().toString()); |
|
|
|
}, 500); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
@ -29,5 +115,282 @@ export default class DiscordBot extends Service |
|
|
|
*/ |
|
|
|
public async shutdown() { |
|
|
|
this.bot.destroy(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
// Message Handling ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
/** |
|
|
|
* Invoked when a Discord message is received |
|
|
|
*/ |
|
|
|
protected onMessage(message: Message) { |
|
|
|
if (message.content.startsWith(`${COMMAND_PREFIX} `)) { |
|
|
|
this.handleMessage(message); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Handle an incoming Discord message |
|
|
|
*/ |
|
|
|
protected async handleMessage(message: Message) { |
|
|
|
let command = message.content.split(/\s+/)[1]; |
|
|
|
if (message.member?.roles.cache.some(role => role.name == ROLE_ADMIN)) { |
|
|
|
if (command in this.adminCommands) { |
|
|
|
this.adminCommands[command](message); |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
// Check if the channel is an authorized channel
|
|
|
|
if (!(message.channel.id in this.channels)) { |
|
|
|
this.informUserOfAuthorizedChannels(message); |
|
|
|
return; |
|
|
|
} |
|
|
|
if (command in this.guestCommands) { |
|
|
|
this.guestCommands[command](message); |
|
|
|
return; |
|
|
|
} |
|
|
|
// Only allow plex members
|
|
|
|
let account = await DiscordAccount.findOne({ where: { discordId: message.author.id }, relations: ["user", "user.quota"] }); |
|
|
|
if (account === undefined) { |
|
|
|
console.log("Unauthorized"); |
|
|
|
return; |
|
|
|
} |
|
|
|
if (command in this.authCommands) { |
|
|
|
this.authCommands[command](account, message); |
|
|
|
} |
|
|
|
this.cmdRequestMovie(account, message); |
|
|
|
if (message.deletable) { |
|
|
|
message.delete(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Inform a message sender of authorized channels within the same server if available |
|
|
|
*/ |
|
|
|
protected async informUserOfAuthorizedChannels(message: Message) { |
|
|
|
if (!(message.channel instanceof TextChannel)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
let serverId = message.channel.guild.id; |
|
|
|
let authorizedChannels = <Collection<string, TextChannel>>message.channel.client.channels.cache.filter(channel => ( |
|
|
|
channel instanceof TextChannel && |
|
|
|
channel.guild.id == serverId && |
|
|
|
channel.id in this.channels)); |
|
|
|
let response = "Autoplex commands must be issued through authorized channels. "; |
|
|
|
if (authorizedChannels.size == 0) { |
|
|
|
response += `Unfortunately, **${message.channel.guild.name}** does not have any available channels.`; |
|
|
|
} else { |
|
|
|
response += `The following channels in **${message.channel.guild.name}** are:`; |
|
|
|
for (let channel of authorizedChannels) { |
|
|
|
response += `\n - \`#${channel[1].name}\``; |
|
|
|
}; |
|
|
|
} |
|
|
|
this.sendDm(message.author, response); |
|
|
|
} |
|
|
|
|
|
|
|
// Channel Handling ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
/** |
|
|
|
* Load the channels from the database |
|
|
|
*/ |
|
|
|
protected async loadChannels() { |
|
|
|
let channels = await DiscordChannel.find(); |
|
|
|
for (let channel of channels) { |
|
|
|
this.loadChannel(channel); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Load the channel's status message. Reinstall if the message can't be found |
|
|
|
*/ |
|
|
|
protected async loadChannel(discordChannel: DiscordChannel) { |
|
|
|
// Load the channel
|
|
|
|
let channel: TextChannel |
|
|
|
try { |
|
|
|
channel = <TextChannel> await this.bot.channels.fetch(discordChannel.channelId); |
|
|
|
} catch(e) { |
|
|
|
this.uninstall(discordChannel.channelId); |
|
|
|
return; |
|
|
|
} |
|
|
|
// Load the message, attempt to install if failed
|
|
|
|
let message: Message; |
|
|
|
try { |
|
|
|
message = await channel.messages.fetch(discordChannel.messageId); |
|
|
|
} catch(e) { |
|
|
|
await this.install(channel, "Autoplex reinstalled"); |
|
|
|
return; |
|
|
|
} |
|
|
|
this.channels[channel.id] = message; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Install the Discord bot to a channel |
|
|
|
*/ |
|
|
|
protected async install(channel: TextChannel, status?: string): Promise<boolean>; |
|
|
|
protected async install(channelId: string, status?: string): Promise<boolean>; |
|
|
|
protected async install(channel: string | TextChannel, status?: string) { |
|
|
|
// Fetch the channel if necessary
|
|
|
|
if (typeof channel === "string") { |
|
|
|
channel = <TextChannel> await this.bot.channels.fetch(channel); |
|
|
|
} |
|
|
|
// Ensure the channel is a text channel
|
|
|
|
if (!(channel instanceof TextChannel)) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
try { |
|
|
|
// Create the initial status message
|
|
|
|
let message = await channel.send(status ?? "Autoplex installed."); |
|
|
|
// Fetch the channel model or create a new one
|
|
|
|
let discordChannel = (await DiscordChannel.findOne({ where: { channelId: channel.id } })) |
|
|
|
?? new DiscordChannel(); |
|
|
|
discordChannel.channelId = channel.id; |
|
|
|
discordChannel.messageId = message.id; |
|
|
|
await discordChannel.save(); |
|
|
|
this.channels[channel.id] = message; |
|
|
|
} catch(e) { |
|
|
|
console.error("Failed to install to channel.", e); |
|
|
|
this.uninstall(channel) |
|
|
|
return false; |
|
|
|
} |
|
|
|
return true; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Uninstall the Discord bot from a channel |
|
|
|
*/ |
|
|
|
protected async uninstall(channel: TextChannel): Promise<void>; |
|
|
|
protected async uninstall(channelId: string): Promise<void>; |
|
|
|
protected async uninstall(channel: string | TextChannel) { |
|
|
|
// Fetch the channel if necessary
|
|
|
|
if (typeof channel === "string") { |
|
|
|
channel = <TextChannel> await this.bot.channels.fetch(channel); |
|
|
|
} |
|
|
|
if (channel.id in this.channels) { |
|
|
|
delete this.channels[channel.id]; |
|
|
|
} |
|
|
|
await DiscordChannel.delete({ channelId: channel.id }); |
|
|
|
console.error("Channel uninstalled"); |
|
|
|
} |
|
|
|
|
|
|
|
// Message Handling ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
/** |
|
|
|
* Update the status of the server |
|
|
|
*/ |
|
|
|
public updateStatus(status: string) { |
|
|
|
this.status = status; |
|
|
|
if (this.updateStatusTimeout !== null) { |
|
|
|
return; |
|
|
|
} |
|
|
|
this.updateStatusTimeout = setTimeout(() => { |
|
|
|
this.lastStatusUpdate = Date.now(); |
|
|
|
this.updateStatusTimeout = null; |
|
|
|
for (let channelId in this.channels) { |
|
|
|
this.updateStatusMessage(channelId, status); |
|
|
|
} |
|
|
|
}, this.lastStatusUpdate + UPDATE_STATUS_INTERVAL - Date.now()); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Update the status message in a single channel |
|
|
|
*/ |
|
|
|
protected async updateStatusMessage(channelId: string, status: string) { |
|
|
|
try { |
|
|
|
await this.channels[channelId].edit(status); |
|
|
|
} catch(e) { |
|
|
|
await this.install(channelId, status); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Send a DM to a user |
|
|
|
*/ |
|
|
|
protected async sendDm(user: DiscordUser, message: string) { |
|
|
|
return await (await user.createDM()).send(message); |
|
|
|
} |
|
|
|
|
|
|
|
// Discord Commands ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
/** |
|
|
|
* Install the Autoplex bot onto a channel |
|
|
|
*/ |
|
|
|
protected async cmdInstallChannel(message: Message) { |
|
|
|
let isInstalled = await this.install(message.channel.id); |
|
|
|
if (!isInstalled) { |
|
|
|
message.reply("Installation failed. Check server logs"); |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Link a Discord account to an Autoplex account |
|
|
|
*/ |
|
|
|
protected async cmdLinkAccount(message: Message) { |
|
|
|
// Ensure the account hasn't already been linked
|
|
|
|
let numAccounts = await DiscordAccount.count({ where: { discordId: message.author.id } }); |
|
|
|
if (numAccounts > 0) { |
|
|
|
this.sendDm(message.author, "This Discord account has already been linked to an Autoplex account"); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// Create the link request
|
|
|
|
let linkRequest = await DiscordLinkRequest.findOne({ |
|
|
|
where: { discordId: message.author.id } |
|
|
|
}) ?? new DiscordLinkRequest(); |
|
|
|
linkRequest.discordId = message.author.id; |
|
|
|
linkRequest.token = await generateToken(); |
|
|
|
await linkRequest.save(); |
|
|
|
|
|
|
|
// Send the link to the user
|
|
|
|
this.sendDm(message.author, |
|
|
|
"Link your Autoplex account by clicking the link and logging in.\n" + |
|
|
|
`${env("BASE_URL")}/link/discord/${linkRequest.token}` |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Request a movie |
|
|
|
*/ |
|
|
|
protected async cmdRequestMovie(account: DiscordAccount, message: Message) { |
|
|
|
// Ensure an IMDb ID was entered
|
|
|
|
let imdbIds = message.content.match(/\btt[0-9]{7,25}\b/g); |
|
|
|
if (imdbIds === null) { |
|
|
|
this.sendDm(message.author, "Usage: `!plex https://www.imdb.com/title/ttxxxxxx/`"); |
|
|
|
return; |
|
|
|
} |
|
|
|
let imdbId = formatImdbId(imdbIds[0]); |
|
|
|
|
|
|
|
// Ensure this movie hasn't already been requested
|
|
|
|
let ticket = await MovieTicket.findOne({ where: { imdbId, isCanceled: false }, relations: ["user"] }); |
|
|
|
if (ticket !== undefined) { |
|
|
|
if (ticket.user.id == account.user.id) { |
|
|
|
this.sendDm(message.author, `You have already requested the movie: *${ticket.title}*.`); |
|
|
|
} else { |
|
|
|
this.sendDm(message.author, `The movie *${ticket.title}* `); |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// Ensure the user hasn't gone over their quota
|
|
|
|
let quota = await account.user.availableQuota(); |
|
|
|
if (quota !== null && quota < 1) { |
|
|
|
this.sendDm( |
|
|
|
message.author, |
|
|
|
`You have reach your quota limit of ${account.user.quota?.moviesPerWeek}` + |
|
|
|
` movie${account.user.quota?.moviesPerWeek != 0 ? 's' : ''} for this week.`); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// Fetch the movie details and create the ticket
|
|
|
|
let movie = await this.app.service<MovieSearch>("Movie Search").findImdb(imdbId); |
|
|
|
if (movie === null) { |
|
|
|
this.sendDm( |
|
|
|
message.author, |
|
|
|
`Unfortunately, the movie you requested could net be located: https://imdb.com/title/${imdbId}/`); |
|
|
|
return; |
|
|
|
} |
|
|
|
let info = await this.app.service<MovieSearch>("Movie Search").details(movie.id); |
|
|
|
await MovieTicket.requestTmdb(account.user, info); |
|
|
|
this.sendDm(message.author, `*${info.title}* has been requested successfully!`); |
|
|
|
} |
|
|
|
} |