diff --git a/.gitignore b/.gitignore index d49f89a349..f842ee1ac4 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ stack.yml .pdm-python compose.override.yml + + +s3-data/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e7fba85c..031a59522b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,23 @@ however, insignificant breaking changes do not guarantee a major version bump, s # [UNRELEASED] ### Breaking + - Completely rewritten blocklist system. Blocklisting now runs off its own mongoDB collection. This once again introduces backwards incompatible schema changes, so a manual migration is required. You may upgrade from both v4.0 and v4.1 using the `[p]migrate blocklist` command. This removes any need to perform the previous migration steps in v4.1.0, you may upgrade directly to this version. After running the command, blocklist functionality will return and legacy config based blocks will have been deleted. You should always back up your config before migration. - Remove internal logviewer plugin - Bump Python version to >= 3.12 ### Deprecated + - Legacy blocklist properties are deprecated and no longer function. They now log a warning when used and provide no functionality. They have been replaced with methods in blocklist.py ### Added + - Added `content_type` to attachments stored in the database. - `?log key ` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196)) - Add Forced plugins. Allows auto installing un-removable plugins via `FORCED_PLUGINS` environment variable contain a comma separate list of plugins. (GH#5) +- S3 based attachment archival + + Commands: * `snooze`: Initiates a snooze action. * `snoozed`: Displays snoozed items. @@ -54,8 +60,8 @@ Configuration Options: * Anonymous prompt support (`thread_creation_menu_anonymous_menu`). - ### Changed + - Changing a threads title or NSFW status immediately updates the status in the database. - Replace Pipenv with PDM - Bump discord.py version to 2.3.2 @@ -64,6 +70,7 @@ Configuration Options: - Upgrade dependencies for modern python ### Removed + - Remove Discord.py dependency version check - Remove modmail telemetry - Remove lottie sticker support @@ -71,6 +78,7 @@ Configuration Options: - Autoupdating was prone to serious issues and cannot be used within container images, the only supported distribution method of OpenModmail. ### Fixed + - Persistent notes have been fixed after the previous discord.py update. - `is_image` now is true only if the image is actually an image. - Fix contact command reporting user was blocked when they weren't. @@ -86,6 +94,7 @@ Configuration Options: ### Internal + - Add `update_title` and `update_nsfw` methods to `ApiClient` to update thread title and nsfw status in the database. - `thread.set_title` now requires `channel_id` to be passed as keyword arguments. - New `thread.set_nsfw_status` method to set nsfw status of a thread. diff --git a/README.md b/README.md index 3bf6bea99d..67e9a4b05c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ logs for this fork. * Login via Discord to protect your logs (optional feature). * See past logs of a user with `?logs`. * Searchable by text queries using `?logs search`. + * **S3 Attachment Archival:** Archive message attachments to durable S3 storage to preserve access beyond Discord's URL expiration (~7 days). Supports AWS S3 and S3-compatible services. * **Robust implementation:** * Schedule tasks in human time, e.g. `?close in 2 hours silently`. diff --git a/bot.py b/bot.py index 131ba3ab27..5026cdeb5c 100644 --- a/bot.py +++ b/bot.py @@ -1521,7 +1521,7 @@ async def on_message(self, message): # Only log if not a command perms = message.channel.permissions_for(message.author) if perms.manage_messages or perms.administrator: - await self.api.append_log(message, type_="internal") + await self.api.append_log(message, thread_key=thread.key, type_="internal") await self.process_commands(message) diff --git a/core/clients.py b/core/clients.py index 41b39ce40e..a9fe2290e9 100644 --- a/core/clients.py +++ b/core/clients.py @@ -1,5 +1,6 @@ import secrets import sys +import typing from json import JSONDecodeError from typing import Any, Dict, Optional, Union @@ -12,6 +13,10 @@ from pymongo.uri_parser import parse_uri from core.models import InvalidConfigError, getLogger +from core.s3_archive import S3ArchiveConfig, S3AttachmentArchiver + +if typing.TYPE_CHECKING: + from bot import ModmailBot logger = getLogger(__name__) @@ -299,7 +304,7 @@ class ApiClient: The bot's current running `ClientSession`. """ - def __init__(self, bot, db): + def __init__(self, bot: "ModmailBot", db): self.bot = bot self.db = db self.session = bot.session @@ -675,11 +680,64 @@ async def append_log( *, message_id: str | int = "", channel_id: str | int = "", + thread_key: str, type_: str = "thread_message", ) -> dict: channel_id = str(channel_id) or str(message.channel.id) message_id = str(message_id) or str(message.id) + # Build attachments list with base metadata + attachments = [ + { + "id": a.id, + "filename": a.filename, + # In previous versions this was true for both videos and images + "is_image": a.content_type and a.content_type.startswith("image/"), + "size": a.size, + "url": a.url, + "content_type": a.content_type, + "description": a.description, + "type": "openmodmail_discord_v1", + "width": a.width, + "height": a.height, + } + for a in message.attachments + ] + + # Archive attachments to S3 if configured + if message.attachments and self.bot.config.get("s3_enabled"): + try: + # We use self.bot.user later and should awlways be logged in at the point we are calling this code + assert self.bot.user + s3_config = S3ArchiveConfig( + enabled=self.bot.config.get("s3_enabled") or False, + bucket=self.bot.config.get("s3_bucket"), + region=self.bot.config.get("s3_region") or "", + access_key_id=self.bot.config.get("s3_access_key_id"), + secret_access_key=self.bot.config.get("s3_secret_access_key"), + endpoint=self.bot.config.get("s3_endpoint"), + # We should always be logged in at this point + key_prefix=self.bot.config.get("s3_key_prefix") or f"modmail/{self.bot.user.id}/attachments/", + ) + archiver = S3AttachmentArchiver(s3_config) + + s3_metadata_list = await archiver.archive_attachments( + message.attachments, + thread_id=thread_key, + message_id=message_id, + ) + + # Merge S3 metadata into attachments + for i, att in enumerate(attachments): + meta = s3_metadata_list[i] if i < len(s3_metadata_list) else None + if meta is not None: + att["s3"] = meta + att["type"] = "openmodmail_s3_v1" + except Exception as e: + logger.error("S3 archival failed for message %s: %s", message_id, e) + raise e + # Continue without S3 archival; Discord URLs are still available + data = { "timestamp": str(message.created_at), "message_id": message_id, @@ -692,18 +750,7 @@ async def append_log( }, "content": message.content, "type": type_, - "attachments": [ - { - "id": a.id, - "filename": a.filename, - # In previous versions this was true for both videos and images - "is_image": a.content_type and a.content_type.startswith("image/"), - "size": a.size, - "url": a.url, - "content_type": a.content_type, - } - for a in message.attachments - ], + "attachments": attachments, "messageReference": { "message_id": message.reference.message_id, "channel_id": message.reference.channel_id, diff --git a/core/config.py b/core/config.py index 91f6d9d374..45dd55c3a8 100644 --- a/core/config.py +++ b/core/config.py @@ -163,6 +163,12 @@ class ConfigManager: "thread_creation_menu_embed_large_image": False, "thread_creation_menu_embed_footer_icon_url": None, "thread_creation_menu_embed_color": str(discord.Color.green()), + # --- S3 ATTACHMENT ARCHIVAL --- + "s3_enabled": False, # Enable archival of attachments to S3 + "s3_bucket": None, # S3 bucket name for attachment storage + "s3_region": None, # AWS region (e.g., us-east-1, eu-west-1) + "s3_endpoint": None, # Optional custom S3 endpoint (for MinIO, etc.) + "s3_key_prefix": "modmail/attachments/", # Prefix for stored objects } private_keys = { @@ -213,6 +219,9 @@ class ConfigManager: # github access token for private repositories "github_token": None, "disable_updates": False, + # S3 credentials + "s3_access_key_id": None, # AWS access key ID for S3 + "s3_secret_access_key": None, # AWS secret access key for S3 # Logging "log_level": "INFO", "stream_log_format": "plain", @@ -279,6 +288,8 @@ class ConfigManager: "registry_plugins_only", # snooze "snooze_store_attachments", + # S3 attachment archival + "s3_enabled", # thread creation menu booleans "thread_creation_send_dm_embed", "thread_creation_menu_enabled", diff --git a/core/config_help.json b/core/config_help.json index 46b1b96e4d..138f09dc7c 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1557,5 +1557,70 @@ "notes": [ "Color names map to the built-in palette (e.g., 'red', 'green', 'blurple')." ] + }, + "s3_enabled": { + "default": "No", + "description": "Enable archival of message attachments to S3 storage. When enabled, attachments are uploaded to S3 after successful Discord transmission and logged metadata includes S3 references for resilient playback.", + "examples": [ + "`{prefix}config set s3_enabled yes`", + "`{prefix}config set s3_enabled no`" + ], + "notes": [ + "Requires `s3_bucket`, `s3_region`, and `s3_access_key_id`/`s3_secret_access_key` to be configured.", + "S3 archival applies to logged messages and thread replay scenarios. Live relay to Discord is unaffected.", + "See also: `s3_bucket`, `s3_region`, `s3_endpoint`, `s3_key_prefix`." + ] + }, + "s3_bucket": { + "default": "None", + "description": "The name of the S3 bucket where message attachments will be archived.", + "examples": [ + "`{prefix}config set s3_bucket my-modmail-attachments`", + "`{prefix}config set s3_bucket modmail-prod`" + ], + "notes": [ + "This configuration can only be set through `.env` file or environment (config) variables for security.", + "Required when `s3_enabled` is set to yes.", + "See also: `s3_enabled`, `s3_region`, `s3_key_prefix`." + ] + }, + "s3_region": { + "default": "None", + "description": "The region where the S3 bucket is located.", + "examples": [ + "`{prefix}config set s3_region us-east-1`", + "`{prefix}config set s3_region eu-west-1`", + "`{prefix}config set s3_region ap-southeast-1`" + ], + "notes": [ + "This configuration can only be set through `.env` file or environment (config) variables.", + "See also: `s3_enabled`, `s3_bucket`, `s3_endpoint`." + ] + }, + "s3_endpoint": { + "default": "None (uses AWS S3)", + "description": "Optional custom S3-compatible endpoint URL for use with MinIO, DigitalOcean Spaces, or other S3-compatible services.", + "examples": [ + "`{prefix}config set s3_endpoint https://minio.example.com:9000`", + "`{prefix}config set s3_endpoint https://nyc3.digitaloceanspaces.com`" + ], + "notes": [ + "This configuration can only be set through `.env` file or environment (config) variables.", + "Leave unset to use AWS S3 directly.", + "See also: `s3_enabled`, `s3_region`." + ] + }, + "s3_key_prefix": { + "default": "modmail/attachments/", + "description": "Prefix path within the S3 bucket where archived attachments will be stored.", + "examples": [ + "`{prefix}config set s3_key_prefix modmail/attachments/`", + "`{prefix}config set s3_key_prefix archive/files/`" + ], + "notes": [ + "Should include a trailing slash.", + "Useful for organizing archived attachments within a larger S3 bucket.", + "See also: `s3_enabled`, `s3_bucket`." + ] } } diff --git a/core/s3_archive.py b/core/s3_archive.py new file mode 100644 index 0000000000..d9a8e4cb63 --- /dev/null +++ b/core/s3_archive.py @@ -0,0 +1,226 @@ +""" +S3 Attachment Archive Service + +Handles archival of Discord message attachments to S3 storage. +Provides resilient attachment storage for logs and thread replay. +""" + +import asyncio +import logging +import typing +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import aiohttp +from discord import Attachment + +if typing.TYPE_CHECKING: + from mypy_boto3_s3 import S3Client + +try: + import boto3 + from botocore.exceptions import BotoCoreError, ClientError + + BOTO3_AVAILABLE = True +except ImportError: + BOTO3_AVAILABLE = False + ClientError = Exception + BotoCoreError = Exception + +logger = logging.getLogger(__name__) + + +class S3ArchiveConfig: + """Configuration for S3 attachment archival.""" + + def __init__( + self, + enabled: bool, + bucket: Optional[str], + region: str, + access_key_id: Optional[str], + secret_access_key: Optional[str], + endpoint: Optional[str] = None, + key_prefix: str = "modmail/attachments/", + ): + self.enabled = enabled + self.bucket = bucket or "modmail-attachments" + self.region = region + self.access_key_id = access_key_id + self.secret_access_key = secret_access_key + self.endpoint = endpoint + self.key_prefix = key_prefix or "modmail/attachments/" + + def is_valid(self) -> bool: + """Check if S3 configuration is valid and complete.""" + if not self.enabled: + return True + + required = [self.bucket, self.access_key_id, self.secret_access_key] + return all(required) and BOTO3_AVAILABLE + + +class S3AttachmentArchiver: + """Manages archival of message attachments to S3.""" + + def __init__(self, config: S3ArchiveConfig): + self.config = config + self._client: S3Client | None = None + self._session = None + self._presigned_url_expiry = 604800 # 7 days in seconds + + def _get_s3_client(self): + """Get or create S3 client.""" + if not self.config.is_valid(): + logger.error("S3 archival is not properly configured.") + return None + + if self._client is not None: + return self._client + + try: + session_kwargs = { + "aws_access_key_id": self.config.access_key_id, + "aws_secret_access_key": self.config.secret_access_key, + "region_name": self.config.region, + } + session = boto3.Session(**session_kwargs) + + client_kwargs = {} + if self.config.endpoint: + client_kwargs["endpoint_url"] = self.config.endpoint + + self._client = session.client("s3", **client_kwargs) + logger.info("S3 client initialized successfully.") + return self._client + except Exception as e: + logger.error("Failed to initialize S3 client: %s", e) + return None + + @staticmethod + async def _download_attachment(url: str, filename: str) -> Optional[bytes]: + """Download attachment from Discord CDN.""" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status == 200: + return await resp.read() + else: + logger.warning("Failed to download attachment from %s: HTTP %d", url, resp.status) + return None + except asyncio.TimeoutError: + logger.warning("Timeout downloading attachment from %s", url) + return None + except Exception as e: + logger.warning("Error downloading attachment from %s: %s", url, e) + return None + + async def archive_attachment( + self, + attachment: Attachment, + thread_id: str, + message_id: str, + ) -> Optional[Dict[str, Any]]: + """ + Archive a single attachment to S3. + + Args: + attachment: discord.Attachment object + thread_id: ID of the thread this attachment belongs to + message_id: ID of the message containing the attachment + + Returns: + Dictionary with S3 metadata if successful, None otherwise + """ + if not self.config.enabled or not self.config.is_valid(): + logger.debug("S3 archival disabled or not configured.") + return None + + client = self._get_s3_client() + if client is None: + logger.error("S3 client unavailable; attachment archival skipped.") + return None + + # Generate S3 key + # sanitized_filename = quote(attachment.filename, safe="") + s3_key = f"{self.config.key_prefix}{thread_id}/{attachment.id}" + + # Upload to S3 + try: + client.put_object( + Bucket=self.config.bucket, + Key=s3_key, + Body=await attachment.read(), + ContentType=attachment.content_type or "application/octet-stream", + ) + logger.debug("Archived attachment to S3: %s", s3_key) + return { + "archived": True, + "bucket": self.config.bucket, + "key": s3_key, + "archived_at": datetime.now(timezone.utc), + } + except (ClientError, BotoCoreError) as e: + logger.error("S3 upload failed for %s: %s", s3_key, e) + return None + except Exception as e: + logger.error("Unexpected error archiving attachment: %s", e) + return None + + async def archive_attachments( + self, + attachments: list[Attachment], + thread_id: str, + message_id: str, + ) -> Dict[int, Optional[Dict[str, Any]]]: + """ + Archive multiple attachments concurrently. + + Args: + attachments: List of discord.Attachment objects + thread_id: ID of the thread + message_id: ID of the message + + Returns: + Dictionary mapping attachment index to archive metadata + """ + if not self.config.enabled or not self.config.is_valid(): + return {} + + tasks = [self.archive_attachment(att, thread_id, message_id) for att in attachments] + + results = await asyncio.gather(*tasks, return_exceptions=False) + return {i: result for i, result in enumerate(results)} + + +async def get_archiver(config_getter) -> Optional[S3AttachmentArchiver]: + """ + Factory function to create an S3 archiver from bot config. + + Args: + config_getter: Callable that returns bot config dict + + Returns: + S3AttachmentArchiver instance if S3 is properly configured, None otherwise + """ + try: + config_dict = config_getter() if callable(config_getter) else config_getter + s3_config = S3ArchiveConfig( + enabled=config_dict.get("s3_enabled", False), + bucket=config_dict.get("s3_bucket"), + region=config_dict.get("s3_region", "us-east-1"), + access_key_id=config_dict.get("s3_access_key_id"), + secret_access_key=config_dict.get("s3_secret_access_key"), + endpoint=config_dict.get("s3_endpoint"), + key_prefix=config_dict.get("s3_key_prefix", "modmail/attachments/"), + ) + + if not s3_config.is_valid(): + if s3_config.enabled: + logger.warning("S3 archival enabled but configuration is invalid or incomplete.") + return None + + return S3AttachmentArchiver(s3_config) + except Exception as e: + logger.error("Failed to initialize S3 archiver: %s", e) + return None diff --git a/core/thread.py b/core/thread.py index 0be4c52ecf..d4a3b58a06 100644 --- a/core/thread.py +++ b/core/thread.py @@ -72,7 +72,7 @@ def __init__( manager: "ThreadManager", recipient: typing.Union[discord.Member, discord.User, int], channel: discord.DMChannel | discord.TextChannel | None = None, - other_recipients: typing.List[typing.Union[discord.Member, discord.User]] | None = None, + other_recipients: typing.List[discord.Member | discord.User] | None = None, ): self.manager = manager self.bot = manager.bot @@ -100,8 +100,7 @@ def __init__( self.snoozed: bool = False # True if thread is snoozed self.log_key: str | None = None # Ensure log_key always exists - - self.snooze_data: typing.Optional[self._SnoozeData] = None # Dict with channel/category/position/messages for restoration + self.snooze_data: typing.Optional[Thread._SnoozeData] = None # Dict with channel/category/position/messages for restoration # --- UNSNOOZE COMMAND QUEUE --- self._unsnoozing = False # True while restore_from_snooze is running self._command_queue = [] # Queue of (ctx, command) tuples; close commands always last @@ -119,7 +118,6 @@ async def create(cls, # Perform any async initialization here if needed if channel is not None: log = await manager.bot.api.get_log(str(channel.id)) - logger.debug(log) if log and "key" in log: self._key = log["key"] self.log_key = log["key"] @@ -210,7 +208,7 @@ def log_url(self) -> str: f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{self.log_key}" ) - async def snooze(self, moderator: discord.User|discord.Member=None, command_used=None, snooze_for=None, ignored_message_ids: set[int]=None ): + async def snooze(self, moderator: discord.User | discord.Member | None=None, command_used=None, snooze_for=None, ignored_message_ids: set[int]=None ): """ Save channel/category/position/messages to DB, mark as snoozed. Behavior is configurable: @@ -276,7 +274,15 @@ async def snooze(self, moderator: discord.User|discord.Member=None, command_used { "author_id": m.author.id, "content": m.content, - "attachments": [a.url for a in m.attachments], + "attachments": [ + { + "filename": a.filename, + "url": a.url, + "size": a.size, + "content_type": a.content_type, + } + for a in m.attachments + ], "embeds": [e.to_dict() for e in m.embeds], "created_at": m.created_at.isoformat(), "type": ( @@ -943,7 +949,6 @@ async def setup(self, *, creator=None, category=None, initial_message=None): log_count = sum(1 for log in log_data if not log["open"]) except Exception: logger.error("An error occurred while posting logs to the database.", exc_info=True) - log_url = log_count = None # ensure core functionality still works self.ready = True @@ -1169,7 +1174,7 @@ async def close( after: int = 0, silent: bool = False, delete_channel: bool = True, - message: str = None, + message: str | None = None, auto_close: bool = False, ) -> None: """Close a thread now or after a set time in seconds""" @@ -1208,7 +1213,7 @@ async def _close( closer: discord.Member | discord.User, silent=False, delete_channel=True, - message=None, + message: str | None = None, scheduled=False, ): # Proactively disable any DM thread-creation menu so users can't keep interacting @@ -1712,7 +1717,7 @@ async def note( # Log as 'note' type for logviewer self.bot.loop.create_task( - self.bot.api.append_log(message, message_id=msg.id, channel_id=self.channel.id, type_="note") + self.bot.api.append_log(message, message_id=msg.id, channel_id=self.channel.id, type_="note", thread_key = self.key) ) return msg @@ -1853,6 +1858,7 @@ async def reply( message, message_id=msg.id, channel_id=self.channel.id, + thread_key=self.key, type_="anonymous" if anonymous else "thread_message", ) ) @@ -1964,7 +1970,7 @@ async def send( await self.wait_until_ready() if not from_mod and not note: - self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id)) + self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id, thread_key=self.key)) destination = destination or self.channel @@ -1976,6 +1982,7 @@ async def send( # snooze-aware typing block below to handle typing and NotFound cases robustly. author = message.author + assert self.bot.guild is not None member = self.bot.guild.get_member(author.id) if member: avatar_url = member.display_avatar.url @@ -2084,13 +2091,15 @@ async def send( ext = [(a.url, a.filename, False) for a in message.attachments] - images = [] - attachments = [] - for attachment in ext: - if is_image_url(attachment[0]): - images.append(attachment) - else: - attachments.append(attachment) + images: list[tuple[str, str | None, bool]] = [] + attachments: list[tuple[str, str, bool]] = [] + + for attachment in message.attachments: + if attachment.content_type in ["image/png", "image/jpeg", "image/gif", "video/webm", "video/mp4"]: + images.append((attachment.url, attachment.filename, False)) + else: + attachments.append((attachment.url, attachment.filename, False)) + image_urls = re.findall( r"http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", diff --git a/dev.docker-compose.yml b/dev.docker-compose.yml index fcc5628abd..2a9f8c5c67 100644 --- a/dev.docker-compose.yml +++ b/dev.docker-compose.yml @@ -14,8 +14,40 @@ services: environment: - CONNECTION_URI=mongodb://mongo - TOKEN=${DISCORD_BOT_TOKEN} + - s3_endpoint=http://versitygw:7070 + - s3_bucket=modmail-dev + - s3_enabled=true + depends_on: - mongo + mongo: + restart: no + ports: + - "127.0.0.1:27017:27017" + + versitygw: + image: ghcr.io/versity/versitygw:latest + ports: + - "7070:7070" + - "7071:7071" + - "8080:8080" + environment: + ROOT_ACCESS_KEY: myaccesskey + ROOT_SECRET_KEY: mysecretkey + VGW_PORT: ":7070" + VGW_IAM_DIR: /data/iam + VGW_VERSIONING_DIR: /data/versioning + VGW_BACKEND: posix + VGW_BACKEND_ARGS: /data/s3 + VGW_ADMIN_PORT: ":7071" + VGW_WEBUI_PORT: ":8080" + VGW_WEBUI_GATEWAYS: "http://localhost:7070" + VGW_WEBUI_ADMIN_GATEWAYS: "http://localhost:7071" + volumes: + - ./s3-data/s3:/data/s3 + - ./s3-data/versioning:/data/versioning + - ./s3-data/iam:/data/iam + volumes: mongodb: diff --git a/pdm.lock b/pdm.lock index 79a86a62df..16f473c985 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "supportutils"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:1e7b54422a9a66fc478e1eecbc732feb9481cd256f09831c854e56f130c53630" +content_hash = "sha256:1e1f0fd5a82e400548ede16ba08c770ac809eb302b473e1bcef3f1505e5191dd" [[metadata.targets]] requires_python = ">=3.12" @@ -209,6 +209,84 @@ files = [ {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, ] +[[package]] +name = "boto3" +version = "1.43.29" +requires_python = ">=3.10" +summary = "The AWS SDK for Python" +groups = ["default"] +dependencies = [ + "botocore<1.44.0,>=1.43.29", + "jmespath<2.0.0,>=0.7.1", + "s3transfer<0.19.0,>=0.18.0", +] +files = [ + {file = "boto3-1.43.29-py3-none-any.whl", hash = "sha256:77c27ada27cdbf619a3bbc41fa9e991caef818d3a2988cf92ea722e107d90108"}, + {file = "boto3-1.43.29.tar.gz", hash = "sha256:354006c512cdb87ef8214a095f2ade961c8145734475cd7a7e6b39260ff5494a"}, +] + +[[package]] +name = "boto3-stubs" +version = "1.43.34" +requires_python = ">=3.9" +summary = "Type annotations for boto3 1.43.34 generated with mypy-boto3-builder 8.12.0" +groups = ["dev"] +dependencies = [ + "botocore-stubs", + "types-s3transfer", + "typing-extensions>=4.1.0; python_version < \"3.12\"", +] +files = [ + {file = "boto3_stubs-1.43.34-py3-none-any.whl", hash = "sha256:66794a638cf96307622124eaa786f32e3eff7f87b683f1d0d60cf4bc40ce69ae"}, + {file = "boto3_stubs-1.43.34.tar.gz", hash = "sha256:2b6580065e1e145687fd576f15bcf5a399619c8d5121769c8a59c784c2dd22ac"}, +] + +[[package]] +name = "boto3-stubs" +version = "1.43.34" +extras = ["s3"] +requires_python = ">=3.9" +summary = "Type annotations for boto3 1.43.34 generated with mypy-boto3-builder 8.12.0" +groups = ["dev"] +dependencies = [ + "boto3-stubs==1.43.34", + "mypy-boto3-s3<1.44.0,>=1.43.0", +] +files = [ + {file = "boto3_stubs-1.43.34-py3-none-any.whl", hash = "sha256:66794a638cf96307622124eaa786f32e3eff7f87b683f1d0d60cf4bc40ce69ae"}, + {file = "boto3_stubs-1.43.34.tar.gz", hash = "sha256:2b6580065e1e145687fd576f15bcf5a399619c8d5121769c8a59c784c2dd22ac"}, +] + +[[package]] +name = "botocore" +version = "1.43.29" +requires_python = ">=3.10" +summary = "Low-level, data-driven core of boto 3." +groups = ["default"] +dependencies = [ + "jmespath<2.0.0,>=0.7.1", + "python-dateutil<3.0.0,>=2.1", + "urllib3!=2.2.0,<3,>=1.25.4", +] +files = [ + {file = "botocore-1.43.29-py3-none-any.whl", hash = "sha256:5d62f2a03ed279a50207ca2824e009313df15f082b6bb591a095a4f04c7faef3"}, + {file = "botocore-1.43.29.tar.gz", hash = "sha256:dce39d33b707aa162aa3820975f99d7f8f746d46576169fb42ce4f2b3b56b261"}, +] + +[[package]] +name = "botocore-stubs" +version = "1.43.14" +requires_python = ">=3.9" +summary = "Type annotations and code completion for botocore" +groups = ["dev"] +dependencies = [ + "types-awscrt", +] +files = [ + {file = "botocore_stubs-1.43.14-py3-none-any.whl", hash = "sha256:fb98f1475c92fd718644e786b5c543a20f1b1f610e89e0a7191c3f1f429c75aa"}, + {file = "botocore_stubs-1.43.14.tar.gz", hash = "sha256:9e3bc1fdd51da7473f0df726c82747a1b0ae913449d629659765c247fecc2039"}, +] + [[package]] name = "certifi" version = "2025.6.15" @@ -448,6 +526,17 @@ files = [ {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, ] +[[package]] +name = "jmespath" +version = "1.1.0" +requires_python = ">=3.9" +summary = "JSON Matching Expressions" +groups = ["default"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + [[package]] name = "lottie" version = "0.7.2" @@ -540,6 +629,20 @@ files = [ {file = "multidict-6.6.2.tar.gz", hash = "sha256:c1e8b8b0523c0361a78ce9b99d9850c51cf25e1fa3c5686030ce75df6fdf2918"}, ] +[[package]] +name = "mypy-boto3-s3" +version = "1.43.31" +requires_python = ">=3.9" +summary = "Type annotations for boto3 S3 1.43.31 service generated with mypy-boto3-builder 8.12.0" +groups = ["dev"] +dependencies = [ + "typing-extensions; python_version < \"3.12\"", +] +files = [ + {file = "mypy_boto3_s3-1.43.31-py3-none-any.whl", hash = "sha256:b21c97c0db23cffcf0704625b42cf369366ad4bbdf84a4eb2fa265431c60ae83"}, + {file = "mypy_boto3_s3-1.43.31.tar.gz", hash = "sha256:fb8674063f3a491f1364c025371c3155077cd780bd04176497f8b31b5a8dd34f"}, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -831,6 +934,20 @@ files = [ {file = "ruff-0.12.1.tar.gz", hash = "sha256:806bbc17f1104fd57451a98a58df35388ee3ab422e029e8f5cf30aa4af2c138c"}, ] +[[package]] +name = "s3transfer" +version = "0.18.0" +requires_python = ">=3.10" +summary = "An Amazon S3 Transfer Manager" +groups = ["default"] +dependencies = [ + "botocore<2.0a.0,>=1.37.4", +] +files = [ + {file = "s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6"}, + {file = "s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd"}, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -894,6 +1011,28 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "types-awscrt" +version = "0.34.1" +requires_python = ">=3.8" +summary = "Type annotations and code completion for awscrt" +groups = ["dev"] +files = [ + {file = "types_awscrt-0.34.1-py3-none-any.whl", hash = "sha256:20c752b6031544d8f694803c35174aee129f1be5ddf886ae46d22f7ffd9b7d75"}, + {file = "types_awscrt-0.34.1.tar.gz", hash = "sha256:559aa04250f6a419a617dfb788f3e10903aaf74700ef23e521b64a411b83b803"}, +] + +[[package]] +name = "types-s3transfer" +version = "0.16.0" +requires_python = ">=3.9" +summary = "Type annotations and code completion for s3transfer" +groups = ["dev"] +files = [ + {file = "types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef"}, + {file = "types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443"}, +] + [[package]] name = "typing-extensions" version = "4.6.3" diff --git a/pyproject.toml b/pyproject.toml index d1a55c7481..32dd60c04c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,7 @@ max-complexity = 25 [tool.pdm] -plugins = [ - "sync-pre-commit-lock" -] +plugins = ["sync-pre-commit-lock"] [[tool.pdm.source]] name = "pypi" @@ -60,9 +58,7 @@ name = "Discord-OpenModmail" version = "5.0.0-alpha.2" description = "Fork² of a feature rich Discord Modmail bot written in Python." license = 'AGPL-3.0-only' -authors = [ - {name = "", email = ""}, -] +authors = [{ name = "", email = "contact@khakers.dev" }] dependencies = [ "aiohttp~=3.13.2", "colorama~=0.4.6", @@ -82,6 +78,7 @@ dependencies = [ "setuptools>=69.0.3", "packaging>=24.1", "lottie>=0.7.2", + "boto3>=1.26.0", # S3 attachment archival support ] requires-python = ">=3.12" readme = "README.md" @@ -112,4 +109,5 @@ dev = [ "tomli", "typing-extensions==4.6.3", "pre-commit", + "boto3-stubs[s3]>=1.43.34", ]