diff --git a/lib/bot.ts b/lib/bot.ts index 412414c..c45a8d9 100644 --- a/lib/bot.ts +++ b/lib/bot.ts @@ -1,129 +1,49 @@ -import { - AllWebhookMessageOptions, - APIError, - ClientOptions, - DiscordAPIError, - Dlog, - escapeStringRegexp, - Member, - PKAPI, - Queue, - TTL, -} from './deps.ts'; -import { AllowedMentionType, Guild, Message, User } from './deps.ts'; -import { formatFromDiscordToIRC, formatFromIRCToDiscord } from './formatting.ts'; -import { DEFAULT_NICK_COLORS, wrap } from './colors.ts'; -import { delay, Dictionary, replaceAsync } from './helpers.ts'; -import { Config, GameLogConfig, IgnoreConfig } from './config.ts'; +import { Mediator } from './mediator.ts'; +import { ClientOptions, Dlog } from './deps.ts'; +import { Dictionary } from './helpers.ts'; +import { Config } from './config.ts'; import { ChannelMapper } from './channelMapping.ts'; import { DiscordClient } from './discordClient.ts'; import { CustomIrcClient } from './ircClient.ts'; +import { DEBUG, VERBOSE } from './env.ts'; -// Usernames need to be between 2 and 32 characters for webhooks: -const USERNAME_MIN_LENGTH = 2; -const USERNAME_MAX_LENGTH = 32; - -const patternMatch = /{\$(.+?)}/g; -const MILLISECONDS_PER_SECOND = 1000; - -/** - * An IRC bot, works as a middleman for all communication - * @param {object} options - server, nickname, channelMapping, outgoingToken, incomingURL, partialMatch - */ export default class Bot { + mediator?: Mediator; discord: DiscordClient; logger: Dlog; config: Config; - channels: string[]; - ignoreConfig?: IgnoreConfig; - gameLogConfig?: GameLogConfig; - formatIRCText: string; - formatURLAttachment: string; - formatCommandPrelude: string; - formatDiscord: string; - formatWebhookAvatarURL: string; - channelUsers: Dictionary>; + channelUsers: Dictionary> = {}; channelMapping: ChannelMapper | null = null; ircClient?: CustomIrcClient; - pkApi?: PKAPI; - pkQueue?: Queue; - pkCache?: TTL; - ircNickColors: string[] = DEFAULT_NICK_COLORS; - debug: boolean = (Deno.env.get('DEBUG') ?? Deno.env.get('VERBOSE') ?? 'false') - .toLowerCase() === 'true'; - verbose: boolean = (Deno.env.get('VERBOSE') ?? 'false').toLowerCase() === 'true'; + debug: boolean = DEBUG; + verbose: boolean = VERBOSE; exiting = false; constructor(config: Config) { this.config = config; - this.discord = new DiscordClient(this); - if (config.pluralKit) { - this.pkApi = new PKAPI(); - this.pkQueue = new Queue(); - const fiveMinutesInSeconds = 300; - if (config.pkCacheSeconds !== 0) { - this.pkCache = new TTL((config.pkCacheSeconds ?? fiveMinutesInSeconds) * MILLISECONDS_PER_SECOND); - } - } - // Make usernames lowercase for IRC ignore - if (this.config.ignoreConfig) { - this.config.ignoreConfig.ignorePingIrcUsers = this.config.ignoreConfig.ignorePingIrcUsers - ?.map((s) => s.toLocaleLowerCase()); - } if (config.logToFile) { this.logger = new Dlog(config.nickname, true, config.logFolder ?? '.'); } else { this.logger = new Dlog(config.nickname); } - this.channels = Object.values(config.channelMapping); - if (config.allowRolePings === undefined) { - config.allowRolePings = true; - } - - this.gameLogConfig = config.gameLogConfig; - this.ignoreConfig = config.ignoreConfig; - - // "{$keyName}" => "variableValue" - // displayUsername: nickname with wrapped colors - // attachmentURL: the URL of the attachment (only applicable in formatURLAttachment) - this.formatIRCText = config.format?.ircText || - '<{$displayUsername} [@{$discordUsername}]> {$text}'; - this.formatURLAttachment = config.format?.urlAttachment || - '<{$displayUsername}> {$attachmentURL}'; - - // "{$keyName}" => "variableValue" - // side: "Discord" or "IRC" - if (config.format && config.format.commandPrelude) { - this.formatCommandPrelude = config.format.commandPrelude; - } else { - this.formatCommandPrelude = 'Command sent from {$side} by {$nickname}:'; - } - - // "{$keyName}" => "variableValue" - // withMentions: text with appropriate mentions reformatted - this.formatDiscord = config.format?.discord || - '**<{$author}>** {$withMentions}'; - - // "{$keyName} => "variableValue" - // nickname: nickame of IRC message sender - this.formatWebhookAvatarURL = config.format?.webhookAvatarURL ?? ''; - - // Keep track of { channel => [list, of, usernames] } for ircStatusNotices - this.channelUsers = {}; - - if (config.ircNickColors) { - this.ircNickColors = config.ircNickColors; - } + this.discord = new DiscordClient( + config.discordToken, + this.channelUsers, + this.logger, + config.sendMessageUpdates ?? false, + ); } async connect() { this.debug && this.logger.debug('Initializing...'); - - this.logger.info('Connecting to Discord'); + if (this.ircClient) { + this.logger.info('Connecting to Discord'); + } await this.discord.connect(); // Extract id and token from Webhook urls and connect. this.channelMapping = await ChannelMapper.CreateAsync(this.config, this, this.discord); + // Create IRC client const ircOptions: ClientOptions = { nick: this.config.nickname, username: this.config.nickname, @@ -135,8 +55,16 @@ export default class Bot { }, ...this.config.ircOptions, }; - this.ircClient = new CustomIrcClient(ircOptions, this); + this.mediator = new Mediator( + this.discord, + this.ircClient, + this.config, + this.channelMapping, + this.channelUsers, + this.logger, + ); + await this.ircClient.connect(this.config.server, this.config.port, this.config.tls); } @@ -146,543 +74,4 @@ export default class Bot { this.ircClient?.disconnect(); await this.discord.destroy(); } - - async getDiscordUserByString(userString: string, guild: Guild | undefined) { - const members = await guild?.members.search(userString) ?? []; - return members.find((m) => m.user.username.toLocaleLowerCase() === userString.toLocaleLowerCase()); - } - - async replaceUserMentions( - content: string, - mention: User, - message: Message, - ): Promise { - if (!message.guild) return ''; - try { - const member = await message.guild.members.fetch(mention.id); - const displayName = member.nick || mention.displayName || mention.username; - - const userMentionRegex = RegExp(`<@(&|!)?${mention.id}>`, 'g'); - return content.replace(userMentionRegex, `@${displayName}`); - } catch (e) { - // Happens when a webhook is mentioned similar to a user, prevent 404 from crashing bot - if (e instanceof DiscordAPIError) { - this.logger.error(`Discord API error in user mention lookup, falling back to no mention:\n${e}`); - } else { - this.logger.error(e); - } - return ''; - } - } - - replaceNewlines(text: string): string { - return text.replace(/\n|\r\n|\r/g, ' '); - } - - async replaceChannelMentions(text: string): Promise { - return await replaceAsync( - text, - /<#(\d+)>/g, - async (_, channelId: string) => { - const channel = await this.discord.channels.fetch(channelId); - if (channel && channel.isGuildText()) return `#${channel.name}`; - return '#deleted-channel'; - }, - ); - } - - async replaceRoleMentions( - text: string, - message: Message, - ): Promise { - return await replaceAsync(text, /<@&(\d+)>/g, async (_, roleId) => { - const role = await message.guild?.roles.fetch(roleId); - if (role) return `@${role.name}`; - return '@deleted-role'; - }); - } - - replaceEmotes(text: string): string { - return text.replace(//g, (_, emoteName) => emoteName); - } - - async parseText(message: Message) { - let text = message.content; - for (const mention of message.mentions.users.values()) { - text = await this.replaceUserMentions(text, mention, message); - } - - return this.replaceEmotes( - await this.replaceRoleMentions( - await this.replaceChannelMentions(this.replaceNewlines(text)), - message, - ), - ); - } - - isCommandMessage(message: string) { - return this.config.commandCharacters?.some((prefix: string) => message.startsWith(prefix)) ?? false; - } - - ignoredIrcUser(user: string) { - return this.config.ignoreUsers?.irc?.some( - (i: string) => i.toLowerCase() === user.toLowerCase(), - ) ?? false; - } - - ignoredDiscordUser(discordUser: { username: string; id: string }) { - const ignoredName = this.config.ignoreUsers?.discord?.some( - (i) => i.toLowerCase() === discordUser.username.toLowerCase(), - ); - const ignoredId = this.config.ignoreUsers?.discordIds?.some( - (i) => i === discordUser.id, - ); - return ignoredName || ignoredId || false; - } - - static substitutePattern( - message: string, - patternMapping: { - [x: string]: any; - author?: any; - nickname?: any; - displayUsername?: any; - discordUsername?: any; - text?: any; - discordChannel?: string; - ircChannel?: any; - }, - ) { - return message.replace( - patternMatch, - (match: any, varName: string | number) => patternMapping[varName] || match, - ); - } - - sendIRCMessageWithSplitAndQueue(ircChannel: string, input: string) { - // Split up the string and use `reduce` - // to iterate over it - const accumulatedChunks = input.split(' ').reduce((accumulator: string[][], fragment: string) => { - // Get the number of nested arrays - const currIndex = accumulator.length - 1; - - // Join up the last array and get its length - const currLen = accumulator[currIndex].join(' ').length; - - // If the length of that content and the new word - // in the iteration exceeds 400 chars push the new - // word to a new array - if (currLen + fragment.length > 400) { - accumulator.push([fragment]); - - // otherwise add it to the existing array - } else { - accumulator[currIndex].push(fragment); - } - - return accumulator; - }, [[]]); - - // Join up all the nested arrays - const messageChunks = accumulatedChunks.map((arr) => arr.join(' ')); - - for (const chunk of messageChunks) { - this.ircClient?.privmsg(ircChannel, chunk.trim()); - } - } - - filterMessageTags(members: Member[], message: Message) { - const tags = members.flatMap((m) => m.proxy_tags ?? []); - for (const tag of tags) { - if (!(tag.prefix || tag.suffix)) continue; - const prefix = escapeStringRegexp(tag.prefix ?? ''); - const suffix = escapeStringRegexp(tag.suffix ?? ''); - const regex = new RegExp(`^${prefix}.*${suffix}$`); - if (regex.test(message.content)) { - return true; - } - } - return false; - } - - async testForPluralKitMessage(author: User, message: Message): Promise { - if (!this.pkApi) return false; - try { - const cachedMembers = this.pkCache?.get(author.id); - if (cachedMembers) { - return this.filterMessageTags(cachedMembers, message); - } else { - const system = await this.pkApi.getSystem({ system: author.id }); - const membersMap = await this.pkApi.getMembers({ system: system.id }); - const members = Array.from(membersMap.values()); - this.pkCache?.set(author.id, members); - return this.filterMessageTags(members, message); - } - // An exception means the user was not in PK or we are rate limited usually - } catch (e) { - if (e instanceof APIError && e.message === '429: too many requests') { - // Ensure API requests are dispatched in single queue despite potential burst messaging - return await this.pkQueue?.push(async () => { - // Wait one second for API to be ready - await delay(1000); - return await this.testForPluralKitMessage(author, message); - }) ?? false; - } - return false; - } - } - - async sendToIRC(message: Message, updated = false) { - const { author } = message; - // Ignore messages sent by the bot itself: - if (author.id === this.discord.user?.id || this.channelMapping?.webhooks.find((w) => w.id === message.author.id)) { - return; - } - - // Do not send to IRC if this user is on the ignore list. - if (this.ignoredDiscordUser(author)) { - return; - } - - const channel = message.channel; - if (!channel.isGuildText()) return; - const channelName = `#${channel.name}`; - const ircChannel = this.channelMapping?.discordIdToMapping.get(channel.id)?.ircChannel; - - if (!ircChannel) return; - const fromGuild = message.guild; - if (!fromGuild) return; - let displayUsername = ''; - let discordUsername = ''; - const member = await fromGuild.members.get(author.id); - if (member) { - displayUsername = member.nick || author.displayName || author.username; - discordUsername = member.user.username; - } else { - // Author is a webhook - displayUsername = message.author.displayName; - discordUsername = message.author.username; - } - - // Check for PluralKit proxying - if (!author.bot && await this.testForPluralKitMessage(author, message)) { - return; - } - - let text = await this.parseText(message); - - if (this.config.ircNickColor) { - const displayColorIdx = (displayUsername.charCodeAt(0) + displayUsername.length) % - this.ircNickColors.length ?? 0; - const discordColorIdx = (discordUsername.charCodeAt(0) + discordUsername.length) % - this.ircNickColors.length ?? 0; - displayUsername = wrap( - this.ircNickColors[displayColorIdx], - displayUsername, - ); - discordUsername = wrap( - this.ircNickColors[discordColorIdx], - discordUsername, - ); - } - - const patternMap = { - author: displayUsername, - nickname: displayUsername, - displayUsername, - discordUsername, - text, - discordChannel: channelName, - ircChannel, - attachmentURL: '', - }; - - if (this.isCommandMessage(text) && !updated) { - //patternMap.side = 'Discord'; - this.debug && this.logger.debug( - `Sending command message to IRC ${ircChannel} -- ${text}`, - ); - // if (prelude) this.ircClient.say(ircChannel, prelude); - if (this.formatCommandPrelude) { - const prelude = Bot.substitutePattern( - this.formatCommandPrelude, - patternMap, - ); - this.ircClient?.privmsg(ircChannel, prelude); - } - this.sendIRCMessageWithSplitAndQueue(ircChannel, text); - } else { - if (text !== '') { - // Convert formatting - text = formatFromDiscordToIRC(text); - patternMap.text = text; - - text = Bot.substitutePattern(this.formatIRCText, patternMap); - this.debug && this.logger.debug( - `Sending ${updated ? 'edit' : 'message'} to IRC ${ircChannel} -- ${text}`, - ); - if (updated) { - text = `(edited) ${text}`; - } - this.sendIRCMessageWithSplitAndQueue(ircChannel, text); - } - - if (message.attachments && message.attachments.length && !updated) { - message.attachments.forEach((a) => { - patternMap.attachmentURL = a.url; - const urlMessage = Bot.substitutePattern( - this.formatURLAttachment, - patternMap, - ); - - this.debug && this.logger.debug( - `Sending attachment URL to IRC ${ircChannel} ${urlMessage}`, - ); - this.sendIRCMessageWithSplitAndQueue(ircChannel, urlMessage); - }); - } - } - } - - findDiscordChannel(ircChannel: string) { - return this.channelMapping?.ircNameToMapping.get(ircChannel.toLowerCase())?.discordChannel; - } - - findWebhook(ircChannel: string) { - return this.channelMapping?.ircNameToMapping.get(ircChannel.toLowerCase())?.webhook; - } - - async getDiscordAvatar(nick: string, channel: string) { - nick = nick.toLowerCase(); - const channelRef = this.findDiscordChannel(channel); - if (!channelRef?.isGuildText()) return null; - const guildMembers = await channelRef.guild.members.search(nick); - - // Try to find exact matching case - // No matching user or more than one => default avatar - if (guildMembers) { - const member = guildMembers.find((m) => - [m.user.username, m.user.displayName, m.nick].find((s) => s?.toLowerCase() === nick.toLowerCase()) - ); - const url = member?.avatarURL(); - if (url) return url; - } - - // If there isn't a URL format, don't send an avatar at all - if (this.formatWebhookAvatarURL) { - return Bot.substitutePattern(this.formatWebhookAvatarURL, { - nickname: nick, - }); - } - return null; - } - - // compare two strings case-insensitively - // for discord mention matching - static caseComp(str1: string, str2: string) { - return str1.toUpperCase() === str2.toUpperCase(); - } - - // check if the first string starts with the second case-insensitively - // for discord mention matching - static caseStartsWith(str1: string, str2: string) { - return str1.toUpperCase().startsWith(str2.toUpperCase()); - } - - static shouldIgnoreMessage( - text: string, - ircChannel: string, - config: IgnoreConfig, - ): boolean { - if (!config.ignorePatterns) return false; - if (!config.ignorePatterns[ircChannel]) return false; - for (const pattern of config.ignorePatterns[ircChannel]) { - if (text.indexOf(pattern) !== -1) { - return true; - } - } - return false; - } - - async sendToDiscord(author: string, ircChannel: string, text: string) { - if ( - this.ignoreConfig && - Bot.shouldIgnoreMessage(text, ircChannel, this.ignoreConfig) - ) { - return; - } - const discordChannel = this.findDiscordChannel(ircChannel); - if (!discordChannel) return; - const channelName = discordChannel.mention; - - // Do not send to Discord if this user is on the ignore list. - if (this.ignoredIrcUser(author)) { - return; - } - - // Convert text formatting (bold, italics, underscore) - const withFormat = formatFromIRCToDiscord(text, author, this.gameLogConfig); - - const patternMap = { - author, - nickname: author, - displayUsername: author, - text: withFormat, - discordChannel: `#${channelName}`, - ircChannel: ircChannel, - withMentions: '', - side: '', - }; - - if (this.isCommandMessage(text)) { - patternMap.side = 'IRC'; - this.debug && this.logger.debug( - `Sending command message to Discord #${channelName} -- ${text}`, - ); - if (this.formatCommandPrelude) { - const prelude = Bot.substitutePattern( - this.formatCommandPrelude, - patternMap, - ); - if (discordChannel.isGuildText()) { - discordChannel.send(prelude); - } - } - if (discordChannel.isGuildText()) { - discordChannel.send(text); - } - return; - } - - let guild: Guild | undefined = undefined; - if (discordChannel.isGuildText()) { - guild = discordChannel.guild; - } - const roles = await guild?.roles.fetchAll(); - if (!roles) return; - const channels = await guild?.channels.array(); - if (!channels) return; - - const processMentionables = async (input: string) => { - if (this.config.ignoreConfig?.ignorePingIrcUsers?.includes(author.toLocaleLowerCase())) return input; - return await replaceAsync( - input, - /([^@\s:,]+):|@([^\s]+)/g, - async (match, colonRef, atRef) => { - const reference = colonRef || atRef; - const member = await this.getDiscordUserByString(reference, guild); - - // @username => mention, case insensitively - if (member) return `<@${member.id}>`; - - if (!this.config.allowRolePings) return match; - // @role => mention, case insensitively - const role = roles.find( - (x) => x.mentionable && Bot.caseComp(x.name, reference), - ); - if (role) return `<@&${role.id}>`; - return match; - }, - ); - }; - - const processEmoji = async (input: string) => { - return await replaceAsync(input, /:(\w+):/g, async (match, ident) => { - // :emoji: => mention, case sensitively - const emoji = (await guild?.emojis.array())?.find((x) => x.name === ident && x.requireColons); - if (emoji) return `${emoji.name}`; - - return match; - }); - }; - - const processChannels = (input: string) => { - return input.replace(/#([^\s#@'!?,.]+)/g, (match, channelName) => { - // channel names can't contain spaces, #, @, ', !, ?, , or . - // (based on brief testing. they also can't contain some other symbols, - // but these seem likely to be common around channel references) - - // discord matches channel names case insensitively - const chan = channels.find((x) => Bot.caseComp(x.name, channelName)); - return chan?.name ? `${chan.mention}` : match; - }); - }; - - const withMentions = processChannels( - await processEmoji( - await processMentionables(withFormat), - ), - ); - - // Webhooks first - const webhook = this.findWebhook(ircChannel); - if (webhook) { - if (discordChannel.isGuildText()) { - this.debug && this.logger.debug( - `Sending message to Discord via webhook ${withMentions} ${ircChannel} -> #${discordChannel.name}`, - ); - } - if (this.discord.user === null) return; - // const permissions = discordChannel.permissionsFor(this.discord.user); - const canPingEveryone = false; - /* - if (permissions) { - canPingEveryone = permissions.has(discord.Permissions.FLAGS.MENTION_EVERYONE); - } - */ - const avatarURL = (await this.getDiscordAvatar(author, ircChannel)) ?? - undefined; - const username = author.substring(0, USERNAME_MAX_LENGTH).padEnd( - USERNAME_MIN_LENGTH, - '_', - ); - const payload: AllWebhookMessageOptions = { - name: username, - avatar: avatarURL, - allowedMentions: { - parse: canPingEveryone - ? [ - AllowedMentionType.Roles, - AllowedMentionType.Users, - AllowedMentionType.Everyone, - ] - : [AllowedMentionType.Roles, AllowedMentionType.Users], - replied_user: true, - }, - }; - try { - await webhook.client.send(withMentions, payload); - } catch (e) { - this.logger.error( - `Received error on webhook send: ${JSON.stringify(e, null, 2)}`, - ); - } - return; - } - - patternMap.withMentions = withMentions; - - // Add bold formatting: - // Use custom formatting from config / default formatting with bold author - const withAuthor = Bot.substitutePattern(this.formatDiscord, patternMap); - if (discordChannel.isGuildText()) { - this.debug && this.logger.debug( - `Sending message to Discord ${withAuthor} ${ircChannel} -> #${discordChannel.name}`, - ); - discordChannel.send(withAuthor); - } - } - - /* Sends a message to Discord exactly as it appears */ - async sendExactToDiscord(channel: string, text: string) { - const discordChannel = await this.findDiscordChannel(channel); - if (!discordChannel) return; - - if (discordChannel.isGuildText()) { - this.debug && this.logger.debug( - `Sending special message to Discord ${text} ${channel} -> #${discordChannel.name}`, - ); - await discordChannel.send(text); - } - } } diff --git a/lib/cache/asyncCache.ts b/lib/cache/asyncCache.ts new file mode 100644 index 0000000..2787907 --- /dev/null +++ b/lib/cache/asyncCache.ts @@ -0,0 +1,23 @@ +import { TTL } from '../deps.ts'; + +export abstract class AsyncCache { + ttl: TTL; + + constructor(ttlMilliseconds: number) { + this.ttl = new TTL(ttlMilliseconds); + } + + protected abstract fetch(id: string): Promise; + + async get(id: string) { + let result = this.ttl.get(id); + if (result) return result; + result = await this.fetch(id); + this.ttl.set(id, result); + return result; + } + + set(id: string, val: T) { + this.ttl.set(id, val); + } +} diff --git a/lib/cache/guildMemberCache.ts b/lib/cache/guildMemberCache.ts new file mode 100644 index 0000000..c0b455d --- /dev/null +++ b/lib/cache/guildMemberCache.ts @@ -0,0 +1,15 @@ +import { AsyncCache } from './asyncCache.ts'; +import { Guild, GuildMember } from '../deps.ts'; + +const TTL = 30_000; + +export class GuildMemberCache extends AsyncCache { + guild: Guild; + constructor(guild: Guild) { + super(TTL); + this.guild = guild; + } + async fetch(id: string): Promise { + return await this.guild.members.fetch(id); + } +} diff --git a/lib/cache/memberRoleCache.ts b/lib/cache/memberRoleCache.ts new file mode 100644 index 0000000..909b240 --- /dev/null +++ b/lib/cache/memberRoleCache.ts @@ -0,0 +1,16 @@ +import { Role } from '../deps.ts'; +import { AsyncCache } from './asyncCache.ts'; +import { GuildMemberCache } from './guildMemberCache.ts'; + +const TTL = 30_000; +export class MemberRoleCache extends AsyncCache { + memberCache: GuildMemberCache; + constructor(memberCache: GuildMemberCache) { + super(TTL); + this.memberCache = memberCache; + } + async fetch(id: string): Promise { + const member = await this.memberCache.get(id); + return await member.roles.array(); + } +} diff --git a/lib/config.ts b/lib/config.ts index 7bcbdbd..749c401 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -14,6 +14,7 @@ export type IgnoreUsers = { irc?: string[]; discord?: string[]; discordIds?: string[]; + roles?: string[]; }; export type GameLogConfig = { @@ -78,6 +79,7 @@ export const IgnoreUsersSchema = z.object({ irc: z.array(z.string()).optional(), discord: z.array(z.string()).optional(), discordIds: z.array(z.string()).optional(), + roles: z.array(z.string()).optional(), }); export const GameLogConfigSchema = z.object({ diff --git a/lib/deps.ts b/lib/deps.ts index b1b743c..b4e8a64 100644 --- a/lib/deps.ts +++ b/lib/deps.ts @@ -24,7 +24,9 @@ export { GatewayIntents, Guild, GuildTextChannel, + Member as GuildMember, Message, + Role, User, Webhook, } from 'https://raw.githubusercontent.com/harmonyland/harmony/main/mod.ts'; @@ -38,7 +40,11 @@ export { parse as parseJSONC } from 'https://deno.land/std@0.203.0/jsonc/mod.ts' import Dlog from 'https://deno.land/x/dlog2@2.0/classic.ts'; export { Dlog }; // PluralKit support -export { APIError, Member, PKAPI } from 'https://raw.githubusercontent.com/aronson/pkapi.ts/main/lib/mod.ts'; +export { + APIError, + Member as PKMember, + PKAPI, +} from 'https://raw.githubusercontent.com/aronson/pkapi.ts/main/lib/mod.ts'; // Queue export { Queue } from 'https://deno.land/x/queue@1.2.0/mod.ts'; // Time to Live cache diff --git a/lib/discordClient.ts b/lib/discordClient.ts index 5bb72fd..0c14590 100644 --- a/lib/discordClient.ts +++ b/lib/discordClient.ts @@ -1,21 +1,30 @@ -import Bot from './bot.ts'; -import { escapeMarkdown } from './helpers.ts'; -import { Command, CommandClient, CommandContext, event, GatewayIntents, Message } from './deps.ts'; +import { ChannelMapper } from './channelMapping.ts'; +import { Dictionary, escapeMarkdown } from './helpers.ts'; +import { Command, CommandClient, CommandContext, Dlog, event, GatewayIntents, Message } from './deps.ts'; +import { DEBUG, VERBOSE } from './env.ts'; class Names extends Command { name = 'names'; - private bot: Bot; + private channelMapping?: ChannelMapper; + private channelUsers: Dictionary; + private logger: Dlog; - constructor(bot: Bot) { + constructor(channelUsers: Dictionary>, logger: Dlog) { super(); - this.bot = bot; + this.channelUsers = channelUsers; + this.logger = logger; + this.execute = this.execute.bind(this); + } + + bindMap(map: ChannelMapper) { + this.channelMapping = map; } async execute(ctx: CommandContext): Promise { - const ircChannel = this.bot?.channelMapping?.discordIdToMapping.get(ctx.channel.id)?.ircChannel; + const ircChannel = this.channelMapping?.discordIdToMapping.get(ctx.channel.id)?.ircChannel; // return early if message was in channel we don't post to if (!ircChannel) return; - const users = this.bot?.channelUsers[ircChannel]; + const users = this.channelUsers[ircChannel]; if (users && users.length > 0) { const ircNamesArr = new Array(...users); await ctx.message.reply( @@ -26,7 +35,7 @@ class Names extends Command { }`, ); } else { - this.bot.logger.warn( + this.logger.warn( `No channelUsers found for ${ircChannel} when /names requested`, ); } @@ -34,8 +43,15 @@ class Names extends Command { } export class DiscordClient extends CommandClient { - private bot: Bot; - constructor(bot: Bot) { + private logger: Dlog; + private sendMessageUpdates: boolean; + private names: Names; + constructor( + discordToken: string, + channelUsers: Dictionary, + logger: Dlog, + sendMessageUpdates: boolean, + ) { super({ prefix: '/', caseSensitive: false, @@ -45,57 +61,49 @@ export class DiscordClient extends CommandClient { GatewayIntents.GUILD_MESSAGES, GatewayIntents.MESSAGE_CONTENT, ], - token: bot.config.discordToken, + token: discordToken, }); - this.bot = bot; + this.logger = logger; + this.sendMessageUpdates = sendMessageUpdates; // Reconnect event has to be hooked manually due to naming conflict - this.on('reconnect', (shardId) => this.bot?.logger.info(`Reconnected to Discord (shard ID ${shardId})`)); - this.commands.add(new Names(bot)); + this.on('reconnect', (shardId) => logger.info(`Reconnected to Discord (shard ID ${shardId})`)); + this.names = new Names(channelUsers, logger); + this.commands.add(this.names); + } + + bindNotify(notify: (m: Message, b: boolean) => Promise, mapper: ChannelMapper) { + this.on('messageCreate', async (ev) => await notify(ev, false)); + this.on('messageUpdate', async (ev) => { + if (!this.sendMessageUpdates) return; + await notify(ev, true); + }); + this.names.bindMap(mapper); } @event() ready(): void { - this.bot.logger.done('Connected to Discord'); + this.logger.done('Connected to Discord'); } @event() error(error: Error): void { - this.bot.logger.error('Received error event from Discord'); + this.logger.error('Received error event from Discord'); console.log(error); } - @event() - async messageCreate(message: Message): Promise { - if (message.content.trim() === '/names') return; - if (!message.channel.isGuildText()) return; - // return early if message was in channel we don't post to - if (!(this.bot.channelMapping?.discordIdToMapping.get(message.channel.id))) { - return; - } - const ircChannel = this.bot?.channelMapping?.discordIdToMapping.get(message.channel.id)?.ircChannel; - if (!ircChannel) return; - await this.bot.sendToIRC(message); - } - - @event() - async messageUpdate(_: Message, message: Message): Promise { - if (!this.bot.config.sendMessageUpdates) return; - await this.bot.sendToIRC(message, true); - } - @event() debug(message: string): void { - if (!this.bot.verbose && containsIgnoredMessage(message)) { + if (!VERBOSE && containsIgnoredMessage(message)) { return; } - if (!this.bot.debug) return; - this.bot.logger.debug( + if (!DEBUG) return; + this.logger.debug( `Received debug event from Discord: ${JSON.stringify(message, null, 2)}`, ); } } -const ignoreMessages = [/Heartbeat ack/, /heartbeat sent/]; +const ignoreMessages = [/Heartbeat ack/, /heartbeat sent/, /Shard/]; function containsIgnoredMessage(str: string): boolean { return ignoreMessages.some((regex) => regex.test(str)); diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 0000000..6584232 --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,4 @@ +const debug = 'DEBUG'; +const verbose = 'VERBOSE'; +export const DEBUG = (Deno.env.get(debug) ?? Deno.env.get(verbose) ?? 'false').toLowerCase() === 'true'; +export const VERBOSE = (Deno.env.get('VERBOSE') ?? 'false').toLowerCase() === 'true'; diff --git a/lib/ircClient.ts b/lib/ircClient.ts index 5c3ae6f..9b6c80d 100644 --- a/lib/ircClient.ts +++ b/lib/ircClient.ts @@ -1,10 +1,10 @@ +import { Mediator } from './mediator.ts'; import { ChannelMapper, ChannelMapping } from './channelMapping.ts'; import Bot from './bot.ts'; import { AnyRawCommand, ClientError, ClientOptions, - CtcpActionEvent, Dlog, InviteEvent, IrcClient, @@ -13,7 +13,6 @@ import { NicklistEvent, NoticeEvent, PartEvent, - PrivmsgEvent, QuitEvent, RegisterEvent, RemoteAddr, @@ -32,8 +31,7 @@ const Event = (name: string) => Reflect.metadata('event', name); export class CustomIrcClient extends IrcClient { channelUsers: Dictionary; channelMapping: ChannelMapper; - sendToDiscord: (author: string, ircChannel: string, text: string) => Promise; - sendExactToDiscord: (channel: string, text: string) => Promise; + sendExactToDiscord: (channel: string, message: string) => Promise; exiting: () => boolean; botNick: string; debug: boolean; @@ -50,12 +48,11 @@ export class CustomIrcClient extends IrcClient { this.debug = bot.debug; this.autoSendCommands = bot.config.autoSendCommands; this.channelMapping = bot.channelMapping; - this.sendToDiscord = bot.sendToDiscord.bind(bot); - this.sendExactToDiscord = bot.sendExactToDiscord.bind(bot); this.ircStatusNotices = bot.config.ircStatusNotices; this.announceSelfJoin = bot.config.announceSelfJoin; this.exiting = () => bot.exiting; this.bindEvents(); + this.sendExactToDiscord = async () => {}; } // Bind event handlers to base client through Reflect metadata and bind each handler to this instance bindEvents() { @@ -122,13 +119,26 @@ export class CustomIrcClient extends IrcClient { `Received error event from IRC\n${JSON.stringify(error, null, 2)}`, ); } - @Event('privmsg:channel') - async onPrivMessage(event: PrivmsgEvent) { - await this.sendToDiscord( - event.source?.name ?? '', - event.params.target, - event.params.text, - ); + bindNotify( + fn: (author: string, channel: string, message: string, raw: boolean) => Promise, + mediator: Mediator, + ) { + const raw = false; + this.on('privmsg:channel', async (event) => + await fn( + event.source?.name ?? '', + event.params.target, + event.params.text, + raw, + )); + this.on('ctcp_action', async (event) => + await fn( + event.source?.name ?? '', + event.params.target, + `_${event.params.text}_`, + raw, + )); + this.sendExactToDiscord = mediator.sendExactToDiscord; } @Event('notice') onNotice(event: NoticeEvent) { @@ -261,14 +271,6 @@ export class CustomIrcClient extends IrcClient { const channel = channelName.toLowerCase(); this.channelUsers[channel] = nicks.map((n) => n.nick); } - @Event('ctcp_action') - async onAction(event: CtcpActionEvent) { - await this.sendToDiscord( - event.source?.name ?? '', - event.params.target, - `_${event.params.text}_`, - ); - } @Event('invite') onInvite(event: InviteEvent) { const channel = event.params.channel; diff --git a/lib/mediator.ts b/lib/mediator.ts new file mode 100644 index 0000000..2f91c37 --- /dev/null +++ b/lib/mediator.ts @@ -0,0 +1,686 @@ +import { DEBUG } from './env.ts'; +import { ChannelMapper } from './channelMapping.ts'; +import { Config, GameLogConfig, IgnoreConfig, IgnoreUsers } from './config.ts'; +import { CustomIrcClient, CustomIrcClient as IrcClient } from './ircClient.ts'; +import { DiscordClient } from './discordClient.ts'; +import { + AllowedMentionType, + AllWebhookMessageOptions, + APIError, + DiscordAPIError, + Dlog, + escapeStringRegexp, + Guild, + Message, + Message as DiscordMessage, + PKAPI, + PKMember, + Queue, + TTL, + User, +} from './deps.ts'; +import { delay, Dictionary, replaceAsync } from './helpers.ts'; +import { formatFromDiscordToIRC, formatFromIRCToDiscord } from './formatting.ts'; +import { DEFAULT_NICK_COLORS, wrap } from './colors.ts'; + +// Usernames need to be between 2 and 32 characters for webhooks: +const USERNAME_MIN_LENGTH = 2; +const USERNAME_MAX_LENGTH = 32; + +const MILLISECONDS_PER_SECOND = 1000; + +/** + * Parses, transforms, and marshals messages emitted from Discord or IRC to the other. + */ +export class Mediator { + discord: DiscordClient; + irc: IrcClient; + channelMapping: ChannelMapper; + debug: boolean = DEBUG; + logger: Dlog; + config: Config; + channels: string[]; + ignoreConfig?: IgnoreConfig; + gameLogConfig?: GameLogConfig; + formatIRCText: string; + formatURLAttachment: string; + formatCommandPrelude: string; + formatDiscord: string; + formatWebhookAvatarURL: string; + channelUsers: Dictionary>; + ircClient?: CustomIrcClient; + pkApi?: PKAPI; + pkQueue?: Queue; + pkCache?: TTL; + ircNickColors: string[] = DEFAULT_NICK_COLORS; + commandCharacters: string[]; + allowRolePings: boolean; + ignoreUsers?: IgnoreUsers; + constructor( + discord: DiscordClient, + irc: IrcClient, + config: Config, + mapper: ChannelMapper, + channelUsers: Dictionary>, + logger: Dlog, + ) { + this.config = config; + this.channelMapping = mapper; + this.discord = discord; + this.irc = irc; + this.logger = logger; + this.channelUsers = channelUsers; + if (config.pluralKit) { + this.pkApi = new PKAPI(); + this.pkQueue = new Queue(); + const fiveMinutesInSeconds = 300; + if (config.pkCacheSeconds !== 0) { + this.pkCache = new TTL((config.pkCacheSeconds ?? fiveMinutesInSeconds) * MILLISECONDS_PER_SECOND); + } + } + // Make usernames lowercase for IRC ignore + if (this.config.ignoreConfig) { + this.config.ignoreConfig.ignorePingIrcUsers = this.config.ignoreConfig.ignorePingIrcUsers + ?.map((s) => s.toLocaleLowerCase()); + } + this.channels = Object.values(config.channelMapping); + if (config.allowRolePings === undefined) { + this.allowRolePings = true; + } else { + this.allowRolePings = config.allowRolePings; + } + + this.gameLogConfig = config.gameLogConfig; + this.ignoreConfig = config.ignoreConfig; + + // "{$keyName}" => "variableValue" + // displayUsername: nickname with wrapped colors + // attachmentURL: the URL of the attachment (only applicable in formatURLAttachment) + this.formatIRCText = config.format?.ircText || + '<{$displayUsername} [@{$discordUsername}]> {$text}'; + this.formatURLAttachment = config.format?.urlAttachment || + '<{$displayUsername}> {$attachmentURL}'; + + // "{$keyName}" => "variableValue" + // side: "Discord" or "IRC" + if (config.format && config.format.commandPrelude) { + this.formatCommandPrelude = config.format.commandPrelude; + } else { + this.formatCommandPrelude = 'Command sent from {$side} by {$nickname}:'; + } + + // "{$keyName}" => "variableValue" + // withMentions: text with appropriate mentions reformatted + this.formatDiscord = config.format?.discord || + '**<{$author}>** {$withMentions}'; + + // "{$keyName} => "variableValue" + // nickname: nickame of IRC message sender + this.formatWebhookAvatarURL = config.format?.webhookAvatarURL ?? ''; + + if (config.ircNickColors) { + this.ircNickColors = config.ircNickColors; + } + this.commandCharacters = config.commandCharacters ?? []; + + this.bindMethods(); + irc.bindNotify(this.notifyToDiscord, this); + discord.bindNotify(this.notifyToIrc, mapper); + } + + bindMethods() { + this.notifyToIrc = this.notifyToIrc.bind(this); + this.shouldIgnoreByPattern = this.shouldIgnoreByPattern.bind(this); + this.ignoredIrcUser = this.ignoredIrcUser.bind(this); + this.findDiscordChannel = this.findDiscordChannel.bind(this); + this.isCommandMessage = this.isCommandMessage.bind(this); + this.getDiscordUserByString = this.getDiscordUserByString.bind(this); + this.getDiscordAvatar = this.getDiscordAvatar.bind(this); + this.findWebhook = this.findWebhook.bind(this); + this.notifyToDiscord = this.notifyToDiscord.bind(this); + } + filterMessageTags(members: PKMember[], message: Message) { + const tags = members.flatMap((m) => m.proxy_tags ?? []); + for (const tag of tags) { + if (!(tag.prefix || tag.suffix)) continue; + const prefix = escapeStringRegexp(tag.prefix ?? ''); + const suffix = escapeStringRegexp(tag.suffix ?? ''); + const regex = new RegExp(`^${prefix}.*${suffix}$`); + if (regex.test(message.content)) { + return true; + } + } + return false; + } + + async testForPluralKitMessage(author: User, message: Message): Promise { + if (!this.pkApi) return false; + try { + const cachedMembers = this.pkCache?.get(author.id); + if (cachedMembers) { + return this.filterMessageTags(cachedMembers, message); + } else { + const system = await this.pkApi.getSystem({ system: author.id }); + const membersMap = await this.pkApi.getMembers({ system: system.id }); + const members = Array.from(membersMap.values()); + this.pkCache?.set(author.id, members); + return this.filterMessageTags(members, message); + } + // An exception means the user was not in PK or we are rate limited usually + } catch (e) { + if (e instanceof APIError && e.message === '429: too many requests') { + // Ensure API requests are dispatched in single queue despite potential burst messaging + return await this.pkQueue?.push(async () => { + // Wait one second for API to be ready + await delay(1000); + return await this.testForPluralKitMessage(author, message); + }) ?? false; + } + return false; + } + } + + async replaceUserMentions( + content: string, + mention: User, + message: Message, + ): Promise { + if (!message.guild) return ''; + try { + const member = await message.guild.members.fetch(mention.id); + const displayName = member.nick || mention.displayName || mention.username; + + const userMentionRegex = RegExp(`<@(&|!)?${mention.id}>`, 'g'); + return content.replace(userMentionRegex, `@${displayName}`); + } catch (e) { + // Happens when a webhook is mentioned similar to a user, prevent 404 from crashing bot + if (e instanceof DiscordAPIError) { + this.logger.error(`Discord API error in user mention lookup, falling back to no mention:\n${e}`); + } else { + this.logger.error(e); + } + return ''; + } + } + + replaceNewlines(text: string): string { + return text.replace(/\n|\r\n|\r/g, ' '); + } + + async replaceChannelMentions(text: string): Promise { + return await replaceAsync( + text, + /<#(\d+)>/g, + async (_, channelId: string) => { + const channel = await this.discord.channels.fetch(channelId); + if (channel && channel.isGuildText()) return `#${channel.name}`; + return '#deleted-channel'; + }, + ); + } + + async replaceRoleMentions( + text: string, + message: Message, + ): Promise { + return await replaceAsync(text, /<@&(\d+)>/g, async (_, roleId) => { + const role = await message.guild?.roles.fetch(roleId); + if (role) return `@${role.name}`; + return '@deleted-role'; + }); + } + + replaceEmotes(text: string): string { + return text.replace(//g, (_, emoteName) => emoteName); + } + + async parseText(message: Message) { + let text = message.content; + for (const mention of message.mentions.users.values()) { + text = await this.replaceUserMentions(text, mention, message); + } + + return this.replaceEmotes( + await this.replaceRoleMentions( + await this.replaceChannelMentions(this.replaceNewlines(text)), + message, + ), + ); + } + + ignoredDiscordUser(discordUser: User) { + const ignoredName = this.ignoreUsers?.discord?.some( + (i) => i.toLowerCase() === discordUser.username.toLowerCase(), + ); + const ignoredId = this.ignoreUsers?.discordIds?.some( + (i) => i === discordUser.id, + ); + return ignoredName || ignoredId || false; + } + + sendIRCMessageWithSplitAndQueue(ircChannel: string, input: string) { + // Split up the string and use `reduce` + // to iterate over it + const accumulatedChunks = input.split(' ').reduce((accumulator: string[][], fragment: string) => { + // Get the number of nested arrays + const currIndex = accumulator.length - 1; + + // Join up the last array and get its length + const currLen = accumulator[currIndex].join(' ').length; + + // If the length of that content and the new word + // in the iteration exceeds 400 chars push the new + // word to a new array + if (currLen + fragment.length > 400) { + accumulator.push([fragment]); + + // otherwise add it to the existing array + } else { + accumulator[currIndex].push(fragment); + } + + return accumulator; + }, [[]]); + + // Join up all the nested arrays + const messageChunks = accumulatedChunks.map((arr) => arr.join(' ')); + + for (const chunk of messageChunks) { + this.irc.privmsg(ircChannel, chunk.trim()); + } + } + + async sendToIRC(message: Message, updated = false) { + const { author } = message; + // Ignore messages sent by the bot itself: + if (author.id === this.discord.user?.id || this.channelMapping?.webhooks.find((w) => w.id === message.author.id)) { + return; + } + + // Do not send to IRC if this user is on the ignore list. + if (this.ignoredDiscordUser(author)) { + return; + } + + const channel = message.channel; + if (!channel.isGuildText()) return; + const channelName = `#${channel.name}`; + const ircChannel = this.channelMapping?.discordIdToMapping.get(channel.id)?.ircChannel; + + if (!ircChannel) return; + const fromGuild = message.guild; + if (!fromGuild) return; + let displayUsername = ''; + let discordUsername = ''; + const member = await fromGuild.members.get(author.id); + if (member) { + displayUsername = member.nick || author.displayName || author.username; + discordUsername = member.user.username; + } else { + // Author is a webhook + displayUsername = message.author.displayName; + discordUsername = message.author.username; + } + + // Check for PluralKit proxying + if (!author.bot && await this.testForPluralKitMessage(author, message)) { + return; + } + + let text = await this.parseText(message); + + if (this.config.ircNickColor) { + const displayColorIdx = (displayUsername.charCodeAt(0) + displayUsername.length) % + this.ircNickColors.length ?? 0; + const discordColorIdx = (discordUsername.charCodeAt(0) + discordUsername.length) % + this.ircNickColors.length ?? 0; + displayUsername = wrap( + this.ircNickColors[displayColorIdx], + displayUsername, + ); + discordUsername = wrap( + this.ircNickColors[discordColorIdx], + discordUsername, + ); + } + + const patternMap = { + author: displayUsername, + nickname: displayUsername, + displayUsername, + discordUsername, + text, + discordChannel: channelName, + ircChannel, + attachmentURL: '', + }; + + if (this.isCommandMessage(text) && !updated) { + //patternMap.side = 'Discord'; + this.debug && this.logger.debug( + `Sending command message to IRC ${ircChannel} -- ${text}`, + ); + // if (prelude) this.ircClient.say(ircChannel, prelude); + if (this.formatCommandPrelude) { + const prelude = Mediator.substitutePattern( + this.formatCommandPrelude, + patternMap, + ); + this.irc.privmsg(ircChannel, prelude); + } + this.sendIRCMessageWithSplitAndQueue(ircChannel, text); + } else { + if (text !== '') { + // Convert formatting + text = formatFromDiscordToIRC(text); + patternMap.text = text; + + text = Mediator.substitutePattern(this.formatIRCText, patternMap); + this.debug && this.logger.debug( + `Sending ${updated ? 'edit' : 'message'} to IRC ${ircChannel} -- ${text}`, + ); + if (updated) { + text = `(edited) ${text}`; + } + this.sendIRCMessageWithSplitAndQueue(ircChannel, text); + } + + if (message.attachments && message.attachments.length && !updated) { + message.attachments.forEach((a) => { + patternMap.attachmentURL = a.url; + const urlMessage = Mediator.substitutePattern( + this.formatURLAttachment, + patternMap, + ); + + this.debug && this.logger.debug( + `Sending attachment URL to IRC ${ircChannel} ${urlMessage}`, + ); + this.sendIRCMessageWithSplitAndQueue(ircChannel, urlMessage); + }); + } + } + } + + async notifyToIrc(message: DiscordMessage): Promise { + if (message.content.trim() === '/names') return; + if (!message.channel.isGuildText()) return; + // return early if message was in channel we don't post to + if (!(this.channelMapping.discordIdToMapping.get(message.channel.id))) { + return; + } + const ircChannel = this.channelMapping.discordIdToMapping.get(message.channel.id)?.ircChannel; + if (!ircChannel) return; + await this.sendToIRC(message); + } + + shouldIgnoreByPattern(text: string, ircChannel: string): boolean { + if (!this.ignoreConfig?.ignorePatterns) return false; + if (!this.ignoreConfig?.ignorePatterns[ircChannel]) return false; + for (const pattern of this.ignoreConfig?.ignorePatterns[ircChannel] ?? []) { + if (text.indexOf(pattern) !== -1) { + return true; + } + } + return false; + } + + ignoredIrcUser(user: string) { + return this.ignoreUsers?.irc?.some( + (i: string) => i.toLowerCase() === user.toLowerCase(), + ) ?? false; + } + + findDiscordChannel(ircChannel: string) { + return this.channelMapping.ircNameToMapping.get(ircChannel.toLowerCase())?.discordChannel; + } + + isCommandMessage(message: string) { + return this.commandCharacters.some((prefix: string) => message.startsWith(prefix)); + } + + static substitutePattern( + message: string, + patternMapping: { + [x: string]: any; + author?: any; + nickname?: any; + displayUsername?: any; + discordUsername?: any; + text?: any; + discordChannel?: string; + ircChannel?: any; + }, + ) { + const patternMatch = /{\$(.+?)}/g; + return message.replace( + patternMatch, + (match: any, varName: string | number) => patternMapping[varName] || match, + ); + } + + // compare two strings case-insensitively + // for discord mention matching + static caseComp(str1: string, str2: string) { + return str1.toUpperCase() === str2.toUpperCase(); + } + + // check if the first string starts with the second case-insensitively + // for discord mention matching + static caseStartsWith(str1: string, str2: string) { + return str1.toUpperCase().startsWith(str2.toUpperCase()); + } + + async getDiscordUserByString(userString: string, guild: Guild | undefined) { + const members = await guild?.members.search(userString) ?? []; + return members.find((m) => m.user.username.toLocaleLowerCase() === userString.toLocaleLowerCase()); + } + + async getDiscordAvatar(nick: string, channel: string) { + nick = nick.toLowerCase(); + const channelRef = this.findDiscordChannel(channel); + if (!channelRef?.isGuildText()) return null; + const guildMembers = await channelRef.guild.members.search(nick); + + // Try to find exact matching case + // No matching user or more than one => default avatar + if (guildMembers) { + const member = guildMembers.find((m) => + [m.user.username, m.user.displayName, m.nick].find((s) => s?.toLowerCase() === nick.toLowerCase()) + ); + const url = member?.avatarURL(); + if (url) return url; + } + + // If there isn't a URL format, don't send an avatar at all + if (this.formatWebhookAvatarURL) { + return Mediator.substitutePattern(this.formatWebhookAvatarURL, { + nickname: nick, + }); + } + return null; + } + + findWebhook(ircChannel: string) { + return this.channelMapping.ircNameToMapping.get(ircChannel.toLowerCase())?.webhook; + } + + /* Sends a message to Discord exactly as it appears */ + async sendExactToDiscord(channel: string, text: string) { + const discordChannel = this.findDiscordChannel(channel); + if (!discordChannel) return; + + if (discordChannel.isGuildText()) { + this.debug && this.logger.debug( + `Sending special message to Discord ${text} ${channel} -> #${discordChannel.name}`, + ); + await discordChannel.send(text); + } + } + + async notifyToDiscord(author: string, ircChannel: string, text: string) { + if (this.shouldIgnoreByPattern(text, ircChannel)) { + return; + } + const discordChannel = this.findDiscordChannel(ircChannel); + if (!discordChannel) return; + const channelName = discordChannel.mention; + + // Do not send to Discord if this user is on the ignore list. + if (this.ignoredIrcUser(author)) { + return; + } + + // Convert text formatting (bold, italics, underscore) + const withFormat = formatFromIRCToDiscord(text, author, this.gameLogConfig); + + const patternMap = { + author, + nickname: author, + displayUsername: author, + text: withFormat, + discordChannel: `#${channelName}`, + ircChannel: ircChannel, + withMentions: '', + side: '', + }; + + if (this.isCommandMessage(text)) { + patternMap.side = 'IRC'; + this.debug && this.logger.debug( + `Sending command message to Discord #${channelName} -- ${text}`, + ); + if (this.formatCommandPrelude) { + const prelude = Mediator.substitutePattern( + this.formatCommandPrelude, + patternMap, + ); + if (discordChannel.isGuildText()) { + discordChannel.send(prelude); + } + } + if (discordChannel.isGuildText()) { + discordChannel.send(text); + } + return; + } + + let guild: Guild | undefined = undefined; + if (discordChannel.isGuildText()) { + guild = discordChannel.guild; + } + const roles = await guild?.roles.fetchAll(); + if (!roles) return; + const channels = await guild?.channels.array(); + if (!channels) return; + + const processMentionables = async (input: string) => { + if (this.ignoreConfig?.ignorePingIrcUsers?.includes(author.toLocaleLowerCase())) return input; + return await replaceAsync( + input, + /([^@\s:,]+):|@([^\s]+)/g, + async (match, colonRef, atRef) => { + const reference = colonRef || atRef; + const member = await this.getDiscordUserByString(reference, guild); + + // @username => mention, case insensitively + if (member) return `<@${member.id}>`; + + if (!this.allowRolePings) return match; + // @role => mention, case insensitively + const role = roles.find( + (x) => x.mentionable && Mediator.caseComp(x.name, reference), + ); + if (role) return `<@&${role.id}>`; + return match; + }, + ); + }; + + const processEmoji = async (input: string) => { + return await replaceAsync(input, /:(\w+):/g, async (match, ident) => { + // :emoji: => mention, case sensitively + const emoji = (await guild?.emojis.array())?.find((x) => x.name === ident && x.requireColons); + if (emoji) return `${emoji.name}`; + + return match; + }); + }; + + const processChannels = (input: string) => { + return input.replace(/#([^\s#@'!?,.]+)/g, (match, channelName) => { + // channel names can't contain spaces, #, @, ', !, ?, , or . + // (based on brief testing. they also can't contain some other symbols, + // but these seem likely to be common around channel references) + + // discord matches channel names case insensitively + const chan = channels.find((x) => Mediator.caseComp(x.name, channelName)); + return chan?.name ? `${chan.mention}` : match; + }); + }; + + const withMentions = processChannels( + await processEmoji( + await processMentionables(withFormat), + ), + ); + + // Webhooks first + const webhook = this.findWebhook(ircChannel); + if (webhook) { + if (discordChannel.isGuildText()) { + this.debug && this.logger.debug( + `Sending message to Discord via webhook ${withMentions} ${ircChannel} -> #${discordChannel.name}`, + ); + } + // if (this.discord.user === null) return; + // const permissions = discordChannel.permissionsFor(this.discord.user); + const canPingEveryone = false; + /* + if (permissions) { + canPingEveryone = permissions.has(discord.Permissions.FLAGS.MENTION_EVERYONE); + } + */ + const avatarURL = (await this.getDiscordAvatar(author, ircChannel)) ?? + undefined; + const username = author.substring(0, USERNAME_MAX_LENGTH).padEnd( + USERNAME_MIN_LENGTH, + '_', + ); + const payload: AllWebhookMessageOptions = { + name: username, + avatar: avatarURL, + allowedMentions: { + parse: canPingEveryone + ? [ + AllowedMentionType.Roles, + AllowedMentionType.Users, + AllowedMentionType.Everyone, + ] + : [AllowedMentionType.Roles, AllowedMentionType.Users], + replied_user: true, + }, + }; + try { + await webhook.client.send(withMentions, payload); + } catch (e) { + this.logger.error( + `Received error on webhook send: ${JSON.stringify(e, null, 2)}`, + ); + } + return; + } + + patternMap.withMentions = withMentions; + + // Add bold formatting: + // Use custom formatting from config / default formatting with bold author + const withAuthor = Mediator.substitutePattern(this.formatDiscord, patternMap); + if (discordChannel.isGuildText()) { + this.debug && this.logger.debug( + `Sending message to Discord ${withAuthor} ${ircChannel} -> #${discordChannel.name}`, + ); + discordChannel.send(withAuthor); + } + } +}