start making things

This commit is contained in:
Isaac 2022-08-05 22:21:55 +01:00
parent cdb8fa04c4
commit 052c159157
No known key found for this signature in database
GPG Key ID: F4EAABEB0FFCC06A
25 changed files with 669 additions and 46 deletions

View File

@ -12,3 +12,21 @@ SUPER=
https://www.prisma.io/docs/reference/database-reference/supported-databases https://www.prisma.io/docs/reference/database-reference/supported-databases
![](https://static.eartharoid.me/k/22/08/02185801.png) - for user/create, slash/force-close and slash/move ![](https://static.eartharoid.me/k/22/08/02185801.png) - for user/create, slash/force-close and slash/move
menu question max length cannot be higher than question options
- TODO: post stats
- TODO: settings bundle download
- TODO: update notifications
- TODO: check inline to-dos
creation requires an interaction:
- /new -> category? -> topic or questions -> create
- user:create(self) -> category? -> topic or questions -> create
- user:create(staff) -> category? -> DM (channel fallback) button -> topic or questions -> create
- message:create(self) -> category? -> topic or questions -> create
- message:create(staff) -> category? -> DM (channel fallback) button -> topic or questions -> create
- DM -> guild? -> category? -> topic or questions -> create
- panel(interaction) -> topic or questions -> create
- panel(message) -> DM (channel fallback) button -> topic or questions -> create

View File

@ -34,18 +34,18 @@
"node": ">=18.0" "node": ">=18.0"
}, },
"dependencies": { "dependencies": {
"@eartharoid/dbf": "^0.3.2", "@eartharoid/dbf": "^0.3.3",
"@eartharoid/dtf": "^2.0.1", "@eartharoid/dtf": "^2.0.1",
"@eartharoid/i18n": "^1.0.4", "@eartharoid/i18n": "^1.0.4",
"@fastify/cookie": "^6.0.0", "@fastify/cookie": "^6.0.0",
"@fastify/cors": "^8.0.0", "@fastify/cors": "^8.1.0",
"@fastify/jwt": "^5.0.1", "@fastify/jwt": "^5.0.1",
"@fastify/oauth2": "^5.0.0", "@fastify/oauth2": "^5.1.0",
"@prisma/client": "^4.1.0", "@prisma/client": "^4.1.1",
"cryptr": "^6.0.3", "cryptr": "^6.0.3",
"discord.js": "^14.0.2", "discord.js": "^14.1.2",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"fastify": "^4.2.1", "fastify": "^4.3.0",
"figlet": "^1.5.2", "figlet": "^1.5.2",
"fs-extra": "^10.1.0", "fs-extra": "^10.1.0",
"keyv": "^4.3.3", "keyv": "^4.3.3",
@ -55,14 +55,14 @@
"node-dir": "^0.1.17", "node-dir": "^0.1.17",
"node-emoji": "^1.11.0", "node-emoji": "^1.11.0",
"object-diffy": "^1.0.4", "object-diffy": "^1.0.4",
"prisma": "^4.1.0", "prisma": "^4.1.1",
"semver": "^7.3.7", "semver": "^7.3.7",
"terminal-link": "^2.1.1", "terminal-link": "^2.1.1",
"yaml": "^1.10.2" "yaml": "^1.10.2"
}, },
"devDependencies": { "devDependencies": {
"all-contributors-cli": "^6.20.0", "all-contributors-cli": "^6.20.0",
"eslint": "^8.20.0", "eslint": "^8.21.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"nodemon": "^2.0.19" "nodemon": "^2.0.19"
} }

View File

@ -2,7 +2,7 @@ const { randomBytes } = require('crypto');
const { short } = require('leeks.js'); const { short } = require('leeks.js');
console.log(short( console.log(short(
'Set the "ENCRYPTION_KEY" environment variable to: \n&1&!f' + 'Set the "ENCRYPTION_KEY" environment variable to: \n&!b' +
randomBytes(24).toString('hex') + randomBytes(24).toString('hex') +
'&r\n\n&0&!e WARNING &r &e&lIf you lose the encryption key, most of the data in the database will become unreadable, requiring a new key and a full reset.', '&r\n\n&0&!e WARNING &r &e&lIf you lose the encryption key, most of the data in the database will become unreadable, requiring a new key and a full reset.',
)); ));

View File

