Skip to content

Commit

Permalink
Release 2.4.0
Browse files Browse the repository at this point in the history
  • Loading branch information
elijaholmos authored Oct 13, 2021
2 parents 8ed4494 + a343393 commit 157f925
Show file tree
Hide file tree
Showing 19 changed files with 1,466 additions and 341 deletions.
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
# [2.4.0](https://github.com/Leyline-gg/leyline-discord/releases/tag/v2.4.0) (2021-10-12)

## Dev Notes
This update adds a couple new features, most notable being a custom punishment command system.

A major pro of a custom punishment system is the ability to retain complete control & customization over the functionality. Additionally, all actions are synchronized with our private database, which allows for several data analysis opportunities that would be otherwise unavailable with an external service.

## New Features
- Punishment command system
- This is releasing now in anticipation of the rules restructure
- 5 `punish` commands are added for staff members to use:
- `warn` - issues a written warning
- `mute` - temporarily prevents a user from sending messages in channels/threads, speaking in voice channels, or creating threads
- `kick` - removes a user from the server without revoking their ability to rejoin
- `ban` - removes a user from the server and revokes their ability to rejoin
- `history` - displays the 25 most recent punishments issued to a user
- All punishments issued will be logged in a private, staff-only channel
- All bans will be logged in a public channel, #mod-log
- Any punishments issued will require staff to confirm the target user & reason for punishment, to minimize mistakes
- Cloud configuration synchronization system
- Several bot options can now be configured in real-time, preventing the need to draft a new release for minor changes
- For example, the LLP values for good-acts approvals can now be changed dynamically

## Existing Feature Changes
- Internal code cleanups

# [2.3.2](https://github.com/Leyline-gg/leyline-discord/releases/tag/v2.3.2) (2021-10-08)

## Dev Notes
Expand Down
59 changes: 59 additions & 0 deletions classes/CloudConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//A local cache that stays in sync with Firestore configuration but can be queried synchronously
import admin from 'firebase-admin';
import fs from 'fs/promises';

