""" cogs/weekly.py — automated weekly threat digest. A background loop fires every Monday at 09:00 UTC and posts a rich digest to each configured guild channel: * total new CVEs (last 7 days) * how many are already in CISA KEV / newly added to KEV * the top 8 most critical CVEs by CVSS * source links Admins can force a run with /weekly. The schedule uses nextcord.ext.tasks with an explicit UTC time, and a guard so it only sends on Mondays. """ from __future__ import annotations import datetime as dt import logging import nextcord from nextcord import Interaction from nextcord.ext import commands, tasks from config import settings from utils.api import CVERecord, KEVEntry from utils.embeds import build_error_embed, build_info_embed, build_weekly_embed from utils.helpers import SEVERITY_ORDER log = logging.getLogger("vulnforge.cogs.weekly") GUILD_IDS = [settings.guild_id] if settings.guild_id else None # 09:00 UTC trigger. The loop runs every 24h aligned to this time; we then # check the weekday so it only actually posts on Mondays. RUN_AT = dt.time(hour=9, minute=0, tzinfo=dt.timezone.utc) class WeeklyCog(commands.Cog): """Scheduled weekly digest + manual trigger.""" def __init__(self, bot: commands.Bot) -> None: self.bot = bot self.weekly_digest.start() def cog_unload(self) -> None: self.weekly_digest.cancel() # ── scheduled loop ─────────────────────────────────────── @tasks.loop(time=RUN_AT) async def weekly_digest(self) -> None: # Only fire on Mondays (weekday() == 0). if dt.datetime.now(dt.timezone.utc).weekday() != 0: return log.info("Weekly digest schedule triggered (Monday 09:00 UTC)") await self._run_for_all_guilds() @weekly_digest.before_loop async def _before(self) -> None: await self.bot.wait_until_ready() # ── data assembly ──────────────────────────────────────── async def _build_digest_embed(self, days: int = 7) -> nextcord.Embed: api = self.bot.api recent: list[CVERecord] = await api.fetch_recent_cves(days=days) newly_added_kev: list[KEVEntry] = await api.kev_added_since(days=days) # Count how many recent CVEs are present in KEV. in_kev = 0 for cve in recent: if await api.is_in_kev(cve.cve_id): in_kev += 1 # Top N by CVSS score (then by severity rank as tiebreaker). scored = [c for c in recent if c.cvss_score is not None] scored.sort( key=lambda c: (c.cvss_score or 0.0, SEVERITY_ORDER.get(c.cvss_severity, 0)), reverse=True, ) top = scored[: settings.digest_top_n] return build_weekly_embed( total_new=len(recent), in_kev_count=in_kev, newly_added_kev=newly_added_kev, top_cves=top, days=days, ) async def _run_for_all_guilds(self) -> None: embed = await self._build_digest_embed() configs = await self.bot.store.all_guild_configs() # Fall back to env-configured channel if no guild has run /setup. if not configs and settings.digest_channel_id: configs = [ { "digest_channel_id": settings.digest_channel_id, "notify_role_id": settings.notify_role_id, } ] for cfg in configs: channel = self.bot.get_channel(cfg["digest_channel_id"]) if channel is None: log.warning("Digest channel %s not found/visible", cfg["digest_channel_id"]) continue content = f"<@&{cfg['notify_role_id']}>" if cfg.get("notify_role_id") else None try: await channel.send(content=content, embed=embed) log.info("Weekly digest posted to channel %s", channel.id) except nextcord.DiscordException as exc: log.error("Failed posting digest to %s: %s", channel.id, exc) # ── admin command ──────────────────────────────────────── @nextcord.slash_command( name="threatlens_weekly", description="Admin: force-post the weekly threat digest now.", guild_ids=GUILD_IDS, ) async def weekly(self, interaction: Interaction) -> None: perms = interaction.user.guild_permissions if interaction.guild else None if not perms or not perms.manage_guild: await interaction.response.send_message( embed=build_error_embed( "Permission Denied", "You need the **Manage Server** permission to force a digest.", ), ephemeral=True, ) return await interaction.response.defer(ephemeral=True) embed = await self._build_digest_embed() cfg = await self.bot.store.get_guild_config(interaction.guild_id) target_id = (cfg or {}).get("digest_channel_id") or settings.digest_channel_id role_id = (cfg or {}).get("notify_role_id") or settings.notify_role_id channel = self.bot.get_channel(target_id) if target_id else interaction.channel if channel is None: await interaction.followup.send( embed=build_error_embed( "No Channel Configured", "Run `/setup` first to choose a digest channel.", ), ephemeral=True, ) return content = f"<@&{role_id}>" if role_id else None await channel.send(content=content, embed=embed) await interaction.followup.send( embed=build_info_embed("Digest Sent", f"Weekly digest posted to {channel.mention}."), ephemeral=True, ) def setup(bot: commands.Bot) -> None: bot.add_cog(WeeklyCog(bot))