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 { 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;