service-discord/discord/plugin.ts

517 lines
13 KiB
TypeScript

import {
Plugin,
Configurable,
EventListener,
InjectService,
} from '@squeebot/core/lib/plugin';
import util from 'util';
import Discord, {
ClientUser,
GatewayIntentBits,
Message,
Partials,
PermissionsBitField,
TextChannel,
User,
} from 'discord.js';
import { logger } from '@squeebot/core/lib/core';
import {
thousandsSeparator,
timeSince,
toHHMMSS,
} from '@squeebot/core/lib/common';
import {
EMessageType,
Formatter,
IMessage,
IMessageTarget,
MarkdownFormatter,
Protocol,
ProtocolFeatureFlag,
} from '@squeebot/core/lib/types';
class DiscordFormatter extends MarkdownFormatter {
public compose(objs: any): any {
const embed = new Discord.EmbedBuilder();
for (const i in objs) {
const elem = objs[i];
const elemType = elem[0];
let elemValue = elem[1];
const elemParams = elem[2];
if (!elemValue || (elemType !== 'field' && elemType !== 'bold')) {
continue;
}
// Special types
if (elemParams?.type) {
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;
}
}
if (elemParams?.type === 'description') {
embed.setDescription(
embed.data.description
? (embed.data.description += `\n${elemValue}`)
: elemValue
);
continue;
}
if (elemParams?.type === 'title') {
embed.setTitle(elemValue);
if (elemParams.color) {
embed.setColor(elemParams.color.toUpperCase());
}
continue;
}
if (elemParams?.label) {
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.addFields([{ name: label, value: elemValue, inline: true }]);
} else {
embed.setDescription(
embed.data.description
? (embed.data.description += `\n${elemValue}`)
: elemValue
);
}
}
// May return an object, but your protocol must support it.
return embed;
}
}
class DiscordMessageAdapter implements IMessage {
public time: Date = new Date();
public resolved = false;
public direct = false;
public isReply = 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);
}
public reply(...args: any[]): void {
this.resolved = true;
this.isReply = true;
this.source.resolve(this, ...args);
}
public reject(error: Error): void {
this.resolved = true;
this.source.resolve(this, error.message);
}
public mention(user: IMessageTarget): string {
return `<@${user.id}>`;
}
}
class DiscordProtocol extends Protocol {
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.REPLY,
ProtocolFeatureFlag.MENTION,
ProtocolFeatureFlag.MULTISERVER,
ProtocolFeatureFlag.EVENT_MESSAGE,
ProtocolFeatureFlag.EVENT_ROOM_JOIN,
ProtocolFeatureFlag.EVENT_ROOM_LEAVE,
ProtocolFeatureFlag.KICK,
ProtocolFeatureFlag.BAN,
ProtocolFeatureFlag.MUTE,
];
public format: Formatter = new DiscordFormatter();
public type = 'DiscordProtocol';
private client = new Discord.Client({
intents: [
GatewayIntentBits.MessageContent,
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildScheduledEvents,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.DirectMessageTyping,
],
partials: [
Partials.Channel,
Partials.GuildMember,
Partials.Message,
Partials.User,
Partials.Reaction,
Partials.GuildScheduledEvent,
],
});
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;
}
});
this.attachEvents();
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);
}
);
}
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;
}
this.eventsAttached = true;
this.client.on('error', (e: Error) => {
this.emit('error', e.message);
});
this.client.on('disconnect', () => this.stop(true));
this.client.on('messageCreate', (message) => {
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 },
{
name: chanName,
id: message.channel.id,
server: message.guildId || undefined,
}
);
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 },
{ server: member.guild.id, id: '*', name: '*' }
);
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 },
{ server: member.guild.id, id: '*', name: '*' }
);
this.plugin.stream.emitTo('channel', 'event', newMessage);
});
}
public resolve(msg: DiscordMessageAdapter, ...data: any[]): void {
const toDiscord = this.fromSend(...data);
const msgData = msg.data as Message;
if (!toDiscord) {
return;
}
// Check send permission
if (msgData.guildId) {
const perms = (msgData.channel as TextChannel).permissionsFor(
this.client.user as ClientUser
);
if (perms && !perms.has(PermissionsBitField.Flags.SendMessages, true)) {
return;
}
}
// Send a reply
if (msg.isReply) {
msgData.reply(toDiscord).catch((e: Error) => {
logger.error(e);
});
return;
}
msgData.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.isThread() || channel.isTextBased()) ||
!channel.viewable
) {
return false;
}
// Check send permission
const perms = channel.permissionsFor(this.client.user as ClientUser);
if (perms) {
if (!perms.has(PermissionsBitField.Flags.SendMessages, 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 {
let response = util.format(data[0], ...data.slice(1));
if (!response) {
return null;
}
if (Array.isArray(data[0])) {
try {
response = this.format.compose(data[0]);
} catch (e: any) {
logger.error(
'[%s] Failed to compose message:',
this.fullName,
e.message
);
return null;
}
}
// Discord supports sending objects to the channel for things like embeds
else if (typeof data[0] === 'object') {
response = data[0];
}
const toDiscord: { embeds?: object[]; content?: string } = {};
if (typeof response === 'object') {
toDiscord.embeds = [response];
} else {
toDiscord.content = response;
}
return toDiscord;
}
}
@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) {
const newProto = new DiscordProtocol(this, ins, DiscordProtocol.Features);
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) {
this.service?.stopAll().then(() => this.emit('pluginUnloaded', this));
}
}
}
module.exports = DiscordServicePlugin;