@ -8,5 +8,15 @@ module.exports = class CreateButton extends Button {
}); });
} }
async run(id, interaction) { } /**
* @param {*} id
* @param {import("discord.js").ButtonInteraction} interaction
*/
async run(id, interaction) {
await this.client.tickets.create({
categoryId: id.target,
interaction,
topic: id.topic,
});
}
}; };

View File

@ -1,11 +1,14 @@
const { FrameworkClient } = require('@eartharoid/dbf'); const { FrameworkClient } = require('@eartharoid/dbf');
const { GatewayIntentBits } = require('discord.js'); const {
GatewayIntentBits, Partials,
} = require('discord.js');
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const Keyv = require('keyv'); const Keyv = require('keyv');
const I18n = require('@eartharoid/i18n'); const I18n = require('@eartharoid/i18n');
const fs = require('fs'); const fs = require('fs');
const { join } = require('path'); const { join } = require('path');
const YAML = require('yaml'); const YAML = require('yaml');
const TicketManager = require('./lib/tickets/manager');
const encryptionMiddleware = require('./lib/middleware/prisma-encryption'); const encryptionMiddleware = require('./lib/middleware/prisma-encryption');
const sqliteMiddleware = require('./lib/middleware/prisma-sqlite'); const sqliteMiddleware = require('./lib/middleware/prisma-sqlite');
@ -13,10 +16,19 @@ module.exports = class Client extends FrameworkClient {
constructor(config, log) { constructor(config, log) {
super({ super({
intents: [ intents: [
GatewayIntentBits.DirectMessages,
GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.DirectMessageTyping,
GatewayIntentBits.MessageContent,
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
], ],
partials: [
Partials.Message,
Partials.Channel,
Partials.Reaction,
],
}); });
const locales = {}; const locales = {};
@ -30,6 +42,8 @@ module.exports = class Client extends FrameworkClient {
/** @type {I18n} */ /** @type {I18n} */
this.i18n = new I18n('en-GB', locales); this.i18n = new I18n('en-GB', locales);
/** @type {TicketManager} */
this.tickets = new TicketManager(this);
this.config = config; this.config = config;
this.log = log; this.log = log;
this.supers = (process.env.SUPER ?? '').split(','); this.supers = (process.env.SUPER ?? '').split(',');

View File

@ -2,6 +2,9 @@ test: |
line 1 line 1
line 2 line 2
buttons: buttons:
confirm_open:
emoji:
text: Create ticket
create: create:
emoji: 🎫 emoji: 🎫
text: Create a ticket text: Create a ticket
@ -120,6 +123,9 @@ commands:
user: user:
create: create:
name: Create a ticket for user name: Create a ticket for user
dm:
confirm_open:
title: 'Do you want to open a ticket with the following topic?'
log: log:
admin: admin:
changes: Changes changes: Changes
@ -145,5 +151,15 @@ log:
update: updated update: updated
tickets: tickets:
menus: menus:
create: category:
placeholder: Select a ticket category placeholder: Select a ticket category
guild:
placeholder: Select a server
modals:
feedback:
title: 'Feedback'
topic: 'Topic'
misc:
no_categories:
description: No ticket categories have been configured.
title: ❌ There are no ticket categories

View File

@ -1 +0,0 @@
module.exports.capitalise = string => string.charAt(0).toUpperCase() + string.slice(1);

146
src/lib/tickets/manager.js Normal file
View File

@ -0,0 +1,146 @@
const {
ActionRowBuilder,
ModalBuilder,
SelectMenuBuilder,
SelectMenuOptionBuilder,
TextInputBuilder,
TextInputStyle,
} = require('discord.js');
const emoji = require('node-emoji');
module.exports = class TicketManager {
constructor(client) {
/** @type {import("client")} */
this.client = client;
}
/**
* @param {object} data
* @param {string} data.category
* @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction} data.interaction
* @param {string?} [data.topic]
*/
async create({
categoryId, interaction, topic, reference,
}) {
const category = await this.client.prisma.category.findUnique({
include: {
guild: true,
questions: true,
},
where: { id: Number(categoryId) },
});
// TODO: if member !required roles -> stop
// TODO: if discordCategory has 50 channels -> stop
// TODO: if category has max channels -> stop
// TODO: if member has max -> stop
// TODO: if cooldown -> stop
const getMessage = this.client.i18n.getLocale(category.guild.locale);
if (category.questions.length >= 1) {
await interaction.showModal(
new ModalBuilder()
.setCustomId(JSON.stringify({
action: 'questions',
categoryId,
reference,
}))
.setTitle(category.name)
.setComponents(
category.questions
.sort((a, b) => a.order - b.order)
.map(q => {
if (q.type === 'TEXT') {
return new ActionRowBuilder()
.setComponents(
new TextInputBuilder()
.setCustomId(q.id)
.setLabel(q.label)
.setStyle(q.style)
.setMaxLength(q.maxLength)
.setMinLength(q.minLength)
.setPlaceholder(q.placeholder)
.setRequired(q.required)
.setValue(q.value),
);
} else if (q.type === 'MENU') {
return new ActionRowBuilder()
.setComponents(
new SelectMenuBuilder()
.setCustomId(q.id)
.setPlaceholder(q.placeholder || q.label)
.setMaxValues(q.maxLength)
.setMinValues(q.minLength)
.setOptions(
q.options.map((o, i) => {
const builder = new SelectMenuOptionBuilder()
.setValue(String(i))
.setLabel(o.label);
if (o.description) builder.setDescription(o.description);
if (o.emoji) builder.setEmoji(emoji.hasEmoji(o.emoji) ? emoji.get(o.emoji) : { id: o.emoji });
return builder;
}),
),
);
}
}),
),
);
} else if (category.requireTopic && !topic) {
await interaction.showModal(
new ModalBuilder()
.setCustomId(JSON.stringify({
action: 'topic',
categoryId,
reference,
}))
.setTitle(category.name)
.setComponents(
new ActionRowBuilder()
.setComponents(
new TextInputBuilder()
.setCustomId('topic')
.setLabel(getMessage('modals.topic'))
.setStyle(TextInputStyle.Long),
),
),
);
} else {
await this.postQuestions({
categoryId,
interaction,
topic,
});
}
}
/**
* @param {object} data
* @param {string} data.category
* @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction|import("discord.js").ModalSubmitInteraction} data.interaction
* @param {string?} [data.topic]
*/
async postQuestions({
categoryId, interaction, topic, reference,
}) {
await interaction.deferReply({ ephemeral: true });
console.log(require('util').inspect(interaction, {
colors: true,
depth: 10,
}));
if (interaction.isModalSubmit()) {
}
interaction.editReply({
components: [],
embeds: [],
});
}
};

29
src/lib/users.js Normal file
View File

@ -0,0 +1,29 @@
/**
*
* @param {import("client")} client
* @param {string} userId
* @returns {Promise<Collection<import("discord.js").Guild>}
*/
module.exports.getCommonGuilds = async (client, userId) => await client.guilds.cache.filter(async guild => {
const member = await guild.members.fetch(userId);
return !!member;
});
/**
*
* @param {import("discord.js").Guild} guild
* @param {string} userId
* @returns {Promise<boolean>}
*/
module.exports.isStaff = async (guild, userId) => {
/** @type {import("client")} */
const client = guild.client;
if (guild.client.supers.includes(userId)) return true;
const guildMember = await guild.members.fetch(userId);
if (guildMember?.permissions.has('MANAGE_GUILD')) return true;
const { categories } = await client.prisma.guild.findUnique({
select: { categories: true },
where: { id: guild.id },
});
return categories.some(cat => cat.roles.some(r => guildMember.roles.cache.has(r)));
};

View File

@ -0,0 +1,15 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'error',
});
}
run(error) {
this.client.log.error(error);
}
};

