summaryrefslogtreecommitdiff
path: root/crates/discord-bot/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/discord-bot/src')
-rw-r--r--crates/discord-bot/src/commands/about.rs50
-rw-r--r--crates/discord-bot/src/commands/mod.rs17
-rw-r--r--crates/discord-bot/src/commands/ping.rs20
-rw-r--r--crates/discord-bot/src/commands/track.rs133
-rw-r--r--crates/discord-bot/src/config.rs38
-rw-r--r--crates/discord-bot/src/consts.rs5
-rw-r--r--crates/discord-bot/src/handler.rs112
-rw-r--r--crates/discord-bot/src/jobs.rs36
-rw-r--r--crates/discord-bot/src/lib.rs82
-rw-r--r--crates/discord-bot/src/main.rs10
10 files changed, 503 insertions, 0 deletions
diff --git a/crates/discord-bot/src/commands/about.rs b/crates/discord-bot/src/commands/about.rs
new file mode 100644
index 0000000..e663faf
--- /dev/null
+++ b/crates/discord-bot/src/commands/about.rs
@@ -0,0 +1,50 @@
+use std::sync::Arc;
+
+use crate::http::TeawieClientExt;
+
+use eyre::Result;
+use log::warn;
+use serenity::builder::{
+ CreateCommand, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse,
+ CreateInteractionResponseMessage,
+};
+use serenity::model::application::{CommandInteraction, InstallationContext};
+use serenity::prelude::Context;
+
+const VERSION: &str = env!("CARGO_PKG_VERSION");
+const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
+
+pub async fn respond<T>(ctx: &Context, http: &Arc<T>, command: &CommandInteraction) -> Result<()>
+where
+ T: TeawieClientExt,
+{
+ let mut embed = CreateEmbed::new()
+ .title("About nixpkgs-tracker-bot")
+ .description("I help track what branches PRs to nixpkgs have reached. If you've used [Nixpkgs Pull Request Tracker](https://nixpk.gs/pr-tracker.html), you probably know what this is about.")
+ .fields([
+ ("Version", VERSION, true),
+ ("Source code", &format!("[getchoo/nixpkgs-tracker-bot]({REPOSITORY})"), true),
+ ("Issues/Feature Requests", &format!("[getchoo/nixpkgs-tracker-bot/issues]({REPOSITORY}/issues)"), true)
+ ]);
+
+ let random_teawie = http.random_teawie().await?;
+
+ if let Some(teawie_url) = random_teawie.url {
+ let footer = CreateEmbedFooter::new("Images courtesy of @sympathytea");
+ embed = embed.image(teawie_url).footer(footer);
+ } else if let Some(error) = random_teawie.error {
+ warn!("Error from TeawieAPI: {error:#?}");
+ };
+
+ let message = CreateInteractionResponseMessage::new().embed(embed);
+ let response = CreateInteractionResponse::Message(message);
+ command.create_response(&ctx, response).await?;
+
+ Ok(())
+}
+
+pub fn register() -> CreateCommand {
+ CreateCommand::new("about")
+ .description("Learn more about me")
+ .add_integration_type(InstallationContext::User)
+}
diff --git a/crates/discord-bot/src/commands/mod.rs b/crates/discord-bot/src/commands/mod.rs
new file mode 100644
index 0000000..79fce17
--- /dev/null
+++ b/crates/discord-bot/src/commands/mod.rs
@@ -0,0 +1,17 @@
+use serenity::builder::CreateCommand;
+
+pub mod about;
+pub mod ping;
+pub mod track;
+
+macro_rules! cmd {
+ ($module: ident) => {
+ $module::register()
+ };
+}
+
+/// Return a list of all our [`CreateCommand`]s
+#[must_use]
+pub fn to_vec() -> Vec<CreateCommand> {
+ vec![cmd!(about), cmd!(ping), cmd!(track)]
+}
diff --git a/crates/discord-bot/src/commands/ping.rs b/crates/discord-bot/src/commands/ping.rs
new file mode 100644
index 0000000..30150dc
--- /dev/null
+++ b/crates/discord-bot/src/commands/ping.rs
@@ -0,0 +1,20 @@
+use eyre::Result;
+use serenity::builder::{
+ CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage,
+};
+use serenity::model::application::{CommandInteraction, InstallationContext};
+use serenity::prelude::Context;
+
+pub async fn respond(ctx: &Context, command: &CommandInteraction) -> Result<()> {
+ let message = CreateInteractionResponseMessage::new().content("Pong!");
+ let response = CreateInteractionResponse::Message(message);
+ command.create_response(&ctx, response).await?;
+
+ Ok(())
+}
+
+pub fn register() -> CreateCommand {
+ CreateCommand::new("ping")
+ .description("Check if the bot is up")
+ .add_integration_type(InstallationContext::User)
+}
diff --git a/crates/discord-bot/src/commands/track.rs b/crates/discord-bot/src/commands/track.rs
new file mode 100644
index 0000000..f071ebf
--- /dev/null
+++ b/crates/discord-bot/src/commands/track.rs
@@ -0,0 +1,133 @@
+use crate::{config::Config, consts::NIXPKGS_REMOTE, http::GitHubClientExt};
+
+use std::sync::Arc;
+
+use eyre::Result;
+use log::debug;
+use serenity::builder::{
+ CreateCommand, CreateCommandOption, CreateEmbed, CreateInteractionResponseFollowup,
+};
+use serenity::model::{
+ application::{
+ CommandInteraction, CommandOptionType, InstallationContext, ResolvedOption, ResolvedValue,
+ },
+ Timestamp,
+};
+use serenity::prelude::Context;
+
+const REPO_OWNER: &str = "NixOS";
+const REPO_NAME: &str = "nixpkgs";
+
+pub async fn respond<T>(
+ ctx: &Context,
+ http: &Arc<T>,
+ config: &Config,
+ command: &CommandInteraction,
+) -> Result<()>
+where
+ T: GitHubClientExt,
+{
+ // this will probably take a while
+ command.defer(&ctx).await?;
+
+ let options = command.data.options();
+ let Some(ResolvedOption {
+ value: ResolvedValue::Integer(pr),
+ ..
+ }) = options.first()
+ else {
+ let resp = CreateInteractionResponseFollowup::new()
+ .content("PR numbers aren't negative or that big...");
+ command.create_followup(&ctx, resp).await?;
+
+ return Ok(());
+ };
+
+ let Ok(id) = u64::try_from(*pr) else {
+ let resp = CreateInteractionResponseFollowup::new()
+ .content("PR numbers aren't negative or that big...");
+ command.create_followup(&ctx, resp).await?;
+
+ return Ok(());
+ };
+
+ // find out what commit our PR was merged in
+ let pull_request = http.pull_request(REPO_OWNER, REPO_NAME, id).await?;
+ if !pull_request.merged {
+ let response = CreateInteractionResponseFollowup::new()
+ .content("It looks like that PR isn't merged yet! Try again when it is 😄");
+ command.create_followup(&ctx, response).await?;
+
+ return Ok(());
+ }
+
+ // seems older PRs may not have this
+ let Some(commit_sha) = pull_request.merge_commit_sha else {
+ let response = CreateInteractionResponseFollowup::new()
+ .content("It seems this pull request is very old. I can't track it");
+ command.create_followup(&ctx, response).await?;
+
+ return Ok(());
+ };
+
+ let status_results = git_tracker::collect_statuses_in(
+ &config.nixpkgs_path,
+ &commit_sha,
+ &config.nixpkgs_branches,
+ )?;
+
+ // find branches containing our PR and trim the remote ref prefix
+ let found_branches: Vec<String> = status_results
+ .iter()
+ .filter(|&(_, has_pr)| *has_pr)
+ .map(|(branch_name, _)| {
+ // remove the ref prefix that we add in our Config struct
+ let start_pos = format!("{NIXPKGS_REMOTE}/").len();
+ branch_name[start_pos..].to_string()
+ })
+ .collect();
+
+ // if we didn't find any, bail
+ if found_branches.is_empty() {
+ let response = CreateInteractionResponseFollowup::new()
+ .content("This PR has been merged...but I can't seem to find it anywhere. I might not be tracking it's base branch");
+ command.create_followup(&ctx, response).await?;
+
+ return Ok(());
+ }
+
+ let mut embed = CreateEmbed::new()
+ .title(format!("Nixpkgs PR #{} Status", pull_request.number))
+ .url(&pull_request.html_url)
+ .description(&pull_request.title)
+ .fields(
+ found_branches
+ .iter()
+ .map(|branch_name| (branch_name, "✅", true)),
+ );
+
+ if let Some(merged_at) = pull_request.merged_at {
+ if let Ok(timestamp) = Timestamp::parse(&merged_at) {
+ embed = embed.timestamp(timestamp);
+ } else {
+ debug!("Couldn't parse timestamp from GitHub! Ignoring.");
+ }
+ } else {
+ debug!("Couldn't find `merged_at` information for a supposedly merged PR! Ignoring.");
+ }
+
+ let resp = CreateInteractionResponseFollowup::new().embed(embed);
+ command.create_followup(&ctx, resp).await?;
+
+ Ok(())
+}
+
+pub fn register() -> CreateCommand {
+ CreateCommand::new("track")
+ .description("Track a nixpkgs PR")
+ .add_integration_type(InstallationContext::User)
+ .add_option(
+ CreateCommandOption::new(CommandOptionType::Integer, "pull_request", "PR to track")
+ .required(true),
+ )
+}
diff --git a/crates/discord-bot/src/config.rs b/crates/discord-bot/src/config.rs
new file mode 100644
index 0000000..5076eb9
--- /dev/null
+++ b/crates/discord-bot/src/config.rs
@@ -0,0 +1,38 @@
+use crate::consts::NIXPKGS_REMOTE;
+
+use std::env;
+
+/// The Discord client's configuration
+#[derive(Clone, Debug)]
+pub struct Config {
+ /// Path to clone a new or use an existing nixpkgs repository
+ pub nixpkgs_path: String,
+ // A comma separated list of nixpkgs branch to track commits for
+ pub nixpkgs_branches: Vec<String>,
+}
+
+impl Config {
+ /// Take in a comma separated list and split it into a [`Vec<String>`]
+ fn split_string_list(branches: &str) -> Vec<String> {
+ branches
+ .split(',')
+ .map(|branch| format!("{NIXPKGS_REMOTE}/{}", branch.trim()))
+ .collect()
+ }
+
+ /// Create a new instance of [`Config`] based on variables from the environment
+ ///
+ /// # Errors
+ ///
+ /// Will return [`Err`] if a variable is not found
+ pub fn from_env() -> Result<Self, env::VarError> {
+ let nixpkgs_path = env::var("BOT_NIXPKGS_PATH")?;
+ let nixpkgs_branches_raw = env::var("BOT_NIXPKGS_BRANCHES")?;
+ let nixpkgs_branches = Self::split_string_list(&nixpkgs_branches_raw);
+
+ Ok(Self {
+ nixpkgs_path,
+ nixpkgs_branches,
+ })
+ }
+}
diff --git a/crates/discord-bot/src/consts.rs b/crates/discord-bot/src/consts.rs
new file mode 100644
index 0000000..9396da0
--- /dev/null
+++ b/crates/discord-bot/src/consts.rs
@@ -0,0 +1,5 @@
+/// URL to the nixpkgs repository
+pub const NIXPKGS_URL: &str = "https://github.com/NixOS/nixpkgs";
+
+/// The Git remote for upstream nixpkgs in our local copy
+pub const NIXPKGS_REMOTE: &str = "origin";
diff --git a/crates/discord-bot/src/handler.rs b/crates/discord-bot/src/handler.rs
new file mode 100644
index 0000000..4abb99b
--- /dev/null
+++ b/crates/discord-bot/src/handler.rs
@@ -0,0 +1,112 @@
+use crate::{commands, SharedConfig, SharedHttp};
+
+use eyre::{OptionExt, Result};
+use log::{debug, error, info, trace, warn};
+use serenity::all::CreateBotAuthParameters;
+use serenity::async_trait;
+use serenity::builder::{
+ CreateEmbed, CreateInteractionResponse, CreateInteractionResponseFollowup,
+ CreateInteractionResponseMessage,
+};
+use serenity::model::{
+ application::{Command, CommandInteraction, Interaction},
+ colour::Colour,
+ gateway::Ready,
+};
+use serenity::prelude::{Context, EventHandler};
+
+#[derive(Clone, Copy, Debug)]
+pub struct Handler;
+
+impl Handler {
+ async fn register_commands(&self, ctx: &Context) -> Result<()> {
+ let commands = commands::to_vec();
+ let commands_len = commands.len();
+ for command in commands {
+ Command::create_global_command(&ctx.http, command).await?;
+ }
+
+ debug!("Registered {} commands", commands_len);
+ Ok(())
+ }
+
+ /// Dispatch our commands from a [`CommandInteraction`]
+ async fn dispatch_command(ctx: &Context, command: &CommandInteraction) -> Result<()> {
+ let command_name = command.data.name.as_str();
+
+ // grab our configuration & http client from the aether
+ let (http, config) = {
+ let read = ctx.data.read().await;
+ let http = read
+ .get::<SharedHttp>()
+ .ok_or_eyre("Couldn't get shared HTTP client! WHY??????")?
+ .clone();
+ let config = read
+ .get::<SharedConfig>()
+ .ok_or_eyre("Couldn't get shared bot configuration!")?
+ .clone();
+ (http, config)
+ };
+
+ match command_name {
+ "about" => commands::about::respond(ctx, &http, command).await?,
+ "ping" => commands::ping::respond(ctx, command).await?,
+ "track" => commands::track::respond(ctx, &http, &config, command).await?,
+ _ => {
+ let message = CreateInteractionResponseMessage::new().content(format!(
+ "It doesn't look like you can use `{command_name}`. Sorry :("
+ ));
+ let response = CreateInteractionResponse::Message(message);
+ command.create_response(&ctx, response).await?;
+ }
+ };
+
+ Ok(())
+ }
+
+ async fn invite_link(ctx: &Context) {
+ if let Ok(invite_link) = CreateBotAuthParameters::new().auto_client_id(ctx).await {
+ let link = invite_link.build();
+ info!("You can install me as an app at {link}");
+ } else {
+ warn!("Couldn't figure out our own client ID! Something might be wrong");
+ }
+ }
+}
+
+#[async_trait]
+impl EventHandler for Handler {
+ /// Dispatch our commands and try to handle errors from them
+ async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
+ if let Interaction::Command(command) = interaction {
+ let command_name = &command.data.name;
+ trace!("Received command: {}", command_name);
+
+ if let Err(why) = Handler::dispatch_command(&ctx, &command).await {
+ error!(
+ "Ran into an error while dispatching command {}:\n{why:?}",
+ command_name
+ );
+
+ let embed = CreateEmbed::new()
+ .title("An error occurred")
+ .description("Sorry about that!")
+ .color(Colour::RED);
+ let response = CreateInteractionResponseFollowup::new().embed(embed);
+
+ if let Err(why) = command.create_followup(&ctx.http, response).await {
+ error!("Ran into an error while trying to recover from an error!\n{why:?}");
+ }
+ }
+ }
+ }
+
+ async fn ready(&self, ctx: Context, ready: Ready) {
+ info!("Connected as {}!", ready.user.name);
+ Handler::invite_link(&ctx).await;
+
+ if let Err(why) = self.register_commands(&ctx).await {
+ error!("Couldn't register commands!\n{why:?}");
+ };
+ }
+}
diff --git a/crates/discord-bot/src/jobs.rs b/crates/discord-bot/src/jobs.rs
new file mode 100644
index 0000000..40d34cc
--- /dev/null
+++ b/crates/discord-bot/src/jobs.rs
@@ -0,0 +1,36 @@
+use crate::{config::Config, consts::NIXPKGS_REMOTE, consts::NIXPKGS_URL};
+
+use std::{path::Path, time::Duration};
+
+use eyre::Result;
+use git_tracker::ManagedRepository;
+use log::error;
+
+const TTL_SECS: u64 = 60 * 5; // 5 minutes
+
+/// Run our jobs an initial time, then loop them on a separate thread
+///
+/// # Errors
+///
+/// Will return [`Err`] if any jobs fail
+pub fn dispatch(config: Config) -> Result<()> {
+ let managed_repository = ManagedRepository {
+ path: Path::new(&config.nixpkgs_path).to_path_buf(),
+ tracked_branches: config.nixpkgs_branches,
+ upstream_remote_url: NIXPKGS_URL.to_string(),
+ upstream_remote_name: NIXPKGS_REMOTE.to_string(),
+ };
+
+ managed_repository.fetch_or_update()?;
+
+ tokio::spawn(async move {
+ loop {
+ tokio::time::sleep(Duration::from_secs(TTL_SECS)).await;
+ if let Err(why) = managed_repository.fetch_or_update() {
+ error!("Could not fetch or update repository!\n{why:?}");
+ };
+ }
+ });
+
+ Ok(())
+}
diff --git a/crates/discord-bot/src/lib.rs b/crates/discord-bot/src/lib.rs
new file mode 100644
index 0000000..7ac2e98
--- /dev/null
+++ b/crates/discord-bot/src/lib.rs
@@ -0,0 +1,82 @@
+use std::sync::Arc;
+
+use eyre::Result;
+use log::trace;
+use serenity::prelude::{Client, GatewayIntents, TypeMapKey};
+
+mod commands;
+mod config;
+mod consts;
+mod handler;
+mod jobs;
+
+use config::Config;
+use handler::Handler;
+use nixpkgs_tracker_http as http;
+
+/// Container for [`http::Client`]
+struct SharedHttp;
+
+impl TypeMapKey for SharedHttp {
+ type Value = Arc<http::Client>;
+}
+
+/// Container for [`Config`]
+struct SharedConfig;
+
+impl TypeMapKey for SharedConfig {
+ type Value = Arc<Config>;
+}
+
+/// Fetch our bot token
+fn token() -> Result<String> {
+ let token = std::env::var("DISCORD_BOT_TOKEN")?;
+ Ok(token)
+}
+
+/// Create our client
+///
+/// # Errors
+///
+/// Will return [`Err`] if a [`Client`] cannot be created or configuration
+/// cannot be created from the environment.
+///
+/// # Panics
+///
+/// Will [`panic!`] if the bot token isn't found or the ctrl+c handler can't be made
+pub async fn client() -> Result<Client> {
+ let token = token().expect("Couldn't find token in environment! Is DISCORD_BOT_TOKEN set?");
+
+ let intents = GatewayIntents::default();
+ trace!("Creating client");
+ let client = Client::builder(token, intents)
+ .event_handler(Handler)
+ .await?;
+
+ // add state stuff
+ let http_client = <http::Client as http::Ext>::default();
+ let config = Config::from_env()?;
+
+ {
+ let mut data = client.data.write().await;
+
+ data.insert::<SharedHttp>(Arc::new(http_client));
+ data.insert::<SharedConfig>(Arc::new(config.clone()));
+ }
+
+ let shard_manager = client.shard_manager.clone();
+
+ // gracefully shutdown on ctrl+c
+ tokio::spawn(async move {
+ #[cfg(target_family = "unix")]
+ tokio::signal::ctrl_c()
+ .await
+ .expect("Couldn't register ctrl+c handler!");
+ shard_manager.shutdown_all().await;
+ });
+
+ // run our jobs
+ jobs::dispatch(config)?;
+
+ Ok(client)
+}
diff --git a/crates/discord-bot/src/main.rs b/crates/discord-bot/src/main.rs
new file mode 100644
index 0000000..acbf9fd
--- /dev/null
+++ b/crates/discord-bot/src/main.rs
@@ -0,0 +1,10 @@
+#[tokio::main]
+async fn main() -> eyre::Result<()> {
+ dotenvy::dotenv().ok();
+ env_logger::try_init()?;
+
+ let mut client = discord_bot::client().await?;
+ client.start().await?;
+
+ Ok(())
+}