#![warn(clippy::pedantic)] use std::{borrow::Cow, collections::HashMap, env, fs::File, io::Read, sync::Arc}; use serenity::{ async_trait, futures::StreamExt, http::{CacheHttp, Http}, model::{ channel::{Channel, GuildChannel, Message, MessageReference, MessageType}, guild::{Member, Role}, id::{ChannelId, GuildId, RoleId, UserId}, prelude::Ready, webhook::Webhook, }, prelude::*, Client as DiscordClient, }; use tokio::{select, sync::Mutex}; use irc::{ client::{data::Config, Client as IrcClient, Sender}, proto::Command, }; use regex::{Captures, Replacer}; use pulldown_cmark::Parser; use serde::Deserialize; #[derive(Deserialize)] struct DircordConfig { token: String, nickname: Option, server: String, port: Option, mode: Option, tls: Option, raw_prefix: Option, channels: HashMap, webhooks: Option>, } struct StrChunks<'a> { v: &'a str, size: usize, } impl<'a> Iterator for StrChunks<'a> { type Item = &'a str; fn next(&mut self) -> Option { if self.v.is_empty() { return None; } if self.v.len() < self.size { let res = self.v; self.v = &self.v[self.v.len()..]; return Some(res); } let mut offset = self.size; let res = loop { match self.v.get(..offset) { Some(v) => break v, None => { offset -= 1; } } }; self.v = &self.v[self.v.len()..]; Some(res) } } impl<'a> StrChunks<'a> { fn new(v: &'a str, size: usize) -> Self { Self { v, size } } } async fn create_prefix(msg: &Message, is_reply: bool, http: impl CacheHttp) -> (String, usize) { let nick = match msg.member(http).await { Ok(Member { nick: Some(nick), .. }) => Cow::Owned(nick), _ => Cow::Borrowed(&msg.author.name), }; let mut chars = nick.char_indices(); let first_char = chars.next().unwrap().1; let second_char_offset = chars.next().unwrap().0; let colour_index = (first_char as usize + nick.len()) % 12; let prefix = format!( "{}<\x03{:02}{}\u{200B}{}\x0F> ", if is_reply { "(reply to) " } else { "" }, colour_index, &nick[..second_char_offset], &nick[second_char_offset..] ); let content_limit = 510 - prefix.len(); (prefix, content_limit) } struct Handler; #[async_trait] impl EventHandler for Handler { async fn message(&self, ctx: Context, msg: Message) { match msg.kind { MessageType::Regular | MessageType::InlineReply => {} _ => return, } let ctx_data = ctx.data.read().await; let user_id = ctx_data.get::().copied().unwrap(); let sender = ctx_data.get::().unwrap(); let members = ctx_data.get::().unwrap(); let raw_prefix = ctx_data .get::() .unwrap() .as_deref() .unwrap_or("++"); let mapping = ctx_data.get::().unwrap().clone(); if user_id == msg.author.id || msg.author.bot { return; } let (prefix, content_limit) = create_prefix(&msg, false, &ctx).await; let (channel, channel_id) = match mapping.iter().find(|(_, &v)| v == msg.channel_id.0) { Some((k, v)) => (k.as_str(), ChannelId::from(*v)), None => return, }; let attachments: Vec<&str> = msg.attachments.iter().map(|a| a.url.as_str()).collect(); let roles = channel_id .to_channel(&ctx) .await .unwrap() .guild() .unwrap() .guild_id .roles(&ctx) .await .unwrap(); let members_lock = members.lock().await; let computed = discord_to_irc_processing(&msg.content, &**members_lock, &ctx, &roles).await; if let Some(MessageReference { guild_id, channel_id, message_id: Some(message_id), .. }) = msg.message_reference { if let Ok(mut reply) = channel_id.message(&ctx, message_id).await { reply.guild_id = guild_id; // lmao let (reply_prefix, reply_content_limit) = create_prefix(&reply, true, &ctx).await; let mut content = reply.content; content = content.replace("\r\n", " "); // just in case content = content.replace('\n', " "); content = format!( "{} {}", content, reply .attachments .iter() .map(|a| &*a.url) .collect::() ); let to_send = if content.len() > reply_content_limit { format!("{}...", &content[..reply_content_limit - 3]) } else { content }; sender .send_privmsg(channel, &format!("{}{}", reply_prefix, to_send)) .unwrap(); } } if let Some((stripped, false)) = computed .strip_prefix(&raw_prefix) .map(str::trim) .and_then(|v| v.strip_suffix('\x0F')) .map(|v| (v, v.is_empty())) { sender.send_privmsg(channel, &prefix).unwrap(); sender.send_privmsg(channel, stripped).unwrap(); } else { for line in computed.lines() { for chunk in StrChunks::new(line, content_limit) { sender .send_privmsg(channel, &format!("{}{}", prefix, chunk)) .unwrap(); } } } for attachment in attachments { sender .send_privmsg(channel, &format!("{}{}", prefix, attachment)) .unwrap(); } } async fn ready(&self, ctx: Context, info: Ready) { let id = info.user.id; let mut data = ctx.data.write().await; data.insert::(id); } async fn guild_member_addition(&self, ctx: Context, _: GuildId, new_member: Member) { let ctx_data = ctx.data.read().await; let mut members = ctx_data.get::().unwrap().lock().await; members.push(new_member); } async fn guild_member_update(&self, ctx: Context, _: Option, new: Member) { let ctx_data = ctx.data.read().await; let mut members = ctx_data.get::().unwrap().lock().await; let x = members .iter() .position(|m| m.user.id == new.user.id) .unwrap(); members.remove(x); members.push(new); } } macro_rules! type_map_key { ($($name:ident => $value:ty),* $(,)?) => { $( struct $name; impl ::serenity::prelude::TypeMapKey for $name { type Value = $value; } )* }; } type_map_key!( HttpKey => Arc, ChannelIdKey => ChannelId, UserIdKey => UserId, SenderKey => Sender, MembersKey => Arc>>, StringKey => String, OptionStringKey => Option, ChannelMappingKey => HashMap, ); #[cfg(unix)] async fn terminate_signal() { use tokio::signal::unix::{signal, SignalKind}; let mut sigterm = signal(SignalKind::terminate()).unwrap(); let mut sigint = signal(SignalKind::interrupt()).unwrap(); select! { _ = sigterm.recv() => {}, _ = sigint.recv() => {}, } } #[cfg(windows)] async fn terminate_signal() { use tokio::signal::windows::ctrl_c; let mut ctrlc = ctrl_c().unwrap(); let _ = ctrlc.recv().await; } #[tokio::main] async fn main() -> anyhow::Result<()> { let filename = env::args() .nth(1) .map_or(Cow::Borrowed("config.toml"), Cow::Owned); let mut data = String::new(); File::open(&*filename)?.read_to_string(&mut data)?; let conf: DircordConfig = toml::from_str(&data)?; let mut discord_client = DiscordClient::builder(&conf.token) .event_handler(Handler) .await?; let config = Config { nickname: conf.nickname, server: Some(conf.server), port: conf.port, channels: conf.channels.keys().map(Clone::clone).collect(), use_tls: conf.tls, umodes: conf.mode, ..Config::default() }; let irc_client = IrcClient::from_config(config).await?; let http = discord_client.cache_and_http.http.clone(); let members = Arc::new(Mutex::new({ let channel_id = ChannelId::from(*conf.channels.iter().next().unwrap().1); channel_id .to_channel(discord_client.cache_and_http.clone()) .await? .guild() .unwrap() // we can panic here because if it's not a guild channel then the bot shouldn't even work .guild_id .members(&http, None, None) .await? })); let channels = Arc::new(conf.channels); { let mut data = discord_client.data.write().await; data.insert::(irc_client.sender()); data.insert::(members.clone()); data.insert::(conf.raw_prefix); data.insert::((*channels).clone()); } let mut webhooks_transformed: HashMap = HashMap::new(); if let Some(webhooks) = conf.webhooks { for (channel, wh) in webhooks { let parsed = parse_webhook_url(http.clone(), wh) .await .expect("Invalid webhook URL"); webhooks_transformed.insert(channel.clone(), parsed); } } select! { r = irc_loop(irc_client, http.clone(), channels.clone(), webhooks_transformed, members) => r?, r = discord_client.start() => r?, _ = terminate_signal() => { for (_, &v) in channels.iter() { let channel_id = ChannelId::from(v); channel_id.say(&http, format!("dircord shutting down! (dircord {}-{})", env!("VERGEN_GIT_BRANCH"), &env!("VERGEN_GIT_SHA")[..7])).await?; } }, } Ok(()) } macro_rules! unwrap_or_continue { ($opt:expr) => { match $opt { ::core::option::Option::Some(v) => v, ::core::option::Option::None => continue, } }; } async fn irc_loop( mut client: IrcClient, http: Arc, mapping: Arc>, webhooks: HashMap, members: Arc>>, ) -> anyhow::Result<()> { let mut avatar_cache: HashMap> = HashMap::new(); let mut id_cache: HashMap> = HashMap::new(); let mut channel_users: HashMap> = HashMap::new(); client.identify()?; let mut stream = client.stream()?; for k in mapping.keys() { client.send(Command::NAMES(Some(k.clone()), None))?; } while let Some(orig_message) = stream.next().await.transpose()? { let nickname = unwrap_or_continue!(orig_message.source_nickname()); if let Command::PRIVMSG(ref channel, ref message) = orig_message.command { let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel))); let channels = channel_id .to_channel(&http) .await? .guild() .unwrap() .guild_id .channels(&http) .await?; let members_lock = members.lock().await; let computed = irc_to_discord_processing(message, &*members_lock, &mut id_cache, &channels); if let Some(webhook) = webhooks.get(channel) { let avatar = &*avatar_cache.entry(nickname.to_owned()).or_insert_with(|| { members_lock.iter().find_map(|member| { (*member.display_name() == nickname) .then(|| member.user.avatar_url()) .flatten() }) }); webhook .execute(&http, false, |w| { if let Some(ref url) = avatar { w.avatar_url(url); } w.username(nickname).content(computed) }) .await?; } else { channel_id .say(&http, format!("<{}> {}", nickname, computed)) .await?; } } else if let Command::JOIN(ref channel, _, _) = orig_message.command { let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel))); let users = unwrap_or_continue!(channel_users.get_mut(channel)); users.push(nickname.to_string()); channel_id .say(&http, format!("*{}* has joined the channel", nickname)) .await?; } else if let Command::PART(ref channel, ref reason) = orig_message.command { let users = unwrap_or_continue!(channel_users.get_mut(channel)); let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel))); let pos = unwrap_or_continue!(users.iter().position(|u| u == nickname)); users.swap_remove(pos); let reason = reason.as_deref().unwrap_or("Connection closed"); channel_id .say(&http, format!("*{}* has quit ({})", nickname, reason)) .await?; } else if let Command::QUIT(ref reason) = orig_message.command { for (channel, users) in &mut channel_users { let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel))); let pos = unwrap_or_continue!(users.iter().position(|u| u == nickname)); users.swap_remove(pos); let reason = reason.as_deref().unwrap_or("Connection closed"); channel_id .say(&http, format!("*{}* has quit ({})", nickname, reason)) .await?; } } else if let Command::Response(ref response, ref args) = orig_message.command { use irc::client::prelude::Response; if let Response::RPL_NAMREPLY = response { let channel = args[2].to_string(); let users = args[3] .split(' ') .map(ToOwned::to_owned) .collect::>(); channel_users.insert(channel, users); } } else if let Command::NICK(ref new_nick) = orig_message.command { for (channel, users) in &mut channel_users { let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel))); let pos = unwrap_or_continue!(users.iter().position(|u| u == nickname)); users[pos] = new_nick.to_string(); channel_id .say( &http, format!("*{}* is now known as *{}*", nickname, new_nick), ) .await?; } } } Ok(()) } struct OptionReplacer(F); impl, F: for<'r, 't> FnMut(&'r Captures<'t>) -> Option> Replacer for OptionReplacer { fn replace_append(&mut self, caps: &Captures<'_>, dst: &mut String) { match (self.0)(caps) { Some(v) => dst.push_str(v.as_ref()), None => dst.push_str(caps.get(0).unwrap().as_str()), } } } macro_rules! regex { ($(static $name:ident = $regex:literal;)*) => { ::lazy_static::lazy_static! { $( static ref $name: ::regex::Regex = ::regex::Regex::new($regex).unwrap(); )* } }; } fn irc_to_discord_processing( message: &str, members: &[Member], id_cache: &mut HashMap>, channels: &HashMap, ) -> String { struct MemberReplacer<'a> { id_cache: &'a mut HashMap>, members: &'a [Member], } impl<'a> Replacer for MemberReplacer<'a> { fn replace_append(&mut self, caps: &Captures<'_>, dst: &mut String) { let slice = &caps[1]; let id = self .id_cache .entry(slice.to_owned()) .or_insert_with(|| { self.members.iter().find_map(|member| { (slice == member.display_name().as_str()).then(|| member.user.id.0) }) }) .map(UserId); if let Some(id) = id { dst.push_str(&id.mention().to_string()); } else { dst.push_str(caps.get(0).unwrap().as_str()); } } } regex! { static PING_NICK_1 = r"^([\w+]+)(?::|,)"; static PING_RE_2 = r"@([\w\S]+)"; static CONTROL_CHAR_RE = r"\x1f|\x02|\x12|\x0f|\x16|\x03(?:\d{1,2}(?:,\d{1,2})?)?"; static WHITESPACE_RE = r"^\s"; static CHANNEL_RE = r"#([A-Za-z-*]+)"; } if WHITESPACE_RE.is_match(message) && !PING_RE_2.is_match(message) { return format!("`{}`", message); } let mut computed = message.to_owned(); computed = PING_NICK_1 .replace_all(&computed, MemberReplacer { id_cache, members }) .into_owned(); computed = PING_RE_2 .replace_all(&computed, MemberReplacer { id_cache, members }) .into_owned(); computed = CHANNEL_RE .replace_all( &computed, OptionReplacer(|caps: &Captures| { channels .iter() .find_map(|(id, c)| (c.name == caps[1]).then(|| format!("<#{}>", id.0))) }), ) .into_owned(); computed = { let mut new = String::with_capacity(computed.len()); let mut has_opened_bold = false; let mut has_opened_italic = false; for c in computed.chars() { if c == '\x02' || (c == '\x0F' && has_opened_bold) { new.push_str("**"); has_opened_bold = !has_opened_bold; } else if c == '\x1D' || (c == '\x0F' && has_opened_italic) { new.push('*'); has_opened_italic = !has_opened_italic; } else { new.push(c); } } if has_opened_italic { new.push('*'); } if has_opened_bold { new.push_str("**"); } CONTROL_CHAR_RE.replace_all(&new, "").into_owned() }; computed } async fn discord_to_irc_processing( message: &str, members: &[Member], ctx: &Context, roles: &HashMap, ) -> String { struct MemberReplacer<'a> { members: &'a [Member], } impl<'a> Replacer for MemberReplacer<'a> { fn replace_append(&mut self, caps: &Captures<'_>, dst: &mut String) { let id = caps[1].parse::().unwrap(); let display_name = self.members.iter().find_map(|member| { (id == member.user.id.0).then(|| member.display_name().into_owned()) }); if let Some(display_name) = display_name { dst.push_str(&format!("@{}", display_name)); } else { dst.push_str(caps.get(0).unwrap().as_str()); } } } regex! { static PING_RE_1 = r"<@([0-9]+)>"; static PING_RE_2 = r"<@!([0-9]+)>"; static EMOJI_RE = r"<:(\w+):[0-9]+>"; static CHANNEL_RE = r"<#([0-9]+)>"; static ROLE_RE = r"<@&([0-9]+)>"; } let mut computed = message.to_owned(); computed = PING_RE_1 .replace_all(&computed, MemberReplacer { members }) .into_owned(); computed = PING_RE_2 .replace_all(&computed, MemberReplacer { members }) .into_owned(); computed = EMOJI_RE.replace_all(&computed, "$1").into_owned(); // FIXME: the await makes it impossible to use `replace_all`, idk how to fix this for caps in CHANNEL_RE.captures_iter(&computed.clone()) { let replacement = match ChannelId(caps[1].parse().unwrap()).to_channel(&ctx).await { Ok(Channel::Guild(gc)) => Cow::Owned(format!("#{}", gc.name)), Ok(Channel::Category(cat)) => Cow::Owned(format!("#{}", cat.name)), _ => Cow::Borrowed("#deleted-channel"), }; computed = CHANNEL_RE.replace(&computed, replacement).to_string(); } computed = ROLE_RE .replace_all( &computed, OptionReplacer(|caps: &Captures| { roles .get(&RoleId(caps[1].parse().unwrap())) .map(|role| format!("@{}", role.name)) }), ) .into_owned(); computed = { #[allow(clippy::enum_glob_use)] use pulldown_cmark::{Event::*, Tag::*}; let mut new = String::with_capacity(computed.len()); for line in computed.lines() { let parser = Parser::new(line); let mut computed_line = String::with_capacity(line.len()); for event in parser { match event { Text(t) | Html(t) => computed_line.push_str(&t), Code(t) => computed_line.push_str(&format!("`{}`", t)), End(_) => computed_line.push('\x0F'), Start(Emphasis) => computed_line.push('\x1D'), Start(Strong) => computed_line.push('\x02'), Start(Link(_, dest, _)) => { computed_line.push_str(&dest); continue; } Start(List(num)) => { if let Some(num) = num { computed_line.push_str(&format!("{}. ", num)); } else { computed_line.push_str("- "); } } Start(BlockQuote) => computed_line.push_str("> "), _ => {} } } computed_line.push('\n'); new.push_str(&computed_line); } new }; computed } async fn parse_webhook_url(http: Arc, url: String) -> anyhow::Result { let url = url.trim_start_matches("https://discord.com/api/webhooks/"); let split = url.split('/').collect::>(); let id = split[0].parse::()?; let token = split[1].to_string(); let webhook = http.get_webhook_with_token(id, &token).await?; Ok(webhook) }