cleared up all unwraps from the reminder sender. cleared up clippy lints. added undo button

This commit is contained in:
jude 2022-05-13 23:08:52 +01:00
parent 8bad95510d
commit 7d43aa5918
15 changed files with 318 additions and 158 deletions

View File

@ -58,10 +58,10 @@ fn fmt_displacement(format: &str, seconds: u64) -> String {
pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").unwrap().as_str();
let format = caps.name("format").unwrap().as_str();
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
if let Ok(final_time) = final_time.parse::<i64>() {
if let (Some(final_time), Some(format)) = (final_time, format) {
let dt = NaiveDateTime::from_timestamp(final_time, 0);
let now = Utc::now().naive_utc();
@ -81,13 +81,11 @@ pub fn substitute(string: &str) -> String {
TIMENOW_REGEX
.replace(&new, |caps: &Captures| {
let timezone = caps.name("timezone").unwrap().as_str();
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
println!("{}", timezone);
if let Ok(tz) = timezone.parse::<Tz>() {
let format = caps.name("format").unwrap().as_str();
let now = Utc::now().with_timezone(&tz);
if let (Some(timezone), Some(format)) = (timezone, format) {
let now = Utc::now().with_timezone(&timezone);
now.format(format).to_string()
} else {
@ -122,7 +120,7 @@ impl Embed {
pool: impl Executor<'_, Database = Database> + Copy,
id: u32,
) -> Option<Self> {
let mut embed = sqlx::query_as!(
match sqlx::query_as!(
Self,
r#"
SELECT
@ -142,8 +140,8 @@ impl Embed {
)
.fetch_one(pool)
.await
.unwrap();
{
Ok(mut embed) => {
embed.title = substitute(&embed.title);
embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer);
@ -160,6 +158,14 @@ impl Embed {
}
}
Err(e) => {
warn!("Error loading embed from reminder: {:?}", e);
None
}
}
}
pub fn has_content(&self) -> bool {
if self.title.is_empty()
&& self.description.is_empty()
@ -251,9 +257,9 @@ pub struct Reminder {
impl Reminder {
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
sqlx::query_as_unchecked!(
match sqlx::query_as!(
Reminder,
"
r#"
SELECT
reminders.`id` AS id,
@ -261,20 +267,20 @@ SELECT
channels.`webhook_id` AS webhook_id,
channels.`webhook_token` AS webhook_token,
channels.`paused` AS channel_paused,
channels.`paused_until` AS channel_paused_until,
reminders.`enabled` AS enabled,
channels.`paused` AS "channel_paused:_",
channels.`paused_until` AS "channel_paused_until:_",
reminders.`enabled` AS "enabled:_",
reminders.`tts` AS tts,
reminders.`pin` AS pin,
reminders.`tts` AS "tts:_",
reminders.`pin` AS "pin:_",
reminders.`content` AS content,
reminders.`attachment` AS attachment,
reminders.`attachment_name` AS attachment_name,
reminders.`utc_time` AS 'utc_time',
reminders.`utc_time` AS "utc_time:_",
reminders.`timezone` AS timezone,
reminders.`restartable` AS restartable,
reminders.`expires` AS expires,
reminders.`restartable` AS "restartable:_",
reminders.`expires` AS "expires:_",
reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_months` AS 'interval_months',
@ -288,18 +294,26 @@ ON
reminders.channel_id = channels.id
WHERE
reminders.`utc_time` < NOW()
",
"#,
)
.fetch_all(pool)
.await
.unwrap()
{
Ok(reminders) => reminders
.into_iter()
.map(|mut rem| {
rem.content = substitute(&rem.content);
rem
})
.collect::<Vec<Self>>()
.collect::<Vec<Self>>(),
Err(e) => {
warn!("Could not fetch reminders: {:?}", e);
vec![]
}
}
}
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
@ -319,7 +333,7 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
let mut updated_reminder_time = self.utc_time;
if let Some(interval) = self.interval_months {
let row = sqlx::query!(
match sqlx::query!(
// use the second date_add to force return value to datetime
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
updated_reminder_time,
@ -327,9 +341,25 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
)
.fetch_one(pool)
.await
.unwrap();
{
Ok(row) => match row.new_time {
Some(datetime) => {
updated_reminder_time = datetime;
}
None => {
warn!("Could not update interval by months: got NULL");
updated_reminder_time = row.new_time.unwrap();
updated_reminder_time += Duration::days(30);
}
},
Err(e) => {
warn!("Could not update interval by months: {:?}", e);
// naively fallback to adding 30 days
updated_reminder_time += Duration::days(30);
}
}
}
if let Some(interval) = self.interval_seconds {
@ -538,7 +568,7 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
error!("Error sending {:?}: {:?}", self, e);
if let Error::Http(error) = e {
if error.status_code() == Some(StatusCode::from_u16(404).unwrap()) {
if error.status_code() == Some(StatusCode::NOT_FOUND) {
error!("Seeing channel is deleted. Removing reminder");
self.force_delete(pool).await;
} else {

View File

@ -71,7 +71,7 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Info")
.description(format!(
.description(
"Help: `/help`
**Welcome to Reminder Bot!**
@ -81,7 +81,7 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :)
Invite the bot: https://invite.reminder-bot.com/
Use our dashboard: https://reminder-bot.com/",
))
)
.footer(footer)
.color(*THEME_COLOR)
})

View File

@ -1,3 +1,5 @@
use std::collections::hash_map::Entry;
use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein;
@ -52,7 +54,7 @@ pub async fn timezone(
.description(format!(
"Timezone has been set to **{}**. Your current time should be `{}`",
timezone,
now.format("%H:%M").to_string()
now.format("%H:%M")
))
.color(*THEME_COLOR)
})
@ -75,10 +77,7 @@ pub async fn timezone(
let fields = filtered_tz.iter().map(|tz| {
(
tz.to_string(),
format!(
"🕗 `{}`",
Utc::now().with_timezone(tz).format("%H:%M").to_string()
),
format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")),
true,
)
});
@ -98,11 +97,7 @@ pub async fn timezone(
}
} else {
let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
(
t.to_string(),
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
true,
)
(t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true)
});
ctx.send(|m| {
@ -142,7 +137,7 @@ WHERE
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or(vec![])
.unwrap_or_default()
.iter()
.map(|s| s.name.clone())
.collect()
@ -200,14 +195,11 @@ Please select a unique name for your macro.",
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if lock.contains_key(&(guild_id, ctx.author().id)) {
false
} else {
lock.insert(
(guild_id, ctx.author().id),
CommandMacro { guild_id, name, description, commands: vec![] },
);
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false
}
};

View File

@ -9,13 +9,14 @@ use chrono_tz::Tz;
use num_integer::Integer;
use poise::{
serenity::{builder::CreateEmbed, model::channel::Channel},
serenity_prelude::{ButtonStyle, ReactionType},
CreateReply,
};
use crate::{
component_models::{
pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector,
ComponentDataModel, DelSelector, UndoReminder,
},
consts::{
EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES,
@ -500,8 +501,7 @@ pub async fn start_timer(
if count >= 25 {
ctx.say("You already have 25 timers. Please delete some timers before creating a new one")
.await?;
} else {
if name.len() <= 32 {
} else if name.len() <= 32 {
Timer::create(&name, owner, &ctx.data().database).await;
ctx.say("Created a new timer").await?;
@ -512,7 +512,6 @@ pub async fn start_timer(
))
.await?;
}
}
Ok(())
}
@ -589,8 +588,7 @@ pub async fn remind(
};
let scopes = {
let list =
channels.map(|arg| parse_mention_list(&arg.to_string())).unwrap_or_default();
let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default();
if list.is_empty() {
if ctx.guild_id().is_some() {
@ -610,7 +608,7 @@ pub async fn remind(
{
(
parse_duration(repeat)
.or_else(|_| parse_duration(&format!("1 {}", repeat.to_string())))
.or_else(|_| parse_duration(&format!("1 {}", repeat)))
.ok(),
{
if let Some(arg) = &expires {
@ -653,8 +651,33 @@ pub async fn remind(
let (errors, successes) = builder.build().await;
let embed = create_response(successes, errors, time);
let embed = create_response(&successes, &errors, time);
if successes.len() == 1 {
let reminder = successes.iter().next().map(|(r, _)| r.id).unwrap();
let undo_button = ComponentDataModel::UndoReminder(UndoReminder {
user_id: ctx.author().id,
reminder_id: reminder,
});
ctx.send(|m| {
m.embed(|c| {
*c = embed;
c
})
.components(|c| {
c.create_action_row(|r| {
r.create_button(|b| {
b.emoji(ReactionType::Unicode("🔕".to_string()))
.label("Cancel")
.style(ButtonStyle::Danger)
.custom_id(undo_button.to_custom_id())
})
})
})
})
.await?;
} else {
ctx.send(|m| {
m.embed(|c| {
*c = embed;
@ -664,6 +687,7 @@ pub async fn remind(
.await?;
}
}
}
None => {
ctx.say("Time could not be processed").await?;
}
@ -673,8 +697,8 @@ pub async fn remind(
}
fn create_response(
successes: HashSet<ReminderScope>,
errors: HashSet<ReminderError>,
successes: &HashSet<(Reminder, ReminderScope)>,
errors: &HashSet<ReminderError>,
time: i64,
) -> CreateEmbed {
let success_part = match successes.len() {
@ -682,7 +706,8 @@ fn create_response(
n => format!(
"Reminder{s} for {locations} set for <t:{offset}:R>",
s = if n > 1 { "s" } else { "" },
locations = successes.iter().map(|l| l.mention()).collect::<Vec<String>>().join(", "),
locations =
successes.iter().map(|(_, l)| l.mention()).collect::<Vec<String>>().join(", "),
offset = time
),
};

View File

@ -336,7 +336,7 @@ pub fn show_todo_page(
opt.create_option(|o| {
o.label(format!("Mark {} complete", count + first_num))
.value(id)
.description(disp.split_once(" ").unwrap_or(("", "")).1)
.description(disp.split_once(' ').unwrap_or(("", "")).1)
});
}

View File

@ -3,14 +3,20 @@ pub(crate) mod pager;
use std::io::Cursor;
use chrono_tz::Tz;
use poise::serenity::{
use log::warn;
use poise::{
serenity::{
builder::CreateEmbed,
client::Context,
model::{
channel::Channel,
interactions::{message_component::MessageComponentInteraction, InteractionResponseType},
interactions::{
message_component::MessageComponentInteraction, InteractionResponseType,
},
prelude::InteractionApplicationCommandCallbackDataFlags,
},
},
serenity_prelude as serenity,
};
use rmp_serde::Serializer;
use serde::{Deserialize, Serialize};
@ -38,6 +44,7 @@ pub enum ComponentDataModel {
DelSelector(DelSelector),
TodoSelector(TodoSelector),
MacroPager(MacroPager),
UndoReminder(UndoReminder),
}
impl ComponentDataModel {
@ -334,6 +341,70 @@ WHERE guilds.guild = ?",
})
.await;
}
ComponentDataModel::UndoReminder(undo_reminder) => {
if component.user.id == undo_reminder.user_id {
let reminder =
Reminder::from_id(&data.database, undo_reminder.reminder_id).await;
if let Some(reminder) = reminder {
match reminder.delete(&data.database).await {
Ok(()) => {
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
d.embed(|e| {
e.title("Reminder Canceled")
.description(
"This reminder has been canceled.",
)
.color(*THEME_COLOR)
})
.components(|c| c)
})
})
.await;
}
Err(e) => {
warn!("Error canceling reminder: {:?}", e);
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.content(
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
.ephemeral(true)
})
})
.await;
}
}
} else {
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.content(
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
.ephemeral(true)
})
})
.await;
}
} else {
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.content(
"Only the user who performed the command can use this button.")
.ephemeral(true)
})
})
.await;
}
}
}
}
}
@ -351,3 +422,9 @@ pub struct TodoSelector {
pub channel_id: Option<u64>,
pub guild_id: Option<u64>,
}
#[derive(Serialize, Deserialize)]
pub struct UndoReminder {
pub user_id: serenity::UserId,
pub reminder_id: u32,
}

View File

@ -36,15 +36,11 @@ lazy_static! {
);
pub static ref CNC_GUILD: Option<u64> =
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL")
.ok()
.map(|inner| inner.parse::<i64>().ok())
.flatten()
.unwrap_or(600);
pub static ref MIN_INTERVAL: i64 =
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
.ok()
.map(|inner| inner.parse::<i64>().ok())
.flatten()
.and_then(|inner| inner.parse::<i64>().ok())
.unwrap_or(60 * 60 * 24 * 365 * 50);
pub static ref LOCAL_TIMEZONE: String =
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());

View File

@ -42,7 +42,7 @@ pub async fn listener(
};
});
} else {
warn!("Not running postman")
warn!("Not running postman");
}
if !run_settings.contains("web") {
@ -50,7 +50,7 @@ pub async fn listener(
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web")
warn!("Not running web");
}
data.is_loop_running.swap(true, Ordering::Relaxed);
@ -114,14 +114,13 @@ pub async fn listener(
.execute(&data.database)
.await;
}
poise::Event::InteractionCreate { interaction } => match interaction {
Interaction::MessageComponent(component) => {
poise::Event::InteractionCreate { interaction } => {
if let Interaction::MessageComponent(component) = interaction {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await;
}
_ => {}
},
}
_ => {}
}

View File

@ -30,19 +30,13 @@ async fn macro_check(ctx: Context<'_>) -> bool {
.await;
}
false
} else {
true
return false;
}
} else {
true
}
} else {
true
}
} else {
true
}
true
}
async fn check_self_permissions(ctx: Context<'_>) -> bool {
@ -56,14 +50,13 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
let (view_channel, send_messages, embed_links) = ctx
.channel_id()
.to_channel_cached(&ctx.discord())
.map(|c| {
.and_then(|c| {
if let Channel::Guild(channel) = c {
channel.permissions_for_user(&ctx.discord(), user_id).ok()
} else {
None
}
})
.flatten()
.map_or((false, false, false), |p| {
(p.view_channel(), p.send_messages(), p.embed_links())
});

View File

@ -75,7 +75,7 @@ impl fmt::Display for Error {
match self {
Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset),
Error::NumberExpected(offset) => write!(f, "expected number at {}", offset),
Error::UnknownUnit { unit, value, .. } if &unit == &"" => {
Error::UnknownUnit { unit, value, .. } if unit.is_empty() => {
write!(f, "time unit needed, for example {0}sec or {0}ms", value,)
}
Error::UnknownUnit { unit, .. } => {
@ -162,11 +162,11 @@ impl<'a> Parser<'a> {
};
let mut nsec = self.current.2 + nsec;
if nsec > 1_000_000_000 {
sec = sec + nsec / 1_000_000_000;
sec += nsec / 1_000_000_000;
nsec %= 1_000_000_000;
}
sec = self.current.1 + sec;
month = self.current.0 + month;
sec += self.current.1;
month += self.current.0;
self.current = (month, sec, nsec);

View File

@ -1,4 +1,5 @@
#![feature(int_roundings)]
#[macro_use]
extern crate lazy_static;
@ -23,7 +24,7 @@ use std::{
use chrono_tz::Tz;
use dotenv::dotenv;
use poise::serenity::model::{
gateway::{Activity, GatewayIntents},
gateway::GatewayIntents,
id::{GuildId, UserId},
};
use sqlx::{MySql, Pool};
@ -52,7 +53,7 @@ pub struct Data {
broadcast: Sender<()>,
}
impl std::fmt::Debug for Data {
impl Debug for Data {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Data {{ .. }}")
}

View File

@ -5,11 +5,11 @@ use serde::{Deserialize, Serialize};
use crate::{Context, Data, Error};
fn default_none<U, E>() -> Option<
for<'a> fn(
type Func<U, E> = for<'a> fn(
poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>,
> {
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>;
fn default_none<U, E>() -> Option<Func<U, E>> {
None
}
@ -17,11 +17,7 @@ fn default_none<U, E>() -> Option<
pub struct RecordedCommand<U, E> {
#[serde(skip)]
#[serde(default = "default_none::<U, E>")]
pub action: Option<
for<'a> fn(
poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>,
>,
pub action: Option<Func<U, E>>,
pub command_name: String,
pub options: Vec<ApplicationCommandInteractionDataOption>,
}
@ -59,7 +55,7 @@ SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND
.iter()
.find(|c| c.identifying_name == recorded_command.command_name);
recorded_command.action = command.map(|c| c.slash_action).flatten().clone();
recorded_command.action = command.map(|c| c.slash_action).flatten();
}
let command_macro = CommandMacro {

View File

@ -126,7 +126,7 @@ INSERT INTO reminders (
.await
.unwrap();
Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap())
Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap())
}
}
@ -207,7 +207,7 @@ impl<'a> MultiReminderBuilder<'a> {
self.scopes = scopes;
}
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<(Reminder, ReminderScope)>) {
let mut errors = HashSet::new();
let mut ok_locs = HashSet::new();
@ -309,8 +309,8 @@ impl<'a> MultiReminderBuilder<'a> {
};
match builder.build().await {
Ok(_) => {
ok_locs.insert(scope);
Ok(r) => {
ok_locs.insert((r, scope));
}
Err(e) => {
errors.insert(e);

View File

@ -4,6 +4,8 @@ pub mod errors;
mod helper;
pub mod look_flags;
use std::hash::{Hash, Hasher};
use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use poise::{
@ -32,11 +34,22 @@ pub struct Reminder {
pub set_by: Option<u64>,
}
impl Hash for Reminder {
fn hash<H: Hasher>(&self, state: &mut H) {
self.uid.hash(state);
}
}
impl PartialEq<Self> for Reminder {
fn eq(&self, other: &Self) -> bool {
self.uid == other.uid
}
}
impl Eq for Reminder {}
impl Reminder {
pub async fn from_uid(
pool: impl Executor<'_, Database = Database>,
uid: String,
) -> Option<Self> {
pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> {
sqlx::query_as_unchecked!(
Self,
"
@ -72,6 +85,42 @@ WHERE
.ok()
}
pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option<Self> {
sqlx::query_as_unchecked!(
Self,
"
SELECT
reminders.id,
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval_seconds,
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.content,
reminders.embed_description,
users.user AS set_by
FROM
reminders
INNER JOIN
channels
ON
reminders.channel_id = channels.id
LEFT JOIN
users
ON
reminders.set_by = users.id
WHERE
reminders.id = ?
",
id
)
.fetch_one(pool)
.await
.ok()
}
pub async fn from_channel<C: Into<ChannelId>>(
pool: impl Executor<'_, Database = Database>,
channel_id: C,
@ -240,6 +289,13 @@ WHERE
.unwrap()
}
pub async fn delete(
&self,
db: impl Executor<'_, Database = Database>,
) -> Result<(), sqlx::Error> {
sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ())
}
pub fn display_content(&self) -> &str {
if self.content.is_empty() {
&self.embed_description
@ -254,10 +310,7 @@ WHERE
count + 1,
self.display_content(),
self.channel,
timezone
.timestamp(self.utc_time.timestamp(), 0)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S")
)
}

View File

@ -211,14 +211,12 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
.output()
.await
.ok()
.map(|inner| {
.and_then(|inner| {
if inner.status.success() {
Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap())
} else {
None
}
})
.flatten()
.map(|inner| if inner < 0 { None } else { Some(inner) })
.flatten()
.and_then(|inner| if inner < 0 { None } else { Some(inner) })
}