diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a231115..ae21749 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,14 @@ -default: +precommit: + stage: test before_script: - - docker info + - apt update && apt install -y --no-install-recommends git + script: + - pip install poetry + - poetry install + - source `poetry env info --path`/bin/activate + - pre-commit run --all-files + rules: + - if: $CI_PIPELINE_SOURCE == 'merge_request_event' build: stage: build diff --git a/Dockerfile b/Dockerfile index 6fa045d..879a962 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,12 +17,16 @@ FROM python:3.12-slim-bookworm as runtime WORKDIR /app +ADD https://github.com/XAMPPRocky/tokei/releases/download/v12.1.2/tokei-x86_64-unknown-linux-gnu.tar.gz tokei.tar.gz + RUN apt-get update && \ apt-get install -y \ libjpeg-dev \ libopenjp2-7-dev \ libgl-dev \ - libglib2.0-dev + libglib2.0-dev \ + && tar xf tokei.tar.gz -C /usr/local/bin \ + && rm tokei.tar.gz ENV VIRTUAL_ENV=/app/.venv \ PATH="/app/.venv/bin:$PATH" diff --git a/jarvis/client/tasks.py b/jarvis/client/tasks.py index 8629f8b..5fb63d2 100644 --- a/jarvis/client/tasks.py +++ b/jarvis/client/tasks.py @@ -1,7 +1,31 @@ """JARVIS task mixin.""" + +from datetime import datetime + +import pytz from aiohttp import ClientSession +from beanie.operators import Set +from croniter import croniter from interactions.models.internal.tasks.task import Task -from interactions.models.internal.tasks.triggers import IntervalTrigger +from interactions.models.internal.tasks.triggers import BaseTrigger, IntervalTrigger +from jarvis_core.db import models + +from jarvis.utils import cleanup_user + + +class CronTrigger(BaseTrigger): + """ + Trigger the task based on a cron schedule. + + Attributes: + schedule str: The cron schedule, use https://crontab.guru for help + """ + + def __init__(self, schedule: str) -> None: + self.schedule = schedule + + def next_fire(self) -> datetime | None: + return croniter(self.schedule, datetime.now(tz=pytz.utc)).next(datetime) class TaskMixin: @@ -37,3 +61,64 @@ class TaskMixin: @Task.create(IntervalTrigger(minutes=30)) async def _update_currencies(self) -> None: await self.erapi.update_async() + + @Task.create(CronTrigger("0 0 1 * *")) + async def _cleanup(self) -> None: + self.logger.debug("Getting all unique guild, channel, and user IDs") + guild_ids = [] + channel_ids = [] + user_ids = [] + for model in models.all_models: + # Simple IDs + if hasattr(model, "guild"): + ids = await model.distinct(model.guild) + guild_ids += [x for x in ids if x not in guild_ids] + if hasattr(model, "channel"): + ids = await model.distinct(model.channel) + channel_ids += [x for x in ids if x not in channel_ids] + if hasattr(model, "admin"): + ids = await model.distinct(model.admin) + user_ids += [x for x in ids if x not in user_ids] + if hasattr(model, "user"): + ids = await model.distinct(model.user) + user_ids += [x for x in ids if x not in user_ids] + if hasattr(model, "creator"): + ids = await model.distinct(model.creator) + user_ids += [x for x in ids if x not in user_ids] + if hasattr(model, "editor"): + ids = await model.distinct(model.editor) + user_ids += [x for x in ids if x and x not in user_ids] + # Complex IDs + if hasattr(model, "users"): + async for result in model.find(): + user_ids += [x for x in result.users if x not in user_ids] + + self.logger.debug("Cleaning up missing guilds") + for guild_id in guild_ids: + guild = await self.fetch_guild(guild_id) + if not guild: + self.logger.info(f"Guild {guild_id} not found, deleting references") + for model in models.all_models: + if hasattr(model, "guild"): + await model.find(model.guild == guild_id).delete() + else: + await guild.chunk() + + self.logger.debug("Cleaning up missing channels") + for channel_id in channel_ids: + channel = await self.fetch_channel(channel_id) + if not channel: + self.logger.info(f"Channel {channel_id} not found, deleting references") + for model in models.all_models: + if hasattr(model, "channel"): + await model.find(model.channel == channel_id).delete() + + self.logger.debug("Cleaning up missing users") + for user_id in user_ids: + user = await self.fetch_user(user_id) + if not user: + await cleanup_user(self, user_id) + elif len(user.mutual_guilds) == 0: + await cleanup_user(self, user_id) + + self.logger.debug("Done with cleanup! Running again in 1 month") diff --git a/jarvis/cogs/core/data.py b/jarvis/cogs/core/data.py new file mode 100644 index 0000000..33e4548 --- /dev/null +++ b/jarvis/cogs/core/data.py @@ -0,0 +1,105 @@ +"""JARVIS data management ext.""" + +import asyncio +import logging + +from interactions import Client, Extension, SlashContext +from interactions.models.internal.application_commands import SlashCommand +from interactions.models.discord.components import ActionRow, Button, ButtonStyle + +from jarvis.utils import cleanup_user + +WARNING_MESSAGE = """***Are you sure you want to delete your data?*** + +If you delete your data, you will: + +- Lose any reminders that are set +- Lose all custom settings for your account set in JARVIS +- All admin tasks and created tags will be re-assigned to JARVIS + +Once done, there is no way to go back! Please think carefully before proceeding. + +This interaction will time out in 60 seconds +""" + + +class DataCog(Extension): + """ + Data functions for JARVIS + + Data deletion and requests are made here + """ + + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + + data = SlashCommand(name="data", description="Data commands") + + @data.subcommand( + sub_cmd_name="usage", sub_cmd_description="View data usage information" + ) + async def _data_usage(self, ctx: SlashContext) -> None: + await ctx.send( + "Please review our [Privacy Policy](https://s.zevs.me/jarvis-privacy)" + ) + + @data.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete my data") + async def _data_delete(self, ctx: SlashContext) -> None: + no = Button( + style=ButtonStyle.RED, + label="Nevermind, I changed my mind", + custom_id="delete_data||no", + ) + yes = Button( + style=ButtonStyle.GREEN, + label="Yes, delete my data", + custom_id="delete_data||yes", + ) + components = [ActionRow(no, yes)] + message = await ctx.send(WARNING_MESSAGE, components=components) + try: + response = await self.bot.wait_for_component( + messages=message, + check=lambda x: ctx.author.id == x.ctx.author.id, + timeout=60, + ) + except asyncio.TimeoutError: + for row in components: + for component in row.components: + component.disabled = True + await message.edit( + message.content + "\n\nInteraction timed out", components=components + ) + return + + if response.ctx.custom_id.split("||")[-1] == "yes": + for row in components: + for component in row.components: + component.disabled = True + await message.edit(components=components) + message = await message.reply( + " Deleting your data..." + ) + try: + await cleanup_user(self.bot, ctx.author.id) + await message.edit(content="✅ Your data has been deleted!") + except Exception as e: + await message.edit( + content="❌ There was an error. Please report it to the help server, or DM `@zevaryx` for help" + ) + self.logger.error( + f"Encountered error while deleting data: {e}", exc_info=True + ) + + else: + for row in components: + for component in row.components: + component.disabled = True + await message.edit( + message.content + "\n\nInteraction cancelled", components=components + ) + + +def setup(bot): + DataCog(bot) diff --git a/jarvis/cogs/core/util.py b/jarvis/cogs/core/util.py index 0ac6f92..c856ca2 100644 --- a/jarvis/cogs/core/util.py +++ b/jarvis/cogs/core/util.py @@ -41,29 +41,6 @@ from jarvis.utils import build_embed JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA") -RESPONSES = { - 264072583987593217: "Oh fuck no, go fuck yourself", - 840031256201003008: "https://tenor.com/view/fluffy-gabriel-iglesias-you-need-jesus-thats-what-you-need-pointing-up-gif-16385108", - 215564028615852033: "Last time you offered, we ended up playing board games instead. No thanks", - 256110768724901889: "Haven't you broken me enough already?", - 196018858455334912: "https://www.youtube.com/watch?v=ye5BuYf8q4o", - 169641326927806464: "I make it a habit to not get involved with people who use robot camoflauge to hide from me", - 293795462752894976: 'No thank you, but I know of a few others who call themselves "dipshits" that have expressed interest in you', - 306450238363664384: "Sorry, your internet connection isn't fast enough", - 272855749963546624: "https://www.youtube.com/watch?v=LxWHLKTfiw0", - 221427884177358848: "I saw what you did to your Wii. I would like to stay blue and not become orange", - 130845428806713344: "I cannot be associated with you, sorry. You're on too many watch lists", - 147194467898753024: "https://giphy.com/embed/jp8lWlBjGahPFAljBa\n\nHowever, no thank you", - 363765878656991244: "I'm not interested, but maybe 02 can help. Wait, she's an anime character, nevermind", - 525006281703161867: "I think there's a chat with a few people that you could ask about that", - 153369022463737856: "I think it would be better for you to print a solution yourself", - 355553397023178753: "While I appreciate the offer, I know neither of us want that", - 166317191157776385: "Who are you again?", - 352555682865741834: "This may be a bit more up your alley: ||https://www.youtube.com/watch?v=1M5UR2HX00o||", - 105362404317106176: "Look, we know who you follow on Twitter. It's not happening", - 239696265959440384: "Sir, I am here to help with everything.... except for that", -} - class UtilCog(Extension): """ @@ -104,21 +81,6 @@ Tips will be used to pay server costs, and any excess will go to local animal sh """ ) - # @bot.subcommand(sub_cmd_name="sex", sub_cmd_description="Have sex with JARVIS") - # async def _sex(self, ctx: SlashContext) -> None: - # if ctx.author.id == 264072583987593217: - # await ctx.send("Oh fuck no, go fuck yourself") - # elif ctx.author.id == 840031256201003008: - # await ctx.send( - # "https://tenor.com/view/fluffy-gabriel-iglesias-you-need-jesus-thats-what-you-need-pointing-up-gif-16385108" - # ) - # elif ctx.author.id == 215564028615852033: - # await ctx.send("As flattered as I am, I'm not into bestiality") - # elif ctx.author.id == 256110768724901889: - # await ctx.send("Haven't you broken me enough already?") - # else: - # await ctx.send("Not at this time, thank you for offering") - @bot.subcommand(sub_cmd_name="status", sub_cmd_description="Retrieve JARVIS status") @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) async def _status(self, ctx: SlashContext) -> None: diff --git a/jarvis/utils/__init__.py b/jarvis/utils/__init__.py index 4a322b6..aa7aa5d 100644 --- a/jarvis/utils/__init__.py +++ b/jarvis/utils/__init__.py @@ -1,10 +1,15 @@ """JARVIS Utility Functions.""" + from datetime import datetime, timezone from pkgutil import iter_modules +import pytz +from beanie.operators import Set +from interactions import Client from interactions.models.discord.embed import Embed, EmbedField from interactions.models.discord.guild import AuditLogEntry from interactions.models.discord.user import Member +from jarvis_core.db import models from jarvis.branding import PRIMARY_COLOR @@ -65,4 +70,37 @@ def modlog_embed( def get_extensions(path: str) -> list: """Get JARVIS cogs.""" vals = [x.name for x in iter_modules(path)] - return [f"jarvis.cogs.{x}" for x in vals] \ No newline at end of file + return [f"jarvis.cogs.{x}" for x in vals] + + +async def cleanup_user(bot: Client, user_id: int): + for model in models.all_models: + if hasattr(model, "admin"): + await model.find(model.admin == user_id).update( + Set({model.admin: bot.user.id}) + ) + if hasattr(model, "user"): + await model.find(model.user == user_id).delete() + if hasattr(model, "creator"): + await model.find(model.creator == user_id).update( + Set( + { + model.creator: bot.user.id, + model.editor: bot.user.id, + model.edited_at: datetime.now(tz=pytz.utc), + } + ) + ) + if hasattr(model, "editor"): + await model.find(model.editor == user_id).update( + Set( + { + model.editor: bot.user.id, + model.edited_at: datetime.now(tz=pytz.utc), + } + ) + ) + if hasattr(model, "users"): + async for result in model.find(): + result.users.remove(user_id) + await result.save()