Skip to content

Commit e98a540

Browse files
authored
feat: s3 attachment archival (#15)
closes #3 closes #12
1 parent cab32ba commit e98a540

12 files changed

Lines changed: 580 additions & 39 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,6 @@ stack.yml
142142
.pdm-python
143143

144144
compose.override.yml
145+
146+
147+
s3-data/*

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,23 @@ however, insignificant breaking changes do not guarantee a major version bump, s
99
# [UNRELEASED]
1010

1111
### Breaking
12+
1213
- 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.
1314
- Remove internal logviewer plugin
1415
- Bump Python version to >= 3.12
1516

1617
### Deprecated
18+
1719
- 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
1820

1921
### Added
22+
2023
- Added `content_type` to attachments stored in the database.
2124
- `?log key <key>` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196))
2225
- Add Forced plugins. Allows auto installing un-removable plugins via `FORCED_PLUGINS` environment variable contain a comma separate list of plugins. (GH#5)
26+
- S3 based attachment archival
27+
28+
2329
Commands:
2430
* `snooze`: Initiates a snooze action.
2531
* `snoozed`: Displays snoozed items.
@@ -54,8 +60,8 @@ Configuration Options:
5460
* Anonymous prompt support (`thread_creation_menu_anonymous_menu`).
5561

5662

57-
5863
### Changed
64+
5965
- Changing a threads title or NSFW status immediately updates the status in the database.
6066
- Replace Pipenv with PDM
6167
- Bump discord.py version to 2.3.2
@@ -64,13 +70,15 @@ Configuration Options:
6470
- Upgrade dependencies for modern python
6571

6672
### Removed
73+
6774
- Remove Discord.py dependency version check
6875
- Remove modmail telemetry
6976
- Remove lottie sticker support
7077
- Autoupdate system
7178
- Autoupdating was prone to serious issues and cannot be used within container images, the only supported distribution method of OpenModmail.
7279

7380
### Fixed
81+
7482
- Persistent notes have been fixed after the previous discord.py update.
7583
- `is_image` now is true only if the image is actually an image.
7684
- Fix contact command reporting user was blocked when they weren't.
@@ -86,6 +94,7 @@ Configuration Options:
8694

8795

8896
### Internal
97+
8998
- Add `update_title` and `update_nsfw` methods to `ApiClient` to update thread title and nsfw status in the database.
9099
- `thread.set_title` now requires `channel_id` to be passed as keyword arguments.
91100
- New `thread.set_nsfw_status` method to set nsfw status of a thread.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ logs for this fork.
4949
* Login via Discord to protect your logs (optional feature).
5050
* See past logs of a user with `?logs`.
5151
* Searchable by text queries using `?logs search`.
52+
* **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.
5253

5354
* **Robust implementation:**
5455
* Schedule tasks in human time, e.g. `?close in 2 hours silently`.

bot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,7 @@ async def on_message(self, message):
15211521
# Only log if not a command
15221522
perms = message.channel.permissions_for(message.author)
15231523
if perms.manage_messages or perms.administrator:
1524-
await self.api.append_log(message, type_="internal")
1524+
await self.api.append_log(message, thread_key=thread.key, type_="internal")
15251525

15261526
await self.process_commands(message)
15271527

core/clients.py

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import secrets
22
import sys
3+
import typing
34
from json import JSONDecodeError
45
from typing import Any, Dict, Optional, Union
56

@@ -12,6 +13,10 @@
1213
from pymongo.uri_parser import parse_uri
1314

1415
from core.models import InvalidConfigError, getLogger
16+
from core.s3_archive import S3ArchiveConfig, S3AttachmentArchiver
17+
18+
if typing.TYPE_CHECKING:
19+
from bot import ModmailBot
1520

1621
logger = getLogger(__name__)
1722

@@ -299,7 +304,7 @@ class ApiClient:
299304
The bot's current running `ClientSession`.
300305
"""
301306

302-
def __init__(self, bot, db):
307+
def __init__(self, bot: "ModmailBot", db):
303308
self.bot = bot
304309
self.db = db
305310
self.session = bot.session
@@ -675,11 +680,64 @@ async def append_log(
675680
*,
676681
message_id: str | int = "",
677682
channel_id: str | int = "",
683+
thread_key: str,
678684
type_: str = "thread_message",
679685
) -> dict:
680686
channel_id = str(channel_id) or str(message.channel.id)
681687
message_id = str(message_id) or str(message.id)
682688

689+
# Build attachments list with base metadata
690+
attachments = [
691+
{
692+
"id": a.id,
693+
"filename": a.filename,
694+
# In previous versions this was true for both videos and images
695+
"is_image": a.content_type and a.content_type.startswith("image/"),
696+
"size": a.size,
697+
"url": a.url,
698+
"content_type": a.content_type,
699+
"description": a.description,
700+
"type": "openmodmail_discord_v1",
701+
"width": a.width,
702+
"height": a.height,
703+
}
704+
for a in message.attachments
705+
]
706+
707+
# Archive attachments to S3 if configured
708+
if message.attachments and self.bot.config.get("s3_enabled"):
709+
try:
710+
# We use self.bot.user later and should awlways be logged in at the point we are calling this code
711+
assert self.bot.user
712+
s3_config = S3ArchiveConfig(
713+
enabled=self.bot.config.get("s3_enabled") or False,
714+
bucket=self.bot.config.get("s3_bucket"),
715+
region=self.bot.config.get("s3_region") or "",
716+
access_key_id=self.bot.config.get("s3_access_key_id"),
717+
secret_access_key=self.bot.config.get("s3_secret_access_key"),
718+
endpoint=self.bot.config.get("s3_endpoint"),
719+
# We should always be logged in at this point
720+
key_prefix=self.bot.config.get("s3_key_prefix") or f"modmail/{self.bot.user.id}/attachments/",
721+
)
722+
archiver = S3AttachmentArchiver(s3_config)
723+
724+
s3_metadata_list = await archiver.archive_attachments(
725+
message.attachments,
726+
thread_id=thread_key,
727+
message_id=message_id,
728+
)
729+
730+
# Merge S3 metadata into attachments
731+
for i, att in enumerate(attachments):
732+
meta = s3_metadata_list[i] if i < len(s3_metadata_list) else None
733+
if meta is not None:
734+
att["s3"] = meta
735+
att["type"] = "openmodmail_s3_v1"
736+
except Exception as e:
737+
logger.error("S3 archival failed for message %s: %s", message_id, e)
738+
raise e
739+
# Continue without S3 archival; Discord URLs are still available
740+
683741
data = {
684742
"timestamp": str(message.created_at),
685743
"message_id": message_id,
@@ -692,18 +750,7 @@ async def append_log(
692750
},
693751
"content": message.content,
694752
"type": type_,
695-
"attachments": [
696-
{
697-
"id": a.id,
698-
"filename": a.filename,
699-
# In previous versions this was true for both videos and images
700-
"is_image": a.content_type and a.content_type.startswith("image/"),
701-
"size": a.size,
702-
"url": a.url,
703-
"content_type": a.content_type,
704-
}
705-
for a in message.attachments
706-
],
753+
"attachments": attachments,
707754
"messageReference": {
708755
"message_id": message.reference.message_id,
709756
"channel_id": message.reference.channel_id,

core/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ class ConfigManager:
163163
"thread_creation_menu_embed_large_image": False,
164164
"thread_creation_menu_embed_footer_icon_url": None,
165165
"thread_creation_menu_embed_color": str(discord.Color.green()),
166+
# --- S3 ATTACHMENT ARCHIVAL ---
167+
"s3_enabled": False, # Enable archival of attachments to S3
168+
"s3_bucket": None, # S3 bucket name for attachment storage
169+
"s3_region": None, # AWS region (e.g., us-east-1, eu-west-1)
170+
"s3_endpoint": None, # Optional custom S3 endpoint (for MinIO, etc.)
171+
"s3_key_prefix": "modmail/attachments/", # Prefix for stored objects
166172
}
167173

168174
private_keys = {
@@ -213,6 +219,9 @@ class ConfigManager:
213219
# github access token for private repositories
214220
"github_token": None,
215221
"disable_updates": False,
222+
# S3 credentials
223+
"s3_access_key_id": None, # AWS access key ID for S3
224+
"s3_secret_access_key": None, # AWS secret access key for S3
216225
# Logging
217226
"log_level": "INFO",
218227
"stream_log_format": "plain",
@@ -279,6 +288,8 @@ class ConfigManager:
279288
"registry_plugins_only",
280289
# snooze
281290
"snooze_store_attachments",
291+
# S3 attachment archival
292+
"s3_enabled",
282293
# thread creation menu booleans
283294
"thread_creation_send_dm_embed",
284295
"thread_creation_menu_enabled",

core/config_help.json

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,5 +1557,70 @@
15571557
"notes": [
15581558
"Color names map to the built-in palette (e.g., 'red', 'green', 'blurple')."
15591559
]
1560+
},
1561+
"s3_enabled": {
1562+
"default": "No",
1563+
"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.",
1564+
"examples": [
1565+
"`{prefix}config set s3_enabled yes`",
1566+
"`{prefix}config set s3_enabled no`"
1567+
],
1568+
"notes": [
1569+
"Requires `s3_bucket`, `s3_region`, and `s3_access_key_id`/`s3_secret_access_key` to be configured.",
1570+
"S3 archival applies to logged messages and thread replay scenarios. Live relay to Discord is unaffected.",
1571+
"See also: `s3_bucket`, `s3_region`, `s3_endpoint`, `s3_key_prefix`."
1572+
]
1573+
},
1574+
"s3_bucket": {
1575+
"default": "None",
1576+
"description": "The name of the S3 bucket where message attachments will be archived.",
1577+
"examples": [
1578+
"`{prefix}config set s3_bucket my-modmail-attachments`",
1579+
"`{prefix}config set s3_bucket modmail-prod`"
1580+
],
1581+
"notes": [
1582+
"This configuration can only be set through `.env` file or environment (config) variables for security.",
1583+
"Required when `s3_enabled` is set to yes.",
1584+
"See also: `s3_enabled`, `s3_region`, `s3_key_prefix`."
1585+
]
1586+
},
1587+
"s3_region": {
1588+
"default": "None",
1589+
"description": "The region where the S3 bucket is located.",
1590+
"examples": [
1591+
"`{prefix}config set s3_region us-east-1`",
1592+
"`{prefix}config set s3_region eu-west-1`",
1593+
"`{prefix}config set s3_region ap-southeast-1`"
1594+
],
1595+
"notes": [
1596+
"This configuration can only be set through `.env` file or environment (config) variables.",
1597+
"See also: `s3_enabled`, `s3_bucket`, `s3_endpoint`."
1598+
]
1599+
},
1600+
"s3_endpoint": {
1601+
"default": "None (uses AWS S3)",
1602+
"description": "Optional custom S3-compatible endpoint URL for use with MinIO, DigitalOcean Spaces, or other S3-compatible services.",
1603+
"examples": [
1604+
"`{prefix}config set s3_endpoint https://minio.example.com:9000`",
1605+
"`{prefix}config set s3_endpoint https://nyc3.digitaloceanspaces.com`"
1606+
],
1607+
"notes": [
1608+
"This configuration can only be set through `.env` file or environment (config) variables.",
1609+
"Leave unset to use AWS S3 directly.",
1610+
"See also: `s3_enabled`, `s3_region`."
1611+
]
1612+
},
1613+
"s3_key_prefix": {
1614+
"default": "modmail/attachments/",
1615+
"description": "Prefix path within the S3 bucket where archived attachments will be stored.",
1616+
"examples": [
1617+
"`{prefix}config set s3_key_prefix modmail/attachments/`",
1618+
"`{prefix}config set s3_key_prefix archive/files/`"
1619+
],
1620+
"notes": [
1621+
"Should include a trailing slash.",
1622+
"Useful for organizing archived attachments within a larger S3 bucket.",
1623+
"See also: `s3_enabled`, `s3_bucket`."
1624+
]
15601625
}
15611626
}

0 commit comments

Comments
 (0)