Files
rc/main.py
2026-05-24 18:13:28 +02:00

695 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
import pickle
import json
from typing import Callable, Awaitable
from dataclasses import dataclass, field
import discord
from discord.ext import commands
import traceback
from PIL import Image, ImageDraw
from io import BytesIO
bot = commands.Bot(command_prefix="\0", intents=discord.Intents.all())
@dataclass
class ReactionRole:
emoji: str
role_id: int
description: str
def __str__(self):
return f"{self.emoji} - {self.description}"
@dataclass
class TrackedMessage:
guild_id: int
channel_id: int
message_id: int
header: str
own_message: bool
reactions: list[ReactionRole] = field(default_factory=list)
def __str__(self):
return f"message at https://discord.com/channels/{self.guild_id}/{self.channel_id}/{self.message_id} with {len(self.reactions)} reaction roles"
def __repr__(self):
return (self.header or " ") + '\n' + ("\n".join(
str(rr) for rr in self.reactions) or f"-# waiting for reactions, message id is {self.message_id}")
class Database:
def __init__(self, d: str):
self.__tracked_messages: dict[int, TrackedMessage] = {}
self.__file: str = d
self.__load()
def __load(self) -> None:
try:
self.__tracked_messages = pickle.load(open(self.__file, "rb"))
except FileNotFoundError:
self.__tracked_messages = {}
def __save(self) -> None:
pickle.dump(self.__tracked_messages, open(self.__file, "wb"))
def track_message(self, guild_id: int, channel_id: int, message_id: int, header: str, own_message: bool) -> bool:
if message_id in self.__tracked_messages:
return True
self.__tracked_messages[message_id] = TrackedMessage(guild_id, channel_id, message_id, header, own_message, [])
self.__save()
return False
def edit_header(self, message_id: int, header: str) -> None:
self.__tracked_messages[message_id].header = header
def untrack_message(self, message_id: int):
del self.__tracked_messages[message_id]
self.__save()
def add_reaction_role(self, message_id: int, emoji: str, role_id: int, description: str) -> bool:
if message_id not in self.__tracked_messages:
return True
if any(rr.emoji == emoji or rr.role_id == role_id for rr in self.__tracked_messages[message_id].reactions):
return True
self.__tracked_messages[message_id].reactions.append(ReactionRole(emoji, role_id, description))
self.__save()
return False
def edit_reaction_role_description(self, message_id: int, emoji: str, description: str) -> str | None:
if message_id not in self.__tracked_messages:
return None
for rr in self.__tracked_messages[message_id].reactions:
if rr.emoji == emoji:
old_desc = rr.description
rr.description = description
self.__save()
return old_desc
return None
def remove_reaction_role(self, message_id: int, emoji: str) -> None:
self.__tracked_messages[message_id].reactions = [x for x in self.__tracked_messages[message_id].reactions if
x.emoji != emoji]
self.__save()
def embed_message(self, message_id: int) -> discord.Embed:
m = self.__tracked_messages[message_id]
return discord.Embed(
title=f"message with {len(m.reactions)} reactions",
description="\n".join(f"{x.emoji}" + (f" ({x.emoji.removesuffix('>').split(':')[2]})" if ':' in x.emoji else "") + f" - <@&{x.role_id}>" for x in m.reactions),
color=discord.Color(0xa2d2df)
)
def format_message(self, message_id: int) -> str:
return repr(self.__tracked_messages[message_id])
def is_tracked_already(self, message_id: int):
return message_id in self.__tracked_messages
def is_my_message(self, message_id: int):
return self.__tracked_messages[message_id].own_message
async def get_message(self, message_id: int) -> discord.Message | None:
if message_id not in self.__tracked_messages:
return None
tm = self.__tracked_messages[message_id]
channel = bot.get_channel(tm.channel_id)
if channel is None:
return channel
message = await channel.fetch_message(tm.message_id)
return message
def get_reaction_role(self, message_id: int, emoji: str) -> ReactionRole | None:
if message_id not in self.__tracked_messages:
return None
for rr in self.__tracked_messages[message_id].reactions:
if rr.emoji == emoji:
return rr
return None
class Config:
def __init__(self, d: str):
self.__file = d
self.__load()
def __load(self):
try:
c: dict[str, str | list[int]] = json.load(open(self.__file))
self.__prefix: str = c["prefix"]
self.__trusted: list[int] = c["trusted"]
self.__token: str = c["token"]
except FileNotFoundError:
json.dumps({
"prefix": "hey rc ",
"trusted": [],
"token": "https://youtu.be/dQw4w9WgXcQ"
}, open(self.__file, "w"), indent=4)
raise FileNotFoundError("Config not found")
@property
def prefix(self):
return self.__prefix
@property
def trusted(self):
return self.__trusted
def run_bot(self):
bot.run(self.__token)
class EmojiStorage:
def __init__(self, d: str):
self.__emoji_storage: dict[int, dict[str, int | list[str]]] = {}
self.db_file: str = d
self.__load()
def __load(self) -> None:
try:
self.__emoji_storage = pickle.load(open(self.db_file, "rb"))
except (FileNotFoundError, EOFError):
self.__emoji_storage = {}
def __save(self) -> None:
pickle.dump(self.__emoji_storage, open(self.db_file, "wb"))
def add_emoji_storage(self, server_id: int) -> bool:
if server_id in self.__emoji_storage:
return True
self.__emoji_storage[server_id] = {"emoji_message_channel": 0, "emoji_message": 0, "emojis": []}
self.__save()
return False
def set_emoji_message(self, server_id: int, channel_id: int, message_id: int):
self.__emoji_storage[server_id]["emoji_message_channel"] = channel_id
self.__emoji_storage[server_id]["emoji_message"] = message_id
self.__save()
def remove_emoji_storage(self, server_id: int) -> bool:
if server_id not in self.__emoji_storage:
return True
del self.__emoji_storage[server_id]
self.__save()
return False
async def __add_emoji(self, server_id: int, emoji: str):
self.__emoji_storage[server_id]["emojis"].append(emoji)
self.__save()
channel = bot.get_channel(self.__emoji_storage[server_id]["emoji_message_channel"])
message = await channel.fetch_message(self.__emoji_storage[server_id]["emoji_message"])
await message.edit(content=''.join(self.__emoji_storage[server_id]["emojis"] or "-# dust"))
async def try_add_emoji(self, name: str, image: bytes) -> discord.Emoji | None:
for x in self.__emoji_storage:
g = bot.get_guild(x)
if len(g.emojis) == g.emoji_limit:
continue
emoji = await g.create_custom_emoji(name=name, image=image)
await self.__add_emoji(x, str(emoji))
return emoji
return None
CONFIG: Config = Config("config.json")
DATABASE: Database = Database("db.dat")
EMOJI_STORAGE: EmojiStorage = EmojiStorage("emojis.dat")
rc_func = Callable[[list[str], discord.Message], Awaitable[tuple[str | discord.Embed, bool]]]
registry: list[tuple[str, rc_func, int]] = []
def register(name: str, args_amount: int):
def decorator(func: rc_func):
registry.append((name, func, args_amount))
return func
return decorator
@register("show", 1)
async def show(args: list[str], message: discord.Message) -> tuple[str | discord.Embed, bool]:
if len(args) != 1:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid message id", True
if not DATABASE.is_tracked_already(int(args[0])):
return "not tracked", True
return DATABASE.embed_message(int(args[0])), False
@register("send", 2)
async def send(args: list[str], message: discord.Message) -> tuple[str, bool]:
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if not 1 <= len(args) <= 2:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid channel id", True
channel = bot.get_channel(int(args[0]))
if channel is None:
return "channel not found", True
try:
sent_message = await channel.send("hold on")
except discord.Forbidden:
return "no permission", True
DATABASE.track_message(sent_message.guild.id, sent_message.channel.id, sent_message.id,
args[1] if len(args) == 2 else "", True)
await sent_message.edit(content=DATABASE.format_message(sent_message.id))
return "<:thumbsup:1486044381784834168>", True
@register("track", 2)
async def track(args: list[str], message: discord.Message) -> tuple[str, bool]:
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if len(args) != 2:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid channel id", True
if not args[1].isdigit():
return "not a valid message id", True
if DATABASE.is_tracked_already(int(args[1])):
return "already tracked", True
channel = bot.get_channel(int(args[0]))
if channel is None:
return "channel not found", True
try:
tracked_msg = await channel.fetch_message(int(args[1]))
DATABASE.track_message(tracked_msg.guild.id, tracked_msg.channel.id, tracked_msg.id, "", False)
return f"successfully tracked message {args[1]}", True
except discord.NotFound:
return "message not found", True
except discord.Forbidden:
return "no permission to access that message", True
@register("add", 4)
async def add(args: list[str], message: discord.Message) -> tuple[str, bool]:
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if not 3 <= len(args) <= 4:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid message id", True
if not DATABASE.is_tracked_already(int(args[0])):
return "that message is not tracked. use `track` or `send` command first", True
tracked_msg = await DATABASE.get_message(int(args[0]))
if tracked_msg is None:
return "could not fetch the message", True
my = DATABASE.is_my_message(int(args[0]))
if my and len(args) != 4:
return "that's MY message and i want a description for it", True
role = discord.utils.get(tracked_msg.guild.roles, name=args[2])
if role is None:
if args[2].isdigit():
role = tracked_msg.guild.get_role(int(args[2]))
else:
return "not a role", True
if args[1].isdigit():
args[1] = bot.get_emoji(int(args[1]))
if args[1] is None:
return "emoji not found", True
args[1] = str(args[1])
if DATABASE.add_reaction_role(int(args[0]), args[1], role.id, args[3] if len(args) == 4 else ""):
return "that emoji already has a reaction role on this message", True
try:
await tracked_msg.add_reaction(args[1])
except discord.NotFound:
return "invalid emoji", True
except discord.Forbidden:
return "missing permissions to add reactions", True
except discord.HTTPException as e:
return f"failed to add reaction: {e}", True
if my:
await tracked_msg.edit(content=DATABASE.format_message(int(args[0])))
return f"successfully added reaction role: {args[1]} -> {role.name}" + (f" ({args[3]})" if len(args) == 4 else ""), True
@register("remove", 2)
async def remove(args: list[str], message: discord.Message) -> tuple[str, bool]:
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if len(args) != 2:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid message id", True
if not DATABASE.is_tracked_already(int(args[0])):
return "that message is not tracked", True
if args[1].isdigit():
args[1] = bot.get_emoji(int(args[1]))
if args[1] is None:
return "emoji not found", True
args[1] = str(args[1])
reaction_role = DATABASE.get_reaction_role(int(args[0]), args[1])
if reaction_role is None:
return "no reaction role found with that emoji on this message", True
tracked_msg = await DATABASE.get_message(int(args[0]))
if tracked_msg is None:
return "could not fetch the message", True
try:
await tracked_msg.clear_reaction(args[1])
except discord.Forbidden:
return "missing permissions to remove reactions", True
except discord.NotFound:
pass
except discord.HTTPException as e:
return f"failed to remove reaction: {e}", True
DATABASE.remove_reaction_role(int(args[0]), args[1])
if DATABASE.is_my_message(int(args[0])):
await tracked_msg.edit(content=DATABASE.format_message(int(args[0])))
return f"successfully removed reaction role: {args[1]}", True
@register("delete", 1)
async def delete(args: list[str], message: discord.Message) -> tuple[str, bool]:
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if len(args) != 1:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid message id", True
if not DATABASE.is_tracked_already(int(args[0])):
return "that message is not tracked", True
if not DATABASE.is_my_message(int(args[0])):
return "i can only delete messages that i own (messages created with the `send` command)", True
tracked_msg = await DATABASE.get_message(int(args[0]))
if tracked_msg:
try:
await tracked_msg.delete()
except discord.Forbidden:
return "missing permissions to delete this message", True
except discord.NotFound:
pass
except discord.HTTPException as e:
return f"failed to delete message: {e}", True
DATABASE.untrack_message(int(args[0]))
return f"successfully deleted tracked message {int(args[0])}", True
@register("add this server to emoji storage", 0)
async def add_this_server_to_emoji_storage(args: list[str], message: discord.Message) -> tuple[str, bool]:
if message.author.id not in CONFIG.trusted:
return "no", True
if EMOJI_STORAGE.add_emoji_storage(message.guild.id):
return "already added", True
sent_message = await message.channel.send("ok")
EMOJI_STORAGE.set_emoji_message(sent_message.guild.id, sent_message.channel.id, sent_message.id)
return "should've worked", True
@register("addemoji", 1)
async def addemoji(args: list[str], message: discord.Message) -> tuple[str, bool]:
if message.guild.id != 1480697029780045875:
return "this command cannot be ran in this server", True
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if len(args) != 1:
return "i dont understand this command", True
if len(message.attachments) != 1:
return "nothing to add", True
attachment = message.attachments[0]
b: bytes = await attachment.read()
emoji = await EMOJI_STORAGE.try_add_emoji(args[0], b)
if emoji is None:
return "failed to add emoji", True
if args[0] == "arch":
await message.channel.send("should've alpined")
return f"added {emoji} with id {emoji.id}", False
@register("addcolor", 1)
async def addcolor(args: list[str], message: discord.Message) -> tuple[str, bool]:
if message.guild.id != 1480697029780045875:
return "this command cannot be ran in this server", True
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if len(args) != 1:
return "i dont understand this command", True
image = Image.new("RGBA", (128, 128), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
draw.rounded_rectangle([0, 0, 128, 128], radius=32, fill=args[0])
buffer = BytesIO()
image.save(buffer, format="PNG")
image_bytes = buffer.getvalue()
emoji = await EMOJI_STORAGE.try_add_emoji(args[0].removeprefix('#'), image_bytes)
if emoji is None:
return "failed to add emoji", True
return f"added {emoji} with id {emoji.id}", False
@register("editdesc", 3)
async def editdesc(args: list[str], message: discord.Message) -> tuple[str, bool]:
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if len(args) != 3:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid message id", True
if not DATABASE.is_tracked_already(int(args[0])):
return "that message is not tracked. use `track` or `send` command first", True
tracked_msg = await DATABASE.get_message(int(args[0]))
if tracked_msg is None:
return "could not fetch the message", True
my = DATABASE.is_my_message(int(args[0]))
if not my:
return "can't help it it's not my message", True
if args[1].isdigit():
args[1] = bot.get_emoji(int(args[1]))
if args[1] is None:
return "emoji not found", True
args[1] = str(args[1])
result = DATABASE.edit_reaction_role_description(int(args[0]), args[1], args[2])
if result is None:
return "reaction role not found", True
await tracked_msg.edit(content=DATABASE.format_message(int(args[0])))
return f"changed reaction role description from {result} to {args[2]}", True
@register("edithdr", 2)
async def edithdr(args: list[str], message: discord.Message) -> tuple[str, bool]:
if not message.author.guild_permissions.manage_guild:
return "no permission", True
if len(args) != 2:
return "i dont understand your command", True
if not args[0].isdigit():
return "not a valid message id", True
if not DATABASE.is_tracked_already(int(args[0])):
return "that message is not tracked. use `track` or `send` command first", True
tracked_msg = await DATABASE.get_message(int(args[0]))
if tracked_msg is None:
return "could not fetch the message", True
my = DATABASE.is_my_message(int(args[0]))
if not my:
return "can't help it it's not my message", True
DATABASE.edit_header(int(args[0]), args[1])
await tracked_msg.edit(content=DATABASE.format_message(int(args[0])))
return "i have done the thing", True
@bot.event
async def on_ready():
print(f"{bot.user} is ready")
await bot.change_presence(activity=discord.CustomActivity("gaming"))
await bot.tree.sync()
async def handle_command(msg: str, message: discord.Message) -> tuple[str | discord.Embed, bool]:
for reg in registry:
if msg.lower().startswith(reg[0] + ' '):
return await (reg[1])(
msg.removeprefix(reg[0] + ' ').split(' ', max(0, reg[2] - 1)) if msg != reg[0] else [],
message
)
return "", True
@bot.event
async def on_message(message: discord.Message):
if message.content.lower() == "rc":
await message.reply("reaction bot is here")
return
try:
msg = message.content
if not msg.lower().startswith(CONFIG.prefix):
return
result, d = await handle_command(msg.removeprefix(CONFIG.prefix), message)
if not result:
return
if isinstance(result, str):
if d:
await message.reply(result, delete_after=60)
else:
await message.reply(result)
elif isinstance(result, discord.Embed):
await message.reply(embed=result)
except Exception as e:
await message.reply("```py\n" + '\n'.join(traceback.format_exception(e)) + "\n```")
@bot.event
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
if payload.user_id == bot.user.id:
return
reaction_role = DATABASE.get_reaction_role(payload.message_id, str(payload.emoji))
if reaction_role is None:
return
guild = bot.get_guild(payload.guild_id)
if guild is None:
return
member = guild.get_member(payload.user_id)
if member is None:
return
role = guild.get_role(reaction_role.role_id)
if role is None:
return
try:
await member.add_roles(role, reason="reaction role")
except discord.Forbidden:
print(f"missing permissions to add role {role.name} to {member.name}")
except discord.HTTPException as e:
print(f"failed to add role: {e}")
@bot.event
async def on_raw_reaction_remove(payload: discord.RawReactionActionEvent):
if payload.user_id == bot.user.id:
return
reaction_role = DATABASE.get_reaction_role(payload.message_id, str(payload.emoji))
if reaction_role is None:
return
guild = bot.get_guild(payload.guild_id)
if guild is None:
return
member = guild.get_member(payload.user_id)
if member is None:
return
role = guild.get_role(reaction_role.role_id)
if role is None:
return
try:
await member.remove_roles(role, reason="reaction role")
except discord.Forbidden:
print(f"missing permissions to remove role {role.name} from {member.name}")
except discord.HTTPException as e:
print(f"failed to remove role: {e}")
@bot.tree.command(name="help", description="this description is here to help you use your help hope it helps")
async def help_slash_command(interaction: discord.Interaction):
await interaction.response.send_message(embed=discord.Embed(
title="reaction bot",
description=rf"""made by tema5002
hosted by `[insert your name here at line {__import__('inspect').currentframe().f_lineno} at {__import__('os').path.abspath(__import__('os').path.abspath(__file__))}]`
https://gitea.codersquack.nl/tema5002/rc.git
"rc" stands for reaction clanker
Usage:
- {CONFIG.prefix}show message-id: Shows info about the message
- {CONFIG.prefix}send channel-id header (optional): Sends a message with `header` as it's top contents to be used for reaction roles
- {CONFIG.prefix}track channel-id message-id: Track an existing message for reaction roles
- {CONFIG.prefix}add message-id emoji/emoji-id role-id/role-name description: Adds a reaction role to message with `message-id` displayed as `emoji` `description` that gives you `role`
- {CONFIG.prefix}remove message-id emoji: Removes a reaction role from a tracked message
- {CONFIG.prefix}delete message-id: Deletes a tracked message that the bot owns
- {CONFIG.prefix}addemoji emoji-name + attachment: Adds emoji to the emoji storage. Only available for the "fuck alpinedevs,systemd,... server"
- {CONFIG.prefix}addcolor #RRGGBB: Same as addemoji but for color emojis
- {CONFIG.prefix}editdesc message-id emoji header: Edit description for a reaction roles emoji
- {CONFIG.prefix}edithdr message-id header: Edit header for a reaction roles message""",
color=discord.Color(0xa2d2df)
))
CONFIG.run_bot()