View File

@ -0,0 +1,15 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'guildCreate',
});
}
run(guild) {
this.client.log.success(`Added to guild "${guild.name}"`);
}
};

View File

@ -0,0 +1,15 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'guildDelete',
});
}
run(guild) {
this.client.log.info(`Removed from guild "${guild.name}"`);
}
};

View File

@ -0,0 +1,15 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'guildMemberRemove',
});
}
run(member) {
// TODO: close tickets
}
};

View File

@ -0,0 +1,184 @@
const { Listener } = require('@eartharoid/dbf');
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle: { Success },
ChannelType,
ComponentType,
EmbedBuilder,
SelectMenuBuilder,
SelectMenuOptionBuilder,
} = require('discord.js');
const { getCommonGuilds } = require('../../lib/users');
const ms = require('ms');
const emoji = require('node-emoji');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'messageCreate',
});
}
/**
* @param {string} guildId
* @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction} interaction
*/
async useGuild(settings, interaction, topic) {
const getMessage = this.client.i18n.getLocale(settings.locale);
if (settings.categories.length === 0) {
interaction.editReply({
components: [],
embeds: [
new EmbedBuilder()
.setColor(settings.errorColour)
.setTitle(getMessage('misc.no_categories.title'))
.setDescription(getMessage('misc.no_categories.description')),
],
});
} else if (settings.categories.length === 1) {
await this.client.tickets.create({
categoryId: settings.categories[0].id,
interaction,
topic,
});
} else {
const sent = await interaction.editReply({
components: [
new ActionRowBuilder()
.setComponents(
new SelectMenuBuilder()
.setCustomId(JSON.stringify({
action: 'create',
topic,
}))
.setPlaceholder(getMessage('menus.category.placeholder'))
.setOptions(
settings.categories.map(category =>
new SelectMenuOptionBuilder()
.setValue(String(category.id))
.setLabel(category.name)
.setDescription(category.description)
.setEmoji(emoji.hasEmoji(category.emoji) ? emoji.get(category.emoji) : { id: category.emoji }),
),
),
),
],
});
sent.awaitMessageComponent({
componentType: ComponentType.SelectMenu,
filter: () => true,
time: ms('30s'),
})
.then(async () => {
await sent.delete();
})
.catch(error => {
if (error) this.client.log.error(error);
sent.delete();
});
}
}
/**
* @param {import("discord.js").Message} message
*/
async run(message) {
/** @type {import("client")} */
const client = this.client;
if (message.channel.type === ChannelType.DM) {
if (message.author.bot) return false;
const commonGuilds = await getCommonGuilds(this.client, message.author.id);
if (commonGuilds.size === 0) {
return false;
} else if (commonGuilds.size === 1) {
const settings = await client.prisma.guild.findUnique({
select: {
categories: true,
errorColour: true,
locale: true,
primaryColour: true,
},
where: { id: commonGuilds.at(0).id },
});
const getMessage = this.client.i18n.getLocale(settings.locale);
const sent = await message.reply({
components: [
new ActionRowBuilder()
.setComponents(
new ButtonBuilder()
.setCustomId(message.id)
.setStyle(Success)
.setLabel(getMessage('buttons.confirm_open.text'))
.setEmoji(getMessage('buttons.confirm_open.emoji')),
),
],
embeds: [
new EmbedBuilder()
.setColor(settings.primaryColour)
.setTitle(getMessage('dm.confirm_open.title'))
.setDescription(message.content),
],
});
sent.awaitMessageComponent({
componentType: ComponentType.Button,
filter: interaction => interaction.deferUpdate(),
time: ms('30s'),
})
.then(async interaction => await this.useGuild(settings, interaction, message.content))
.catch(error => {
if (error) this.client.log.error(error);
sent.delete();
});
} else {
const getMessage = this.client.i18n.getLocale();
const sent = await message.reply({
components: [
new ActionRowBuilder()
.setComponents(
new SelectMenuBuilder()
.setCustomId(message.id)
.setPlaceholder(getMessage('menus.guild.placeholder'))
.setOptions(
commonGuilds.map(g =>
new SelectMenuOptionBuilder()
.setValue(String(g.id))
.setLabel(g.name),
),
),
),
],
});
sent.awaitMessageComponent({
componentType: ComponentType.SelectMenu,
filter: interaction => interaction.deferUpdate(),
time: ms('30s'),
})
.then(async interaction => {
const settings = await client.prisma.guild.findUnique({
select: {
categories: true,
errorColour: true,
locale: true,
primaryColour: true,
},
where: { id: interaction.values[0] },
});
await this.useGuild(settings, interaction, message.content);
})
.catch(error => {
if (error) this.client.log.error(error);
sent.delete();
});
}
} else {
// TODO: archive messages in tickets
// TODO: auto tag
}
}
};

