diff --git a/.forgejo/workflows/build-release-container.yaml b/.forgejo/workflows/build-release-container.yaml index cf2c4f7..32af75c 100644 --- a/.forgejo/workflows/build-release-container.yaml +++ b/.forgejo/workflows/build-release-container.yaml @@ -6,6 +6,10 @@ on: - release-* workflow_dispatch: +env: + BUILD_TARGET: DOCKER + BUILD_LABEL: release + jobs: build: runs-on: node-20 @@ -31,5 +35,5 @@ jobs: uses: docker/build-push-action@v6 with: push: true - tags: neintonine/pnp-scheduler:release + tags: neintonine/pnp-scheduler:${{ env.BUILD_LABEL }} context: . diff --git a/build/build-cli.mjs b/build/build-cli.mjs index 089060b..72d43c1 100644 --- a/build/build-cli.mjs +++ b/build/build-cli.mjs @@ -1,4 +1,14 @@ +if (!process.env.BUILD_TARGET) { + process.env.BUILD_TARGET = "LOCAL"; +} + +if (!process.env.BUILD_LABEL) { + process.env.BUILD_LABEL = "development"; +} + +import "./create-build-file.mjs"; import context from './context.mjs'; await context.rebuild(); -await context.dispose(); \ No newline at end of file +await context.dispose(); + diff --git a/build/create-build-file.mjs b/build/create-build-file.mjs new file mode 100644 index 0000000..601164e --- /dev/null +++ b/build/create-build-file.mjs @@ -0,0 +1,12 @@ +import * as os from "node:os"; +import * as child_process from "node:child_process"; +import {json} from "node:stream/consumers"; +import * as fs from "node:fs"; + +const buildContext = { + target: process.env.BUILD_TARGET ?? 'LOCAL', + commitHash: child_process.execSync("git rev-parse HEAD").toString(), + label: process.env.BUILD_LABEL ?? 'development', +} + +fs.writeFileSync("./dist/deploy.json", JSON.stringify(buildContext)) diff --git a/source/Configuration/Groups/GroupConfigurationProvider.ts b/source/Configuration/Groups/GroupConfigurationProvider.ts index 1eb8c5b..18c1287 100644 --- a/source/Configuration/Groups/GroupConfigurationProvider.ts +++ b/source/Configuration/Groups/GroupConfigurationProvider.ts @@ -15,7 +15,7 @@ export type RuntimeGroupConfiguration = { }; export type ChannelRuntimeGroupConfiguration = { - newPlaydates: ChannelId, + notifications: ChannelId, playdateReminders: ChannelId } @@ -75,7 +75,7 @@ export class GroupConfigurationProvider implements ConfigurationProvider< return new ConfigurationTransformer( [ { - path: ['channels', 'newPlaydates'], + path: ['channels', 'notifications'], type: TransformerType.Channel, }, { diff --git a/source/Database/Repositories/GroupRepository.ts b/source/Database/Repositories/GroupRepository.ts index 9936376..24a9df1 100644 --- a/source/Database/Repositories/GroupRepository.ts +++ b/source/Database/Repositories/GroupRepository.ts @@ -2,7 +2,7 @@ import {Repository} from "./Repository"; import {GroupModel} from "../Models/GroupModel"; import Groups, {DBGroup} from "../tables/Groups"; import {DatabaseConnection} from "../DatabaseConnection"; -import {GuildMember, UserFlagsBitField} from "discord.js"; +import {GuildMember} from "discord.js"; import {Nullable} from "../../types/Nullable"; import {PlaydateRepository} from "./PlaydateRepository"; import {Container} from "../../Container/Container"; diff --git a/source/Database/Repositories/Repository.ts b/source/Database/Repositories/Repository.ts index 3488f4e..a4967b8 100644 --- a/source/Database/Repositories/Repository.ts +++ b/source/Database/Repositories/Repository.ts @@ -65,12 +65,12 @@ export class Repository 0; } - public getById(id: number): Nullable { + public getById(id: number|bigint): Nullable { const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`; return this.convertToModelType(this.database.fetch(sql, id)); } - public delete(id: number) { + public delete(id: number|bigint) { const sql = `DELETE FROM ${this.schema.name} WHERE id = ?`; return this.database.execute(sql, id); } diff --git a/source/Discord/Commands/Bot.ts b/source/Discord/Commands/Bot.ts new file mode 100644 index 0000000..e2a8975 --- /dev/null +++ b/source/Discord/Commands/Bot.ts @@ -0,0 +1,60 @@ +import {CacheType, ChatInputCommandInteraction, hyperlink, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {ChatInteractionCommand, Command} from "./Command"; +import * as fs from "node:fs"; +import {UserError} from "../UserError"; +import {ifError} from "node:assert"; +import {BuildContextGetter} from "../../Utilities/BuildContext"; +import {EmbedLibrary} from "../EmbedLibrary"; + +export class BotCommand implements Command, ChatInteractionCommand { + definition(): SlashCommandBuilder { + return new SlashCommandBuilder() + .setName("bot") + .setDescription("Offers some information about the bot") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(command => command + .setName("build") + .setDescription("Displays some information about the build the bot is running on.") + ) + } + + execute(interaction: ChatInputCommandInteraction): Promise { + switch (interaction.options.getSubcommand()) { + case "build": + this.displayBuildInfos(interaction); + break; + } + } + + private displayBuildInfos(interaction: ChatInputCommandInteraction) { + const buildContext = new BuildContextGetter().getContext(); + if (!buildContext) { + throw new UserError("Can't find required deploy information", "Using a valid docker image or (when running on a dev build) running `npm run build` once."); + } + + const embed = EmbedLibrary.base("Current Build") + .setFields( + [ + { + name: "Build Target", + value: buildContext.target, + inline: true + }, + { + name: "Build Label", + value: buildContext.label, + inline: true + }, + { + name: "Latest Commit", + value: hyperlink(buildContext.commitHash, buildContext.commitLink) + } + ] + ) + + interaction.reply({ + embeds: [embed] + }) + } + +} \ No newline at end of file diff --git a/source/Discord/Commands/Commands.ts b/source/Discord/Commands/Commands.ts index 129f7b3..352da29 100644 --- a/source/Discord/Commands/Commands.ts +++ b/source/Discord/Commands/Commands.ts @@ -5,12 +5,14 @@ import {PlaydatesCommand} from "./Playdates"; import {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; import {Nullable} from "../../types/Nullable"; import {ServerCommand} from "./Server"; +import {BotCommand} from "./Bot"; const commands: Set = new Set([ new HelloWorldCommand(), new GroupCommand(), new PlaydatesCommand(), - new ServerCommand() + new ServerCommand(), + new BotCommand() ]); export default class Commands { diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index 4082b8f..b30e701 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -33,6 +33,10 @@ import {ServerConfigurationProvider} from "../../Configuration/Server/ServerConf import {ServerConfigurationRepository} from "../../Database/Repositories/ServerConfigurationRepository"; import {PermissionError} from "../PermissionError"; import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; +import {EventHandler} from "../../Events/EventHandler"; +import {ElementChangedEvent} from "../../Events/EventClasses/ElementChangedEvent"; +import GroupConfiguration from "../../Database/tables/GroupConfiguration"; +import Groups from "../../Database/tables/Groups"; export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { private static GOODBYE_MESSAGES: string[] = [ @@ -50,7 +54,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple definition(): SlashCommandBuilder { // @ts-expect-error Slash command expects more than needed. return new SlashCommandBuilder() - .setName('groups') + .setName('group') .setDescription(`Manages groups`) .addSubcommand(create => create.setName("create") @@ -289,9 +293,9 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple type: MenuItemType.Collection, children: [ { - traversalKey: "newPlaydates", - label: "New Playdates", - description: "Sets the channel, where the group gets notified, when new Playdates are set.", + traversalKey: "notifications", + label: "Notifications", + description: "Sets the channel, where the group gets notified, when things are happening, such as a new playdate is created.", }, { traversalKey: "playdateReminders", @@ -369,6 +373,17 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple group.leader.memberid = newLeader.id repo.update(group); + Container.get(EventHandler.name) + .dispatch(new ElementChangedEvent( + Groups.name, + { + id: group.id, + leader: { + memberid: newLeader.id + } + } + )) + await interaction.reply({ embeds: [ EmbedLibrary.base( diff --git a/source/Discord/Commands/Playdates.ts b/source/Discord/Commands/Playdates.ts index 58e2e8f..c4f2e67 100644 --- a/source/Discord/Commands/Playdates.ts +++ b/source/Discord/Commands/Playdates.ts @@ -34,7 +34,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter definition(): SlashCommandBuilder { // @ts-expect-error Command builder is improperly marked as incomplete. return new SlashCommandBuilder() - .setName("playdates") + .setName("playdate") .setDescription("Manage your playdates") .addSubcommand((subcommand) => subcommand .setName("create") @@ -153,7 +153,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter playdateRepo.create(playdate); - const embed = EmbedLibrary.playdate( + const embed = EmbedLibrary.withGroup( group, "Created a play-date.", ":white_check_mark: Your playdate has been created! You and your group get notified, when its time.", @@ -198,7 +198,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter private async list(interaction: ChatInputCommandInteraction, group: GroupModel) { const playdates = Container.get(PlaydateRepository.name).findFromGroup(group); - const embed = EmbedLibrary.playdate( + const embed = EmbedLibrary.withGroup( group, "Created a play-date.", null, @@ -242,7 +242,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter repo.delete(playdateId); - const embed = EmbedLibrary.playdate( + const embed = EmbedLibrary.withGroup( group, "Playdate deleted", `:x: Deleted ${time(selected.from_time, 'F')} - ${time(selected.to_time, 'F')}`, @@ -296,7 +296,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter }); } - const embed = EmbedLibrary.playdate( + const embed = EmbedLibrary.withGroup( group, "Imported play-dates", `:white_check_mark: Your ${playdates.length} playdates has been created! You and your group get notified, when its time.`, diff --git a/source/Discord/EmbedLibrary.ts b/source/Discord/EmbedLibrary.ts index d0aa825..c17f2da 100644 --- a/source/Discord/EmbedLibrary.ts +++ b/source/Discord/EmbedLibrary.ts @@ -33,7 +33,7 @@ export class EmbedLibrary { return embed } - public static playdate( + public static withGroup( group: GroupModel, title: string, description: string|null = null, diff --git a/source/Discord/UserError.ts b/source/Discord/UserError.ts index 64c3770..6918922 100644 --- a/source/Discord/UserError.ts +++ b/source/Discord/UserError.ts @@ -10,7 +10,6 @@ export class UserError extends Error { public readonly tryInstead: string | null = null ) { super(message); - } public getEmbed(e: UserError): EmbedBuilder { diff --git a/source/Events/DefaultEvents.ts b/source/Events/DefaultEvents.ts index 44e4ddb..c1ce4bd 100644 --- a/source/Events/DefaultEvents.ts +++ b/source/Events/DefaultEvents.ts @@ -7,6 +7,9 @@ import {PlaydateModel} from "../Database/Models/PlaydateModel"; import {TimedEvent} from "./EventHandler.types"; import {CleanupEvent} from "./Handlers/CleanupEvent"; import {Logger} from "log4js"; +import {ElementChangedEvent} from "./EventClasses/ElementChangedEvent"; +import {GroupModel} from "../Database/Models/GroupModel"; +import {sendLeaderChangeNotificationEventHandler} from "./Handlers/LeaderChanged"; export class DefaultEvents { public static setupTimed() { @@ -29,5 +32,9 @@ export class DefaultEvents { method: sendCreatedNotificationEventHandler, persistent: true }); + eventHandler.addHandler>(ElementChangedEvent.name, { + method: sendLeaderChangeNotificationEventHandler, + persistent: true + }) } } \ No newline at end of file diff --git a/source/Events/EventClasses/ElementChangedEvent.ts b/source/Events/EventClasses/ElementChangedEvent.ts new file mode 100644 index 0000000..7acb599 --- /dev/null +++ b/source/Events/EventClasses/ElementChangedEvent.ts @@ -0,0 +1,13 @@ +import {Model} from "../../Database/Models/Model"; +import {EventType, NormalEvent} from "../EventHandler.types"; +import {DeepPartial} from "../../types/Partial"; + +export class ElementChangedEvent implements NormalEvent { + constructor( + public readonly tableName: string, + public readonly changes: DeepPartial & Model, + ) { + } + + type: EventType.Normal = EventType.Normal; +} diff --git a/source/Events/Handlers/LeaderChanged.ts b/source/Events/Handlers/LeaderChanged.ts new file mode 100644 index 0000000..d1661bc --- /dev/null +++ b/source/Events/Handlers/LeaderChanged.ts @@ -0,0 +1,81 @@ +import {ElementChangedEvent} from "../EventClasses/ElementChangedEvent"; +import {GroupModel} from "../../Database/Models/GroupModel"; +import Groups from "../../Database/tables/Groups"; +import {Container} from "../../Container/Container"; +import {GroupRepository} from "../../Database/Repositories/GroupRepository"; +import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; +import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel"; +import { + GroupConfigurationProvider, + RuntimeGroupConfiguration +} from "../../Configuration/Groups/GroupConfigurationProvider"; +import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; +import {DiscordClient} from "../../Discord/DiscordClient"; +import {EmbedLibrary} from "../../Discord/EmbedLibrary"; +import {roleMention, userMention} from "discord.js"; +import * as util from "node:util"; +import {ArrayUtils} from "../../Utilities/ArrayUtils"; + +const CHANGED_LINES = [ + "Look who now manages your group, its %s. He will do a fantastic job!", + "Oh the 14th god changed... again, now its %s", + "This group was given to %s", + "This world was given over to %s, lets hope you survive the next adventure :smiling_imp:" +] + +export async function sendLeaderChangeNotificationEventHandler(event: ElementChangedEvent) { + if (event.tableName !== Groups.name) { + return; + } + + if (!event.changes.leader?.memberid) { + return; + } + + const group = Container.get(GroupRepository.name).getById(event.changes.id); + if (!group) { + return; + } + + const groupConfig = new ConfigurationHandler( + new GroupConfigurationProvider( + Container.get(GroupConfigurationRepository.name), + group + ) + ); + + const targetChannel = groupConfig.getConfigurationByPath('channels.notifications').value; + if (!targetChannel) { + return; + } + + + const channel = await Container.get(DiscordClient.name).Client.channels.fetch(targetChannel) + if (!channel) { + return; + } + + if (!channel.isTextBased()) { + return; + } + + if (!channel.isSendable()) { + return; + } + + const embed = EmbedLibrary.withGroup( + group, + "You have a new leader", + util.format(ArrayUtils.chooseRandom(CHANGED_LINES), userMention(group.leader.memberid)) + ) + + channel.send({ + content: roleMention(group.role.roleid), + embeds: [ + embed + ], + allowedMentions: { + roles: [group.role.roleid] + } + }) +} \ No newline at end of file diff --git a/source/Events/Handlers/SendCreatedNotification.ts b/source/Events/Handlers/SendCreatedNotification.ts index 9caa28e..26143ae 100644 --- a/source/Events/Handlers/SendCreatedNotification.ts +++ b/source/Events/Handlers/SendCreatedNotification.ts @@ -1,7 +1,7 @@ import {ElementCreatedEvent} from "../EventClasses/ElementCreatedEvent"; import {PlaydateModel} from "../../Database/Models/PlaydateModel"; import PlaydateTableConfiguration from "../../Database/tables/Playdate"; -import {EmbedBuilder, roleMention, time} from "discord.js"; +import {EmbedBuilder, roleMention, time, userMention} from "discord.js"; import {ArrayUtils} from "../../Utilities/ArrayUtils"; import {Container} from "../../Container/Container"; import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; @@ -12,6 +12,7 @@ import { GroupConfigurationProvider, RuntimeGroupConfiguration } from "../../Configuration/Groups/GroupConfigurationProvider"; +import {EmbedLibrary} from "../../Discord/EmbedLibrary"; const NEW_PLAYDATE_MESSAGES = [ 'A new playdate was added. Lets hope, your GM has not planned to kill you. >:]', @@ -37,7 +38,7 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE ) ); - const targetChannel = groupConfig.getConfigurationByPath('channels.newPlaydates').value; + const targetChannel = groupConfig.getConfigurationByPath('channels.notifications').value; if (!targetChannel) { return; } @@ -55,17 +56,14 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE return; } - const embed = new EmbedBuilder() - .setTitle("New Playdate added") - .setDescription( - ArrayUtils.chooseRandom(NEW_PLAYDATE_MESSAGES) - ) + const embed = EmbedLibrary.withGroup( + playdate.group, + "New Playdate added", + ArrayUtils.chooseRandom(NEW_PLAYDATE_MESSAGES) + ) .addFields({ name: "Playdate:", value: `${time(playdate.from_time, "F")} - ${time(playdate.to_time, 'F')}`, - }) - .setFooter({ - text: `Group: ${playdate.group.name}` }); channel.send({ diff --git a/source/Utilities/BuildContext.ts b/source/Utilities/BuildContext.ts new file mode 100644 index 0000000..3c82a0e --- /dev/null +++ b/source/Utilities/BuildContext.ts @@ -0,0 +1,39 @@ +import * as fs from "node:fs"; +import {Nullable} from "../types/Nullable"; +import {DataManager} from "discord.js"; +import * as util from "node:util"; + +export type BuildContext = { + target: string, + commitHash: string, + commitLink: string, + label: string, +} + +export class BuildContextGetter { + private static EXPECTED_PATHS = [ + "./deploy.json", + "./dist/deploy.json" + ]; + private static GIT_PATH = "https://git.iedsoftworks.com/neintonine/pnp-scheduler/commit/%s"; + + getContext(): Nullable { + const path = this.findValidPath(); + if (!path) { + return null; + } + + const jsonData = fs.readFileSync(path).toString(); + const data = JSON.parse(jsonData); + return { + ...data, + commitLink: util.format(BuildContextGetter.GIT_PATH, data.commitHash) + } + } + + private findValidPath(): Nullable { + return BuildContextGetter.EXPECTED_PATHS.find((path) => { + return fs.existsSync(path); + }); + } +} \ No newline at end of file diff --git a/source/types/Partial.ts b/source/types/Partial.ts new file mode 100644 index 0000000..af5a287 --- /dev/null +++ b/source/types/Partial.ts @@ -0,0 +1,3 @@ +export type DeepPartial = T extends object ? { + [P in keyof T]?: DeepPartial; +} : T; \ No newline at end of file