diff --git a/package.json b/package.json index b364c7d..c1d1491 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@nestjs/core": "^9.0.0", "@nestjs/event-emitter": "^1.3.1", "@nestjs/platform-express": "^9.0.0", + "@nestjs/schedule": "^2.1.0", "date-fns": "^2.29.3", "discord.js": "^14.7.1", "joi": "^17.7.0", @@ -44,6 +45,7 @@ "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.0.0", + "@types/cron": "^2.0.0", "@types/express": "^4.17.13", "@types/jest": "28.1.8", "@types/node": "^16.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 3490c27..d36a401 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,12 +4,14 @@ import * as Joi from 'joi'; import { DiscordModule } from '@discord-nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; import { DiscordConfigService } from './clients/discord/discord.config.service'; import { DiscordClientModule } from './clients/discord/discord.module'; import { JellyfinClientModule } from './clients/jellyfin/jellyfin.module'; import { CommandModule } from './commands/command.module'; import { PlaybackModule } from './playback/playback.module'; +import { UpdatesModule } from './updates/updates.module'; @Module({ imports: [ @@ -19,8 +21,10 @@ import { PlaybackModule } from './playback/playback.module'; JELLYFIN_SERVER_ADDRESS: Joi.string().required(), JELLYFIN_AUTHENTICATION_USERNAME: Joi.string().required(), JELLYFIN_AUTHENTICATION_PASSWORD: Joi.string().required(), + UPDATER_DISABLE_NOTIFICATIONS: Joi.boolean(), }), }), + ScheduleModule.forRoot(), DiscordModule.forRootAsync({ useClass: DiscordConfigService, }), @@ -30,6 +34,7 @@ import { PlaybackModule } from './playback/playback.module'; DiscordClientModule, JellyfinClientModule, PlaybackModule, + UpdatesModule, ], controllers: [], providers: [], diff --git a/src/clients/jellyfin/jellyfin.service.ts b/src/clients/jellyfin/jellyfin.service.ts index 343733e..e00d354 100644 --- a/src/clients/jellyfin/jellyfin.service.ts +++ b/src/clients/jellyfin/jellyfin.service.ts @@ -20,7 +20,7 @@ export class JellyfinService { this.jellyfin = new Jellyfin({ clientInfo: { name: Constants.Metadata.ApplicationName, - version: Constants.Metadata.Version, + version: Constants.Metadata.Version.All(), }, deviceInfo: { id: 'jellyfin-discord-bot', diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 9e13232..be116a4 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -56,7 +56,7 @@ export class StatusCommand implements DiscordCommand { return embedBuilder.addFields([ { name: 'Bot Version', - value: Constants.Metadata.Version, + value: Constants.Metadata.Version.All(), inline: true, }, { diff --git a/src/models/github-release.ts b/src/models/github-release.ts new file mode 100644 index 0000000..7d703e0 --- /dev/null +++ b/src/models/github-release.ts @@ -0,0 +1,6 @@ +export interface GithubRelease { + html_url: string; + tag_name: string; + name: string; + published_at: string; +} diff --git a/src/updates/updates.module.ts b/src/updates/updates.module.ts new file mode 100644 index 0000000..065dafb --- /dev/null +++ b/src/updates/updates.module.ts @@ -0,0 +1,12 @@ +import { DiscordModule } from '@discord-nestjs/core'; +import { Module } from '@nestjs/common'; +import { DiscordClientModule } from '../clients/discord/discord.module'; +import { UpdatesService } from './updates.service'; + +@Module({ + imports: [DiscordModule.forFeature(), DiscordClientModule], + providers: [UpdatesService], + controllers: [], + exports: [], +}) +export class UpdatesModule {} diff --git a/src/updates/updates.service.ts b/src/updates/updates.service.ts new file mode 100644 index 0000000..739769c --- /dev/null +++ b/src/updates/updates.service.ts @@ -0,0 +1,112 @@ +import { InjectDiscordClient } from '@discord-nestjs/core'; +import { ButtonBuilder } from '@discordjs/builders'; +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import axios from 'axios'; +import { formatRelative, parseISO } from 'date-fns'; +import { ActionRowBuilder, ButtonStyle, Client } from 'discord.js'; +import { DiscordMessageService } from '../clients/discord/discord.message.service'; +import { GithubRelease } from '../models/github-release'; +import { Constants } from '../utils/constants'; + +@Injectable() +export class UpdatesService { + private readonly logger = new Logger(UpdatesService.name); + + constructor( + @InjectDiscordClient() private readonly client: Client, + private readonly discordMessageService: DiscordMessageService, + ) {} + + @Cron('0 0 */1 * * *') + async handleCron() { + const isDisabled = process.env.UPDATER_DISABLE_NOTIFICATIONS; + + if (isDisabled === 'true') { + return; + } + + this.logger.debug('Checking for available updates...'); + + const latestGitHubRelease = await this.fetchLatestGithubRelease(); + const currentVersion = Constants.Metadata.Version.All(); + + if (latestGitHubRelease.tag_name <= currentVersion) { + return; + } + + await this.contactOwnerAboutUpdate(currentVersion, latestGitHubRelease); + } + + private async contactOwnerAboutUpdate( + currentVersion: string, + latestVersion: GithubRelease, + ) { + const guilds = this.client.guilds.cache; + + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('See update') + .setStyle(ButtonStyle.Link) + .setURL(latestVersion.html_url), + new ButtonBuilder() + .setLabel('Report an issue') + .setStyle(ButtonStyle.Link) + .setURL(Constants.Links.ReportIssue), + new ButtonBuilder() + .setLabel('Turn this notification off') + .setStyle(ButtonStyle.Link) + .setURL(Constants.Links.Wiki.DisableNotifications), + ); + + const isoDate = parseISO(latestVersion.published_at); + const relativeReadable = formatRelative(isoDate, new Date()); + + guilds.forEach(async (guild, key) => { + const owner = await guild.fetchOwner(); + + await owner.send({ + content: 'Update notification', + embeds: [ + this.discordMessageService.buildMessage({ + title: 'Update is available', + description: `Hello @${owner.user.tag},\nI'd like to inform you, that there is a new update available.\nTo ensure best security and being able to use the latest features, please update to the newest version.\n\n**${latestVersion.name}** (published ${relativeReadable})\n`, + mixin(embedBuilder) { + return embedBuilder.addFields([ + { + name: 'Your version', + value: currentVersion, + inline: true, + }, + { + name: 'Newest version', + value: latestVersion.tag_name, + inline: true, + }, + ]); + }, + }), + ], + components: [actionRow], + }); + }); + } + + private async fetchLatestGithubRelease(): Promise { + return axios({ + method: 'GET', + url: Constants.Links.Api.GetLatestRelease, + }) + .then((response) => { + if (response.status !== 200) { + return null; + } + + return response.data as GithubRelease; + }) + .catch((err) => { + this.logger.error('Error while checking for updates', err); + return null; + }); + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 766a0b1..2db6990 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,6 +1,12 @@ export const Constants = { Metadata: { - Version: '0.0.1', + Version: { + Major: 0, + Minor: 0, + Patch: 1, + All: () => + `${Constants.Metadata.Version.Major}.${Constants.Metadata.Version.Minor}.${Constants.Metadata.Version.Patch}`, + }, ApplicationName: 'Discord Jellyfin Music Bot', }, Links: { @@ -12,6 +18,16 @@ export const Constants = { new URL( `https://github.com/manuel-rw/jellyfin-discord-music-bot/issues/new?assignees=&labels=&template=bug_report.md&title=${title}`, ), + ReleasesPage: + 'https://github.com/manuel-rw/jellyfin-discord-music-bot/releases', + Wiki: { + DisableNotifications: + 'https://github.com/manuel-rw/jellyfin-discord-music-bot/wiki/%F0%9F%93%A2-Update-Notifications', + }, + Api: { + GetLatestRelease: + 'https://api.github.com/repos/manuel-rw/jellyfin-discord-music-bot/releases/latest', + }, }, Design: { InvisibleSpace: '\u1CBC', diff --git a/yarn.lock b/yarn.lock index 8687676..73d8de4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1155,6 +1155,20 @@ __metadata: languageName: node linkType: hard +"@nestjs/schedule@npm:^2.1.0": + version: 2.1.0 + resolution: "@nestjs/schedule@npm:2.1.0" + dependencies: + cron: 2.0.0 + uuid: 8.3.2 + peerDependencies: + "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 + "@nestjs/core": ^7.0.0 || ^8.0.0 || ^9.0.0 + reflect-metadata: ^0.1.12 + checksum: 43423eb0491c692c08dcdb6d18d34ec758fe29cda52f4674a945e06933ec5b4e23229193c1b071971842b50db57024d6f1c55fe8f4c6392754b6a597b31eb423 + languageName: node + linkType: hard + "@nestjs/schematics@npm:^9.0.0": version: 9.0.3 resolution: "@nestjs/schematics@npm:9.0.3" @@ -1430,6 +1444,16 @@ __metadata: languageName: node linkType: hard +"@types/cron@npm:^2.0.0": + version: 2.0.0 + resolution: "@types/cron@npm:2.0.0" + dependencies: + "@types/luxon": "*" + "@types/node": "*" + checksum: 392d2cfca51504140397533c30be8facd2196251074eb26ee09232a7e983144ff1d8364cd64922ed22d142686a4724934a70672fc8353b441fea729ac0ed0611 + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.3": version: 3.7.4 resolution: "@types/eslint-scope@npm:3.7.4" @@ -1538,6 +1562,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:*": + version: 3.1.0 + resolution: "@types/luxon@npm:3.1.0" + checksum: 04768029342ad76fc2a9339436c143ea64797b35cf9b03ddded787c13eae30f0ca1246e51c2c5365ed912f98068e13a967a3931b137eb4585248a0ad7ec3fa86 + languageName: node + linkType: hard + "@types/mime@npm:*": version: 3.0.1 resolution: "@types/mime@npm:3.0.1" @@ -2894,6 +2925,15 @@ __metadata: languageName: node linkType: hard +"cron@npm:2.0.0": + version: 2.0.0 + resolution: "cron@npm:2.0.0" + dependencies: + luxon: ^1.23.x + checksum: 179ec137ada4ceb44cafe51c55491e84954308d7012d2a44539f0dadbbb1ffbbe3072c2f7aaa88595d60bd56e0d536aae2e4aaa4430c869317d6c2abd966988b + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -4452,8 +4492,10 @@ __metadata: "@nestjs/core": ^9.0.0 "@nestjs/event-emitter": ^1.3.1 "@nestjs/platform-express": ^9.0.0 + "@nestjs/schedule": ^2.1.0 "@nestjs/schematics": ^9.0.0 "@nestjs/testing": ^9.0.0 + "@types/cron": ^2.0.0 "@types/express": ^4.17.13 "@types/jest": 28.1.8 "@types/node": ^16.0.0 @@ -5189,6 +5231,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^1.23.x": + version: 1.28.0 + resolution: "luxon@npm:1.28.0" + checksum: 5250cb9f138b6048eeb0b3a9044a4ac994d0058f680c72a0da4b6aeaec8612460385639cba2b1052ef6d5564879e9ed144d686f26d9d97b38ab920d82e18281c + languageName: node + linkType: hard + "macos-release@npm:^2.5.0": version: 2.5.0 resolution: "macos-release@npm:2.5.0"