View File

@ -0,0 +1,15 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'messageDelete',
});
}
run(message) {
// TODO: archive messages in tickets
}
};

View File

@ -0,0 +1,15 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'messageUpdate',
});
}
run(oldMessage, newMessage) {
// TODO: archive messages in tickets
}
};

View File

@ -0,0 +1,15 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client,
event: 'warn',
});
}
run(warn) {
this.client.log.warn(warn);
}
};

View File

@ -0,0 +1,19 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client.commands,
event: 'error',
});
}
run({
command,
error,
}) {
this.client.log.error.commands(`"${command.name}" command execution error:`, error);
return true;
}
};

View File

@ -0,0 +1,24 @@
const { Listener } = require('@eartharoid/dbf');
module.exports = class extends Listener {
constructor(client, options) {
super(client, {
...options,
emitter: client.commands,
event: 'run',
});
}
run({
command,
interaction,
}) {
const types = {
1: 'slash',
2: 'user',
3: 'message',
};
this.client.log.info.commands(`${interaction.user.tag} used the "${command.name}" ${types[command.type]} command`);
return true;
}
};

View File

@ -8,5 +8,15 @@ module.exports = class CreateMenu extends Menu {
}); });
} }
async run(id, interaction) { } /**
* @param {*} id
* @param {import("discord.js").SelectMenuInteraction} interaction
*/
async run(id, interaction) {
await this.client.tickets.create({
categoryId: interaction.values[0],
interaction,
topic: id.topic,
});
}
}; };

