Skip to content
This repository has been archived by the owner on Jul 8, 2024. It is now read-only.

Commit

Permalink
Add Persistant (Sticky) Role Support
Browse files Browse the repository at this point in the history
Porygon-Z now supports sticky roles. These are roles for which the bot
will re-add to a user if they leave and then rejoin the discord server.

This is useful for punishment roles so users cannot evade their
punishments (eg: Mute roles like Comp Muted).
  • Loading branch information
HoeenCoder committed Aug 8, 2020
1 parent 61819b3 commit 6f1ad32
Show file tree
Hide file tree
Showing 7 changed files with 392 additions and 62 deletions.
4 changes: 2 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export async function verifyData(data: Discord.Message | IDatabaseInsert) {
if (!worker) worker = await pgPool.connect();
let res = await worker.query('SELECT * FROM servers WHERE serverid = $1', [data.guild.id]);
if (!res.rows.length) {
await worker.query('INSERT INTO servers (serverid, servername, logchannel) VALUES ($1, $2, $3)', [data.guild.id, data.guild.name, null]);
await worker.query('INSERT INTO servers (serverid, servername, logchannel, sticky) VALUES ($1, $2, $3, $4)', [data.guild.id, data.guild.name, null, []]);
}
servers.add(data.guild.id);
}
Expand Down Expand Up @@ -89,7 +89,7 @@ export async function verifyData(data: Discord.Message | IDatabaseInsert) {
if (!worker) worker = await pgPool.connect();
let res = await worker.query('SELECT * FROM userlist WHERE serverid = $1 AND userid = $2', [data.guild.id, data.author.id]);
if (!res.rows.length) {
await worker.query('INSERT INTO userlist (serverid, userid, boosting) VALUES ($1, $2, $3)', [data.guild.id, data.author.id, null]);
await worker.query('INSERT INTO userlist (serverid, userid, boosting, sticky) VALUES ($1, $2, $3, $4)', [data.guild.id, data.author.id, null, []]);
}
userlist.add(data.guild.id + ',' + data.author.id);
}
Expand Down
44 changes: 31 additions & 13 deletions src/command_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,8 @@ export abstract class BaseCommand {
rawChannel = rawChannel.trim();
channelid = rawChannel.substring(2, rawChannel.length - 1);
} else if (this.guild && allowName) {
for (let [k, v] of this.guild.channels.cache) {
if (toID(v.name) === toID(rawChannel) && ['news', 'text'].includes(v.type)) {
// Validation of visibility handled below
channelid = k;
break;
}
}
let targetChannel = this.guild.channels.cache.find(channel => toID(channel.name) === toID(rawChannel) && ['news', 'text'].includes(channel.type));
if (targetChannel) channelid = targetChannel.id;
}
if (!channelid) channelid = rawChannel;

