2020-12-05 16:13:06 +00:00
|
|
|
import {
|
|
|
|
Plugin,
|
|
|
|
Configurable,
|
|
|
|
EventListener,
|
|
|
|
InjectService
|
|
|
|
} from '@squeebot/core/lib/plugin';
|
|
|
|
|
|
|
|
import util from 'util';
|
|
|
|
|
2021-10-01 17:31:10 +00:00
|
|
|
import Discord, { ClientUser, Intents, Permissions, TextChannel, User } from 'discord.js';
|
2020-12-05 16:13:06 +00:00
|
|
|
|
|
|
|
import { logger } from '@squeebot/core/lib/core';
|
2021-04-14 20:07:05 +00:00
|
|
|
import { thousandsSeparator, timeSince, toHHMMSS } from '@squeebot/core/lib/common';
|
2020-12-05 16:13:06 +00:00
|
|
|
import {
|
|
|
|
EMessageType,
|
|
|
|
Formatter,
|
|
|
|
IMessage,
|
|
|
|
IMessageTarget,
|
|
|
|
MarkdownFormatter,
|
2023-08-02 17:03:44 +00:00
|
|
|
Protocol,
|
|
|
|
ProtocolFeatureFlag
|
2020-12-05 16:13:06 +00:00
|
|
|
} from '@squeebot/core/lib/types';
|
|
|
|
|
2021-04-14 20:07:05 +00:00
|
|
|
class DiscordFormatter extends MarkdownFormatter {
|
|
|
|
public compose(objs: any): any {
|
|
|
|
const embed = new Discord.MessageEmbed();
|
|
|
|
|
|
|
|
for (const i in objs) {
|
|
|
|
const elem = objs[i];
|
|
|
|
|
|
|
|
const elemType = elem[0];
|
|
|
|
let elemValue = elem[1];
|
|
|
|
const elemParams = elem[2];
|
|
|
|
|
2021-09-03 17:22:33 +00:00
|
|
|
if (!elemValue || (elemType !== 'field' && elemType !== 'bold')) {
|
2021-04-14 20:07:05 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Special types
|
2021-09-03 17:22:33 +00:00
|
|
|
if (elemParams?.type) {
|
2021-04-14 20:07:05 +00:00
|
|
|
switch (elemParams.type) {
|
|
|
|
case 'time':
|
|
|
|
elemValue = new Date(elemValue).toString();
|
|
|
|
break;
|
|
|
|
case 'metric':
|
|
|
|
elemValue = thousandsSeparator(elemValue);
|
|
|
|
break;
|
|
|
|
case 'timesince':
|
|
|
|
elemValue = timeSince(elemValue);
|
|
|
|
break;
|
|
|
|
case 'duration':
|
|
|
|
elemValue = toHHMMSS(elemValue);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-03 17:22:33 +00:00
|
|
|
if (elemParams?.type === 'description') {
|
|
|
|
embed.setDescription(embed.description
|
|
|
|
? embed.description += `\n${elemValue}`
|
|
|
|
: elemValue
|
|
|
|
);
|
2021-04-14 20:07:05 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-09-03 17:22:33 +00:00
|
|
|
if (elemParams?.type === 'title') {
|
2021-04-14 20:07:05 +00:00
|
|
|
embed.setTitle(elemValue);
|
|
|
|
if (elemParams.color) {
|
|
|
|
embed.setColor(elemParams.color.toUpperCase());
|
|
|
|
}
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-09-03 17:22:33 +00:00
|
|
|
if (elemParams?.label) {
|
2021-04-14 20:07:05 +00:00
|
|
|
let label = elemParams.label;
|
|
|
|
|
|
|
|
// If the label param is an array, choose the last element
|
|
|
|
// The last element is generally the text version, as opposed to
|
|
|
|
// the first element being an icon.
|
|
|
|
if (typeof label === 'object') {
|
|
|
|
label = elemParams.label[elemParams.label.length - 1];
|
|
|
|
}
|
|
|
|
|
|
|
|
embed.addField(label, elemValue, true);
|
|
|
|
} else {
|
2021-09-03 17:22:33 +00:00
|
|
|
embed.setDescription(embed.description
|
|
|
|
? embed.description += `\n${elemValue}`
|
|
|
|
: elemValue
|
|
|
|
);
|
2021-04-14 20:07:05 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// May return an object, but your protocol must support it.
|
|
|
|
return embed;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-12-05 16:13:06 +00:00
|
|
|
class DiscordMessageAdapter implements IMessage {
|
|
|
|
public time: Date = new Date();
|
|
|
|
public resolved = false;
|
|
|
|
public direct = false;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
public type: EMessageType,
|
|
|
|
public data: any,
|
|
|
|
public source: Protocol,
|
|
|
|
public sender: IMessageTarget,
|
|
|
|
public target?: IMessageTarget) {}
|
|
|
|
|
|
|
|
public get fullSenderID(): string {
|
|
|
|
return this.source.fullName + '/' + this.sender.id;
|
|
|
|
}
|
|
|
|
|
|
|
|
public get fullRoomID(): string {
|
|
|
|
if (!this.target) {
|
|
|
|
return this.source.fullName;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.target.server) {
|
|
|
|
return this.source.fullName + '/s:' + this.target.server + '/' + this.target.id;
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.source.fullName + '/' + this.target.id;
|
|
|
|
}
|
|
|
|
|
|
|
|
public get text(): string {
|
|
|
|
return this.data.content;
|
|
|
|
}
|
|
|
|
|
|
|
|
public resolve(...args: any[]): void {
|
|
|
|
this.resolved = true;
|
|
|
|
this.source.resolve(this, ...args);
|
|
|
|
}
|
|
|
|
|
2020-12-13 10:17:25 +00:00
|
|
|
public reject(error: Error): void {
|
|
|
|
this.resolved = true;
|
|
|
|
this.source.resolve(this, error.message);
|
|
|
|
}
|
|
|
|
|
2020-12-05 16:13:06 +00:00
|
|
|
public mention(user: IMessageTarget): string {
|
|
|
|
return `<@${user.id}>`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class DiscordProtocol extends Protocol {
|
2023-08-02 17:03:44 +00:00
|
|
|
public static Features = [
|
|
|
|
ProtocolFeatureFlag.COLORS,
|
|
|
|
ProtocolFeatureFlag.FORMATTING,
|
|
|
|
ProtocolFeatureFlag.PLAIN,
|
|
|
|
ProtocolFeatureFlag.MARKDOWN,
|
|
|
|
ProtocolFeatureFlag.CUSTOM_EMBEDS,
|
|
|
|
ProtocolFeatureFlag.EMOJI,
|
|
|
|
ProtocolFeatureFlag.IMAGES,
|
|
|
|
ProtocolFeatureFlag.VOICE,
|
|
|
|
ProtocolFeatureFlag.VIDEO,
|
|
|
|
ProtocolFeatureFlag.THREADS,
|
|
|
|
ProtocolFeatureFlag.REACTIONS,
|
|
|
|
ProtocolFeatureFlag.MENTION,
|
|
|
|
ProtocolFeatureFlag.MULTISERVER,
|
|
|
|
ProtocolFeatureFlag.EVENT_MESSAGE,
|
|
|
|
ProtocolFeatureFlag.EVENT_ROOM_JOIN,
|
|
|
|
ProtocolFeatureFlag.EVENT_ROOM_LEAVE,
|
|
|
|
ProtocolFeatureFlag.KICK,
|
|
|
|
ProtocolFeatureFlag.BAN,
|
|
|
|
ProtocolFeatureFlag.MUTE,
|
|
|
|
];
|
|
|
|
|
2021-04-14 20:07:05 +00:00
|
|
|
public format: Formatter = new DiscordFormatter();
|
2020-12-05 16:13:06 +00:00
|
|
|
public type = 'DiscordProtocol';
|
|
|
|
|
2021-09-03 17:22:33 +00:00
|
|
|
private client = new Discord.Client({
|
|
|
|
intents: [
|
|
|
|
Intents.FLAGS.GUILDS,
|
|
|
|
Intents.FLAGS.GUILD_MESSAGES,
|
|
|
|
Intents.FLAGS.GUILD_MESSAGE_REACTIONS,
|
|
|
|
Intents.FLAGS.GUILD_PRESENCES,
|
|
|
|
Intents.FLAGS.GUILD_VOICE_STATES,
|
|
|
|
Intents.FLAGS.GUILD_BANS,
|
|
|
|
Intents.FLAGS.DIRECT_MESSAGES,
|
|
|
|
Intents.FLAGS.DIRECT_MESSAGE_REACTIONS,
|
|
|
|
Intents.FLAGS.DIRECT_MESSAGE_TYPING,
|
|
|
|
],
|
2021-10-01 17:31:10 +00:00
|
|
|
partials: ['CHANNEL', 'GUILD_MEMBER', 'MESSAGE', 'USER'],
|
2021-09-03 17:22:33 +00:00
|
|
|
});
|
2020-12-05 16:13:06 +00:00
|
|
|
|
|
|
|
private eventsAttached = false;
|
|
|
|
|
|
|
|
public start(...args: any[]): void {
|
|
|
|
this.running = true;
|
|
|
|
this.client.once('ready', () => {
|
|
|
|
if (this.client.user) {
|
|
|
|
this.me.id = this.client.user.id;
|
|
|
|
this.me.name = this.client.user.username;
|
|
|
|
}
|
|
|
|
});
|
2021-09-03 17:22:33 +00:00
|
|
|
|
2020-12-05 16:13:06 +00:00
|
|
|
this.attachEvents();
|
2021-09-03 17:22:33 +00:00
|
|
|
|
2021-10-01 17:31:10 +00:00
|
|
|
this.client.on('error', (e: Error) => {
|
|
|
|
this.emit('error', e.message);
|
|
|
|
});
|
|
|
|
this.client.on('disconnect', () => this.stop(true));
|
|
|
|
|
|
|
|
this.client.login(this.config.token)
|
|
|
|
.then(() => this.emit('running'),
|
|
|
|
(reason) => {
|
|
|
|
this.emit('error', reason);
|
|
|
|
this.stop(true);
|
2021-09-03 17:22:33 +00:00
|
|
|
});
|
2020-12-05 16:13:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public stop(force = false): void {
|
|
|
|
if (!this.running) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.running = false;
|
|
|
|
this.stopped = true;
|
|
|
|
|
|
|
|
this.client.destroy();
|
|
|
|
|
|
|
|
if (force) {
|
|
|
|
this.failed = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.emit('stopped');
|
|
|
|
}
|
|
|
|
|
|
|
|
private attachEvents(): void {
|
|
|
|
if (this.eventsAttached) {
|
|
|
|
return;
|
|
|
|
}
|
2021-09-03 17:22:33 +00:00
|
|
|
|
2020-12-05 16:13:06 +00:00
|
|
|
this.eventsAttached = true;
|
2021-09-03 17:22:33 +00:00
|
|
|
|
|
|
|
this.client.on('error', (e: Error) => {
|
|
|
|
this.emit('error', e.message);
|
|
|
|
});
|
|
|
|
|
2020-12-05 16:13:06 +00:00
|
|
|
this.client.on('disconnect', () => this.stop(true));
|
2021-09-03 17:22:33 +00:00
|
|
|
|
|
|
|
this.client.on('messageCreate', (message) => {
|
2020-12-05 16:13:06 +00:00
|
|
|
if (this.me && this.me.id && message.author.id === this.me.id) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const chanName = 'name' in message.channel ? message.channel.name : message.author.username;
|
|
|
|
const msg = new DiscordMessageAdapter(EMessageType.message,
|
|
|
|
message,
|
|
|
|
this,
|
|
|
|
{ name: message.author.username, id: message.author.id },
|
2021-10-01 17:31:10 +00:00
|
|
|
{ name: chanName, id: message.channel.id, server: message.guildId || undefined });
|
2020-12-05 16:13:06 +00:00
|
|
|
this.plugin.stream.emitTo('channel', 'message', msg);
|
|
|
|
});
|
|
|
|
|
|
|
|
this.client.on('guildMemberAdd', (member) => {
|
|
|
|
if (this.me && this.me.id && member.user.id === this.me.id) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const newMessage = new DiscordMessageAdapter(
|
|
|
|
EMessageType.roomJoin,
|
|
|
|
member,
|
|
|
|
this,
|
|
|
|
{ id: member.user.id, name: member.user.username },
|
2020-12-13 10:17:25 +00:00
|
|
|
{ server: member.guild.id, id: '*', name: '*'});
|
2020-12-05 16:13:06 +00:00
|
|
|
this.plugin.stream.emitTo('channel', 'event', newMessage);
|
|
|
|
});
|
|
|
|
|
|
|
|
this.client.on('guildMemberRemove', (member) => {
|
|
|
|
if (!member.user) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (this.me && this.me.id && member.user.id === this.me.id) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const newMessage = new DiscordMessageAdapter(
|
|
|
|
EMessageType.roomLeave,
|
|
|
|
member,
|
|
|
|
this,
|
|
|
|
{ id: member.user.id, name: member.user.username },
|
2020-12-13 10:17:25 +00:00
|
|
|
{ server: member.guild.id, id: '*', name: '*'});
|
2020-12-05 16:13:06 +00:00
|
|
|
this.plugin.stream.emitTo('channel', 'event', newMessage);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public resolve(msg: DiscordMessageAdapter, ...data: any[]): void {
|
2021-10-01 17:31:10 +00:00
|
|
|
const toDiscord = this.fromSend(...data);
|
|
|
|
|
|
|
|
if (!toDiscord) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check send permission
|
|
|
|
if (msg.data.guildId) {
|
|
|
|
const perms = msg.data.channel.permissionsFor(this.client.user as ClientUser);
|
|
|
|
if (perms && !perms.has(Permissions.FLAGS.SEND_MESSAGES, true)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
msg.data.channel.send(toDiscord).catch((e: Error) => {
|
|
|
|
logger.error(e);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public async sendTo(target: string, ...data: any[]): Promise<boolean> {
|
|
|
|
const rxSplit = target.split('/');
|
|
|
|
if (rxSplit[0] !== 'discord' || rxSplit[1] !== this.name) {
|
|
|
|
// Invalid protocol, we do not want this!;
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const toDiscord = this.fromSend(...data);
|
|
|
|
if (!toDiscord) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
let channel: TextChannel | User | null = null;
|
|
|
|
// Message to guild
|
|
|
|
if (rxSplit.length === 4) {
|
|
|
|
// Fetch the guild and ensure it's available
|
|
|
|
const guildId = rxSplit[2].substring(2);
|
|
|
|
const guild = await this.client.guilds.fetch(guildId);
|
|
|
|
if (!guild || !guild.available || !guild.channels) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fetch the channel and ensure we can send to it
|
|
|
|
const channelId = rxSplit[3];
|
|
|
|
channel = await guild.channels.fetch(channelId) as TextChannel;
|
|
|
|
if (!channel || !(channel.isText || channel.isThread) || !channel.viewable) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check send permission
|
|
|
|
const perms = channel.permissionsFor(this.client.user as ClientUser);
|
|
|
|
if (perms) {
|
|
|
|
if (!perms.has(Permissions.FLAGS.SEND_MESSAGES, true)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Message to user
|
|
|
|
} else if (rxSplit.length === 3) {
|
|
|
|
const userId = rxSplit[2];
|
|
|
|
channel = await this.client.users.fetch(userId);
|
|
|
|
if (!channel) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
await channel!.send(toDiscord);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
private fromSend(...data: any[]): { embeds?: object[]; content?: string } | null {
|
2020-12-05 16:13:06 +00:00
|
|
|
let response = util.format(data[0], ...data.slice(1));
|
|
|
|
if (!response) {
|
2021-10-01 17:31:10 +00:00
|
|
|
return null;
|
2020-12-05 16:13:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (Array.isArray(data[0])) {
|
|
|
|
try {
|
|
|
|
response = this.format.compose(data[0]);
|
2021-09-03 17:22:33 +00:00
|
|
|
} catch (e: any) {
|
2020-12-05 16:13:06 +00:00
|
|
|
logger.error('[%s] Failed to compose message:', this.fullName, e.message);
|
2021-10-01 17:31:10 +00:00
|
|
|
return null;
|
2020-12-05 16:13:06 +00:00
|
|
|
}
|
|
|
|
}
|
2021-03-06 09:37:49 +00:00
|
|
|
// Discord supports sending objects to the channel for things like embeds
|
|
|
|
else if (typeof data[0] === 'object') {
|
|
|
|
response = data[0];
|
|
|
|
}
|
2020-12-05 16:13:06 +00:00
|
|
|
|
2021-09-03 17:22:33 +00:00
|
|
|
const toDiscord: { embeds?: object[]; content?: string } = {};
|
|
|
|
if (typeof response === 'object') {
|
|
|
|
toDiscord.embeds = [ response ];
|
|
|
|
} else {
|
|
|
|
toDiscord.content = response;
|
|
|
|
}
|
|
|
|
|
2021-10-01 17:31:10 +00:00
|
|
|
return toDiscord;
|
2020-12-05 16:13:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@InjectService(DiscordProtocol)
|
|
|
|
@Configurable({instances: []})
|
|
|
|
class DiscordServicePlugin extends Plugin {
|
|
|
|
initialize(): void {
|
|
|
|
const protoList = this.validateConfiguration();
|
|
|
|
this.startAll(protoList);
|
|
|
|
}
|
|
|
|
|
|
|
|
private startAll(list: any[]): void {
|
|
|
|
for (const ins of list) {
|
2023-08-02 17:03:44 +00:00
|
|
|
const newProto = new DiscordProtocol(this, ins, DiscordProtocol.Features);
|
2020-12-05 16:13:06 +00:00
|
|
|
logger.log('[%s] Starting Discord service "%s".', this.name, ins.name);
|
|
|
|
this.monitor(newProto);
|
|
|
|
this.service?.use(newProto, true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private monitor(proto: Protocol): void {
|
|
|
|
proto.on('running', () => this.emit('protocolNew', proto));
|
|
|
|
proto.on('stopped', () => this.emit('protocolExit', proto));
|
|
|
|
}
|
|
|
|
|
|
|
|
private validateConfiguration(): any[] {
|
|
|
|
if (!this.config.config.instances) {
|
|
|
|
throw new Error('Configuration incomplete!');
|
|
|
|
}
|
|
|
|
|
|
|
|
const instances = this.config.config.instances;
|
|
|
|
const runnables: any[] = [];
|
|
|
|
for (const ins of instances) {
|
|
|
|
if (ins.enabled === false) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!ins.name || !ins.token) {
|
|
|
|
throw new Error('Invalid instance configuration!');
|
|
|
|
}
|
|
|
|
|
|
|
|
runnables.push(ins);
|
|
|
|
}
|
|
|
|
|
|
|
|
return runnables;
|
|
|
|
}
|
|
|
|
|
|
|
|
@EventListener('pluginUnload')
|
|
|
|
public unloadEventHandler(plugin: string | Plugin): void {
|
|
|
|
if (plugin === this.name || plugin === this) {
|
2021-10-02 09:33:51 +00:00
|
|
|
this.service?.stopAll().then(() =>
|
|
|
|
this.emit('pluginUnloaded', this));
|
2020-12-05 16:13:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = DiscordServicePlugin;
|