View File

@ -8,5 +8,13 @@ module.exports = class QuestionsModal extends Modal {
}); });
} }
async run(id, interaction) { } async run(id, interaction) {
console.log(id);
console.log(require('util').inspect(interaction, {
colors: true,
depth: 10,
}));
// TODO: custom topic
}
}; };

18
src/modals/topic.js Normal file
View File

@ -0,0 +1,18 @@
const { Modal } = require('@eartharoid/dbf');
module.exports = class TopicModal extends Modal {
constructor(client, options) {
super(client, {
...options,
id: 'topic',
});
}
async run(id, interaction) {
console.log(id);
console.log(require('util').inspect(interaction, {
colors: true,
depth: 10,
}));
}
};

View File

@ -84,8 +84,7 @@ module.exports.post = fastify => ({
.setLabel(getMessage('buttons.create.text')) .setLabel(getMessage('buttons.create.text'))
.setEmoji(getMessage('buttons.create.emoji')), .setEmoji(getMessage('buttons.create.emoji')),
); );
} else { } else if (data.type === 'BUTTON') {
if (data.type === 'BUTTON') {
components.push( components.push(
...categories.map(category => ...categories.map(category =>
new ButtonBuilder() new ButtonBuilder()
@ -101,8 +100,8 @@ module.exports.post = fastify => ({
} else { } else {
components.push( components.push(
new SelectMenuBuilder() new SelectMenuBuilder()
.setCustomId('create') .setCustomId(JSON.stringify({ action: 'create' }))
.setPlaceholder(getMessage('menus.create.placeholder')) .setPlaceholder(getMessage('menus.category.placeholder'))
.setOptions( .setOptions(
categories.map(category => categories.map(category =>
new SelectMenuOptionBuilder() new SelectMenuOptionBuilder()
@ -113,7 +112,7 @@ module.exports.post = fastify => ({
), ),
), ),
); );
}
} }
await channel.send({ await channel.send({

24
src/schemas/settings.js Normal file
View File

@ -0,0 +1,24 @@
module.exports = joi.object({
archive: joi.boolean().optional(),
autoClose: joi.number().min(3600000).optional(),
autoTag: [joi.array(), joi.string().valid('ticket', '!ticket', 'all')].optional(),
blocklist: joi.array().optional(),
createdAt: joi.string().optional(),
errorColour: joi.string().optional(),
footer: joi.string().optional(),
id: joi.string().optional(),
logChannel: joi.string().optional(),
primaryColour: joi.string().optional(),
staleAfter: joi.number().min(60000).optional(),
successColour: joi.string().optional(),
workingHours: joi.array().length(8).items(
joi.string(),
joi.array().items(joi.string().required(), joi.string().required()),
joi.array().items(joi.string().required(), joi.string().required()),
joi.array().items(joi.string().required(), joi.string().required()),
joi.array().items(joi.string().required(), joi.string().required()),
joi.array().items(joi.string().required(), joi.string().required()),
joi.array().items(joi.string().required(), joi.string().required()),
joi.array().items(joi.string().required(), joi.string().required()),
).optional(),
});

View File

@ -11,7 +11,7 @@
## |_| |_| \___| |_|\_\ \___| \__| |___/ ## ## |_| |_| \___| |_|\_\ \___| \__| |___/ ##
## ## ## ##
## Documentation: https://discordtickets.app ## ## Documentation: https://discordtickets.app ##
## Support: https://go.eartharoid.me/discord ## ## Support: https://lnk.earth/discord ##
##################################################### #####################################################
logs: logs: