Création d'un Système de Devise
Une fonctionnalité courante des bots Discord est un système de devise. Il est possible de faire tout dans un objet, mais nous pouvons aussi l'abstraire en termes de relations entre les objets. C'est là que la puissance d'un SGBDR (Système de Gestion de Base de Données Relationnelle) brille véritablement. Sequelize appelle cela des associations, nous utiliserons donc ce terme à partir de maintenant.
Aperçu des fichiers
Il y aura plusieurs fichiers : un script d'initialisation DB, vos modèles et votre script bot. Dans le guide sequelize, nous avons placé tout cela dans le même fichier. Avoir tout dans un seul fichier n'est pas une pratique idéale, nous allons donc corriger cela.
Cette fois, nous aurons six fichiers.
app.jsest l'endroit où nous garderons le code principal du bot.dbInit.jsest le fichier d'initialisation de la base de données. Nous l'exécutons une fois et l'oublions.dbObjects.jsest l'endroit où nous importerons les modèles et créerons les associations ici.models/Users.jsest le modèle des utilisateurs. Les utilisateurs auront un attribut de devise ici.models/CurrencyShop.jsest le modèle du magasin. Le magasin aura un nom et un prix pour chaque article.models/UserItems.jsest la table de jonction entre les utilisateurs et le magasin. Une table de jonction connecte deux tables. Notre table de jonction aura un champ supplémentaire pour la quantité de cet article que l'utilisateur possède.
Création de modèles
Voici un diagramme de relation d'entité des modèles que nous allons créer :
Les utilisateurs ont un user_id et un balance. Chaque user_id peut avoir plusieurs liens avec la table UserItems, et chaque entrée du tableau se connecte à l'un des articles du CurrencyShop, qui aura un name et un cost associés.
Pour implémenter cela, commencez par créer un dossier models et créez un fichier Users.js à l'intérieur contenant les éléments suivants :
module.exports = (sequelize, DataTypes) => {
return sequelize.define(
'users',
{
user_id: {
type: DataTypes.STRING,
primaryKey: true,
},
balance: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
},
},
{
timestamps: false,
},
);
};Comme vous le voyez dans le diagramme ci-dessus, le modèle Users n'aura que deux attributs : une clé primaire user_id et un balance. Une clé primaire est un attribut particulier qui devient la colonne par défaut utilisée lors de la jonction de tables ensemble, et elle est automatiquement unique et pas null.
Balance définit également allowNull sur false, ce qui signifie que les deux valeurs doivent être définies conjointement avec la création d'une clé primaire ; sinon, la base de données lèverait une erreur. Cette contrainte garantit l'exactitude de votre stockage de données. Vous n'aurez jamais de valeurs null ou vides, garantissant que si vous oubliez de valider dans l'application que les deux valeurs ne sont pas null, la base de données ferait une validation finale.
Notez que l'objet options définit timestamps sur false. Cette option désactive les colonnes createdAt et updatedAt que sequelize crée habituellement pour vous. Définir user_id sur primary élimine également la clé primaire id que Sequelize génère habituellement pour vous car il ne peut y avoir qu'une seule clé primaire par tableau.
Ensuite, toujours dans le même dossier models, créez un fichier CurrencyShop.js contenant ce qui suit :
module.exports = (sequelize, DataTypes) => {
return sequelize.define(
'currency_shop',
{
name: {
type: DataTypes.STRING,
unique: true,
},
cost: {
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
timestamps: false,
},
);
};Comme le modèle Users, les timestamps ne sont pas nécessaires ici, vous pouvez donc les désactiver. Contrairement au modèle Users, cependant, le champ unique est défini sur true ici, ce qui vous permet de modifier le nom sans affecter la clé primaire qui relie ceci à l'objet suivant. Ceci est généré automatiquement par sequelize car aucune clé primaire n'est définie.
Le fichier suivant sera UserItems.js, la table de jonction.
module.exports = (sequelize, DataTypes) => {
return sequelize.define(
'user_item',
{
user_id: DataTypes.STRING,
item_id: DataTypes.INTEGER,
amount: {
type: DataTypes.INTEGER,
allowNull: false,
default: 0,
},
},
{
timestamps: false,
},
);
};La table de jonction liera user_id et l'id du magasin de devises ensemble. Il contient également un nombre amount, qui indique combien de cet article un utilisateur possède.
Initialisation de la base de données
Maintenant que les modèles sont définis, vous devriez les créer dans votre base de données pour y accéder dans le fichier bot. Nous avons exécuté la synchronisation à l'intérieur de l'événement ready du tutoriel précédent, ce qui est entièrement inutile car il n'a besoin de s'exécuter qu'une fois. Vous pouvez créer un fichier pour initialiser la base de données et ne jamais le retoucher à moins que vous ne vouliez refaire la base de données entière.
Créez un fichier appelé dbInit.js dans le répertoire de base (pas dans le dossier models).
Attention! Security Risk!
Make sure you use version 5 or later of Sequelize! As used in this guide, version 4 and earlier will pose a security threat. You can read more about this issue on the Sequelize issue tracker.
const Sequelize = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'sqlite',
logging: false,
storage: 'database.sqlite',
});
const CurrencyShop = require('./models/CurrencyShop.js')(sequelize, Sequelize.DataTypes);
require('./models/Users.js')(sequelize, Sequelize.DataTypes);
require('./models/UserItems.js')(sequelize, Sequelize.DataTypes);
const force = process.argv.includes('--force') || process.argv.includes('-f');
sequelize
.sync({ force })
.then(async () => {
const shop = [
CurrencyShop.upsert({ name: 'Tea', cost: 1 }),
CurrencyShop.upsert({ name: 'Coffee', cost: 2 }),
CurrencyShop.upsert({ name: 'Cake', cost: 5 }),
];
await Promise.all(shop);
console.log('Database synced');
sequelize.close();
})
.catch(console.error);Ici, vous extrayez les deux modèles et la table de jonction des déclarations de modèles respectives, les synchronisez et ajoutez des articles au magasin.
Une nouvelle fonction ici est la fonction .upsert(). C'est un mélange de update ou insert. upsert est utilisé ici pour éviter de créer des doublons si vous exécutez ce fichier plusieurs fois. Cela ne devrait pas se produire car name est défini comme unique, mais il n'y a pas de mal à être prudent. Upsert a aussi un bel avantage supplémentaire : si vous ajustez le coût, l'article respectif devrait également avoir son coüt mis à jour.
Exécutez node dbInit.js pour créer les tables de la base de données. Sauf si vous apportez une modification aux modèles, vous n'aurez jamais besoin de
retoucher le fichier. Si vous modifiez un modèle, vous pouvez exécuter node dbInit.js --force ou node dbInit.js -f pour forcer
la synchronisation de vos tables. Il est important de noter que cela va vider et refaire vos tables de modèles.
Création d'associations
Ensuite, ajoutez les associations aux modèles. Créez un fichier nommé dbObjects.js dans le répertoire de base, à côté de dbInit.js.
const Sequelize = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'sqlite',
logging: false,
storage: 'database.sqlite',
});
const Users = require('./models/Users.js')(sequelize, Sequelize.DataTypes);
const CurrencyShop = require('./models/CurrencyShop.js')(sequelize, Sequelize.DataTypes);
const UserItems = require('./models/UserItems.js')(sequelize, Sequelize.DataTypes);
UserItems.belongsTo(CurrencyShop, { foreignKey: 'item_id', as: 'item' });
Reflect.defineProperty(Users.prototype, 'addItem', {
value: async (item) => {
const userItem = await UserItems.findOne({
where: { user_id: this.user_id, item_id: item.id },
});
if (userItem) {
userItem.amount += 1;
return userItem.save();
}
return UserItems.create({ user_id: this.user_id, item_id: item.id, amount: 1 });
},
});
Reflect.defineProperty(Users.prototype, 'getItems', {
value: () => {
return UserItems.findAll({
where: { user_id: this.user_id },
include: ['item'],
});
},
});
module.exports = { Users, CurrencyShop, UserItems };Notez que l'objet de connexion pourrait être abstrait dans un autre fichier et étant donné que dbInit.js et dbObjects.js utilisent ce fichier de connexion, mais il n'est pas nécessaire d'abstraire excessivement les choses.
Une autre nouvelle méthode ici est la méthode .belongsTo(). En utilisant cette méthode, vous ajoutez CurrencyShop comme propriété de UserItem de sorte que lorsque vous faites userItem.item, vous obtenez l'article respectivement attaché. Vous utilisez item_id comme clé étrangère pour qu'il sache quel article référencer.
Vous ajoutez ensuite quelques méthodes à l'objet Users pour terminer la jonction : ajouter des articles aux utilisateurs et obtenir leur inventaire actuel. Le code à l'intérieur devrait être un peu familier du dernier tutoriel. .findOne() est utilisé pour obtenir l'article s'il existe dans l'inventaire de l'utilisateur. Si c'est le cas, incrémentez-le ; sinon, créez-le.
Obtenir les articles est similaire ; utilisez .findAll() avec l'id de l'utilisateur comme clé. La clé include est pour associer le CurrencyShop à l'article. Vous devez explicitement dire à Sequelize d'honorer l'association .belongsTo() ; sinon, il empruntera le chemin du moindre effort.
Code d'application
Créez un fichier app.js dans le répertoire de base avec le code squelette suivant pour mettre tout cela ensemble.
const { Op } = require('sequelize');
const { Client, codeBlock, Collection, Events, GatewayIntentBits } = require('discord.js');
const { Users, CurrencyShop } = require('./dbObjects.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] });
const currency = new Collection();
client.once(Events.ClientReady, (readyClient) => {
console.log(`Ready! Logged in as ${readyClient.user.tag}`);
});
client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
addBalance(message.author.id, 1);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
// ...
});
client.login('your-token-goes-here');Nothing special about this skeleton. You import the Users and CurrencyShop models from our dbObjects.js file and add a currency Collection. Every time someone talks, add 1 to their currency count. The rest is just standard discord.js code and a simple if/else command handler. A Collection is used for the currency variable to cache individual users' currency, so you don't have to hit the database for every lookup. An if/else handler is used here, but you can put it in a framework or command handler as long as you maintain a reference to the models and the currency collection.
Helper methods
// ...
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] });
const currency = new Collection();
async function addBalance(id, amount) {
const user = currency.get(id);
if (user) {
user.balance += Number(amount);
return user.save();
}
const newUser = await Users.create({ user_id: id, balance: amount });
currency.set(id, newUser);
return newUser;
}
function getBalance(id) {
const user = currency.get(id);
return user ? user.balance : 0;
}This defines the addBalance() helper function, since it'll be used quite frequently. A getBalance() function is also defined, to ensure that a number is always returned.
Ready event data sync
client.once(Events.ClientReady, async (readyClient) => {
const storedBalances = await Users.findAll();
storedBalances.forEach((b) => currency.set(b.user_id, b));
console.log(`Ready! Logged in as ${readyClient.user.tag}!`);
});In the ready event, sync the currency collection with the database for easy access later.
Show user balance
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
if (commandName === 'balance') {
const target = interaction.options.getUser('user') ?? interaction.user;
return interaction.reply(`${target.tag} has ${getBalance(target.id)}💰`);
}
});Nothing tricky here. The getBalance() function is used to show either the author's or the mentioned user's balance.
Show user inventory
if (commandName === 'balance') {
// ...
}
} else if (commandName === 'inventory') {
const target = interaction.options.getUser('user') ?? interaction.user;
const user = await Users.findOne({ where: { user_id: target.id } });
const items = await user.getItems();
if (!items.length) return interaction.reply(`${target.tag} has nothing!`);
return interaction.reply(`${target.tag} currently has ${items.map((i) => `${i.amount} ${i.item.name}`).join(', ')}`);
}This is where you begin to see the power of associations. Even though users and the shop are different tables, and the data is stored separately, you can get a user's inventory by looking at the junction table and join it with the shop; no duplicated item names that waste space!
Transfer currency to another user
if (commandName === 'balance') {
// ...
} else if (commandName === 'inventory')
// ... /
}
} else if (commandName === 'transfer') {
const currentAmount = getBalance(interaction.user.id);
const transferAmount = interaction.options.getInteger('amount');
const transferTarget = interaction.options.getUser('user');
if (transferAmount > currentAmount) return interaction.reply(`Sorry ${interaction.user}, you only have ${currentAmount}.`);
if (transferAmount <= 0) return interaction.reply(`Please enter an amount greater than zero, ${interaction.user}.`);
addBalance(interaction.user.id, - transferAmount);
addBalance(transferTarget.id, transferAmount);
return interaction.reply(`Successfully transferred ${transferAmount}💰 to ${transferTarget.tag}. Your current balance is ${getBalance(interaction.user.id)}💰`);
}As a bot creator, you should always be thinking about how to make the user experience better. Good UX makes users less frustrated with your commands. If your inputs are different types, don't make them memorize which parameters come before the other.
addBalance() is used for both removing and adding currency. Since transfer amounts below zero are disallowed, it's safe to apply the transfer amount's additive inverse to their balance.
Buying an item
if (commandName === 'balance') {
// ...
} else if (commandName === 'inventory')
// ... /
} else if (commandName === 'transfer') {
// ...
}
} else if (commandName === 'buy') {
const itemName = interaction.options.getString('item');
const item = await CurrencyShop.findOne({ where: { name: { [Op.like]: itemName } } });
if (!item) return interaction.reply(`That item doesn't exist.`);
if (item.cost > getBalance(interaction.user.id)) {
return interaction.reply(`You currently have ${getBalance(interaction.user.id)}, but the ${item.name} costs ${item.cost}!`);
}
const user = await Users.findOne({ where: { user_id: interaction.user.id } });
addBalance(interaction.user.id, -item.cost);
await user.addItem(item);
return interaction.reply(`You've bought: ${item.name}.`);
}For users to search for an item without caring about the letter casing, you can use the $iLike modifier when looking for the name. Keep in mind that this may be slow if you have millions of items, so please don't put a million items in your shop.
Display the shop
if (commandName === 'balance') {
// ...
} else if (commandName === 'inventory')
// ... /
} else if (commandName === 'transfer') {
// ...
} else if (commandName === 'buy') {
// ...
}
} else if (commandName === 'shop') {
const items = await CurrencyShop.findAll();
return interaction.reply(codeBlock(items.map(i => `${i.name}: ${i.cost}💰`).join('\n')));
}There's nothing special here; just a regular .findAll() to get all the items in the shop and .map() to transform that data into something nice looking.
Display the leaderboard
if (commandName === 'balance') {
// ...
} else if (commandName === 'inventory')
// ... /
} else if (commandName === 'transfer') {
// ...
} else if (commandName === 'buy') {
// ...
} else if (commandName === 'shop') {
// ...
}
} else if (commandName === 'leaderboard') {
return interaction.reply(
codeBlock(
currency.sort((a, b) => b.balance - a.balance)
.filter(user => client.users.cache.has(user.user_id))
.first(10)
.map((user, position) => `(${position + 1}) ${(client.users.cache.get(user.user_id).tag)}: ${user.balance}💰`)
.join('\n'),
),
);
}Nothing extraordinary here either. You could query the database for the top ten currency holders, but since you already have access to them locally inside the currency variable, you can sort the Collection and use .map() to display it in a friendly format. The filter is in case the users no longer exist in the bot's cache.