From 0e26fe311f53b897cd4ed0351dd60ef5b7e3fddc Mon Sep 17 00:00:00 2001 From: Rosie Val Date: Tue, 28 Jul 2026 18:06:20 +0100 Subject: [PATCH] Add ImageVault cog: auto-delete images, re-host in vault channel, retrieve on request Watches configured channels for image attachments, reposts each image (with attribution and message text) into a staff-only vault channel, then deletes the original. Retrieval re-fetches the vault message fresh each time so there's no stale-link problem, and spoiler tags round-trip intact. Access to config is admin-only; retrieval defaults to open for everyone. Co-Authored-By: Claude Sonnet 5 --- README.md | 37 +++ imagevault/__init__.py | 24 ++ imagevault/imagevault.py | 547 +++++++++++++++++++++++++++++++++++++++ imagevault/info.json | 22 ++ info.json | 2 +- 5 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 imagevault/__init__.py create mode 100644 imagevault/imagevault.py create mode 100644 imagevault/info.json diff --git a/README.md b/README.md index 2001d7e..3f2822a 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,43 @@ Fires only on `on_member_remove` — covering voluntary leaves, kicks, and bans --- +# ImageVault + +**Auto-deletes posted images and re-hosts them in a private vault channel so they can be retrieved on request.** + +Watches configured channels for image attachments, silently reposts each image (plus the message's text and full attribution) into a staff-only vault channel, then deletes the original — keeping channels clean without anything actually being lost. Because images are re-hosted on Discord rather than linked directly, there's no link-expiry problem: retrieval always fetches a fresh copy straight from the vault message. Spoilered images stay spoilered when they're sent back. + +## Feature Highlights + +| Category | Features | +|----------|----------| +| **Archiving** | Re-uploads images into a private vault channel before deleting the original; preserves spoiler tags round-trip | +| **Scope** | Watch specific channels (or everything), ignore specific channels, ignore bots/webhooks, max file size | +| **Behavior** | Archive-only mode (skip deletion), optional text-content repost after removal | +| **Retrieval** | `show`/`get ` re-sends the image; permission modes: anyone / original poster / staff only | +| **Browsing** | `list` and `mine` to browse the archive, `forget` to drop an index entry | + +## Command Reference (`[p]imagevault`, aliases `iv`/`vault`) + +| Command | Description | +|---------|-------------| +| `enable` / `disable` | Toggle ImageVault (requires a vault channel first) | +| `vaultchannel #channel` | Set the private channel images get archived into | +| `watchchannel add/remove/list` | Channels to watch (empty = all channels) | +| `ignorechannel add/remove/list` | Channels to never touch | +| `deleteoriginal ` | Toggle actually deleting the original vs. archive-only | +| `repost ` | Toggle reposting text content after an image is removed | +| `ignorebots ` | Toggle processing bot/webhook messages | +| `maxsize ` | Max image size to archive | +| `retrieve ` | Who can retrieve archived images | +| `settings` | Full configuration summary | +| `show`/`get ` | Retrieve an archived image | +| `list [page]` / `mine [page]` | Browse the archive | +| `forget ` | Drop an index entry (staff only) | +| `stats` | Lifetime archive count | + +--- + ## Architecture ``` diff --git a/imagevault/__init__.py b/imagevault/__init__.py new file mode 100644 index 0000000..3bc5453 --- /dev/null +++ b/imagevault/__init__.py @@ -0,0 +1,24 @@ +""" +ImageVault - Auto-deletes posted images, re-hosting them in a private vault +channel first so they can be retrieved on request. Spoilered images stay +spoilered when sent back. +""" + +from redbot.core.bot import Red + +from .imagevault import ImageVault + +__red_end_user_data_statement__ = ( + "This cog stores configuration data (enabled state, vault channel, watch/ignore lists) per server. " + "When a member posts an image in a watched channel, this cog reposts that image (and the message's " + "text content) into a staff-configured private vault channel, then deletes the original message. " + "A small per-server index (author ID/name, channel and message IDs, timestamp, and a copy of the " + "message text) is kept on local disk so the image can be located and re-sent on request via " + "`[p]imagevault show`. This data is not sent anywhere off the host running the bot. Staff can remove " + "an index entry at any time with `[p]imagevault forget`." +) + + +async def setup(bot: Red) -> None: + await bot.add_cog(ImageVault(bot)) + diff --git a/imagevault/imagevault.py b/imagevault/imagevault.py new file mode 100644 index 0000000..35daa99 --- /dev/null +++ b/imagevault/imagevault.py @@ -0,0 +1,547 @@ +""" +ImageVault - Auto-deletes posted images, silently re-hosting them in a +private vault channel first, so they can be retrieved on request. Spoilered +images stay spoilered when they're sent back. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional, Union + +import discord +from redbot.core import Config, commands +from redbot.core.bot import Red +from redbot.core.data_manager import cog_data_path + +LOG = logging.getLogger("red.evecogs.imagevault") + +IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".apng") + +COLOUR_OK = discord.Colour.green() +COLOUR_INFO = discord.Colour.blurple() + +RETRIEVE_MODES = ("anyone", "author", "staff") + + +def _is_image(attachment: discord.Attachment) -> bool: + if attachment.content_type and attachment.content_type.startswith("image/"): + return True + return attachment.filename.lower().endswith(IMAGE_EXTENSIONS) + + +def _is_staff(member: discord.Member) -> bool: + perms = member.guild_permissions + return perms.administrator or perms.manage_messages or member.id == member.guild.owner_id + + +class ImageVault(commands.Cog): + """Auto-deletes images and re-hosts them in a private vault for on-request retrieval.""" + + __version__ = "1.0.0" + __author__ = ["everestmcarthur"] + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=0x696D6167_65766175_6C74, force_registration=True) + + default_guild: Dict[str, Any] = { + "enabled": False, + "vault_channel": None, + + "watch_channels": [], # empty = watch every channel + "ignored_channels": [], + + "ignore_bots": True, + "delete_original": True, + "repost_text": True, + "max_size_mb": 25, + "retrieve_permission": "anyone", # anyone | author | staff + + "stats": {"total_stored": 0}, + } + self.config.register_guild(**default_guild) + + self._locks: Dict[int, asyncio.Lock] = {} + + # ══════════════════════════════════════════════════════════ + # Index storage (per-guild JSON file: {"next_id": int, "entries": {id: {...}}}) + # ══════════════════════════════════════════════════════════ + + def _index_path(self, guild_id: int) -> Path: + path = cog_data_path(self) / str(guild_id) + path.mkdir(parents=True, exist_ok=True) + return path / "index.json" + + def _lock(self, guild_id: int) -> asyncio.Lock: + if guild_id not in self._locks: + self._locks[guild_id] = asyncio.Lock() + return self._locks[guild_id] + + def _load_index(self, guild_id: int) -> Dict[str, Any]: + path = self._index_path(guild_id) + if not path.exists(): + return {"next_id": 1, "entries": {}} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + LOG.exception("Failed to read ImageVault index for guild %s, resetting.", guild_id) + return {"next_id": 1, "entries": {}} + + def _save_index(self, guild_id: int, data: Dict[str, Any]) -> None: + self._index_path(guild_id).write_text(json.dumps(data), encoding="utf-8") + + async def _store_entry(self, guild_id: int, record: Dict[str, Any]) -> int: + async with self._lock(guild_id): + data = self._load_index(guild_id) + entry_id = data["next_id"] + data["next_id"] = entry_id + 1 + data["entries"][str(entry_id)] = record + self._save_index(guild_id, data) + return entry_id + + async def _get_entry(self, guild_id: int, entry_id: int) -> Optional[Dict[str, Any]]: + async with self._lock(guild_id): + data = self._load_index(guild_id) + return data["entries"].get(str(entry_id)) + + async def _delete_entry(self, guild_id: int, entry_id: int) -> bool: + async with self._lock(guild_id): + data = self._load_index(guild_id) + if str(entry_id) in data["entries"]: + del data["entries"][str(entry_id)] + self._save_index(guild_id, data) + return True + return False + + async def _list_entries(self, guild_id: int): + async with self._lock(guild_id): + data = self._load_index(guild_id) + return data["entries"] + + # ══════════════════════════════════════════════════════════ + # Core: vault + delete + # ══════════════════════════════════════════════════════════ + + async def _handle_image_message(self, message: discord.Message, conf: Dict[str, Any]) -> None: + guild = message.guild + vault_channel_id = conf["vault_channel"] + vault_channel = guild.get_channel(vault_channel_id) if vault_channel_id else None + if not isinstance(vault_channel, discord.TextChannel): + LOG.warning("ImageVault enabled in guild %s but vault channel is missing/invalid.", guild.id) + return + + perms = vault_channel.permissions_for(guild.me) + if not (perms.send_messages and perms.attach_files and perms.embed_links): + LOG.warning("ImageVault missing permissions in vault channel for guild %s.", guild.id) + return + + max_bytes = conf["max_size_mb"] * 1024 * 1024 + images = [a for a in message.attachments if _is_image(a)] + if not images: + return + if any(a.size > max_bytes for a in images): + # Can't selectively strip one attachment from someone else's message, + # so if any qualifying image is oversized, leave the whole message alone. + return + + try: + files = [await a.to_file(spoiler=a.is_spoiler()) for a in images] + except (discord.HTTPException, discord.NotFound): + LOG.exception("ImageVault failed to fetch attachment bytes in guild %s.", guild.id) + return + + embed = discord.Embed( + description=(message.content or "")[:1000] or None, + colour=COLOUR_INFO, + timestamp=message.created_at, + ) + embed.set_author( + name=f"{message.author} ({message.author.id})", + icon_url=message.author.display_avatar.url if message.author.display_avatar else None, + ) + embed.add_field(name="Channel", value=message.channel.mention, inline=True) + embed.add_field(name="Images", value=str(len(images)), inline=True) + embed.set_footer(text="ImageVault archive") + + try: + vault_message = await vault_channel.send(embed=embed, files=files) + except discord.HTTPException: + LOG.exception("ImageVault failed to post to vault channel in guild %s.", guild.id) + return + + entry_id = await self._store_entry( + guild.id, + { + "author_id": message.author.id, + "author_name": str(message.author), + "origin_channel_id": message.channel.id, + "origin_message_id": message.id, + "vault_message_id": vault_message.id, + "timestamp": message.created_at.astimezone(timezone.utc).isoformat(), + "content": (message.content or "")[:500], + "attachment_count": len(images), + }, + ) + + async with self.config.guild(guild).stats() as stats: + stats["total_stored"] = stats.get("total_stored", 0) + 1 + + if conf["delete_original"]: + try: + await message.delete() + except discord.HTTPException: + LOG.exception("ImageVault failed to delete original message in guild %s.", guild.id) + return + + if conf["repost_text"]: + note = discord.Embed( + description=message.content or None, + colour=COLOUR_INFO, + ) + note.set_author( + name=str(message.author), + icon_url=message.author.display_avatar.url if message.author.display_avatar else None, + ) + note.set_footer( + text=f"🖼️ {len(images)} image(s) archived — retrieve with " + f"[p]imagevault show {entry_id}" + ) + try: + await message.channel.send(embed=note) + except discord.HTTPException: + pass + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + if not message.guild or not message.attachments: + return + guild = message.guild + conf = await self.config.guild(guild).all() + if not conf["enabled"]: + return + if conf["ignore_bots"] and message.author.bot: + return + if message.channel.id == conf["vault_channel"]: + return + + watch = set(conf["watch_channels"]) + ignore = set(conf["ignored_channels"]) + channel_id = message.channel.id + parent_id = getattr(message.channel, "parent_id", None) + if watch: + if channel_id not in watch and parent_id not in watch: + return + elif (channel_id in ignore) or (parent_id is not None and parent_id in ignore): + return + + await self._handle_image_message(message, conf) + + # ══════════════════════════════════════════════════════════ + # Commands + # ══════════════════════════════════════════════════════════ + + @commands.group(name="imagevault", aliases=["iv", "vault"]) + @commands.guild_only() + async def imagevault(self, ctx: commands.Context): + """🖼️ ImageVault — auto-delete images, retrievable on request. + + Configuration and `list` are staff-only; `show`/`get`, `mine`, and `stats` + are open to whoever the `retrieve` permission mode allows. + """ + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @imagevault.command(name="enable", aliases=["on"]) + @commands.admin_or_permissions(administrator=True) + async def iv_enable(self, ctx: commands.Context): + """Enable ImageVault for this server (requires a vault channel to be set first).""" + vault_id = await self.config.guild(ctx.guild).vault_channel() + if not vault_id or not ctx.guild.get_channel(vault_id): + return await ctx.send( + "❌ Set a vault channel first: `[p]imagevault vaultchannel #channel`.\n" + "Use a private channel that regular members can't see." + ) + await self.config.guild(ctx.guild).enabled.set(True) + await ctx.send(embed=discord.Embed( + title="🖼️ ImageVault Enabled", + description="Images posted in watched channels will now be archived and removed.", + colour=COLOUR_OK, + )) + + @imagevault.command(name="disable", aliases=["off"]) + @commands.admin_or_permissions(administrator=True) + async def iv_disable(self, ctx: commands.Context): + """Disable ImageVault for this server.""" + await self.config.guild(ctx.guild).enabled.set(False) + await ctx.send(embed=discord.Embed( + title="🖼️ ImageVault Disabled", colour=COLOUR_INFO, + description="ImageVault will no longer act on new images.", + )) + + @imagevault.command(name="vaultchannel") + @commands.admin_or_permissions(administrator=True) + async def iv_vaultchannel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the private channel images get archived into. + + This should be a channel only staff can see — every removed image is + reposted here in full (with attribution) before the original is deleted. + """ + perms = channel.permissions_for(ctx.guild.me) + if not (perms.send_messages and perms.attach_files and perms.embed_links): + return await ctx.send(f"❌ I need Send Messages, Attach Files, and Embed Links in {channel.mention}.") + await self.config.guild(ctx.guild).vault_channel.set(channel.id) + await ctx.send(embed=discord.Embed( + title="🖼️ Vault Channel Set", + description=f"Archived images will be posted to {channel.mention}.\n" + f"⚠️ Make sure regular members can't view that channel.", + colour=COLOUR_OK, + )) + + @imagevault.group(name="watchchannel") + @commands.admin_or_permissions(administrator=True) + async def iv_watchchannel(self, ctx: commands.Context): + """Manage the channel watch-list. Empty list = watch every channel.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @iv_watchchannel.command(name="add") + async def iv_watchchannel_add( + self, ctx: commands.Context, + channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread], + ): + async with self.config.guild(ctx.guild).watch_channels() as ids: + if channel.id not in ids: + ids.append(channel.id) + await ctx.send(f"✅ Now watching {channel.mention}.") + + @iv_watchchannel.command(name="remove") + async def iv_watchchannel_remove( + self, ctx: commands.Context, + channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread], + ): + async with self.config.guild(ctx.guild).watch_channels() as ids: + if channel.id in ids: + ids.remove(channel.id) + await ctx.send(f"✅ No longer watching {channel.mention}.") + + @iv_watchchannel.command(name="list") + async def iv_watchchannel_list(self, ctx: commands.Context): + ids = await self.config.guild(ctx.guild).watch_channels() + if not ids: + return await ctx.send("Watch list is empty — every channel is watched.") + await ctx.send("Watched channels:\n" + "\n".join(f"<#{i}>" for i in ids)) + + @imagevault.group(name="ignorechannel") + @commands.admin_or_permissions(administrator=True) + async def iv_ignorechannel(self, ctx: commands.Context): + """Manage channels ImageVault never touches (only applies when the watch-list is empty).""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @iv_ignorechannel.command(name="add") + async def iv_ignorechannel_add( + self, ctx: commands.Context, + channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread], + ): + async with self.config.guild(ctx.guild).ignored_channels() as ids: + if channel.id not in ids: + ids.append(channel.id) + await ctx.send(f"✅ {channel.mention} will be ignored.") + + @iv_ignorechannel.command(name="remove") + async def iv_ignorechannel_remove( + self, ctx: commands.Context, + channel: Union[discord.TextChannel, discord.VoiceChannel, discord.Thread], + ): + async with self.config.guild(ctx.guild).ignored_channels() as ids: + if channel.id in ids: + ids.remove(channel.id) + await ctx.send(f"✅ {channel.mention} removed from the ignore list.") + + @iv_ignorechannel.command(name="list") + async def iv_ignorechannel_list(self, ctx: commands.Context): + ids = await self.config.guild(ctx.guild).ignored_channels() + if not ids: + return await ctx.send("No ignored channels.") + await ctx.send("Ignored channels:\n" + "\n".join(f"<#{i}>" for i in ids)) + + @imagevault.command(name="deleteoriginal") + @commands.admin_or_permissions(administrator=True) + async def iv_deleteoriginal(self, ctx: commands.Context, toggle: bool): + """Toggle whether the original message is actually deleted after archiving.""" + await self.config.guild(ctx.guild).delete_original.set(toggle) + await ctx.send(f"✅ Deleting originals is now {'enabled' if toggle else 'disabled (archive-only mode)'}.") + + @imagevault.command(name="repost") + @commands.admin_or_permissions(administrator=True) + async def iv_repost(self, ctx: commands.Context, toggle: bool): + """Toggle reposting the original text content (sans image) after deletion.""" + await self.config.guild(ctx.guild).repost_text.set(toggle) + await ctx.send(f"✅ Text repost {'enabled' if toggle else 'disabled'}.") + + @imagevault.command(name="ignorebots") + @commands.admin_or_permissions(administrator=True) + async def iv_ignorebots(self, ctx: commands.Context, toggle: bool): + """Toggle whether images posted by other bots/webhooks are ignored.""" + await self.config.guild(ctx.guild).ignore_bots.set(toggle) + await ctx.send(f"✅ Bot messages are now {'ignored' if toggle else 'processed'}.") + + @imagevault.command(name="maxsize") + @commands.admin_or_permissions(administrator=True) + async def iv_maxsize(self, ctx: commands.Context, megabytes: int): + """Set the max image size (MB) ImageVault will archive. Larger images are left alone.""" + if megabytes < 1 or megabytes > 100: + return await ctx.send("❌ Must be between 1 and 100 MB.") + await self.config.guild(ctx.guild).max_size_mb.set(megabytes) + await ctx.send(f"✅ Max archived image size set to **{megabytes}MB**.") + + @imagevault.command(name="retrieve") + @commands.admin_or_permissions(administrator=True) + async def iv_retrieve(self, ctx: commands.Context, mode: str): + """Set who can retrieve archived images: `anyone`, `author`, or `staff`.""" + mode = mode.lower() + if mode not in RETRIEVE_MODES: + return await ctx.send(f"❌ Must be one of: {', '.join(RETRIEVE_MODES)}.") + await self.config.guild(ctx.guild).retrieve_permission.set(mode) + await ctx.send(f"✅ Retrieval permission set to **{mode}**.") + + @imagevault.command(name="settings", aliases=["status", "config"]) + @commands.admin_or_permissions(administrator=True) + async def iv_settings(self, ctx: commands.Context): + """View ImageVault's full configuration for this server.""" + conf = await self.config.guild(ctx.guild).all() + vault_ch = ctx.guild.get_channel(conf["vault_channel"]) if conf["vault_channel"] else None + + embed = discord.Embed(title="🖼️ ImageVault Settings", colour=COLOUR_INFO, timestamp=datetime.now(timezone.utc)) + embed.add_field(name="Status", value="✅ Enabled" if conf["enabled"] else "❌ Disabled", inline=True) + embed.add_field(name="Vault Channel", value=vault_ch.mention if vault_ch else "⚠️ Not set", inline=True) + embed.add_field(name="Delete Originals", value="✅" if conf["delete_original"] else "❌ (archive-only)", inline=True) + embed.add_field(name="Repost Text", value="✅" if conf["repost_text"] else "❌", inline=True) + embed.add_field(name="Ignore Bots", value="✅" if conf["ignore_bots"] else "❌", inline=True) + embed.add_field(name="Max Size", value=f"{conf['max_size_mb']}MB", inline=True) + embed.add_field(name="Retrieval", value=conf["retrieve_permission"].title(), inline=True) + watch = conf["watch_channels"] + embed.add_field( + name="Watched Channels", + value=(", ".join(f"<#{i}>" for i in watch) if watch else "All channels"), + inline=False, + ) + if conf["ignored_channels"]: + embed.add_field(name="Ignored Channels", value=", ".join(f"<#{i}>" for i in conf["ignored_channels"]), inline=False) + embed.add_field(name="Total Archived", value=str(conf["stats"].get("total_stored", 0)), inline=True) + embed.set_footer(text=f"ImageVault v{self.__version__}") + await ctx.send(embed=embed) + + @imagevault.command(name="show", aliases=["get"]) + async def iv_show(self, ctx: commands.Context, entry_id: int): + """Retrieve an archived image by its ID (see the archive note or `[p]imagevault list`).""" + conf = await self.config.guild(ctx.guild).all() + record = await self._get_entry(ctx.guild.id, entry_id) + if not record: + return await ctx.send("❌ No archived image with that ID.") + + mode = conf["retrieve_permission"] + if mode == "author" and ctx.author.id != record["author_id"] and not _is_staff(ctx.author): + return await ctx.send("❌ Only the original poster (or staff) can retrieve this image.") + if mode == "staff" and not _is_staff(ctx.author): + return await ctx.send("❌ Only staff can retrieve archived images here.") + + vault_channel = ctx.guild.get_channel(conf["vault_channel"]) + if not isinstance(vault_channel, discord.TextChannel): + return await ctx.send("❌ The vault channel is missing — cannot retrieve.") + + try: + vault_message = await vault_channel.fetch_message(record["vault_message_id"]) + except discord.NotFound: + return await ctx.send("❌ That image's vault entry no longer exists.") + except discord.HTTPException: + return await ctx.send("❌ Failed to fetch that image from the vault.") + + try: + files = [await a.to_file(spoiler=a.is_spoiler()) for a in vault_message.attachments] + except (discord.HTTPException, discord.NotFound): + return await ctx.send("❌ Failed to re-download that image.") + + embed = discord.Embed( + description=record.get("content") or None, + colour=COLOUR_INFO, + ) + embed.set_author(name=record["author_name"]) + embed.set_footer(text=f"Originally posted {record['timestamp'][:19]} UTC — archive #{entry_id}") + await ctx.send(embed=embed, files=files) + + @imagevault.command(name="list") + @commands.admin_or_permissions(administrator=True) + async def iv_list(self, ctx: commands.Context, page: int = 1): + """List recently archived images for this server (staff only — shows all members' entries).""" + entries = await self._list_entries(ctx.guild.id) + if not entries: + return await ctx.send("No images archived yet.") + + per_page = 10 + sorted_ids = sorted((int(k) for k in entries), reverse=True) + total_pages = max(1, (len(sorted_ids) + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + chunk = sorted_ids[(page - 1) * per_page: page * per_page] + + embed = discord.Embed(title="🖼️ ImageVault Archive", colour=COLOUR_INFO) + for eid in chunk: + e = entries[str(eid)] + embed.add_field( + name=f"#{eid} — {e['author_name']}", + value=f"<#{e['origin_channel_id']}> • {e['attachment_count']} image(s) • {e['timestamp'][:19]} UTC", + inline=False, + ) + embed.set_footer(text=f"Page {page}/{total_pages} • [p]imagevault show ") + await ctx.send(embed=embed) + + @imagevault.command(name="mine") + async def iv_mine(self, ctx: commands.Context, page: int = 1): + """List your own archived images.""" + entries = await self._list_entries(ctx.guild.id) + mine = {k: v for k, v in entries.items() if v["author_id"] == ctx.author.id} + if not mine: + return await ctx.send("You don't have any archived images.") + + per_page = 10 + sorted_ids = sorted((int(k) for k in mine), reverse=True) + total_pages = max(1, (len(sorted_ids) + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + chunk = sorted_ids[(page - 1) * per_page: page * per_page] + + embed = discord.Embed(title=f"🖼️ Your Archived Images", colour=COLOUR_INFO) + for eid in chunk: + e = mine[str(eid)] + embed.add_field( + name=f"#{eid}", + value=f"<#{e['origin_channel_id']}> • {e['attachment_count']} image(s) • {e['timestamp'][:19]} UTC", + inline=False, + ) + embed.set_footer(text=f"Page {page}/{total_pages} • [p]imagevault show ") + await ctx.send(embed=embed) + + @imagevault.command(name="forget") + @commands.admin_or_permissions(administrator=True) + async def iv_forget(self, ctx: commands.Context, entry_id: int): + """Remove an entry from the archive index (staff only). The vault message itself is untouched.""" + removed = await self._delete_entry(ctx.guild.id, entry_id) + if removed: + await ctx.send(f"✅ Archive entry #{entry_id} forgotten (the vault post itself is untouched).") + else: + await ctx.send("❌ No archived image with that ID.") + + @imagevault.command(name="stats") + async def iv_stats(self, ctx: commands.Context): + """View lifetime ImageVault stats for this server.""" + conf = await self.config.guild(ctx.guild).all() + await ctx.send(embed=discord.Embed( + title="🖼️ ImageVault Stats", + description=f"**{conf['stats'].get('total_stored', 0)}** images archived", + colour=COLOUR_INFO, + )) + diff --git a/imagevault/info.json b/imagevault/info.json new file mode 100644 index 0000000..532cd62 --- /dev/null +++ b/imagevault/info.json @@ -0,0 +1,22 @@ +{ + "author": ["everestmcarthur"], + "install_msg": "Thanks for installing **ImageVault**! First create a private channel only staff can see, then run `[p]imagevault vaultchannel #that-channel` followed by `[p]imagevault enable`. Members can retrieve a removed image with `[p]imagevault show ` — check `[p]imagevault settings` for the full config.", + "name": "ImageVault", + "short": "Auto-deletes posted images and re-hosts them in a private vault channel so they can be retrieved on request. Spoilers stay spoilered.", + "description": "ImageVault watches for image attachments in configured channels, silently reposts each one (with full attribution and original message text) into a staff-only vault channel, then deletes the original message — keeping channels clean without actually losing anything. Anyone (or, if configured, only the original poster or staff) can bring an image back with `[p]imagevault show `, and if the original was posted as a Discord spoiler it comes back spoilered too. Highly configurable per-server: choose which channels are watched (or watch everything), ignore specific channels, toggle whether originals are actually deleted (archive-only mode) or whether the text content is reposted after removal, set a max file size, ignore bot/webhook posts, and control who is allowed to retrieve archived images. Includes `list`/`mine` to browse the archive and `forget` to drop an index entry.", + "min_bot_version": "3.5.0", + "min_python_version": [3, 9, 0], + "tags": [ + "images", + "moderation", + "cleanup", + "spoiler", + "archive", + "vault", + "declutter", + "media" + ], + "type": "COG", + "requirements": [] +} + diff --git a/info.json b/info.json index 7c30a71..926a76e 100644 --- a/info.json +++ b/info.json @@ -3,6 +3,6 @@ "install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.", "name": "EveCogs", "short": "A collection of advanced cogs for Red-DiscordBot.", - "description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), Support (modernized contact/dm system replacing Red's built-in commands with categories, logging, blocking & customization), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export), UserSlash (user-installable slash-command bridge — every Red command works in servers, DMs & group DMs), and GhostWipe (auto-deletes a departing member's messages server-wide on leave/kick/ban, with a full HTML audit-log viewer).", + "description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), Support (modernized contact/dm system replacing Red's built-in commands with categories, logging, blocking & customization), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export), UserSlash (user-installable slash-command bridge — every Red command works in servers, DMs & group DMs), GhostWipe (auto-deletes a departing member's messages server-wide on leave/kick/ban, with a full HTML audit-log viewer), and ImageVault (auto-deletes posted images, re-hosting them in a private vault channel first so they can be retrieved on request, with spoiler tags preserved).", "type": "REPO" }