diff --git a/.env.ci b/.env.ci index 8eea77872..4c3609e7c 100644 Binary files a/.env.ci and b/.env.ci differ diff --git a/.github/scripts/build-image/index.ts b/.github/scripts/build-image/index.ts index 458157e5f..4a801488d 100644 --- a/.github/scripts/build-image/index.ts +++ b/.github/scripts/build-image/index.ts @@ -71,9 +71,9 @@ async function main() { const gitSha = (await $`git rev-parse --short HEAD`.text()).trim(); const tags = [ - `tahminator/codebloom:${tagPrefix}latest`, - `tahminator/codebloom:${tagPrefix}${timestamp}`, - `tahminator/codebloom:${tagPrefix}${gitSha}`, + `patinanetwork/codebloom:${tagPrefix}latest`, + `patinanetwork/codebloom:${tagPrefix}${timestamp}`, + `patinanetwork/codebloom:${tagPrefix}${gitSha}`, ]; console.log("Building image with following tags:"); @@ -85,7 +85,7 @@ async function main() { console.log("DOCKER_HUB_PAT missing or empty"); } - await $`echo ${dockerHubPat} | docker login -u tahminator --password-stdin`; + await $`echo ${dockerHubPat} | docker login -u patinanetwork --password-stdin`; try { await $`docker buildx create --use --name codebloom-builder`; diff --git a/.github/scripts/build-image/internal/standup-bot.ts b/.github/scripts/build-image/internal/standup-bot.ts index 6b3405133..f83a41705 100644 --- a/.github/scripts/build-image/internal/standup-bot.ts +++ b/.github/scripts/build-image/internal/standup-bot.ts @@ -47,9 +47,9 @@ async function main() { const gitSha = (await $`git rev-parse --short HEAD`.text()).trim(); const tags = [ - `tahminator/codebloom-standup-bot:latest`, - `tahminator/codebloom-standup-bot:${timestamp}`, - `tahminator/codebloom-standup-bot:${gitSha}`, + `patinanetwork/codebloom-standup-bot:latest`, + `patinanetwork/codebloom-standup-bot:${timestamp}`, + `patinanetwork/codebloom-standup-bot:${gitSha}`, ]; console.log("Building image with following tags:"); @@ -61,7 +61,7 @@ async function main() { console.log("DOCKER_HUB_PAT missing or empty"); } - await $`echo ${dockerHubPat} | docker login -u tahminator --password-stdin`; + await $`echo ${dockerHubPat} | docker login -u patinanetwork --password-stdin`; try { await $`docker buildx create --use --name codebloom-standup-bot-builder`; diff --git a/internal/standup-bot/src/discord/client.rs b/internal/standup-bot/src/discord/client.rs index e2c719d11..4f46b352e 100644 --- a/internal/standup-bot/src/discord/client.rs +++ b/internal/standup-bot/src/discord/client.rs @@ -7,6 +7,7 @@ use serenity::{ Client, Error, all::{ + Cache, ChannelId, Colour, CreateEmbed, @@ -14,6 +15,10 @@ use serenity::{ CreateMessage, CreateThread, GatewayIntents, + GuildId, + Member, + RoleId, + UserId, }, http::Http, }; @@ -23,16 +28,18 @@ use crate::{ credentials::DiscordCredentials, handlers::Handler, }, + redis, utils::latch::base::{ CountdownLatch, Latch, }, }; -const INTENTS: GatewayIntents = GatewayIntents::GUILD_MEMBERS; +const INTENTS: GatewayIntents = GatewayIntents::GUILD_MEMBERS.union(GatewayIntents::GUILD_MESSAGES); const ROLE_ID: u64 = 1391944565409316944; static HTTP: OnceLock> = OnceLock::new(); +static CACHE: OnceLock> = OnceLock::new(); pub async fn init_in_bg(discord_creds: &DiscordCredentials) -> Result<(), Error> { let latch = CountdownLatch::new(1); @@ -47,6 +54,11 @@ pub async fn init_in_bg(discord_creds: &DiscordCredentials) -> Result<(), Error> .set(http) .inspect_err(|_| println!("Attempted to save Discord HTTP client more than once")); + let cache = client.cache.clone(); + let _ = CACHE + .set(cache) + .inspect_err(|_| println!("Attempted to save Discord Cache more than once")); + tokio::spawn(async move { if let Err(e) = client.start().await { println!("Client error: {e:?}"); @@ -62,6 +74,10 @@ pub fn get_http() -> Arc { HTTP.get().expect("Discord not initialized").clone() } +pub fn get_cache() -> Arc { + CACHE.get().expect("Discord not initialized").clone() +} + pub async fn send_standup_message(discord_creds: &DiscordCredentials) -> Result<(), Error> { println!("Sending standup message!"); @@ -87,9 +103,42 @@ pub async fn send_standup_message(discord_creds: &DiscordCredentials) -> Result< let thread_builder = CreateThread::new("Daily Standup Thread"); - channel + let thread = channel .create_thread_from_message(get_http().as_ref(), msg.id, thread_builder) .await - .inspect_err(|e| println!("Error creating thread: {e:#?}")) - .map(|_| ()) + .inspect_err(|e| println!("Error creating thread: {e:#?}"))?; + + if let Err(e) = redis::client::set_standup_thread_id(thread.id.get()).await { + eprintln!("Failed to persist standup thread id: {e:#?}"); + } + + Ok(()) +} + +pub async fn get_standup_role_members(guild_id: u64) -> Result, Error> { + let guild = GuildId::new(guild_id); + let role = RoleId::new(ROLE_ID); + + let members = get_cache() + .guild(guild) + .map(|g| { + g.members + .values() + .filter(|m| m.roles.contains(&role)) + .cloned() + .collect() + }) + .unwrap_or_default(); + + Ok(members) +} + +pub async fn send_eod_reminder_dm(user_id: u64) -> Result<(), Error> { + let user = UserId::new(user_id).to_user(get_http().as_ref()).await?; + + let message = CreateMessage::new().content( + "Reminder: you haven't posted your update in today's standup thread yet. Please add one before end of day!", + ); + + user.dm(get_http().as_ref(), message).await.map(|_| ()) } diff --git a/internal/standup-bot/src/discord/handlers.rs b/internal/standup-bot/src/discord/handlers.rs index 8ccb7a6df..9a6ff5f85 100644 --- a/internal/standup-bot/src/discord/handlers.rs +++ b/internal/standup-bot/src/discord/handlers.rs @@ -1,5 +1,8 @@ +use chrono::Utc; +use chrono_tz::US; use serenity::{ all::{ + ChunkGuildFilter, Command, Context, CreateInteractionResponse, @@ -7,6 +10,7 @@ use serenity::{ EventHandler, GuildId, Interaction, + Message, Ready, }, async_trait, @@ -17,6 +21,7 @@ use crate::{ commands, credentials, }, + redis, utils::latch::base::{ CountdownLatch, Latch as _, @@ -51,6 +56,31 @@ impl EventHandler for Handler { } } + async fn message(&self, _ctx: Context, new_message: Message) { + if new_message.author.bot { + return; + } + + let thread_id = match redis::client::get_standup_thread_id().await { + Ok(Some(id)) => id, + Ok(None) => return, + Err(e) => { + eprintln!("Failed to get standup thread id: {e:#?}"); + return; + } + }; + + if new_message.channel_id.get() != thread_id { + return; + } + + let today = Utc::now().with_timezone(&US::Eastern).date_naive(); + if let Err(e) = redis::client::add_standup_reply(today, new_message.author.id.get()).await + { + eprintln!("Failed to record standup reply: {e:#?}"); + } + } + async fn ready(&self, ctx: Context, ready: Ready) { println!("{} is connected!", ready.user.name); @@ -61,6 +91,18 @@ impl EventHandler for Handler { self.latch.count_down(); } + + async fn cache_ready(&self, ctx: Context, _guilds: Vec) { + let creds = credentials::get_discord_credentials(); + + ctx.shard.chunk_guild( + GuildId::new(creds.guild_id), + None, + false, + ChunkGuildFilter::None, + None, + ); + } } pub async fn get_commands(guild_id: u64, ctx: Context) -> Result, serenity::Error> { diff --git a/internal/standup-bot/src/main.rs b/internal/standup-bot/src/main.rs index 62aafc137..49ff81824 100644 --- a/internal/standup-bot/src/main.rs +++ b/internal/standup-bot/src/main.rs @@ -1,10 +1,14 @@ use std::error::Error; use chrono::Utc; +use chrono_tz::US; use dotenvy::dotenv; use tokio::time::interval; -use crate::utils::standup::is_time_to_send_standup_message; +use crate::utils::standup::{ + is_time_to_send_eod_reminder, + is_time_to_send_standup_message, +}; mod discord; mod redis; @@ -52,5 +56,55 @@ async fn main() -> Result<(), Box> { } } }); + + let cloned_discord_creds = discord_creds.clone(); + tokio::spawn(async move { + let today = Utc::now().with_timezone(&US::Eastern).date_naive(); + + let already_sent = match redis::client::get_eod_reminder_sent(today).await { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to get eod_reminder_sent from Redis: {e:#?}"); + return; + } + }; + + if !is_time_to_send_eod_reminder(already_sent) { + return; + } + + let members = + match discord::client::get_standup_role_members(cloned_discord_creds.guild_id) + .await + { + Ok(m) => m, + Err(e) => { + eprintln!("Failed to fetch standup role members: {e:#?}"); + return; + } + }; + + let replied = match redis::client::get_standup_replies(today).await { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to get standup replies from Redis: {e:#?}"); + return; + } + }; + + for member in members { + let user_id = member.user.id.get(); + if replied.contains(&user_id) { + continue; + } + if let Err(e) = discord::client::send_eod_reminder_dm(user_id).await { + eprintln!("Failed to send EOD reminder DM to {user_id}: {e:#?}"); + } + } + + if let Err(e) = redis::client::set_eod_reminder_sent(today).await { + eprintln!("Failed to save eod_reminder_sent to Redis: {e:#?}"); + } + }); } } diff --git a/internal/standup-bot/src/redis/client.rs b/internal/standup-bot/src/redis/client.rs index 0b6c86201..b5e1732d0 100644 --- a/internal/standup-bot/src/redis/client.rs +++ b/internal/standup-bot/src/redis/client.rs @@ -9,8 +9,10 @@ use bb8_redis::{ }; use chrono::{ DateTime, + NaiveDate, Utc, }; +use std::collections::HashSet; use std::sync::OnceLock; use crate::redis::{ @@ -58,3 +60,54 @@ pub async fn get_last_standup() -> Result>, RedisClientErro .transpose() .map_err(RedisClientError::from) } + +pub async fn set_standup_thread_id(thread_id: u64) -> Result<(), RedisClientError> { + let mut conn = get_pool().await?; + + Ok(conn + .set("standup:current_thread_id", thread_id.to_string()) + .await?) +} + +pub async fn get_standup_thread_id() -> Result, RedisClientError> { + let mut conn = get_pool().await?; + + let value: Option = conn.get("standup:current_thread_id").await?; + + Ok(value.and_then(|v| v.parse::().ok())) +} + +pub async fn add_standup_reply(date: NaiveDate, user_id: u64) -> Result<(), RedisClientError> { + let mut conn = get_pool().await?; + + Ok(conn + .sadd(format!("standup:{date}:replied"), user_id.to_string()) + .await?) +} + +pub async fn get_standup_replies(date: NaiveDate) -> Result, RedisClientError> { + let mut conn = get_pool().await?; + + let members: Vec = conn.smembers(format!("standup:{date}:replied")).await?; + + Ok(members + .into_iter() + .filter_map(|v| v.parse::().ok()) + .collect()) +} + +pub async fn set_eod_reminder_sent(date: NaiveDate) -> Result<(), RedisClientError> { + let mut conn = get_pool().await?; + + Ok(conn + .set(format!("standup:{date}:eod_reminder_sent"), "1") + .await?) +} + +pub async fn get_eod_reminder_sent(date: NaiveDate) -> Result { + let mut conn = get_pool().await?; + + let value: Option = conn.get(format!("standup:{date}:eod_reminder_sent")).await?; + + Ok(value.is_some()) +} diff --git a/internal/standup-bot/src/utils/standup.rs b/internal/standup-bot/src/utils/standup.rs index 4d18b1e6c..d52492cf4 100644 --- a/internal/standup-bot/src/utils/standup.rs +++ b/internal/standup-bot/src/utils/standup.rs @@ -30,3 +30,15 @@ pub fn is_time_to_send_standup_message(last_standup_time: Option>) None => true, } } + +pub fn is_time_to_send_eod_reminder(already_sent_today: bool) -> bool { + let now = Utc::now().with_timezone(&US::Eastern); + let is_reminder_day = now.weekday() == Weekday::Sat; + let is_reminder_time = now.hour() >= 17; + + if !(is_reminder_day && is_reminder_time) { + return false; + } + + !already_sent_today +} diff --git a/pom.xml b/pom.xml index 11b7fe7c3..f086ab06f 100644 --- a/pom.xml +++ b/pom.xml @@ -191,11 +191,6 @@ io.micrometer micrometer-registry-prometheus - - com.github.oshi - oshi-core - 6.6.6 - org.springframework.boot spring-boot-starter-jdbc diff --git a/src/main/java/org/patinanetwork/codebloom/utilities/SystemMetricsConfig.java b/src/main/java/org/patinanetwork/codebloom/utilities/SystemMetricsConfig.java index 16aa8b1df..8db6d5a21 100644 --- a/src/main/java/org/patinanetwork/codebloom/utilities/SystemMetricsConfig.java +++ b/src/main/java/org/patinanetwork/codebloom/utilities/SystemMetricsConfig.java @@ -7,18 +7,21 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import oshi.SystemInfo; -import oshi.hardware.GlobalMemory; +/** + * Configuration for system metrics exposed to Prometheus through Spring Actuator. + * Prometheus ingests the metrics from Actuator through a Kubernetes object defined in the k8s-manifest. + * + * @see Service Monitor + */ @Configuration @EnableConfigurationProperties(CommitShaProperties.class) public class SystemMetricsConfig { - @Bean - public SystemInfo systemInfo() { - return new SystemInfo(); - } + /** + * Add commit sha to metrics so that the deployed version of the code being run on the instance is known. + */ @Bean public MeterBinder applicationInfoMetrics(CommitShaProperties commitShaProperties) { return registry -> { @@ -26,18 +29,4 @@ public MeterBinder applicationInfoMetrics(CommitShaProperties commitShaPropertie registry.gauge("application.info", tags, 1, n -> 1.0); }; } - - @Bean - public MeterBinder systemMemoryMetrics(SystemInfo systemInfo) { - return registry -> { - GlobalMemory memory = systemInfo.getHardware().getMemory(); - registry.gauge("system.info.memory.total", memory, GlobalMemory::getTotal); - registry.gauge("system.info.memory.available", memory, GlobalMemory::getAvailable); - registry.gauge("system.info.memory.used", memory, m -> m.getTotal() - m.getAvailable()); - registry.gauge( - "system.info.memory.usage", - memory, - m -> (double) (m.getTotal() - m.getAvailable()) / m.getTotal() * 100); - }; - } }