// Copyright 2019-2022 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const SubModule = require('./subModule.js');
/**
* @description Manages echo-related commands.
* @augments SubModule
* @listens Discord~Client#message
* @listens Command#say
* @listens Command#echo
* @listens Command#become
* @listens Command#self
* @listens Command#be
* @listens Command#character
* @listens Command#impersonate
* @listens Command#who
* @listens Command#whois
* @listens Command#whoami
*/
class Echo extends SubModule {
/**
* @description SubModule managing echo related commands.
*/
constructor() {
super();
/** @inheritdoc */
this.myName = 'Echo';
/**
* @description The id of the last user to use the say command.
*
* @private
* @type {string}
* @default
*/
this._prevUserSayId = '';
/**
* @description The number of times the say command has been used
* consecutively by the previous user.
*
* @private
* @type {number}
* @default
*/
this._prevUserSayCnt = 0;
/**
* @description All npc characters a users are currently being. Mapped by
* guild id, then channel id, then user id.
*
* @private
* @type {object}
* @default
*/
this._characters = {};
this.save = this.save.bind(this);
this._commandSay = this._commandSay.bind(this);
this._commandBecome = this._commandBecome.bind(this);
this._commandWhoIs = this._commandWhoIs.bind(this);
this._commandWhoAmI = this._commandWhoAmI.bind(this);
this._commandResetCharacters = this._commandResetCharacters.bind(this);
this._onMessage = this._onMessage.bind(this);
}
/** @inheritdoc */
initialize() {
this.command.on(['say', 'echo'], this._commandSay);
this.command.on(new this.command.SingleCommand(
['become', 'self', 'be', 'character', 'impersonate'],
this._commandBecome, {
validOnlyInGuild: true,
defaultDisabled: true,
permissions: this.Discord.PermissionsBitField.Flags.ManageMessages |
this.Discord.PermissionsBitField.Flags.ManageWebhooks |
this.Discord.PermissionsBitField.Flags.ManageGuild,
}));
this.command.on(
new this.command.SingleCommand(
['who', 'whois'], this._commandWhoIs, {validOnlyInGuild: true}));
this.command.on('whoami', this._commandWhoAmI);
this.command.on(new this.command.SingleCommand(
['resetcharacters', 'deletecharacters'], this._commandResetCharacters, {
validOnlyInGuild: true,
defaultDisabled: true,
permissions: this.Discord.PermissionsBitField.Flags.ManageMessages |
this.Discord.PermissionsBitField.Flags.ManageWebhooks |
this.Discord.PermissionsBitField.Flags.ManageGuild,
}));
this.client.on('messageCreate', this._onMessage);
this.client.guilds.cache.forEach((g) => {
this.common.readAndParse(
`${this.common.guildSaveDir}${g.id}/characters.json`,
(err, parsed) => {
if (err) return;
this._characters[g.id] = parsed;
});
});
}
/** @inheritdoc */
shutdown() {
this.command.removeListener('say');
this.command.removeListener('become');
this.command.removeListener('whoami');
this.command.removeListener('whois');
this.command.removeListener('resetcharacters');
this.client.removeListener('messageCreate', this._onMessage);
}
/** @inheritdoc */
save(opt) {
if (!this.initialized) return;
Object.entries(this._characters).forEach((obj) => {
if (!obj[1] || !obj[1].updated) return;
delete obj[1].updated;
const dir = `${this.common.guildSaveDir}${obj[0]}/`;
const filename = `${dir}characters.json`;
if (opt == 'async') {
this.common.mkAndWrite(filename, dir, JSON.stringify(obj[1]));
} else {
this.common.mkAndWriteSync(filename, dir, JSON.stringify(obj[1]));
}
});
}
/**
* Handle receiving a message for webhook replacing.
*
* @private
* @param {Discord~Message} msg The message that was sent.
* @listens Discord~Client#message
*/
_onMessage(msg) {
if (!msg.guild || msg.author.bot) return;
if (!msg.content || msg.content.length === 0) return;
if (!this.client.user) return;
const char = this._characters[msg.guild.id] &&
this._characters[msg.guild.id][msg.channel.id] &&
this._characters[msg.guild.id][msg.channel.id][msg.author.id];
if (!char) {
return;
}
if (!msg.channel.permissionsFor(msg.guild.members.me)
.has(this.Discord.PermissionsBitField.Flags.ManageWebhooks)) {
return;
}
msg.channel.fetchWebhooks()
.then((hooks) => {
const hook =
hooks.find((h) => h.owner && h.owner.id == this.client.user.id);
if (!hook) return;
hook.send(msg.content, {
username: char.username,
avatarURL: char.avatarURL,
// files: msg.attachments.map((el) => el.url),
}).catch((err) => {
this.error('Failed to send webhook: ' + msg.channel.id);
console.error(err);
});
if (msg.channel.permissionsFor(msg.guild.members.me)
.has(this.Discord.PermissionsBitField.Flags.ManageMessages)) {
msg.delete().catch((err) => {
this.error('Failed to delete message: ' + msg.channel.id);
console.error(err);
});
}
})
.catch((err) => {
this.error('Unable to fetch webhooks for channel: ' + msg.channel.id);
console.error(err);
});
}
/**
* @description The user's message will be deleted and the bot will send an
* identical message without the command to make it seem like the bot sent the
* message.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#say
* @listens Command#echo
*/
_commandSay(msg) {
if (msg.delete) msg.delete().catch(() => {});
const content = msg.text.trim();
msg.channel.send({content: content || '\u200B'}).catch((err) => {
this.warn(
'Failed to send message in channel: ' + msg.channel.id + ': ' +
content);
console.error(err);
});
if (msg.fabricated || this._prevUserSayId != msg.author.id) {
this._prevUserSayId = msg.author.id;
this._prevUserSayCnt = 0;
}
this._prevUserSayCnt++;
if (this._prevUserSayCnt % 3 === 0) {
msg.channel.send({
content: 'Help! ' + this.common.mention(msg) +
' is putting words into my mouth!',
});
}
}
/**
* @description Replace all following messages from a user with a character.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#become
* @listens Command#self
* @listens Command#be
* @listens Command#character
* @listens Command#imprsonate
*/
_commandBecome(msg) {
if (!this._characters[msg.guild.id]) {
this._characters[msg.guild.id] = {};
}
let channel = msg.channel;
if (msg.mentions.channels.size > 0) {
channel = msg.mentions.channels.first();
msg.text =
msg.text.replace(this.Discord.MessageMentions.CHANNELS_PATTERN, '');
}
if (!this._characters[msg.guild.id][channel.id]) {
this._characters[msg.guild.id][channel.id] = {};
}
if (msg.text.length > 1) {
let url;
if (msg.attachments.size == 1) {
const a = msg.attachments.first();
url = a.proxyURL || a.url;
} else if (msg.attachments.size == 0) {
url = msg.text.match(Echo._urlRegex);
if (url) url = url[0];
}
if (typeof url !== 'string' || url.length == 0) {
/* this.common.reply(
msg, 'Hmm, you didn\'t give me an image to use as an avatar.');
return; */
url = undefined;
}
const username = this._formatUsername(msg.text, url);
if (username.length < 2) {
this.common.reply(msg, 'Please specify a valid username.', username);
return;
}
// Wait one loop to prevent the command from triggering the event.
setTimeout(() => {
this._characters[msg.guild.id][channel.id][msg.author.id] =
new Character(username, url);
});
channel.fetchWebhooks()
.then((hooks) => {
const hook = hooks.find((h) => h.owner.id == this.client.user.id);
if (!hook) {
if (!channel.permissionsFor(msg.guild.members.me)
.has(this.Discord.PermissionsBitField.Flags.ManageWebhooks)) {
this.common.reply(
msg, 'Failed to create webhook',
'I need permission to manage webhooks.');
return;
}
channel
.createWebhook(
'SpikeyBot NPCs',
{reason: 'Used for becoming other characters.'})
.then(() => {
this.common.reply(msg, 'Created', username);
})
.catch((err) => {
this.error(
'Failed to create webhook for channel: ' + channel.id);
console.error(err);
this.common.reply(
msg, 'Failed to create webhook', err.message);
});
} else {
this.common.reply(msg, 'Created', username);
}
})
.catch((err) => {
this.error('Failed to fetch webhooks for channel: ' + channel.id);
console.error(err);
this.common.reply(msg, 'Unable to fetch webhooks.', err.message);
});
} else {
delete this._characters[msg.guild.id][channel.id][msg.author.id];
this.common.reply(msg, 'Disabled character');
}
this._characters[msg.guild.id].updated = true;
}
/**
* Tell the user who they are.
*
* @private
* @type {Command~commandHandler}
* @param {Discord~Message} msg The message that triggered this command.
* @listens Command#whoami
*/
_commandWhoAmI(msg) {
let member = msg.softMentions.members.first() || msg.member;
const user = member.user;
const chanChar = member.guild && this._characters[member.guild.id];
const charList = [];
if (chanChar) {
for (const chan of Object.entries(chanChar)) {
if (!chan[1] || chan[0] === 'updated') continue;
const userList = Object.entries(chan[1]);
if (userList.length == 0) continue;
for (const u of userList) {
if (u[0] !== user.id || !u[1]) continue;
const channel = msg.guild.channels.resolve(chan[0]);
const name = (channel && channel.name) || chan[0];
charList.push(`#${name}: ${u[1].username}`);
}
}
}
let numReq = 1;
let numDone = 0;
const tag = `${user.tag} (${user.id})`;
let num = 0;
const embed = new this.Discord.EmbedBuilder();
const self = this;
const send = function() {
numDone++;
if (numDone < numReq) return;
const joinDate = member.joinedAt ?
`\nJoined Server: ${member.joinedAt.toUTCString()}` :
'';
const createDate = `\nAccount Created: ${user.createdAt.toUTCString()}`;
const dates = `${joinDate}${createDate}`;
const mutual =
num > 0 ? `\n${num} mutual server${num > 1 ? 's' : ''}.` : '';
const nick = ` (${member.nickname||'*No Nickname*'})`;
const name = `${user.username}${nick}${dates}${mutual}`;
embed.setColor([255, 0, 255]);
embed.setTitle(tag);
embed.setThumbnail(user.displayAvatarURL({size: 32}));
if (charList.length == 1) {
embed.setDescription(`${name}\n**Character**: ${charList[0]}`);
} else if (charList.length > 0) {
embed.setDescription(
`${name}\n**Characters**:\n${charList.join('\n')}`);
} else {
embed.setDescription(name);
}
msg.channel.send({content: self.common.mention(msg), embeds: [embed]})
.catch(() => self.common.reply(msg, tag, name));
};
if (!member.joinedAt) {
numReq++;
member.fetch()
.then((mem) => {
member = mem;
send();
})
.catch(send);
}
if (this.client.shard) {
this.client.shard
.broadcastEval(eval(
'((client) => client.guilds.cache.filter((g) => ' +
`g.members.resolve('${user.id}').size))`))
.then((res) => {
res.forEach((el) => num += el);
send();
});
} else {
this.client.guilds.cache.forEach((g) => {
if (g.members.resolve(member.id)) num++;
});
send();
}
}
/**
* List all characters currently active in all channels of a guild.
*
* @private
* @type {Command~commandHandler}
* @param {Discord~Message} msg The message that triggered this command.
* @listens Command#who
* @listens Command#whois
*/
_commandWhoIs(msg) {
if (msg.softMentions.members.size > 0) {
this._commandWhoAmI(msg);
return;
}
const chars = msg.guild && this._characters[msg.guild.id];
let output = [];
for (let channel in chars) {
if (!channel || channel === 'updated') continue;
channel = msg.guild.channels.resolve(channel);
if (!channel) continue;
const list = [];
for (let member in chars[channel.id]) {
if (!member) continue;
member = msg.guild.members.resolve(member);
if (!member) continue;
list.push(
`${member.user.tag}: ${chars[channel.id][member.id].username}`);
}
if (list.length > 0) {
output.push(`**#${channel.name}**`);
output = output.concat(list);
}
}
this.common.reply(msg, 'Current Characters', output.join('\n') || 'None');
}
/**
* Reset all current characters, and delete all webhooks.
*
* @private
* @type {Command~commandHandler}
* @param {Discord~Message} msg The message that triggered this command.
* @listens Command#resetcharacters
*/
_commandResetCharacters(msg) {
if (!this._characters[msg.guild.id]) {
this.common.reply(msg, 'No characters to reset.');
return;
}
const channels = Object.keys(this._characters[msg.guild.id]);
channels.forEach((el) => {
if (el === 'updated') return;
const channel = msg.guild.channels.resolve(el);
if (!channel) return;
channel.fetchWebhooks()
.then((hooks) => {
const hook = hooks.find((h) => h.owner.id == this.client.user.id);
hook.delete('Clearing all characaters.').catch(() => {});
})
.catch(() => {});
});
this._characters[msg.guild.id] = {updated: true};
this.common.reply(msg, 'All characters deleted.');
}
/**
* @description Remove url from username, and format to rules similar to
* discord.
*
* @private
* @param {string} u The username.
* @param {string|RegExp} [remove] A substring or RegExp to remove.
* @returns {string} Formatted username.
*/
_formatUsername(u, remove) {
if (!remove) remove = /a^/; // Match nothing by default.
return u.replace(remove, '')
.replace(/^\s+|\s+$|@|#|:|```/g, '')
.replace(/\s{2,}/g, ' ')
.substring(0, 32);
}
}
/**
* @description A character to send as a webhook.
* @memberof Echo
* @inner
*/
class Character {
/**
* @description Create a Character.
* @param {string} username Username for webhook.
* @param {string} [url] Avatar url override for webhook.
*/
constructor(username, url) {
/**
* @description Username of this character.
* @type {string}
*/
this.username = username;
/**
* @description Avatar url of this character.
* @type {string}
*/
this.avatarURL = url;
}
}
Echo.Character = Character;
/**
* Regex to match all URLs in a string.
*
* @private
* @type {RegExp}
* @constant
* @default
* @static
*/
Echo._urlRegex = new RegExp(
'(http(s)?:\\/\\/.)?(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]' +
'{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)(?![^<]*>)',
'g');
module.exports = new Echo();