Adds some more polish

This commit is contained in:
Michel Fedde 2025-06-26 21:38:03 +02:00
parent b3d0b3a90c
commit 11bd836ec3
18 changed files with 272 additions and 29 deletions

View file

@ -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,
},
{

View file

@ -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";

View file

@ -65,12 +65,12 @@ export class Repository<ModelType extends Model, IntermediateModelType = unknown
return result.changes > 0;
}
public getById(id: number): Nullable<ModelType> {
public getById(id: number|bigint): Nullable<ModelType> {
const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`;
return this.convertToModelType(this.database.fetch<number, IntermediateModelType>(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);
}

View file

@ -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<CacheType>): Promise<void> {
switch (interaction.options.getSubcommand()) {
case "build":
this.displayBuildInfos(interaction);
break;
}
}
private displayBuildInfos(interaction: ChatInputCommandInteraction<CacheType>) {
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]
})
}
}

View file

@ -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<Command> = new Set<Command>([
new HelloWorldCommand(),
new GroupCommand(),
new PlaydatesCommand(),
new ServerCommand()
new ServerCommand(),
new BotCommand()
]);
export default class Commands {

View file

@ -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>(EventHandler.name)
.dispatch(new ElementChangedEvent<GroupModel>(
Groups.name,
{
id: group.id,
leader: {
memberid: newLeader.id
}
}
))
await interaction.reply({
embeds: [
EmbedLibrary.base(

View file

@ -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>(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.`,

View file

@ -33,7 +33,7 @@ export class EmbedLibrary {
return embed
}
public static playdate(
public static withGroup(
group: GroupModel,
title: string,
description: string|null = null,

View file

@ -10,7 +10,6 @@ export class UserError extends Error {
public readonly tryInstead: string | null = null
) {
super(message);
}
public getEmbed(e: UserError): EmbedBuilder {

View file

@ -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<GroupModel>>(ElementChangedEvent.name, {
method: sendLeaderChangeNotificationEventHandler,
persistent: true
})
}
}

View file

@ -0,0 +1,13 @@
import {Model} from "../../Database/Models/Model";
import {EventType, NormalEvent} from "../EventHandler.types";
import {DeepPartial} from "../../types/Partial";
export class ElementChangedEvent<T extends Model = Model> implements NormalEvent {
constructor(
public readonly tableName: string,
public readonly changes: DeepPartial<T> & Model,
) {
}
type: EventType.Normal = EventType.Normal;
}

View file

@ -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<GroupModel>) {
if (event.tableName !== Groups.name) {
return;
}
if (!event.changes.leader?.memberid) {
return;
}
const group = Container.get<GroupRepository>(GroupRepository.name).getById(event.changes.id);
if (!group) {
return;
}
const groupConfig = new ConfigurationHandler<GroupConfigurationModel, RuntimeGroupConfiguration>(
new GroupConfigurationProvider(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group
)
);
const targetChannel = groupConfig.getConfigurationByPath('channels.notifications').value;
if (!targetChannel) {
return;
}
const channel = await Container.get<DiscordClient>(DiscordClient.name).Client.channels.fetch(<string>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]
}
})
}

View file

@ -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({

View file

@ -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<BuildContext> {
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<string> {
return BuildContextGetter.EXPECTED_PATHS.find((path) => {
return fs.existsSync(path);
});
}
}

3
source/types/Partial.ts Normal file
View file

@ -0,0 +1,3 @@
export type DeepPartial<T> = T extends object ? {
[P in keyof T]?: DeepPartial<T[P]>;
} : T;