Expand Down Expand Up @@ -189,12 +184,8 @@ export abstract class BaseCommand {

if (!/\d{16}/.test(rawServer) && allowName) {
// Server name
for (let [k, v] of client.guilds.cache) {
if (toID(v.name) === toID(rawServer)) {
rawServer = k;
break;
}
}
let targetGuild = client.guilds.cache.find(guild => toID(guild.name) === toID(rawServer));
if (targetGuild) rawServer = targetGuild.id;
}

const server = client.guilds.cache.get(rawServer);
Expand All @@ -208,6 +199,33 @@ export abstract class BaseCommand {
return server;
}

/**
* Get a role from the current or selected server
* @param role - Role id or mention
* @param allowName - Allow fetch roles by name, which can be inconsitant due to roles being allowed to share names.
* @param guild - Guild to use for the search, defaults to current.
*/
protected async getRole(id: string, allowName: boolean = false, guild?: Discord.Guild) {
if (!toID(id)) return;
if (!guild && this.guild) guild = this.guild;
if (!guild) return;
id = id.trim();

await guild.roles.fetch();
if (/<@&\d{18}>/.test(id)) {
// Role mention
id = id.substring(3, id.length - 1);
} else if (!/\d{18}/.test(id) && allowName) {
// Role Name
let targetRole = guild.roles.cache.find(role => toID(role.name) === toID(id));
if (targetRole) id = targetRole.id;
}

const role = guild.roles.cache.get(id);
if (!role) return;
return role;
}

protected verifyData = verifyData;

/**
Expand Down
2 changes: 1 addition & 1 deletion src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class HelpPage extends ReactionPageTurner {
constructor(channel: DiscordChannel, user: Discord.User, data: {[key: string]: string}[]) {
super(channel, user);
this.data = data;
this.lastPage = Math.ceil(this.data.length / 10);
this.lastPage = Math.ceil(this.data.length / 5);
this.rowsPerPage = 5;

this.initalize(channel);
Expand Down
211 changes: 211 additions & 0 deletions src/commands/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import Discord = require('discord.js');
import { ID, prefix, toID, pgPool } from '../common';
import { BaseCommand, DiscordChannel, IAliasList } from '../command_base';
import { client } from '../app';

export class Whois extends BaseCommand {
private readonly KEY_PERMISSIONS: {[key: string]: string}
Expand Down Expand Up @@ -88,6 +89,216 @@ export class Whois extends BaseCommand {
}
}

/**
* Sticky Roles
*/

// Startup script, ensures users who were obtained/lost sticky roles while the bot was offline are accounted for
async function stickyStartup() {
const res = await pgPool.query('SELECT serverid, sticky FROM servers');
if (!res.rows.length) return; // No servers?

for (let i = 0; i < res.rows.length; i++) {
const stickyRoles: string[] = res.rows[i].sticky;
const guildID = res.rows[i].serverid;

// Get list of users and their sticky roles
const serverRes = await pgPool.query('SELECT userid, sticky FROM userlist WHERE serverid = $1', [guildID]);
const server = client.guilds.cache.get(guildID);
if (!server) {
console.error('ERR NO SERVER FOUND');
throw new Error(`Unable to find server when performing sticky roles startup. (${guildID})`);
}
await server.members.fetch();

for (let j = 0; j < serverRes.rows.length; j++) {
const member = server.members.cache.get(serverRes.rows[j].userid);
if (!member) throw new Error(`Unable to find member when performing sticky roles startup. (${guildID}, ${serverRes.rows[j].userid})`);
// Check which of this member's roles are sticky
const roles = [...member.roles.cache.values()].map(r => r.id).filter(r => stickyRoles.includes(r));

// Compare member's current sticky roles to the ones in the database. If they match, do nothing.
const userStickyRoles: string[] = serverRes.rows[j].sticky;
if (!roles.length && userStickyRoles.length) {
await pgPool.query('UPDATE userlist SET sticky = $1 WHERE serverid = $2 AND userid = $3', [roles, guildID, member.user.id]);
continue;
}

if (roles.every(r => userStickyRoles.includes(r))) continue;

// Update database with new roles
await pgPool.query('UPDATE userlist SET sticky = $1 WHERE serverid = $2 AND userid = $3', [roles, guildID, member.user.id]);
}
}
}
setTimeout(() => {
stickyStartup();
}, 5000);

abstract class StickyCommand extends BaseCommand {
async canAssignRole(user: Discord.GuildMember, role: Discord.Role): Promise<boolean> {
if (!this.guild || user.guild.id !== this.guild.id || role.guild.id !== this.guild.id) throw new Error(`Guild missmatch in sticky command`);
// This method does NOT perform a manage roles permission check.
// It simply checks if a user would be able to assign the role provided
// assuming they have permissions to assign roles.

// Bot owner override
if (await this.can('EVAL', user.user)) return true;

// Server owner override
if (this.guild.ownerID === user.user.id) return true;

await this.guild.roles.fetch();
const highestRole = [...user.roles.cache.values()].sort((a, b) => {
return b.comparePositionTo(a);
})[0];
if (role.comparePositionTo(highestRole) >= 0) return false;
return true;
}

async massStickyUpdate(role: Discord.Role, unsticky: boolean = false): Promise<void> {
if (!this.guild || this.guild.id !== role.guild.id) throw new Error(`Guild missmatch in sticky command`);
if (!role.members.size) return; // No members have this role, so no database update needed

await this.guild.members.fetch();
let query = `UPDATE userlist SET sticky = ${unsticky ? 'array_remove' : 'array_append'}(sticky, $1) WHERE serverid = $2 AND userid IN (`;
let argInt = 3;
let args = [role.id, role.guild.id];
for (let [key, member] of role.members) {
await this.verifyData({author: member.user, guild: this.guild});
query += `$${argInt}, `;
args.push(member.user.id);
argInt++;
}
query = query.slice(0, query.length - 2);
query += `);`;

await pgPool.query(query, args);
}
}

export class Sticky extends StickyCommand {
constructor(message: Discord.Message) {
super(message);
}

async execute() {
if (!toID(this.target)) return this.reply(Sticky.help());
if (!this.guild) return this.errorReply(`This command is not mean't to be used in PMs.`);
if (!(await this.can('MANAGE_ROLES'))) return this.errorReply(`Access Denied`);
const bot = this.guild.me ? this.guild.me.user : null;
if (!bot) throw new Error(`Bot user not found.`);
if (!(await this.can('MANAGE_ROLES', bot))) return this.errorReply(`This bot needs the Manage Roles permission to use this command.`);

// Validate @role exists
const role = await this.getRole(this.target, true); // TODO ask about using names for role gets
if (!role) return this.errorReply(`The role "${this.target}" was not found.`);

// Validate @role is something user can assign
await this.guild.members.fetch();
let guildMember = this.guild.members.cache.get(this.author.id);
if (!guildMember) throw new Error(`Cannot get guild member for user`);
if (!(await this.canAssignRole(guildMember, role))) return this.errorReply(`You are not able to assign this role and cannot make it sticky as a result.`);

// Validate @role is something the bot can assign
guildMember = this.guild.members.cache.get(bot.id);
if (!guildMember) throw new Error(`Cannot get guild member for bot`);
if (!(await this.canAssignRole(guildMember, role))) return this.errorReply(`The bot is not able to assign this role and cannot make it sticky as a result.`);

// Validate @role is not already sticky (database query)
this.worker = await pgPool.connect();
let res = await this.worker.query(`SELECT sticky FROM servers WHERE serverid = $1`, [this.guild.id]);
if (!res.rows.length) throw new Error(`Unable to find sticky roles in database for guild: ${this.guild.name} (${this.guild.id})`);
let stickyRoles: string[] = res.rows[0].sticky;

if (stickyRoles.includes(role.id)) {
this.releaseWorker();
return this.errorReply(`That role is already sticky!`);
}

// ---VALIDATION LINE---
// Make @role sticky (database update)
stickyRoles.push(role.id);
try {
await this.worker.query('BEGIN');
await this.worker.query(`UPDATE servers SET sticky = $1 WHERE serverid = $2`, [stickyRoles, this.guild.id]);
// Find all users with @role and perform database update so role is now sticky for them
await this.massStickyUpdate(role);
await this.worker.query('COMMIT');
this.releaseWorker();
} catch(e) {
await this.worker.query('ROLLBACK');
this.releaseWorker();
throw e;
}
// Return success message
this.reply(`The role "${role.name}" is now sticky! Members who leave and rejoin the server with this role will have it reassigned automatically.`);
}

public static help(): string {
return `${prefix}sticky @role - Makes @role sticky, meaning users assigned this role will not be able to have it removed by leaving the server.\n` +
`Requires: Manage Roles Permissions\n` +
`Aliases: None`;
}
}

export class Unsticky extends StickyCommand {
constructor(message: Discord.Message) {
super(message);
}

async execute() {
if (!toID(this.target)) return this.reply(Unsticky.help());
if (!this.guild) return this.errorReply(`This command is not mean't to be used in PMs.`);
if (!(await this.can('MANAGE_ROLES'))) return this.errorReply(`Access Denied`);

// Validate @role exists
const role = await this.getRole(this.target, true); // TODO ask about using names for role gets
if (!role) return this.errorReply(`The role "${this.target}" was not found.`);

// Validate @role is something user can assign
await this.guild.members.fetch();
let guildMember = this.guild.members.cache.get(this.author.id);
if (!guildMember) throw new Error(`Cannot get guild member for user`);
if (!(await this.canAssignRole(guildMember, role))) return this.errorReply(`You are not able to assign this role and cannot revoke it's sticky status as a result.`);

// Validate @role is sticky (database query)
this.worker = await pgPool.connect();
let res = await this.worker.query(`SELECT sticky FROM servers WHERE serverid = $1`, [this.guild.id]);
if (!res.rows.length) throw new Error(`Unable to find sticky roles in database for guild: ${this.guild.name} (${this.guild.id})`);
let stickyRoles: string[] = res.rows[0].sticky;

if (!stickyRoles.includes(role.id)) {
this.releaseWorker();
return this.errorReply(`That role is not sticky!`);
}

// ---VALIDATION LINE---
// Make @role not sticky (database update)
stickyRoles.splice(stickyRoles.indexOf(role.id), 1);
try {
await this.worker.query('BEGIN');
await this.worker.query(`UPDATE servers SET sticky = $1 WHERE serverid = $2`, [stickyRoles, this.guild.id]);
// Find all users with @role and perform database update so role is no longer sticky for them
await this.massStickyUpdate(role, true);
await this.worker.query('COMMIT');
this.releaseWorker();
} catch (e) {
await this.worker.query('ROLLBACK');
this.releaseWorker();
throw e;
}
// Return success message
this.reply(`The role "${role.name}" is no longer sticky.`);
}

public static help(): string {
return `${prefix}unsticky @role - Makes it so @role is no longer sticky. Users will be able to remove @role from themselves by leaving the server.\n` +
`Requires: Manage Roles Permissions\n` +
`Aliases: None`;
}
}

export class EnableLogs extends BaseCommand {
constructor(message: Discord.Message) {
super(message);
Expand Down
Loading

0 comments on commit 6f1ad32

Please sign in to comment.