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 <noreply@anthropic.com>
This commit is contained in:
parent
309d0e3ea7
commit
0e26fe311f
5 changed files with 631 additions and 1 deletions
37
README.md
37
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 <id>` 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 <bool>` | Toggle actually deleting the original vs. archive-only |
|
||||
| `repost <bool>` | Toggle reposting text content after an image is removed |
|
||||
| `ignorebots <bool>` | Toggle processing bot/webhook messages |
|
||||
| `maxsize <mb>` | Max image size to archive |
|
||||
| `retrieve <anyone\|author\|staff>` | Who can retrieve archived images |
|
||||
| `settings` | Full configuration summary |
|
||||
| `show`/`get <id>` | Retrieve an archived image |
|
||||
| `list [page]` / `mine [page]` | Browse the archive |
|
||||
| `forget <id>` | Drop an index entry (staff only) |
|
||||
| `stats` | Lifetime archive count |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
|
|||
24
imagevault/__init__.py
Normal file
24
imagevault/__init__.py
Normal file
|
|
@ -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))
|
||||
|
||||
547
imagevault/imagevault.py
Normal file
547
imagevault/imagevault.py
Normal file
|
|
@ -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 <id>")
|
||||
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 <id>")
|
||||
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,
|
||||
))
|
||||
|
||||
22
imagevault/info.json
Normal file
22
imagevault/info.json
Normal file
|
|
@ -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 <id>` — 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 <id>`, 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": []
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue