Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

9 changed files with 522 additions and 690 deletions

675
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,28 +6,25 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.58"
irc = "0.15.0"
toml = "0.5.9"
serde = "1.0.140"
lazy_static = "1.4.0"
anyhow = "1.0.52"
irc = "0.15"
toml = "0.5"
serde = "1.0"
lazy_static = "1.4"
pulldown-cmark = "0.9.1"
fancy-regex = "0.10.0"
tokio-stream = "0.1.9"
ellipse = "0.2.0"
[dependencies.tokio]
version = "1.20.0"
version = "1.15.0"
features = ["full"]
[dependencies.serenity]
# version = "0.11.4"
git = "https://github.com/serenity-rs/serenity"
rev = "56867af"
version = "0.10"
default-features = false
features = ["builder", "cache", "client", "gateway", "model", "utils", "native_tls_backend"]
[build-dependencies.vergen]
version = "8.2.1"
version = "5"
default-features = false
features = ["git", "gitcl"]
features = ["git"]

14
LICENSE
View file

@ -1,14 +0,0 @@
BSD Zero Clause License
Copyright (c) 2022 Yash Karandikar
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

View file

@ -12,3 +12,5 @@ TODO:
- [x] handle multiple pings
- [x] multiple channels
- [x] IRC and Discord formatting
<sub>For extra fun set the DIRCORD_POLARIAN_MODE environmental variable to any value ;)</sub>

View file

@ -1,7 +1,5 @@
use std::error::Error;
use vergen::EmitBuilder;
use vergen::{vergen, Config};
fn main() -> Result<(), Box<dyn Error>> {
EmitBuilder::builder().git_branch().git_sha(true).emit()?;
Ok(())
fn main() {
vergen(Config::default()).unwrap();
}

View file

@ -1,17 +0,0 @@
token = "..." # REQUIRED: discord bot token
nickname = "dircord" # REQUIRED: IRC nickname
server = "karx.xyz""
port = 6697
tls = true # OPTIONAL: DEFAULT: false
mode = "+B" # OPTIONAL: DEFAULT: none
raw_prefix = "++" # OPTIONAL: DEFAULT: ++
ref_content_limit = 512 # OPTIONAL: where to truncate replied messages. Defaults to ~512 minus the prefix
cache_ttl = 1800 # OPTIONAL: how long to store caches, in seconds. Defaults to 1800 (30 minutes)
[channels]
# irc channel name -> discord channel id
'#channel_name' = 1234
[webhooks] # OPTIONAL
# irc channel name -> discord webhook URL
'#channel_name' = '...'

View file

@ -1,8 +1,6 @@
use crate::{
regex, ChannelMappingKey, MembersKey, OptionReplacer, OptionStringKey, RefContentLimitKey,
SenderKey, UserIdKey,
regex, ChannelMappingKey, MembersKey, OptionReplacer, OptionStringKey, SenderKey, UserIdKey,
};
use ellipse::Ellipse;
use fancy_regex::{Captures, Replacer};
use pulldown_cmark::Parser;
use serenity::{
@ -12,9 +10,7 @@ use serenity::{
model::{
channel::{Channel, Message, MessageReference, MessageType},
guild::Member,
id::GuildId,
prelude::{ChannelId, GuildMemberUpdateEvent, Ready, Role, RoleId},
user::User,
prelude::{ChannelId, GuildId, Ready, Role, RoleId},
},
prelude::*,
};
@ -64,8 +60,16 @@ impl<'a> StrChunks<'a> {
}
async fn create_prefix(msg: &Message, is_reply: bool, http: impl CacheHttp) -> (String, usize) {
// it's okay to unwrap here since we know we're in a guild
let Ok(nick) = msg.member(http).await.map(|m| m.display_name().to_owned()) else { return ("(reply) ".into(), 400 - "(reply) ".len()) };
let mut nick = match msg.member(http).await {
Ok(Member {
nick: Some(nick), ..
}) => Cow::Owned(nick),
_ => Cow::Borrowed(&msg.author.name),
};
if option_env!("DIRCORD_POLARIAN_MODE").is_some() {
nick = Cow::Owned("polarbear".to_string());
}
let mut chars = nick.char_indices();
let first_char = chars.next().unwrap().1;
@ -108,7 +112,6 @@ impl EventHandler for Handler {
.as_deref()
.unwrap_or("++");
let mapping = ctx_data.get::<ChannelMappingKey>().unwrap().clone();
let ref_content_limit = ctx_data.get::<RefContentLimitKey>().unwrap();
if user_id == msg.author.id || msg.author.bot {
return;
@ -116,8 +119,7 @@ impl EventHandler for Handler {
let (prefix, content_limit) = create_prefix(&msg, false, &ctx).await;
let (channel, channel_id) = match mapping.iter().find(|(_, &v)| v == msg.channel_id.0.get())
{
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,
};
@ -137,7 +139,7 @@ impl EventHandler for Handler {
let members_lock = members.lock().await;
let computed = discord_to_irc_processing(&msg.content, &members_lock, &ctx, &roles).await;
let computed = discord_to_irc_processing(&msg.content, &**members_lock, &ctx, &roles).await;
if let Some(MessageReference {
guild_id,
@ -153,37 +155,43 @@ impl EventHandler for Handler {
let mut content = reply.content;
content = content.replace("\r\n", " "); // just in case
content = content.replace('\n', " ");
let atts: Vec<&str> = reply.attachments.iter().map(|a| &*a.url).collect();
content = format!("{} {}", content, atts.join(" "));
content = discord_to_irc_processing(&content, &members_lock, &ctx, &roles).await;
let to_send = (&*content).truncate_ellipse(
ref_content_limit
.map(|l| l as usize)
.unwrap_or(reply_content_limit),
content = format!(
"{} {}",
content,
reply
.attachments
.iter()
.map(|a| &*a.url)
.collect::<String>()
);
content = discord_to_irc_processing(&content, &**members_lock, &ctx, &roles).await;
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}"))
.send_privmsg(channel, &format!("{}{}", reply_prefix, to_send))
.unwrap();
}
}
if let Some((stripped, false)) = computed
.strip_prefix(raw_prefix)
.strip_prefix(&raw_prefix)
.map(str::trim)
.and_then(|v| v.strip_suffix('\x0F'))
.map(|v| (v, v.is_empty()))
{
let to_send = stripped.trim_matches('\u{f}');
sender.send_privmsg(channel, &prefix).unwrap();
sender.send_privmsg(channel, to_send).unwrap();
sender.send_privmsg(channel, stripped).unwrap();
} else {
for line in computed.lines() {
for chunk in StrChunks::new(line, content_limit) {
let to_send = chunk.trim_matches('\u{f}');
sender
.send_privmsg(channel, &format!("{prefix}{to_send}"))
.send_privmsg(channel, &format!("{}{}", prefix, chunk))
.unwrap();
}
}
@ -191,7 +199,7 @@ impl EventHandler for Handler {
for attachment in attachments {
sender
.send_privmsg(channel, &format!("{prefix}{attachment}"))
.send_privmsg(channel, &format!("{}{}", prefix, attachment))
.unwrap();
}
}
@ -203,44 +211,22 @@ impl EventHandler for Handler {
data.insert::<UserIdKey>(id);
}
async fn guild_member_addition(&self, ctx: Context, new_member: Member) {
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::<MembersKey>().unwrap().lock().await;
members.push(new_member);
}
async fn guild_member_update(
&self,
ctx: Context,
_: Option<Member>,
new: Option<Member>,
_: GuildMemberUpdateEvent,
) {
async fn guild_member_update(&self, ctx: Context, _: Option<Member>, new: Member) {
let ctx_data = ctx.data.read().await;
let mut members = ctx_data.get::<MembersKey>().unwrap().lock().await;
if let Some(new) = new {
let x = members
.iter()
.position(|m| m.user.id == new.user.id)
.unwrap();
members.remove(x);
members.push(new);
}
}
async fn guild_member_removal(
&self,
ctx: Context,
_guild_id: GuildId,
user: User,
_member: Option<Member>,
) {
let ctx_data = ctx.data.read().await;
let mut members = ctx_data.get::<MembersKey>().unwrap().lock().await;
let pos = members.iter().position(|m| m.user.id == user.id).unwrap();
members.remove(pos);
let x = members
.iter()
.position(|m| m.user.id == new.user.id)
.unwrap();
members.remove(x);
members.push(new);
}
}
@ -259,11 +245,11 @@ async fn discord_to_irc_processing(
let id = caps[1].parse::<u64>().unwrap();
let display_name = self.members.iter().find_map(|member| {
(id == member.user.id.0.get()).then(|| member.display_name().to_owned())
(id == member.user.id.0).then(|| member.display_name().into_owned())
});
if let Some(display_name) = display_name {
write!(dst, "@{display_name}").unwrap();
write!(dst, "@{}", display_name).unwrap();
} else {
dst.push_str(caps.get(0).unwrap().as_str());
}
@ -273,17 +259,13 @@ async fn discord_to_irc_processing(
regex! {
static PING_RE_1 = r"<@([0-9]+)>";
static PING_RE_2 = r"<@!([0-9]+)>";
static PING_RE_3 = r"\{@([0-9]+)\}";
static EMOJI_RE = r"<:(\w+):[0-9]+>";
static CHANNEL_RE = r"<#([0-9]+)>";
static ROLE_RE = r"<@&([0-9]+)>";
static URL_ESCAPE_RE = r"<(https?://[^\s/$.?#].\S*)>";
}
let mut computed = message.to_owned();
computed = URL_ESCAPE_RE.replace_all(&computed, "$1").into_owned();
computed = PING_RE_1
.replace_all(&computed, MemberReplacer { members })
.into_owned();
@ -292,7 +274,7 @@ async fn discord_to_irc_processing(
.replace_all(&computed, MemberReplacer { members })
.into_owned();
computed = EMOJI_RE.replace_all(&computed, ":$1:").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()) {
@ -301,6 +283,7 @@ async fn discord_to_irc_processing(
.await
{
Ok(Channel::Guild(gc)) => Cow::Owned(format!("#{}", gc.name)),
Ok(Channel::Category(cat)) => Cow::Owned(format!("#{}", cat.name)),
_ => Cow::Borrowed("#deleted-channel"),
};
@ -318,74 +301,47 @@ async fn discord_to_irc_processing(
)
.into_owned();
// switch brackets of unknown pings
computed = PING_RE_1.replace_all(&computed, "{@$1}").into_owned();
computed = {
#[allow(clippy::enum_glob_use)]
use pulldown_cmark::{Event::*, Tag::*};
let mut new = String::with_capacity(computed.len());
let parser = Parser::new(&computed);
for line in computed.lines() {
let parser = Parser::new(line);
let mut list_level = 0;
let mut numbered = false;
let mut next_num = 0;
let mut computed_line = String::with_capacity(line.len());
for event in parser {
match event {
Text(t) | Html(t) => new.push_str(&t),
Code(t) => write!(new, "`{t}`").unwrap(),
Start(Emphasis) => new.push('\x1D'),
Start(Strong) => new.push('\x02'),
Start(Link(_, _, _)) => {
new.push('[');
}
End(Link(_, url, title)) => {
write!(new, "]: {url}").unwrap();
if !title.is_empty() {
write!(new, " ({title})").unwrap();
for event in parser {
match event {
Text(t) | Html(t) => computed_line.push_str(&t),
Code(t) => write!(computed_line, "`{}`", t).unwrap(),
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)) => {
list_level += 1;
if let Some(num) = num {
numbered = true;
next_num = num;
} else {
numbered = false;
Start(List(num)) => {
if let Some(num) = num {
write!(computed_line, "{}. ", num).unwrap();
} else {
computed_line.push_str("- ");
}
}
Start(BlockQuote) => computed_line.push_str("> "),
_ => {}
}
End(List(_)) => list_level -= 1,
Start(Item) => {
let prefix = if numbered {
format!("{next_num}.")
} else {
if list_level > 1 { '◦' } else { '•' }.into()
};
write!(new, "\n{}{} ", " ".repeat(list_level - 1), prefix).unwrap();
}
End(Item) => {
if numbered {
next_num += 1;
}
}
Start(BlockQuote) => new.push_str("> "),
Start(Heading(ty, _, _)) => {
write!(new, "{} \x02", "#".repeat(ty as usize)).unwrap();
}
SoftBreak | HardBreak | End(Paragraph) => new.push('\n'),
End(_) => new.push('\x0F'),
_ => {}
}
computed_line.push('\n');
new.push_str(&computed_line);
}
new
};
// switch them back
computed = PING_RE_3.replace_all(&computed, "<@$1>").into_owned();
computed
}

View file

@ -1,24 +1,19 @@
use irc::{client::Client as IrcClient, proto::Command};
use std::{collections::HashMap, num::NonZeroU64, sync::Arc, time::Instant};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::{mpsc::unbounded_channel, Mutex};
use tokio_stream::wrappers::UnboundedReceiverStream;
use serenity::{
builder::{EditChannel, ExecuteWebhook},
cache::Cache,
futures::StreamExt,
http::Http,
model::{
guild::Emoji,
id::ChannelId,
prelude::{GuildChannel, Member, UserId},
prelude::{ChannelId, GuildChannel, Member, UserId},
webhook::Webhook,
},
prelude::Mentionable,
utils::{content_safe, ContentSafeOptions},
prelude::*,
};
use crate::{regex, OptionReplacer};
@ -38,42 +33,25 @@ macro_rules! unwrap_or_continue {
pub async fn irc_loop(
mut client: IrcClient,
http: Arc<Http>,
cache: Arc<Cache>,
mapping: Arc<HashMap<String, u64>>,
webhooks: HashMap<String, Webhook>,
members: Arc<Mutex<Vec<Member>>>,
cache_ttl: Option<u64>,
) -> anyhow::Result<()> {
let (send, recv) = unbounded_channel();
tokio::spawn(msg_task(UnboundedReceiverStream::new(recv)));
let mut avatar_cache: HashMap<String, Option<String>> = HashMap::new();
let mut id_cache: HashMap<String, Option<u64>> = HashMap::new();
let mut emoji_cache: Vec<Emoji> = Vec::new();
let mut channel_users: HashMap<String, Vec<String>> = HashMap::new();
let mut ttl = Instant::now();
client.identify()?;
let mut stream = client.stream()?;
for k in mapping.keys() {
client.send(Command::NAMES(Some(k.clone()), None))?;
client.send_topic(k, "")?;
}
let mut channels_cache = None;
let mut guild = None;
while let Some(orig_message) = stream.next().await.transpose()? {
if ttl.elapsed().as_secs() > cache_ttl.unwrap_or(1800) {
avatar_cache.clear();
channels_cache = None;
guild = None;
emoji_cache.clear();
ttl = Instant::now();
}
if let Command::Response(response, args) = orig_message.command {
use irc::client::prelude::Response;
@ -85,105 +63,84 @@ pub async fn irc_loop(
.collect::<Vec<String>>();
channel_users.insert(channel, users);
} else if response == Response::RPL_TOPIC {
let channel = &args[1];
let topic = &args[2];
let channel = ChannelId::from(*unwrap_or_continue!(mapping.get(channel)));
let builder = EditChannel::new().topic(topic);
channel.edit(&http, builder).await?;
}
continue;
};
let nickname = unwrap_or_continue!(orig_message.source_nickname());
let mut nickname = unwrap_or_continue!(orig_message.source_nickname());
match orig_message.command {
Command::PRIVMSG(ref channel, ref message)
| Command::NOTICE(ref channel, ref message) => {
let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel)));
if option_env!("DIRCORD_POLARIAN_MODE").is_some() {
nickname = "polarbear";
}
if let Command::PRIVMSG(ref channel, ref message) = orig_message.command {
let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel)));
if channels_cache.is_none() || guild.is_none() || emoji_cache.is_empty() {
let (cc, g, es) = {
let guild = channel_id
.to_channel(&http)
.await?
.guild()
.unwrap()
.guild_id;
let channels = channel_id
.to_channel(&http)
.await?
.guild()
.unwrap()
.guild_id
.channels(&http)
.await?;
let chans = guild.channels(&http).await?;
let emojis = guild.emojis(&http).await?;
let members_lock = members.lock().await;
(chans, guild, emojis)
};
channels_cache = Some(cc);
guild = Some(g);
emoji_cache = es;
}
let channels = channels_cache.as_ref().unwrap();
let computed =
irc_to_discord_processing(message, &*members_lock, &mut id_cache, &channels);
let members_lock = members.lock().await;
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()
})
});
let mut computed = irc_to_discord_processing(
message,
&members_lock,
&mut id_cache,
channels,
&emoji_cache,
);
computed = {
let opts = ContentSafeOptions::new()
.clean_role(false)
.clean_user(false)
.clean_channel(false)
.show_discriminator(false)
.clean_here(true) // setting these to true explicitly isn't needed,
.clean_everyone(true); // but i did it anyway for readability
content_safe(&cache, computed, &opts, &[])
let m = QueuedMessage::Webhook {
webhook: webhook.clone(),
http: http.clone(),
avatar_url: avatar.clone(),
content: computed,
nickname: nickname.to_string(),
};
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()
})
});
send.send(QueuedMessage::Webhook {
webhook: webhook.clone(),
http: http.clone(),
avatar_url: avatar.clone(),
content: computed,
nickname: nickname.to_string(),
})?;
} else {
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("<{nickname}>, {computed}"),
})?;
}
}
Command::JOIN(ref channel, _, _) => {
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());
send.send(m)?;
} else {
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("*{nickname}* has joined the channel"),
message: format!("<{}>, {}", nickname, computed),
})?;
}
Command::PART(ref channel, ref reason) => {
let users = unwrap_or_continue!(channel_users.get_mut(channel));
} 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());
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("*{}* has joined the channel", nickname),
})?;
} 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");
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("*{}* has quit ({})", nickname, reason),
})?;
} 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));
@ -194,56 +151,22 @@ pub async fn irc_loop(
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("*{nickname}* has quit ({reason})"),
message: format!("*{}* has quit ({})", nickname, reason),
})?;
}
Command::QUIT(ref reason) => {
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");
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("*{nickname}* has quit ({reason})"),
})?;
}
}
Command::NICK(ref new_nick) => {
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();
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("*{nickname}* is now known as *{new_nick}*"),
})?;
}
}
Command::TOPIC(ref channel, ref topic) => {
let topic = unwrap_or_continue!(topic.as_ref());
} 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 builder = EditChannel::new().topic(topic);
channel_id.edit(&http, builder).await?;
}
Command::KICK(ref channel, ref user, ref reason) => {
let channel_id = ChannelId::from(*unwrap_or_continue!(mapping.get(channel)));
let reason = reason.as_deref().unwrap_or("None");
let pos = unwrap_or_continue!(users.iter().position(|u| u == nickname));
users[pos] = new_nick.to_string();
send.send(QueuedMessage::Raw {
channel_id,
http: http.clone(),
message: format!("*{nickname}* has kicked *{user}* ({reason})"),
message: format!("*{}* is now known as *{}*", nickname, new_nick),
})?;
}
_ => {}
}
}
Ok(())
@ -254,7 +177,6 @@ fn irc_to_discord_processing(
members: &[Member],
id_cache: &mut HashMap<String, Option<u64>>,
channels: &HashMap<ChannelId, GuildChannel>,
emojis: &[Emoji],
) -> String {
struct MemberReplacer<'a> {
id_cache: &'a mut HashMap<String, Option<u64>>,
@ -270,12 +192,9 @@ fn irc_to_discord_processing(
.entry(slice.to_owned())
.or_insert_with(|| {
self.members.iter().find_map(|member| {
(slice == member.display_name() || slice == member.user.name.as_str())
.then_some(member.user.id.0.get())
(slice == member.display_name().as_str()).then(|| member.user.id.0)
})
})
.map(NonZeroU64::new)
.flatten()
.map(UserId);
if let Some(id) = id {
@ -288,15 +207,14 @@ fn irc_to_discord_processing(
regex! {
static PING_NICK_1 = r"^([\w+]+)(?::|,)";
static PING_RE_2 = r"(?<=\s|^)@(\w+)";
static PING_RE_2 = r"(?<=\s|^)@([\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"#([\w-]+)";
static EMOJI_RE = r":(\w+):";
static CHANNEL_RE = r"#([A-Za-z-*]+)";
}
if WHITESPACE_RE.is_match(message).unwrap() && !PING_RE_2.is_match(message).unwrap() {
return format!("`{message}`");
return format!("`{}`", message);
}
let mut computed = message.to_owned();
@ -320,23 +238,12 @@ fn irc_to_discord_processing(
)
.into_owned();
computed = EMOJI_RE
.replace_all(
&computed,
OptionReplacer(|caps: &Captures| {
emojis
.iter()
.find_map(|e| (e.name == caps[1]).then(|| format!("<:{}:{}>", e.name, e.id.0)))
}),
)
.into_owned();
#[allow(clippy::map_unwrap_or)]
{
computed = computed
.strip_prefix("\x01ACTION ")
.and_then(|s| s.strip_suffix('\x01'))
.map(|s| format!("*{s}*"))
.map(|s| format!("*{}*", s))
.unwrap_or_else(|| computed); // if any step in the way fails, fall back to using computed
}
@ -399,25 +306,21 @@ async fn msg_task(mut recv: UnboundedReceiverStream<QueuedMessage>) -> anyhow::R
content,
nickname,
} => {
if content.is_empty() {
continue;
}
let mut builder = ExecuteWebhook::new();
if let Some(ref url) = avatar_url {
builder = builder.avatar_url(url);
}
builder = builder.username(nickname).content(content);
webhook
.execute(&http, true, |w| {
if let Some(ref url) = avatar_url {
w.avatar_url(url);
}
webhook.execute(&http, true, builder).await?;
w.username(nickname).content(content)
})
.await?;
}
QueuedMessage::Raw {
channel_id,
http,
message,
} => {
if message.is_empty() {
continue;
}
channel_id.say(&http, message).await?;
}
}

View file

@ -8,7 +8,6 @@ use std::{borrow::Cow, collections::HashMap, env, fs::File, io::Read, sync::Arc}
use serenity::{
http::Http,
model::{
gateway::GatewayIntents,
guild::Member,
id::{ChannelId, UserId},
webhook::Webhook,
@ -37,8 +36,6 @@ struct DircordConfig {
raw_prefix: Option<String>,
channels: HashMap<String, u64>,
webhooks: Option<HashMap<String, String>>,
ref_content_limit: Option<u16>,
cache_ttl: Option<u64>,
}
macro_rules! type_map_key {
@ -62,7 +59,6 @@ type_map_key!(
StringKey => String,
OptionStringKey => Option<String>,
ChannelMappingKey => HashMap<String, u64>,
RefContentLimitKey => Option<u16>,
);
#[cfg(unix)]
@ -94,11 +90,7 @@ async fn main() -> anyhow::Result<()> {
let conf: DircordConfig = toml::from_str(&data)?;
let intents = GatewayIntents::non_privileged()
| GatewayIntents::GUILD_MEMBERS
| GatewayIntents::MESSAGE_CONTENT;
let mut discord_client = DiscordClient::builder(&conf.token, intents)
let mut discord_client = DiscordClient::builder(&conf.token)
.event_handler(Handler)
.await?;
@ -114,14 +106,13 @@ async fn main() -> anyhow::Result<()> {
let irc_client = IrcClient::from_config(config).await?;
let http = discord_client.http.clone();
let cache = discord_client.cache.clone();
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.http.clone())
.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
@ -138,7 +129,6 @@ async fn main() -> anyhow::Result<()> {
data.insert::<MembersKey>(members.clone());
data.insert::<OptionStringKey>(conf.raw_prefix);
data.insert::<ChannelMappingKey>((*channels).clone());
data.insert::<RefContentLimitKey>(conf.ref_content_limit);
}
let mut webhooks_transformed: HashMap<String, Webhook> = HashMap::new();
@ -154,7 +144,7 @@ async fn main() -> anyhow::Result<()> {
}
select! {
r = irc_loop(irc_client, http.clone(), cache.clone(), channels.clone(), webhooks_transformed, members, conf.cache_ttl) => r.unwrap(),
r = irc_loop(irc_client, http.clone(), channels.clone(), webhooks_transformed, members) => r.unwrap(),
r = discord_client.start() => r.unwrap(),
_ = terminate_signal() => {
for (_, &v) in channels.iter() {
@ -196,6 +186,6 @@ async fn parse_webhook_url(http: Arc<Http>, url: String) -> anyhow::Result<Webho
let split = url.split('/').collect::<Vec<&str>>();
let id = split[0].parse::<u64>()?;
let token = split[1].to_string();
let webhook = http.get_webhook_with_token(id.into(), &token).await?;
let webhook = http.get_webhook_with_token(id, &token).await?;
Ok(webhook)
}