export class CloudConfig {
static PATH = 'discord/bot';
static _cache = new Map(); //immutablity implies that local changes do not sync w database
static ready = false;

static init() {
const doc_ref = admin.firestore().doc(this.PATH);

// Synchronously load data from Firestore and
// Set up watcher to keep local cache up to date
doc_ref.onSnapshot((doc) => {
if(!doc.exists) return; //document was removed, keep locally cached data the same
const data = doc.get('config');
//fs.writeFile('cache/test.json', data);

//create temporary new cache to copy most recent doc data into
//all deleted doc fields will be removed from the current cache
const tmp = new Map();
for(const [key, val] of Object.entries(data))
tmp.set(key, val);
this._cache = tmp;
this.ready ||= true;
}, console.error);

return this.awaitReady();
}

static get(id) {
//if(this.cache.has(id)) throw new Error(`Could not find ${id} in ${this.constructor.name}`);
return this._cache.get(id);
}

static keys() {
return [...this._cache.keys()];
}

static values() {
return [...this._cache.values()];
}

// Implement setter that updates local & cloud?

/**
*
* @returns {Promise<boolean>} Promise that resolves to true when class has finished initialization
*/
static awaitReady() {
const self = this;
return new Promise((resolve, reject) => {
(function resolveReady() {
self.ready ? resolve(true) : setTimeout(resolveReady, 500);
})();
});
}
}
55 changes: 36 additions & 19 deletions classes/FirebaseCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,44 @@ export class FirebaseCache {
path=null, //firebase path to watch
collection=true, //whether we're watching a collection or a document
} = {}) {
const col_ref = admin.firestore().collection(path);
if(collection) {
const col_ref = admin.firestore().collection(path);

// Synchronously load data from Firestore and
// Set up watcher to keep local cache up to date
col_ref.onSnapshot((snapshot) => {
if(snapshot.empty) return;
for(const docChange of snapshot.docChanges()) {
//if doc was created before the bot came online, ignore it
//if(docChange.doc.createTime.toMillis() < Date.now()) continue;
switch(docChange.type) {
case 'added':
case 'modified':
this._cache.set(docChange.doc.id, docChange.doc.data());
break;
case 'removed':
this._cache.delete(docChange.doc.id);
break;
// Synchronously load data from Firestore and
// Set up watcher to keep local cache up to date
col_ref.onSnapshot((snapshot) => {
if(snapshot.empty) return;
for(const docChange of snapshot.docChanges()) {
//if doc was created before the bot came online, ignore it
//if(docChange.doc.createTime.toMillis() < Date.now()) continue;
switch(docChange.type) {
case 'added':
case 'modified':
this._cache.set(docChange.doc.id, docChange.doc.data());
break;
case 'removed':
this._cache.delete(docChange.doc.id);
break;
}
}
}
this.ready ||= true;
}, console.error);
this.ready ||= true;
}, console.error);
} else {
const doc_ref = admin.firestore().doc(path);

// Synchronously load data from Firestore and
// Set up watcher to keep local cache up to date
doc_ref.onSnapshot((doc) => {
if(!doc.exists) return; //document was removed, keep locally cached data the same
//create temporary new cache to copy most recent doc data into
//all deleted doc fields will be removed from the current cache
const tmp = new Map();
for(const [key, val] of Object.entries(doc.data()))
tmp.set(key, val);
this._cache = tmp;
this.ready ||= true;
}, console.error);
}
}

get(id) {
Expand Down
235 changes: 235 additions & 0 deletions classes/LeylineBot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { Client, Collection } from "discord.js";
import config from '../config.js';
import { ConfirmInteraction, EmbedBase, Logger, CloudConfig } from ".";

// Custom bot class, based off the discord.js Client (bot)
export class LeylineBot extends Client {
//getter for all Config methods that call Config.get()
get connection_tutorial() { return CloudConfig.get('connection_tutorial'); }
get xp_doc() { return CloudConfig.get('xp_doc'); }

constructor(options) {
super(options);

// Custom properties for our bot
this.CURRENT_VERSION = process.env.npm_package_version || '0.0.0-unknown';
this.logger = Logger;
this.config = config[process.env.NODE_ENV || 'development'];
this.commands = new Collection();
this.events = new Collection();
this.firebase_events = new Collection();

// Setup events to log unexpected errors
this.on("disconnect", () => this.logger.warn("Bot is disconnecting..."))
.on("reconnect", () => this.logger.log("Bot reconnecting..."))
.on("error", e => this.logger.error(e))
.on("warn", info => this.logger.warn(info));
}

get leyline_guild() {
return this.guilds.resolve(this.config.leyline_guild_id);
}

// ----- Message Methods -----
/**
* Send a single embed in the `channel` of the `msg` argument
* @param {Object} args
* @param {Message} args.msg Discord.js `Message` object, target channel is taken from this
* @param {EmbedBase} args.embed Singular embed object to be sent in channel
* @returns {Promise<Message>}
*/
sendEmbed({msg, embed, ...options}) {
if(!msg.channel) throw new Error(`No channel property found on the msg object: ${msg}`);
return msg.channel.send({msg,
embeds: [embed],
...options,
});
}

/**
* Send an inline reply to the `msg` that mentions the author
* @param {Object} args
* @param {Message} args.msg Discord.js `Message` object, target author is taken from this
* @param {EmbedBase} args.embed Singular embed object to be sent as response
* @returns {Promise<Message>}
*/
sendReply({msg, embed, ...options}) {
return msg.reply({
embeds: [embed],
failIfNotExists: false,
...options,
});
}

/**
* Send a direct message to the target user, catches error if user has closed DMs
* @param {Object} args
* @param {User} args.user Discord.js `User` object; recipient of msg
* @param {EmbedBase} args.embed Singular embed object to be sent as response
* @param {boolean} [args.send_disabled_msg] Whether or not to send a public message prompting the user to enable messages from server members
* @returns {Promise<Message>}
*/
sendDM({user, embed, send_disabled_msg=true, ...options} = {}) {
return user.send({
embeds: [embed],
...options,
}).catch(() => send_disabled_msg && this.sendDisabledDmMessage(user));
}

/**
* Sends a discord message on the bot's behalf to a private log channel
* @param {Object} args
* @param {EmbedBase} args.embed Singular embed object to be sent in message
* @returns {Promise<Message>} Promise which resolves to the sent message
*/
async logDiscord({embed, ...options}) {
return (await this.channels.fetch(this.config.channels.private_log)).send({
embeds: [embed],
...options,
});
}

/**
* Sends a discord message on the bot's behalf to a public log channel
* @param {Object} args
* @param {EmbedBase} args.embed Singular embed object to be sent in message
* @returns {Promise<Message>} Promise which resolves to the sent message
*/
async msgBotChannel({embed, ...options}) {
return (await this.channels.fetch(this.config.channels.public_log)).send({
embeds: [embed],
...options,
});
}

/**
* Sends a discord message on the bot's behalf to a public log channel, specific for rewards
* @param {Object} args
* @param {EmbedBase} args.embed Singular embed object to be sent in message
* @returns {Promise<Message>} Promise which resolves to the sent message
*/
async logReward({embed, ...options}) {
return (await this.channels.fetch(this.config.channels.reward_log)).send({
embeds: [embed],
...options,
});
}

/**
* Sends a discord message on the bot's behalf to a public log channel, specific for punishments
* @param {Object} args
* @param {EmbedBase} args.embed Singular embed object to be sent in message
* @returns {Promise<Message>} Promise which resolves to the sent message
*/
async logPunishment({embed, ...options}) {
return (await this.channels.fetch(this.config.channels.punishment_log)).send({
embeds: [embed],
...options,
});
}

sendDisabledDmMessage(user) {
this.msgBotChannel({
content: user.toString(),
embed: new EmbedBase(this, {
fields: [
{
name: '❌ You need to enable DMs from server members!',
value: "I tried to send you a direct message, but you currently have them disabled! Navigate to the server's Privacy Settings, then toggle **Allow Direct Messages From Server Members** to the right."
}
],
image: {
url: 'https://i.ibb.co/L8j9dCD/discord-dm-tutorial.png'
},
}).Warn()});
}

// ----- Interaction Methods -----
/**
* Replies to an interaction
* @param {Object} args Destructured arguments
* @param {Interaction} args.intr Discord.js `Interaction`
* @param {EmbedBase} [args.embed] Singular embed object to be included in reply
* @returns {Promise<Message>} The reply that was sent
*/
intrReply({intr, embed, ...options}) {
const payload = {
...embed && { embeds: [embed] },
fetchReply: true,
...options,
};
return (intr.deferred || intr.replied) ? intr.editReply(payload) : intr.reply(payload);
}

intrUpdate({intr, embed, ...options}) {
const payload = {
...embed && { embeds: [embed] },
fetchReply: true,
...options,
};
return intr.replied ? intr.editReply(payload) : intr.update(payload);
}

/**
* Reply to a `CommandInteraction` with a message containing 'Confirm' and 'Cancel' as buttons, among other options passed as parameters
* Returns a promise which resolves to a boolean indicating the user's selection
* @param {Object} args Destructured arguments. `options` will be passed to `Leylinethis.intrReply()` as params
* @param {CommandInteraction} args.intr Discord.js `CommandInteraction` to reply w/ confirmation prompt
* @returns {Promise<boolean>} `true` if user selected 'Confirm', `false` if user selected `Cancel`
*/
async intrConfirm({intr, ...options}) {
try {
const msg = await this[`${intr.isButton() ? 'intrUpdate' : 'intrReply'}`]({intr, ...options, components:[new ConfirmInteraction()]});
const res = await msg.awaitInteractionFromUser({user: intr.user});
//remove components
await res.update({components:[]});
return res.customId === 'confirm';
} catch (err) {
this.logger.error(`intrConfirm err: ${err}`);
return false;
}
}


// ----- Other Methods -----
/**
* Checks if a user has mod permissions on the Leyline server.
* Current mod roles: `Admin`, `Moderator`
* @param {String} uid Discord UID of the user to check
* @returns {boolean} `true` if user has mod perms, `false` otherwise
*/
checkMod(uid) {
return this.leyline_guild.members.cache.get(uid).roles.cache.some(r => this.config.mod_roles.includes(r.id));
}

/**
* Checks if a user has admin permissions on the Leyline server.
* Current admin permission: Anyone with the ADMINISTRATOR permission
* @param {String} uid Discord UID of the user to check
* @returns {boolean} `true` if user has admin perms, `false` otherwise
*/
checkAdmin(uid) {
return this.leyline_guild.members.cache.get(uid).permissions.has('ADMINISTRATOR');
}

/**
* Formats a `User` for logging purposes
* @param {User} user Discord.js `User` object
*/
formatUser(user) {
return !!user?.id ?
`<@!${user.id}> (${user.tag})` :
'Unknown User';
}

/**
* Format a UNIX timestamp to be sent in a Discord message
* @param {Number} [timestamp] UNIX timestamp in milliseconds, default is `Date.now()`
* @param {string} [letter] The suffix to append, resulting in a different display
* @returns {String}
*/
formatTimestamp(timestamp=Date.now(), letter='D') {
return `<t:${timestamp /1000 |0}:${letter}>`;
}

}
Loading

0 comments on commit 157f925

Please sign in to comment.