diff --git a/chatbot.py b/chatbot.py index fd39723..9efb638 100644 --- a/chatbot.py +++ b/chatbot.py @@ -1,69 +1,50 @@ -import scratchattach as sa -import requests -import time +""" +Pollinations AI chatbot integration (currently disabled). +""" + +import logging from urllib.parse import quote +import requests -index = 0 +logger = logging.getLogger(__name__) -# function to encode into url format -def encode(text: str): - """ - Function to encode special character into wen url format. +SYSTEM_PROMPT = ( + "You are a helpful scratch.mit.edu user and bot called ScratchOn. " + "You cannot swear or do anything inappropriate. Never say anything bad " + "about someone or something. Your language must be appropriate for 7-12 " + "year olds. Always refer to yourself as ScratchOn, or _Scratch-On_ as " + "your scratch username. Either refer to the user by their username, or " + "don't refer to them at all. Keep your answers short and concise, up to " + "500 characters. Do not use regular emojis, only use Scratch-style emojis " + "like :), _:D_, or XD. Speak like a scratch.mit.edu user would, using " + "simple words and phrases a 12 years old would use in a chat, for example " + "'Hey!', 'That's cool!', 'Thanks!', 'No problem!', 'I guess you're " + "right lol'. Answer the following user with their question/message." +) - @parameter: - - text: the text to be encoded. - """ - encoded = quote(text, safe="?-_.!~*'()") - - return encoded - -# Function to simplify AI calling -def answer(query: str, username: str): - """ - Function to simplify AI calling +def _encode(text: str) -> str: + """URL-encode *text*, preserving Scratch-safe characters.""" + return quote(text, safe="?-_.!~*'()") - @parameters: - - query: a sample prompt - - username: By what name the AI will call the user +def answer(query: str, username: str) -> dict | None: """ - global index - print("Answering...") - prompt = { "content": "You are a helpful scratch.mit.edu user and bot called ScratchOn. You cannot swear or do anything inappropriate. Never say anything bad about someone or something. Your language must be appropriate for 7-12 year olds. Always refer to yourself as ScratchOn, or _Scratch-On_ as your scratch username. Either refer to the user by their username, or don't refer to them at all. Keep your answers short and concise, up to 500 characters. Do not use regular emojis, only use Scratch-style emojis like :), _:D_, or XD. Speak like a scratch.mit.edu user would, using simple words and phrases a 12 years old would use in a chat, for example 'Hey!', 'That's cool!', 'Thanks!', 'No problem!', 'I guess you're right lol'. Answer the following user with their question/message." } - - res = requests.get(f"https://text.pollinations.ai/{encode(prompt.content + ' : ' + username + ' : ' + query)}") - result = res.json() - print("Answered!") - return result - + Send a prompt to the Pollinations AI and return the JSON response. -# Setup scratch connection (temporarily ignored to avoid bugs with Scratch API) -# with open("private/password.txt") as f: -# session = sa.login(username="_Scratch-On_", password=f.readlines()[0]) - -# profile = session.connect_linked_user() -# events = session.connect_message_events() -# print("Logged in") - -# Main part : message replyer -# @events.event -# def on_message(message): -# print("Message detected") - -# time.sleep(60) # Wait 1 minute before replying to prevent comment glitches - -# comment = profile.comments(page=1, limit=1)[0] - -# comment.reply( -# content=answer(query=message.comment_fragment, username=message.actor_username) -# ) -# """ -# session.connect_user() gets the ScratchOn profile, -# comment_by_id finds the message based on infos provided by the event handler, -# reply() sends a reply by sending the message to the AI setted up earlier. -# """ - - -# events.start(thread=True, ignore_exceptions=True) + :param query: The user's message. + :param username: The Scratch username of the user being addressed. + :return: Parsed JSON response, or ``None`` on failure. + """ + prompt_text = f"{SYSTEM_PROMPT} : {username} : {query}" + try: + response = requests.get( + f"https://text.pollinations.ai/{_encode(prompt_text)}", + timeout=30, + ) + response.raise_for_status() + return response.json() + except requests.RequestException: + logger.exception("Pollinations request failed") + return None diff --git a/commands/__init__.py b/commands/__init__.py index 3b3743f..cf40c8a 100644 --- a/commands/__init__.py +++ b/commands/__init__.py @@ -5,7 +5,7 @@ bot.load_extension("commands.") """ -__all__ = [ +__all__: list[str] = [ "user_commands", "project_commands", "studio_forum_commands", diff --git a/commands/currency_commands.py b/commands/currency_commands.py index 363c9ea..b47e835 100644 --- a/commands/currency_commands.py +++ b/commands/currency_commands.py @@ -1,16 +1,19 @@ """ -Scratch cloud → Discord bridge commands. +Scratch cloud to Discord bridge commands. Handles communication with a BlockBit server using cloud variables. """ import asyncio +import logging import interactions from config import scratch_orange from services import request_search, get_latest_response +logger = logging.getLogger(__name__) + class CurrencyCommands(interactions.Extension): """Currency / BlockBit commands.""" @@ -25,24 +28,21 @@ class CurrencyCommands(interactions.Extension): opt_type=interactions.OptionType.STRING, required=True, ) - async def blockbit_search(self, ctx: interactions.SlashContext, username: str): - """Communicates with the Scratch cloud project to retrieve a balance.""" + async def blockbit_search( + self, ctx: interactions.SlashContext, username: str + ) -> None: + """Communicate with the Scratch cloud project to retrieve a balance.""" await ctx.defer() try: - # Send request to Scratch request_search(username) - - # Wait briefly to allow Scratch to process the request await asyncio.sleep(1.2) - - # Fetch the response response = get_latest_response() if response is None: await ctx.send( embed=interactions.Embed( - title="❌ No response", + title="No response", description="Scratch did not respond in time. Try again.", color=0xFF0000, ) @@ -51,23 +51,23 @@ async def blockbit_search(self, ctx: interactions.SlashContext, username: str): await ctx.send( embed=interactions.Embed( - title="✅ Scratch Response", - description=f"```{username}``` has ```{response}``` Bits", + title="Scratch Response", + description=f"**{username}** has **{response}** Bits", color=scratch_orange, ) ) except Exception as error: + logger.exception("Error in blockbit_search command") await ctx.send( embed=interactions.Embed( - title="⚠️ Error", + title="Error", description=str(type(error).__name__), color=0xFF0000, ), ephemeral=True, ) - print(f"Error in blockbit_search command: {error}") -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: CurrencyCommands(bot) diff --git a/commands/experimental_commands.py b/commands/experimental_commands.py index 34afc0a..1aeff28 100644 --- a/commands/experimental_commands.py +++ b/commands/experimental_commands.py @@ -3,19 +3,25 @@ """ import interactions - import scratchattach as scratch -import requests +import aiohttp from config import scratch_orange, betaembed, button_states from database import get_server_data from utils import ( - dc2scratch, replace_last_screenshot, remove_line_by_index, update_pings, ) +# TODO: Replace with a permission system or config-driven allowlist. +DEVELOPER_USERNAME = "fluffyscratch" + + +def _is_developer(ctx: interactions.SlashContext) -> bool: + """Return ``True`` if the command invoker is the bot developer.""" + return ctx.author.username == DEVELOPER_USERNAME + class ExperimentalCommands(interactions.Extension): """Experimental / beta slash commands.""" @@ -30,133 +36,120 @@ class ExperimentalCommands(interactions.Extension): opt_type=interactions.OptionType.STRING, required=True, ) - async def remixtree(self, ctx: interactions.SlashContext, project: str): - if ctx.author.username == "fluffyscratch": - id = "".join(filter(str.isdigit, project)) - # await directly — we're already inside an async function - await replace_last_screenshot(f"scratch.mit.edu/projects/{id}/remixtree") - await ctx.send(file=interactions.File(file="screenshot.png")) - else: + async def remixtree(self, ctx: interactions.SlashContext, project: str) -> None: + if not _is_developer(ctx): await ctx.send(embed=betaembed) + return + + project_id = "".join(filter(str.isdigit, project)) + await replace_last_screenshot( + f"scratch.mit.edu/projects/{project_id}/remixtree" + ) + await ctx.send(file=interactions.File(file="screenshot.png")) @interactions.slash_command( name="toggle_ping", description="BETA - Enable or disable discord ping when receiving a new message. Requires binding.", ) - async def toggle_ping(self, ctx: interactions.SlashContext): - if ctx.author.username == "fluffyscratch": - target = str(ctx.author) - found = False - i = -1 - - with open("private/dcusers.txt") as file: - for item in file.readlines(): - i += 1 - if item.strip() == target: - found = True - break - - if found: - with open("private/scusers.txt") as file: - s_user = file.readlines()[i] - target = s_user - found = False - i = 0 - - with open("private/users2ping.txt") as file: - for item in file.readlines(): - i += 1 - if item.strip() == target: - found = True - break - - if found: - remove_line_by_index("users2ping.txt", i - 1) - update_pings() - await ctx.send( - embed=interactions.Embed( - title="Success !", - description="Pinging when receiving a scratch message is now disabled for your account !", - color=0x57F287, - ) - ) - else: - with open("private/users2ping.txt", "a+") as file: - file.write(f"{s_user}\n") - update_pings() - await ctx.send( - embed=interactions.Embed( - title="Success !", - description="Pinging when receiving a scratch message is now enabled for your account !", - color=0x57F287, - ) - ) - else: - await ctx.send( - embed=interactions.Embed( - title="Error :", - description="You need to bind your scratch account to use this command. To bind your scratch account, use /bind !", - color=0xFF0000, - ) + async def toggle_ping(self, ctx: interactions.SlashContext) -> None: + if not _is_developer(ctx): + await ctx.send(embed=betaembed) + return + + target = str(ctx.author) + + # Find the user's index in dcusers.txt + with open("private/dcusers.txt") as fh: + dc_lines = [line.strip() for line in fh] + try: + index = dc_lines.index(target) + except ValueError: + await ctx.send( + embed=interactions.Embed( + title="Error :", + description="You need to bind your scratch account to use this command. Use /bind !", + color=0xFF0000, ) + ) + return + + with open("private/scusers.txt") as fh: + sc_lines = [line.strip() for line in fh] + scratch_username = sc_lines[index] + + with open("private/users2ping.txt") as fh: + ping_lines = [line.strip() for line in fh] + + if scratch_username in ping_lines: + ping_index = ping_lines.index(scratch_username) + remove_line_by_index("private/users2ping.txt", ping_index) + update_pings() + await ctx.send( + embed=interactions.Embed( + title="Success !", + description="Pinging when receiving a scratch message is now disabled for your account !", + color=0x57F287, + ) + ) else: - await ctx.send(embed=betaembed) + with open("private/users2ping.txt", "a") as fh: + fh.write(f"{scratch_username}\n") + update_pings() + await ctx.send( + embed=interactions.Embed( + title="Success !", + description="Pinging when receiving a scratch message is now enabled for your account !", + color=0x57F287, + ) + ) @interactions.slash_command( name="settings", description="BETA - Configure ScratchOn settings for this server", ) - async def settings(self, ctx: interactions.SlashContext): - if ctx.author.username == "fluffyscratch": - # Read current state from the database - ai_state = ( - interactions.ButtonStyle.SUCCESS - if get_server_data(ctx.guild_id, "ai") - else interactions.ButtonStyle.DANGER - ) - embed_state = ( - interactions.ButtonStyle.SUCCESS - if get_server_data(ctx.guild_id, "embeds") - else interactions.ButtonStyle.DANGER - ) + async def settings(self, ctx: interactions.SlashContext) -> None: + if not _is_developer(ctx): + await ctx.send(embed=betaembed) + return - embed = interactions.Embed( - title="Settings", - description="Please select your preferences.", - color=0x3498DB, # blue - ) + ai_state = ( + interactions.ButtonStyle.SUCCESS + if get_server_data(ctx.guild_id, "ai") + else interactions.ButtonStyle.DANGER + ) + embed_state = ( + interactions.ButtonStyle.SUCCESS + if get_server_data(ctx.guild_id, "embeds") + else interactions.ButtonStyle.DANGER + ) - # Buttons for toggling features - ai_button = interactions.Button( - label="AI", - style=ai_state, - custom_id="ai_button", - ) - embeds_button = interactions.Button( - label="Embeds", - style=embed_state, - custom_id="embeds_button", - ) + embed = interactions.Embed( + title="Settings", + description="Please select your preferences.", + color=0x3498DB, + ) - # Language dropdown - language_select = interactions.StringSelectMenu( - interactions.StringSelectOption(label="English", value="en"), - interactions.StringSelectOption(label="Français", value="fr"), - placeholder="Select your language here", - custom_id="language_select", - ) + ai_button = interactions.Button( + label="AI", style=ai_state, custom_id="ai_button" + ) + embeds_button = interactions.Button( + label="Embeds", style=embed_state, custom_id="embeds_button" + ) - # Buttons go in one row; the select menu in its own row - action_row = interactions.ActionRow(ai_button, embeds_button) - select_row = interactions.ActionRow(language_select) + language_select = interactions.StringSelectMenu( + interactions.StringSelectOption(label="English", value="en"), + interactions.StringSelectOption(label="Francais", value="fr"), + placeholder="Select your language here", + custom_id="language_select", + ) - # Persist current states so on_component in bot_events can read them - button_states["ai_button"] = ai_state - button_states["embeds_button"] = embed_state + action_row = interactions.ActionRow(ai_button, embeds_button) + select_row = interactions.ActionRow(language_select) - await ctx.send(embed=embed, components=[action_row, select_row]) - else: - await ctx.send(embed=betaembed) + button_states["ai_button"] = ai_state + button_states["embeds_button"] = embed_state + + await ctx.send(embed=embed, components=[action_row, select_row]) @interactions.slash_command( name="scratchgpt", @@ -168,53 +161,58 @@ async def settings(self, ctx: interactions.SlashContext): opt_type=interactions.OptionType.STRING, required=True, ) - async def scratchgpt(self, ctx: interactions.SlashContext, prompt: str): + async def scratchgpt(self, ctx: interactions.SlashContext, prompt: str) -> None: await ctx.defer() - if get_server_data(ctx.guild_id, "ai"): - url = "https://api.penguinai.tech/v1/chat/completions" - headers = {"Content-Type": "application/json"} - data = { - "model": "gpt-4-turbo", - "messages": [ - { - "role": "scratcher", - "content": ( - "Answer this knowing you're a scratch assistant and number one scratch discord bot " - f"called ScratchOn and like scratching, coding and helping people : {prompt}" - ), - } - ], - } - response = requests.post(url=url, headers=headers, json=data) - - if response.status_code == 200: - content = response.json()["choices"][0]["message"]["content"] - await ctx.send( - embed=interactions.Embed(description=content, color=scratch_orange) - ) - else: - await ctx.send( - embed=interactions.Embed( - color=0xFF0000, - title="Sorry, the API we use appears to be down :/", - ) - ) - else: + if not get_server_data(ctx.guild_id, "ai"): if ctx.author.has_permission(interactions.Permissions.ADMINISTRATOR): await ctx.send( embed=interactions.Embed( color=0xFF0000, - title=":x: Sorry, AI is not allowed on this server. Since you're a server admin, you can change this using /settings.", + title="AI is not allowed on this server. As an admin, you can change this using /settings.", ) ) else: await ctx.send( embed=interactions.Embed( color=0xFF0000, - title=":x: Sorry, AI is not allowed on this server.", + title="AI is not allowed on this server.", ) ) + return + + url = "https://api.penguinai.tech/v1/chat/completions" + headers = {"Content-Type": "application/json"} + data = { + "model": "gpt-4-turbo", + "messages": [ + { + "role": "scratcher", + "content": ( + "Answer this knowing you're a scratch assistant and number one scratch discord bot " + f"called ScratchOn and like scratching, coding and helping people : {prompt}" + ), + } + ], + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=data) as resp: + if resp.status == 200: + result = await resp.json() + content = result["choices"][0]["message"]["content"] + await ctx.send( + embed=interactions.Embed( + description=content, color=scratch_orange + ) + ) + else: + await ctx.send( + embed=interactions.Embed( + color=0xFF0000, + title="Sorry, the API we use appears to be down :/", + ) + ) @interactions.slash_command( name="compare", @@ -232,53 +230,56 @@ async def scratchgpt(self, ctx: interactions.SlashContext, prompt: str): opt_type=interactions.OptionType.STRING, required=True, ) - async def compare(self, ctx: interactions.SlashContext, user1: str, user2: str): - if ctx.author.username == "fluffyscratch": - try: - await ctx.defer() - sc_user1 = scratch.get_user(user1) - sc_user2 = scratch.get_user(user2) - - embed = interactions.Embed( - title=f"Comparing {user1} and {user2}", color=scratch_orange - ) - embed.add_field( - name=user1, - value=( - f"Projects: {sc_user1.project_count()}\n" - f"Followers: {sc_user1.follower_count()}\n" - f"Following: {sc_user1.following_count()}\n" - f"Loves: {sc_user1.loves_count()}\n" - f"Favorites: {sc_user1.favorites_count()}" - ), - inline=True, - ) - embed.add_field(name="\u200b", value="\u200b", inline=True) - embed.add_field( - name=user2, - value=( - f"Projects: {sc_user2.project_count()}\n" - f"Followers: {sc_user2.follower_count()}\n" - f"Following: {sc_user2.following_count()}\n" - f"Loves: {sc_user2.loves_count()}\n" - f"Favorites: {sc_user2.favorites_count()}" - ), - inline=True, - ) + async def compare( + self, ctx: interactions.SlashContext, user1: str, user2: str + ) -> None: + if not _is_developer(ctx): + await ctx.send(embed=betaembed) + return - await ctx.send(embed=embed) + try: + await ctx.defer() + sc_user1 = scratch.get_user(user1) + sc_user2 = scratch.get_user(user2) - except scratch.utils.exceptions.UserNotFound: - await ctx.send( - embed=interactions.Embed( - title="Error", - description="One or both of the specified users do not exist on Scratch.", - color=0xFF0000, - ) + embed = interactions.Embed( + title=f"Comparing {user1} and {user2}", color=scratch_orange + ) + embed.add_field( + name=user1, + value=( + f"Projects: {sc_user1.project_count()}\n" + f"Followers: {sc_user1.follower_count()}\n" + f"Following: {sc_user1.following_count()}\n" + f"Loves: {sc_user1.loves_count()}\n" + f"Favorites: {sc_user1.favorites_count()}" + ), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=True) + embed.add_field( + name=user2, + value=( + f"Projects: {sc_user2.project_count()}\n" + f"Followers: {sc_user2.follower_count()}\n" + f"Following: {sc_user2.following_count()}\n" + f"Loves: {sc_user2.loves_count()}\n" + f"Favorites: {sc_user2.favorites_count()}" + ), + inline=True, + ) + + await ctx.send(embed=embed) + + except scratch.utils.exceptions.UserNotFound: + await ctx.send( + embed=interactions.Embed( + title="Error", + description="One or both of the specified users do not exist on Scratch.", + color=0xFF0000, ) - else: - await ctx.send(embed=betaembed) + ) -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: ExperimentalCommands(bot) diff --git a/commands/prefix_commands.py b/commands/prefix_commands.py index 853a3ce..367c43a 100644 --- a/commands/prefix_commands.py +++ b/commands/prefix_commands.py @@ -7,24 +7,29 @@ class PrefixCommands(interactions.Extension): - """Prefix command extension (triggered with the "s " prefix).""" + """Prefix command extension (triggered with the 's ' prefix).""" @prefixed_command(name="ping") - async def ping(self, ctx: PrefixedContext): - """Returns the bot's current latency.""" - msg = interactions.Embed(title="🏓 Pong !", description="Latency in ms :") - msg.add_field( + async def ping(self, ctx: PrefixedContext) -> None: + """Return the bot's current latency.""" + latency_ms = round(self.bot.latency * 1000) + + embed = interactions.Embed( + title="Pong !", + description="Latency in ms :", + color=0xA84300, + ) + embed.add_field( name=f"{self.bot.user.username}'s Latency (ms): ", - value=f"{round(self.bot.latency * 1000)}ms.", + value=f"{latency_ms}ms.", inline=False, ) - msg.set_footer( + embed.set_footer( text=f"Requested by {ctx.author.username}", icon_url=ctx.author.display_avatar.url, ) - msg.color = 0xA84300 # dark orange - await ctx.send(embed=msg) + await ctx.send(embed=embed) -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: PrefixCommands(bot) diff --git a/commands/project_commands.py b/commands/project_commands.py index addbc13..002195d 100644 --- a/commands/project_commands.py +++ b/commands/project_commands.py @@ -3,15 +3,20 @@ """ import os -import interactions from datetime import datetime +import interactions import scratchattach as scratch from config import scratch_orange from utils import limiter +def _extract_project_id(raw: str) -> str: + """Return only the digits from *raw* (works for IDs and URLs).""" + return "".join(filter(str.isdigit, raw)) + + class ProjectCommands(interactions.Extension): """Project-related slash commands.""" @@ -25,36 +30,34 @@ class ProjectCommands(interactions.Extension): opt_type=interactions.OptionType.STRING, required=True, ) - async def modstatus(self, ctx: interactions.SlashContext, project: str): - id = "".join(filter(str.isdigit, project)) - project_obj = scratch.get_project(id) + async def modstatus(self, ctx: interactions.SlashContext, project: str) -> None: + project_id = _extract_project_id(project) + project_obj = scratch.get_project(project_id) status = project_obj.moderation_status() - embeded_msg = interactions.Embed(title="This project is...") + embed = interactions.Embed(title="This project is...") if status == "notsafe": - embeded_msg.description = ( + embed.description = ( "<:Nope:1333795409403052032>Not Safe (NFE) !<:Nope:1333795409403052032>" ) - embeded_msg.color = 0xFF0000 + embed.color = 0xFF0000 elif status == "safe": - embeded_msg.description = ( - "<:Verified:1333795453250175058>Safe (FE) !<:Verified:1333795453250175058>" - ) - embeded_msg.color = 0x57F287 + embed.description = "<:Verified:1333795453250175058>Safe (FE) !<:Verified:1333795453250175058>" + embed.color = 0x57F287 else: - embeded_msg.description = ( + embed.description = ( "<:forumneutral:1341109236679053312>Not Reviewed (counts as FE) !" "<:forumneutral:1341109236679053312>" ) - embeded_msg.color = 0x99AAB5 # light grey + embed.color = 0x99AAB5 - embeded_msg.set_footer(text=f"Project ID : {id}") - await ctx.send(embed=embeded_msg) + embed.set_footer(text=f"Project ID : {project_id}") + await ctx.send(embed=embed) @interactions.slash_command( name="embed", - description="Gives an embeded version of the specified project, mainly for websites.", + description="Gives an embedded version of the specified project, mainly for websites.", ) @interactions.slash_option( name="project", @@ -62,21 +65,21 @@ async def modstatus(self, ctx: interactions.SlashContext, project: str): opt_type=interactions.OptionType.STRING, required=True, ) - async def embed(self, ctx: interactions.SlashContext, project: str): - id = "".join(filter(str.isdigit, project)) - project_obj = scratch.get_project(id) - - link = project_obj.embed_url - embeded_msg = interactions.Embed( - title="This project is now embedded ! <:embed:1343565862077988904>", - description=f"🔗 Link : {link}", - color=scratch_orange, + async def embed(self, ctx: interactions.SlashContext, project: str) -> None: + project_id = _extract_project_id(project) + project_obj = scratch.get_project(project_id) + + await ctx.send( + embed=interactions.Embed( + title="This project is now embedded ! <:embed:1343565862077988904>", + description=f"Link : {project_obj.embed_url}", + color=scratch_orange, + ) ) - await ctx.send(embed=embeded_msg) @interactions.slash_command( name="project", - description="Gets a lot of informations about a project.", + description="Gets a lot of information about a project.", ) @interactions.slash_option( name="project", @@ -84,44 +87,45 @@ async def embed(self, ctx: interactions.SlashContext, project: str): opt_type=interactions.OptionType.STRING, required=True, ) - async def project(self, ctx: interactions.SlashContext, project: str): - id = "".join(filter(str.isdigit, project)) - project_obj = scratch.get_project(id) + async def project(self, ctx: interactions.SlashContext, project: str) -> None: + project_id = _extract_project_id(project) + proj = scratch.get_project(project_id) - msg = interactions.Embed(title=f"{project_obj.title} :") + views = proj.views or 1 # prevent division by zero - msg.add_field(name="Views :", value=f"{project_obj.views} :eye:") - msg.add_field(name="Loves :", value=f"{project_obj.loves} :heart:") - msg.add_field(name="Faves :", value=f"{project_obj.favorites} :star:") - msg.add_field( + embed = interactions.Embed(title=f"{proj.title} :") + embed.add_field(name="Views :", value=f"{proj.views} :eye:") + embed.add_field(name="Loves :", value=f"{proj.loves} :heart:") + embed.add_field(name="Faves :", value=f"{proj.favorites} :star:") + embed.add_field( name="Loves per view :", - value=f"{round(project_obj.loves / project_obj.views, 2)} :heart: / :eye:", + value=f"{round(proj.loves / views, 2)} :heart: / :eye:", ) - msg.add_field( + embed.add_field( name="Faves per view :", - value=f"{round(project_obj.favorites / project_obj.views, 2)} :star: / :eye:", + value=f"{round(proj.favorites / views, 2)} :star: / :eye:", ) - msg.add_field( + embed.add_field( name="Loves per view (%) :", - value=f"{round((project_obj.loves / project_obj.views) * 100)} :heart: / 100 :eye:", + value=f"{round((proj.loves / views) * 100)} :heart: / 100 :eye:", ) - msg.add_field( + embed.add_field( name="Faves per view (%) :", - value=f"{round((project_obj.favorites / project_obj.views) * 100)} :star: / 100 :eye:", + value=f"{round((proj.favorites / views) * 100)} :star: / 100 :eye:", ) - msg.color = scratch_orange - desc = limiter(text=project_obj.instructions, limit=500) - msg.description = ( - f"Made by {project_obj.author_name}, at {project_obj.share_date} " - f"(Last modified at {project_obj.last_modified})\n" - f"<:Turbowarp:1330552274774396979>Turbowarp link : https://turbowarp.org/{id}\n\n" + embed.color = scratch_orange + desc = limiter(text=proj.instructions, limit=500) + embed.description = ( + f"Made by {proj.author_name}, at {proj.share_date} " + f"(Last modified at {proj.last_modified})\n" + f"<:Turbowarp:1330552274774396979>Turbowarp link : https://turbowarp.org/{project_id}\n\n" f"**Description :**\n{desc}\n\n" - f"**Notes and Credits :**\n{project_obj.notes}\n\n" + f"**Notes and Credits :**\n{proj.notes}\n\n" "<:scratchstats:1330550531864662018> Statistics :\n" ) - msg.set_image(url=project_obj.thumbnail_url) - await ctx.send(embed=msg) + embed.set_image(url=proj.thumbnail_url) + await ctx.send(embed=embed) @interactions.slash_command( name="trendscore", @@ -133,13 +137,14 @@ async def project(self, ctx: interactions.SlashContext, project: str): opt_type=interactions.OptionType.STRING, required=True, ) - async def trendscore(self, ctx: interactions.SlashContext, project: str): - id = "".join(filter(str.isdigit, project)) - proj = scratch.get_project(id) + async def trendscore(self, ctx: interactions.SlashContext, project: str) -> None: + project_id = _extract_project_id(project) + proj = scratch.get_project(project_id) diff = datetime.now() - datetime.strptime( proj.share_date, "%Y-%m-%dT%H:%M:%S.%fZ" ) - score = round(proj.views / (diff.days * 24 + diff.seconds / 3600), 3) + hours = diff.days * 24 + diff.seconds / 3600 + score = round(proj.views / hours, 3) if hours else 0 await ctx.send( embed=interactions.Embed( @@ -147,7 +152,7 @@ async def trendscore(self, ctx: interactions.SlashContext, project: str): f"<:popular:1330550904813916272>'{proj.title}' has a trending score of {score} !" "<:popular:1330550904813916272>" ), - color=0xF1C40F, # gold + color=0xF1C40F, ) ) @@ -179,47 +184,45 @@ async def ontrend( project: str, language: str, limit: int, - ): - i = 0 - found = False - id = "".join(filter(str.isdigit, project)) + ) -> None: + project_id = _extract_project_id(project) + position = 0 for item in scratch.explore_projects( language=language, limit=limit, mode="trending" ): - i += 1 - if item.id == int(id): - found = True - break - - if found: - await ctx.send( - embed=interactions.Embed( - title="Project found !", - description=f"This project is on trending, at the **{i}th** position !", - color=0x57F287, - ) - ) - else: - await ctx.send( - embed=interactions.Embed( - title="Project not found !", - description="This project is not on trending !", - color=0xFF0000, + position += 1 + if item.id == int(project_id): + await ctx.send( + embed=interactions.Embed( + title="Project found !", + description=f"This project is on trending, at the **{position}th** position !", + color=0x57F287, + ) ) + return + + await ctx.send( + embed=interactions.Embed( + title="Project not found !", + description="This project is not on trending !", + color=0xFF0000, ) + ) @interactions.slash_command( name="newestprojects", description="Get all the newest published projects.", ) - async def newestprojects(self, ctx: interactions.SlashContext): - import pprint - + async def newestprojects(self, ctx: interactions.SlashContext) -> None: + projects = scratch.newest_projects() + description = "\n".join( + f"- [{p.title}](https://scratch.mit.edu/projects/{p.id})" for p in projects + ) await ctx.send( embed=interactions.Embed( title="<:newscratcher:1330550984971259954>Newest scratch projects :", - description=pprint.pformat(scratch.newest_projects()), + description=description, color=scratch_orange, ) ) @@ -234,10 +237,10 @@ async def newestprojects(self, ctx: interactions.SlashContext): opt_type=interactions.OptionType.STRING, required=True, ) - async def s_download(self, ctx: interactions.SlashContext, project: str): - id = "".join(filter(str.isdigit, project)) + async def s_download(self, ctx: interactions.SlashContext, project: str) -> None: + project_id = _extract_project_id(project) await ctx.defer() - proj = scratch.get_project(id) + proj = scratch.get_project(project_id) proj.download(filename="project.sb3", dir="private") await ctx.send( @@ -247,8 +250,8 @@ async def s_download(self, ctx: interactions.SlashContext, project: str): ) ) - os.remove(path="private/project.sb3") + os.remove("private/project.sb3") -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: ProjectCommands(bot) diff --git a/commands/search_commands.py b/commands/search_commands.py index a5cc593..30b7db1 100644 --- a/commands/search_commands.py +++ b/commands/search_commands.py @@ -6,8 +6,6 @@ from itertools import islice import interactions -import requests - import scratchattach as scratch from config import scratch_orange @@ -26,49 +24,56 @@ class SearchCommands(interactions.Extension): opt_type=interactions.OptionType.INTEGER, required=True, ) - async def randomprojects(self, ctx: interactions.SlashContext, number: int): + async def randomprojects(self, ctx: interactions.SlashContext, number: int) -> None: await ctx.defer() - message = "" - max_project_id = scratch.total_site_stats().get("PROJECT_COUNT") - for i in range(number): + max_project_id = scratch.total_site_stats().get("PROJECT_COUNT", 1) + project_links: list[str] = [] + + for _ in range(number): while True: try: - project = scratch.get_project(random.randint(1, max_project_id)) + proj = scratch.get_project(random.randint(1, max_project_id)) + project_links.append( + f"[**{proj.title}**](https://scratch.mit.edu/projects/{proj.id})" + ) + break except scratch.utils.exceptions.ProjectNotFound: continue - break - message = ( - f"{message} [**{project.title}**]" - f"(https://scratch.mit.edu/projects/{project.id})\n\n" - ) - msg = interactions.Embed() - msg.title = "Here is 1 random project !" if number == 1 else f"Here are {number} random projects !" - msg.description = message - msg.color = scratch_orange - await ctx.send(embed=msg) + title = ( + "Here is 1 random project !" + if number == 1 + else f"Here are {number} random projects !" + ) + await ctx.send( + embed=interactions.Embed( + title=title, + description="\n\n".join(project_links), + color=scratch_orange, + ) + ) @interactions.slash_command( name="christmas", description="Take a look at the best christmas projects easily !", ) - async def christmas(self, ctx: interactions.SlashContext): + async def christmas(self, ctx: interactions.SlashContext) -> None: await ctx.defer() - message = "" projects = scratch.search_projects( query="christmas", mode="popular", language="en", limit=10, offset=0 ) - for item in projects: - message = ( - f"{message}\n\n **[{item.title}]()**\n" - f"-# by [{item.author().username}](https://scratch.mit.edu/users/{item.author().username})" - ) + lines = [ + f"**[{p.title}]()**\n" + f"-# by [{p.author().username}](https://scratch.mit.edu/users/{p.author().username})" + for p in projects + ] + await ctx.send( embed=interactions.Embed( title="<:SantaCat:1444277069826494557>Top 10 popular christmas projects<:SantaCat:1444277069826494557> :", - description=message, + description="\n\n".join(lines), color=scratch_orange, ) ) @@ -99,7 +104,7 @@ async def recommend( ctx: interactions.SlashContext, username: str, recommendation_type: str, - ): + ) -> None: await ctx.defer() try: @@ -114,138 +119,166 @@ async def recommend( ) return - msg = interactions.Embed(color=scratch_orange) + embed = interactions.Embed(color=scratch_orange) if recommendation_type == "projects": - loved_projects = list(islice(user.loved_projects(limit=10), 10)) - - if not loved_projects: - msg.title = f"No recommendations found for {username}" - msg.description = "This user hasn't loved any projects yet!" - await ctx.send(embed=msg) - return - - recommendations = [] - seen_ids = set() - - for project in loved_projects[:3]: - try: - author = project.author() - for proj in list(author.projects(limit=5)): - if proj.id not in seen_ids and proj.id != project.id: - recommendations.append(proj) - seen_ids.add(proj.id) - if len(recommendations) >= 5: - break - except Exception: - continue - if len(recommendations) >= 5: - break + embed = self._recommend_projects(user, username) + elif recommendation_type == "users": + embed = self._recommend_users(user, username) + elif recommendation_type == "studios": + embed = self._recommend_studios(user, username) - if recommendations: - msg.title = f"📚 Project recommendations for {username}" - description = "Based on projects you loved, you might enjoy:\n" - for proj in recommendations[:5]: - description += ( - f"\n**[{proj.title}]()**\n" - f"-# by [{proj.author_name}](https://scratch.mit.edu/users/{proj.author_name}) " - f"• ❤️ {proj.loves} • ⭐ {proj.favorites}\n" - ) - msg.description = description - else: - msg.title = f"No recommendations found for {username}" - msg.description = "Could not find similar projects at this time." + await ctx.send(embed=embed) - elif recommendation_type == "users": - following = list(islice(user.following_names(limit=20), 20)) + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ - if not following: - msg.title = f"No recommendations found for {username}" - msg.description = "This user isn't following anyone yet!" - await ctx.send(embed=msg) - return + def _recommend_projects(self, user, username: str) -> interactions.Embed: + loved = list(islice(user.loved_projects(limit=10), 10)) + if not loved: + return interactions.Embed( + title=f"No recommendations found for {username}", + description="This user hasn't loved any projects yet!", + color=scratch_orange, + ) - recommendations = [] - seen_users = set(following + [username]) + recommendations = [] + seen_ids: set[int] = set() - for followed_username in following[:5]: - try: - followed_user = scratch.get_user(followed_username) - for potential_rec in list(followed_user.following_names(limit=10)): - if potential_rec not in seen_users: - try: - rec_user = scratch.get_user(potential_rec) - recommendations.append(rec_user) - seen_users.add(potential_rec) - if len(recommendations) >= 5: - break - except Exception: - continue - except Exception: - continue - if len(recommendations) >= 5: - break + for project in loved[:3]: + try: + author = project.author() + for proj in list(author.projects(limit=5)): + if proj.id not in seen_ids and proj.id != project.id: + recommendations.append(proj) + seen_ids.add(proj.id) + if len(recommendations) >= 5: + break + except Exception: + continue + if len(recommendations) >= 5: + break - if recommendations: - msg.title = f"👥 User recommendations for {username}" - description = "Based on who you follow, you might like:\n" - for rec_user in recommendations[:5]: - description += ( - f"\n**[{rec_user.username}](https://scratch.mit.edu/users/{rec_user.username})**\n" - f"-# {rec_user.follower_count()} followers • {rec_user.following_count()} following\n" - ) - msg.description = description - else: - msg.title = f"No recommendations found for {username}" - msg.description = "Could not find similar users at this time." + if not recommendations: + return interactions.Embed( + title=f"No recommendations found for {username}", + description="Could not find similar projects at this time.", + color=scratch_orange, + ) - elif recommendation_type == "studios": - curating = list(islice(user.studios_curating(limit=20), 20)) + lines = [ + f"**[{p.title}]()**\n" + f"-# by [{p.author_name}](https://scratch.mit.edu/users/{p.author_name}) " + f"• {p.loves} loves • {p.favorites} faves" + for p in recommendations[:5] + ] + return interactions.Embed( + title=f"Project recommendations for {username}", + description="Based on projects you loved, you might enjoy:\n" + + "\n".join(lines), + color=scratch_orange, + ) - if not curating: - msg.title = f"No recommendations found for {username}" - msg.description = "This user isn't curating any studios yet!" - await ctx.send(embed=msg) - return + def _recommend_users(self, user, username: str) -> interactions.Embed: + following = list(islice(user.following_names(limit=20), 20)) + if not following: + return interactions.Embed( + title=f"No recommendations found for {username}", + description="This user isn't following anyone yet!", + color=scratch_orange, + ) - recommendations = [] - seen_ids = set(s.id for s in curating) + recommendations = [] + seen_users = set(following + [username]) - for studio in curating[:5]: - try: - for curator_name in list(studio.curator_names(limit=10))[:3]: + for followed_name in following[:5]: + try: + followed_user = scratch.get_user(followed_name) + for potential in list(followed_user.following_names(limit=10)): + if potential not in seen_users: try: - curator = scratch.get_user(curator_name) - for potential_studio in list(curator.studios_curating(limit=5)): - if potential_studio.id not in seen_ids: - recommendations.append(potential_studio) - seen_ids.add(potential_studio.id) - if len(recommendations) >= 5: - break + rec_user = scratch.get_user(potential) + recommendations.append(rec_user) + seen_users.add(potential) + if len(recommendations) >= 5: + break except Exception: continue - if len(recommendations) >= 5: - break - except Exception: - continue - if len(recommendations) >= 5: - break + except Exception: + continue + if len(recommendations) >= 5: + break - if recommendations: - msg.title = f"🎨 Studio recommendations for {username}" - description = "Based on studios you're in, you might like:\n" - for studio in recommendations[:5]: - description += ( - f"\n**[{studio.title}]()**\n" - f"-# {studio.project_count} projects • {studio.follower_count} followers\n" - ) - msg.description = description - else: - msg.title = f"No recommendations found for {username}" - msg.description = "Could not find similar studios at this time." + if not recommendations: + return interactions.Embed( + title=f"No recommendations found for {username}", + description="Could not find similar users at this time.", + color=scratch_orange, + ) + + lines = [ + f"**[{u.username}](https://scratch.mit.edu/users/{u.username})**\n" + f"-# {u.follower_count()} followers • {u.following_count()} following" + for u in recommendations[:5] + ] + return interactions.Embed( + title=f"User recommendations for {username}", + description="Based on who you follow, you might like:\n" + "\n".join(lines), + color=scratch_orange, + ) + + def _recommend_studios(self, user, username: str) -> interactions.Embed: + curating = list(islice(user.studios_curating(limit=20), 20)) + if not curating: + return interactions.Embed( + title=f"No recommendations found for {username}", + description="This user isn't curating any studios yet!", + color=scratch_orange, + ) - await ctx.send(embed=msg) + recommendations = [] + seen_ids: set[int] = set(s.id for s in curating) + + for studio in curating[:5]: + try: + for curator_name in list(studio.curator_names(limit=10))[:3]: + try: + curator = scratch.get_user(curator_name) + for potential in list(curator.studios_curating(limit=5)): + if potential.id not in seen_ids: + recommendations.append(potential) + seen_ids.add(potential.id) + if len(recommendations) >= 5: + break + except Exception: + continue + if len(recommendations) >= 5: + break + except Exception: + continue + if len(recommendations) >= 5: + break + + if not recommendations: + return interactions.Embed( + title=f"No recommendations found for {username}", + description="Could not find similar studios at this time.", + color=scratch_orange, + ) + + lines = [ + f"**[{s.title}]()**\n" + f"-# {s.project_count} projects • {s.follower_count} followers" + for s in recommendations[:5] + ] + return interactions.Embed( + title=f"Studio recommendations for {username}", + description="Based on studios you're in, you might like:\n" + + "\n".join(lines), + color=scratch_orange, + ) -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: SearchCommands(bot) diff --git a/commands/studio_forum_commands.py b/commands/studio_forum_commands.py index ec797e0..d70f39b 100644 --- a/commands/studio_forum_commands.py +++ b/commands/studio_forum_commands.py @@ -3,19 +3,23 @@ """ import interactions - import scratchattach as scratch from config import scratch_orange from utils import limiter +def _extract_id(raw: str) -> str: + """Return only the digits from *raw* (works for IDs and URLs).""" + return "".join(filter(str.isdigit, raw)) + + class StudioForumCommands(interactions.Extension): """Studio and forum slash commands.""" @interactions.slash_command( name="studio", - description="Reads informations about a studio.", + description="Reads information about a studio.", ) @interactions.slash_option( name="studio", @@ -23,18 +27,17 @@ class StudioForumCommands(interactions.Extension): opt_type=interactions.OptionType.STRING, required=True, ) - async def studio(self, ctx: interactions.SlashContext, studio: str): - id = "".join(filter(str.isdigit, studio)) - studio_obj = scratch.get_studio(id) + async def studio(self, ctx: interactions.SlashContext, studio: str) -> None: + studio_id = _extract_id(studio) + studio_obj = scratch.get_studio(studio_id) access = "Everyone" if studio_obj.open_to_all else "Only curators" - - msg = interactions.Embed(title=studio_obj.title) - msg.set_image(url=studio_obj.image_url) - msg.set_thumbnail(url=studio_obj.host().icon_url) desc = limiter(text=studio_obj.description, limit=500) - msg.description = ( + embed = interactions.Embed(title=studio_obj.title) + embed.set_image(url=studio_obj.image_url) + embed.set_thumbnail(url=studio_obj.host().icon_url) + embed.description = ( f"Owned by **{studio_obj.host()}**, with id {studio_obj.host_id}\n" f"**{access}** can add projects.\n\n" "**This studio has :**\n" @@ -43,14 +46,14 @@ async def studio(self, ctx: interactions.SlashContext, studio: str): f"- {studio_obj.manager_count} managers\n\n" f"**Description :**\n{desc}" ) - msg.set_footer( + embed.set_footer( text=( f"Studio id : {studio_obj.id}, " f"link : https://scratch.mit.edu/studios/{studio_obj.id}" ) ) - msg.color = scratch_orange - await ctx.send(embed=msg) + embed.color = scratch_orange + await ctx.send(embed=embed) @interactions.slash_command( name="forums", @@ -69,34 +72,48 @@ async def studio(self, ctx: interactions.SlashContext, studio: str): interactions.SlashCommandChoice(name="Project Ideas", value=9), interactions.SlashCommandChoice(name="Collaboration", value=10), interactions.SlashCommandChoice(name="Requests", value=11), - interactions.SlashCommandChoice(name="Project Save & Level Codes", value=60), + interactions.SlashCommandChoice( + name="Project Save & Level Codes", value=60 + ), interactions.SlashCommandChoice(name="Questions about scratch", value=4), interactions.SlashCommandChoice(name="Suggestions", value=1), interactions.SlashCommandChoice(name="Bugs and Glitches", value=3), interactions.SlashCommandChoice(name="Advanced Topics", value=31), - interactions.SlashCommandChoice(name="Connecting to the Physical World", value=32), - interactions.SlashCommandChoice(name="Developing Scratch Extensions", value=48), + interactions.SlashCommandChoice( + name="Connecting to the Physical World", value=32 + ), + interactions.SlashCommandChoice( + name="Developing Scratch Extensions", value=48 + ), interactions.SlashCommandChoice(name="Open Source Projects", value=49), - interactions.SlashCommandChoice(name="Things I'm Making and Creating", value=29), - interactions.SlashCommandChoice(name="Things I'm Reading and Playing", value=30), + interactions.SlashCommandChoice( + name="Things I'm Making and Creating", value=29 + ), + interactions.SlashCommandChoice( + name="Things I'm Reading and Playing", value=30 + ), ], ) - async def forums(self, ctx: interactions.SlashContext, category: int): - msg = interactions.Embed(title="Topics in this category :", color=scratch_orange) - desc = "" + async def forums(self, ctx: interactions.SlashContext, category: int) -> None: + lines: list[str] = [] for item in scratch.get_topic_list(category_id=category, page=1): - desc = ( - f"{desc}" + lines.append( f"\n\n**[{item.title}](https://scratch.mit.edu/discuss/topic/{item.id})** - " f"{item.reply_count} replies - {item.view_count} views " f"(last update : {item.last_updated})" ) - msg.description = desc - await ctx.send(embed=msg) + + await ctx.send( + embed=interactions.Embed( + title="Topics in this category :", + description="".join(lines), + color=scratch_orange, + ) + ) @interactions.slash_command( name="topic", - description="Gives useful infos about a forum topic.", + description="Gives useful info about a forum topic.", ) @interactions.slash_option( name="topic", @@ -104,21 +121,22 @@ async def forums(self, ctx: interactions.SlashContext, category: int): opt_type=interactions.OptionType.STRING, required=True, ) - async def topic(self, ctx: interactions.SlashContext, topic: str): - id = "".join(filter(str.isdigit, topic)) - stopic = scratch.get_topic(id) - msg = interactions.Embed(title=stopic.title, color=scratch_orange) - msg.description = ( - f"Link : https://scratch.mit.edu/discuss/topic/{stopic.id}\n" - f"Category : {stopic.category_name}\n" - f" Last updated : {stopic.last_updated}\n" - f"Author : {stopic.first_post().author_name}\n" + async def topic(self, ctx: interactions.SlashContext, topic: str) -> None: + topic_id = _extract_id(topic) + topic_obj = scratch.get_topic(topic_id) + + embed = interactions.Embed(title=topic_obj.title, color=scratch_orange) + embed.description = ( + f"Link : https://scratch.mit.edu/discuss/topic/{topic_obj.id}\n" + f"Category : {topic_obj.category_name}\n" + f"Last updated : {topic_obj.last_updated}\n" + f"Author : {topic_obj.first_post().author_name}\n" "First post :\n" - f"```{stopic.first_post().content}```" + f"```{topic_obj.first_post().content}```" ) - msg.set_thumbnail(url=stopic.first_post().author().icon_url) - await ctx.send(embed=msg) + embed.set_thumbnail(url=topic_obj.first_post().author().icon_url) + await ctx.send(embed=embed) -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: StudioForumCommands(bot) diff --git a/commands/user_commands.py b/commands/user_commands.py index 67941cd..b43d6c8 100644 --- a/commands/user_commands.py +++ b/commands/user_commands.py @@ -2,9 +2,9 @@ User-related slash commands. """ -import interactions from datetime import datetime +import interactions import scratchattach as scratch from config import scratch_orange, contributors, devs, pending_verifiers @@ -24,14 +24,14 @@ class UserCommands(interactions.Extension): opt_type=interactions.OptionType.STRING, required=True, ) - async def s_profile(self, ctx: interactions.SlashContext, user: str): + async def s_profile(self, ctx: interactions.SlashContext, user: str) -> None: await ctx.defer() - embeded_message = interactions.Embed(title=user) + embed = interactions.Embed(title=user) try: usr = scratch.get_user(user) - # Rank finder + # Determine rank if usr.is_new_scratcher(): rank = "<:newscratcher:1330550984971259954> New scratcher" elif usr.scratchteam: @@ -47,42 +47,53 @@ async def s_profile(self, ctx: interactions.SlashContext, user: str): else: rank = "<:ScratchCat:1330547949721223238> Scratcher" - with open("private/scusers.txt") as f: - lines = [line.rstrip("\n") for line in f] - if usr.name in lines: - idx = lines.index(usr.name) - binded = open("private/dcusers.txt").readlines()[idx].rstrip("\n") - else: - binded = "*No binded account found*" + # Look up Discord binding + with open("private/scusers.txt") as sc_file: + sc_users = [line.strip() for line in sc_file] + + if usr.name in sc_users: + idx = sc_users.index(usr.name) + with open("private/dcusers.txt") as dc_file: + dc_lines = [line.strip() for line in dc_file] + bound = ( + dc_lines[idx] + if idx < len(dc_lines) + else "*No binded account found*" + ) + else: + bound = "*No binded account found*" join_date = datetime.fromisoformat( usr.join_date.replace("Z", "+00:00") ).strftime("%B %d, %Y at %H:%M:%S UTC") - embeded_message.description = ( + featured = usr.featured_data() + embed.description = ( f"**{rank}**\n\n" - f"**Account binded to :** {binded}\n" - f"*Joined scratch on {join_date} - Lives in {usr.country}* \n" - f"**{user}** has **{usr.message_count()}** message(s). \n\n" - f"**<:ocular:1333041343668158515>Ocular :** \n" - f"Color : {usr.ocular_status().get('color')} Status : {usr.ocular_status().get('status')}* \n\n" + f"**Account binded to :** {bound}\n" + f"*Joined scratch on {join_date} - Lives in {usr.country}*\n" + f"**{user}** has **{usr.message_count()}** message(s).\n\n" + f"**<:ocular:1333041343668158515>Ocular :**\n" + f"Color : {usr.ocular_status().get('color')} " + f"Status : {usr.ocular_status().get('status')}*\n\n" f"**About {user}** : \n" - f"{usr.about_me} \n\n" + f"{usr.about_me}\n\n" f"**What is {user} working on** : \n" f"{usr.wiwo}\n\n" f"**{user}** is followed by **{usr.follower_count()}** scratchers, " f"and is following **{usr.following_count()}** scratchers.\n" f"They also loved **{usr.loves_count()} projects** and favourited " f"**{usr.favorites_count()} projects** in total.\n\n" - f"{usr.featured_data()['label']} : [{usr.featured_data()['project']['title']}]" - f"(https://scratch.mit.edu/projects/{usr.featured_data()['project']['id']})" + f"{featured['label']} : " + f"[{featured['project']['title']}]" + f"(https://scratch.mit.edu/projects/{featured['project']['id']})" ) - embeded_message.set_thumbnail(url=usr.icon_url) - embeded_message.set_footer(text=f"{user}'s ID : {usr.id}") - embeded_message.color = scratch_orange - embeded_message.set_image(url=usr.featured_data()["project"]["thumbnail_url"]) - await ctx.send(embed=embeded_message) + embed.set_thumbnail(url=usr.icon_url) + embed.set_footer(text=f"{user}'s ID : {usr.id}") + embed.color = scratch_orange + embed.set_image(url=featured["project"]["thumbnail_url"]) + await ctx.send(embed=embed) except scratch.utils.exceptions.UserNotFound: await ctx.send( @@ -103,15 +114,25 @@ async def s_profile(self, ctx: interactions.SlashContext, user: str): opt_type=interactions.OptionType.STRING, required=True, ) - async def check_username(self, ctx: interactions.SlashContext, username: str): - msg = interactions.Embed(title="This username is...") + async def check_username( + self, ctx: interactions.SlashContext, username: str + ) -> None: + embed = interactions.Embed(title="This username is...") if scratch.check_username(username) == "valid username": - msg.description = "Avaliable ! :partying_face: \n [Claim it]() <:happycat:1330550173335982160>" - msg.color = 0x57F287 # green + embed.description = ( + "Available ! :partying_face: \n" + "[Claim it]() " + "<:happycat:1330550173335982160>" + ) + embed.color = 0x57F287 else: - msg.description = f"Taken ! :smiling_face_with_tear:\n Link : https://scratch.mit.edu/users/{username} " - msg.color = 0xFF0000 # red - await ctx.send(embed=msg) + embed.description = ( + f"Taken ! :smiling_face_with_tear:\n" + f"Link : https://scratch.mit.edu/users/{username} " + f"" + ) + embed.color = 0xFF0000 + await ctx.send(embed=embed) @interactions.slash_command( name="bind", @@ -123,25 +144,24 @@ async def check_username(self, ctx: interactions.SlashContext, username: str): opt_type=interactions.OptionType.STRING, required=True, ) - async def bind(self, ctx: interactions.SlashContext, username: str): + async def bind(self, ctx: interactions.SlashContext, username: str) -> None: await ctx.defer() user_id = ctx.author.id target = str(ctx.author) - found = False # Check if user is already binded - with open("private/dcusers.txt") as file: - for item in file.readlines(): - if item.strip() == target: - found = True - break - - if found: - binded = await dc2scratch(ctx.author.username) + with open("private/dcusers.txt") as fh: + already_bound = any(line.strip() == target for line in fh) + + if already_bound: + bound_user = await dc2scratch(ctx.author.username) await ctx.send( embed=interactions.Embed( - title="❌ A scratch account is already linked to your discord account!", - description=f"Your account is linked to **{binded}**.\nScratchOn can't handle replacements yet.", + title="A scratch account is already linked to your discord account!", + description=( + f"Your account is linked to **{bound_user}**.\n" + "ScratchOn can't handle replacements yet." + ), color=0xFF0000, ) ) @@ -149,48 +169,51 @@ async def bind(self, ctx: interactions.SlashContext, username: str): user = scratch.get_user(username) - # If the user hasn't started verification yet, issue a code + # First step: issue a verification code if user_id not in pending_verifiers: v = user.verify_identity() pending_verifiers[user_id] = v await ctx.send( embed=interactions.Embed( - title="⏳ Wait!", + title="Wait!", description=( - f"To verify ownership, please comment **'{v.code}'** on this project: {v.projecturl}\n" - "Then, run this command again." + f"To verify ownership, please comment **'{v.code}'** on this project: " + f"{v.projecturl}\nThen, run this command again." ), color=scratch_orange, ) ) return - # User already started verification — check now + # Second step: verify the code v = pending_verifiers[user_id] if v.check(): - with open("private/dcusers.txt", "a") as file: - file.write(f"{str(ctx.author)}\n") - with open("private/scusers.txt", "a") as file: - file.write(f"{str(username)}\n") + with open("private/dcusers.txt", "a") as dc_file: + dc_file.write(f"{ctx.author}\n") + with open("private/scusers.txt", "a") as sc_file: + sc_file.write(f"{username}\n") del pending_verifiers[user_id] await ctx.send( embed=interactions.Embed( - title="✅ Success!", - description=f"Your Discord account is now linked to your Scratch account, **{username}**!", + title="Success!", + description=( + f"Your Discord account is now linked to your Scratch account, " + f"**{username}**!" + ), color=0x57F287, ) ) else: await ctx.send( embed=interactions.Embed( - title="⏳ Still waiting...", + title="Still waiting...", description=( - f"Please comment **'{v.code}'** on this project: {v.projecturl}\n" - "Then, run this command again." + f"Please comment **'{v.code}'** on this project: " + f"{v.projecturl}\nThen, run this command again." ), - color=0xE67E22, # orange + color=0xE67E22, ) ) @@ -212,7 +235,7 @@ async def bind(self, ctx: interactions.SlashContext, username: str): ) async def followedby( self, ctx: interactions.SlashContext, username: str, followed_by: str - ): + ) -> None: if scratch.get_user(username).is_followed_by(followed_by): await ctx.send( embed=interactions.Embed( @@ -248,23 +271,18 @@ async def followedby( ) async def mutualfollowers( self, ctx: interactions.SlashContext, user_1: str, user_2: str - ): + ) -> None: await ctx.defer() - msg = interactions.Embed() - count = 0 - desc = "" - followers1 = scratch.get_user(user_1).follower_names( + followers_1 = scratch.get_user(user_1).follower_names( limit=int(scratch.get_user(user_1).follower_count()) ) - followers2 = scratch.get_user(user_2).follower_names( + followers_2 = scratch.get_user(user_2).follower_names( limit=int(scratch.get_user(user_2).follower_count()) ) - for item in followers1: - if item in followers2: - count += 1 - desc = f"{desc}\n{item}" + mutual = [name for name in followers_1 if name in followers_2] + count = len(mutual) if count == 0: await ctx.send( @@ -274,14 +292,17 @@ async def mutualfollowers( ) ) else: - msg.title = ( - f"<:together:1330551758166036500>" - f"{user_1} and {user_2} have {count} mutual followers" - f"<:together:1330551758166036500> :" + await ctx.send( + embed=interactions.Embed( + title=( + f"<:together:1330551758166036500>" + f"{user_1} and {user_2} have {count} mutual followers" + f"<:together:1330551758166036500> :" + ), + description="\n".join(mutual), + color=scratch_orange, + ) ) - msg.description = desc - msg.color = scratch_orange - await ctx.send(embed=msg) @interactions.slash_command( name="scratchactivity", @@ -299,52 +320,54 @@ async def mutualfollowers( opt_type=interactions.OptionType.STRING, required=True, ) - async def activity(self, ctx: interactions.SlashContext, user: str, limit: str): + async def activity( + self, ctx: interactions.SlashContext, user: str, limit: str + ) -> None: await ctx.defer() - msg = interactions.Embed( - title="This user 's past scratch activity :", color=scratch_orange - ) - result = "" - + lines: list[str] = [] for item in scratch.get_user(user).activity(limit=limit): - result = f"{result}\n`{user}` made action {item.type} at " - target = item.target() - if type(target) == scratch.User: + + if isinstance(target, scratch.User): where = f"[{target.username}](https://scratch.mit.edu/users/{target.username})" - elif type(target) == scratch.Project: + elif isinstance(target, scratch.Project): where = f"[{target.id}](https://scratch.mit.edu/projects/{target.id})" - elif type(target) == scratch.Studio: + elif isinstance(target, scratch.Studio): where = f"[{target.id}](https://scratch.mit.edu/studios/{target.id})" - elif type(target) == scratch.Comment: - where = "Comment (I ain't writing 100 lines to support comments links because of API limitations, sorry)" + elif isinstance(target, scratch.Comment): + where = "Comment" else: where = "Unknown" - result = f"{result}{where}." + lines.append(f"`{user}` made action {item.type} at {where}.") - msg.description = result - await ctx.send(embed=msg) + await ctx.send( + embed=interactions.Embed( + title="This user's past scratch activity :", + description="\n".join(lines), + color=scratch_orange, + ) + ) @interactions.slash_command( name="scratchteam", description="Gets all scratch team members !", ) - async def scratchteam(self, ctx: interactions.SlashContext): - msg = interactions.Embed( - title="<:ScratchTeam:1330549427580178472> The Scratch Team is composed of :", - description="", - color=scratch_orange, + async def scratchteam(self, ctx: interactions.SlashContext) -> None: + members = "\n".join( + f"- **[{m['userName']}](https://scratch.mit.edu/users/{m['userName']})** " + f"<:separator:1333808735101124668> {m['name']}" + for m in scratch.scratch_team_members() ) - for item in scratch.scratch_team_members(): - msg.description = ( - f"{msg.description}\n- **[{item['userName']}]" - f"(https://scratch.mit.edu/users/{item['userName']})** " - f"<:separator:1333808735101124668> {item['name']}" + await ctx.send( + embed=interactions.Embed( + title="<:ScratchTeam:1330549427580178472> The Scratch Team is composed of :", + description=members, + color=scratch_orange, ) - await ctx.send(embed=msg) + ) -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: UserCommands(bot) diff --git a/commands/utility_commands.py b/commands/utility_commands.py index bf7df3c..0666e50 100644 --- a/commands/utility_commands.py +++ b/commands/utility_commands.py @@ -2,13 +2,12 @@ Utility slash commands (help, about, stats, tips, etc.). """ -import re import io import pprint +import re from collections import Counter import interactions - import scratchattach as scratch from config import scratch_orange @@ -21,39 +20,38 @@ class UtilityCommands(interactions.Extension): name="help", description="Need help ? No problem ! All commands and their usage are here.", ) - async def help(self, ctx: interactions.SlashContext): + async def help(self, ctx: interactions.SlashContext) -> None: await ctx.send( embed=interactions.Embed( title="<:giga404:1330551323610976339>Help", description=( - "🎉 EVENT COMMANDS 🗓️ :\n\n" - "<:SantaCat:1444277069826494557> **/christmas** ║ Returns the current christmas projects on trending !\n" - "\n═══════════════════════════\n\n" - "<:search:1333037655902130247> **/about** ║ Gives interesting facts and stats about this bot !\n" - "🔗 **/bind** ║ Allows you to bind your scratch account to your discord account, through a simple authentication process.\n" - "<:newscratcher:1330550984971259954> **/check_username** ║ Tells you if a username is available or not, so you can claim it if you want to!\n" - "📧 **/embed** ║ Gives the embed version of a project, useful for websites.\n" - "🔔 **/followedby** ║ Checks if a user is followed by another user!\n" - "📃 **/forums** ║ Gets all topics in a forum category, with their stats. Language topics not supported yet because of discord limitations \n" - "🩺 **/health** ║ Gets 'health data' about Scratch such as version, uptime, and complex data. Made for very advanced tech users.\n" - "✅ **/modstatus** ║ Says if a project is rated either For Everyone (FE) or Not For Everyone (NFE).\n" - "🆕 **/newestprojects** ║ Gets newly shared projects.\n" - "<:ocular:1333041343668158515> **/bettersearch** Allows searching projects just like in scratch, but better. For example, projects with high ids do show up, not like on scratch. Powered by ESDB.\n" - "📈 **/ontrend** ║ Checks if a project is on trending in a certain language (must be precised with 2 letters, such as en for english or fr for french) with a custom limit of how many projects the bot will look at.\n" - "💻 **/project** ║ Gives a lot of useful information about a specific project.\n" - "🎲 **/randomprojects** ║ Returns a pre-defined number of clickable random project titles. Powered by ESDB.\n" - "📋 **/scratchactivity** ║ Find a user's past activity on scratch. Because of API limitations, sometimes you will see '.' as where an action took place. WARNING : TOO HIGH LIMIT = ERROR\n" - "<:New1:1333793269636661288><:New2:1333793304822808677> <:catblock:1330552768171216897> **/scratchblocks** ║ Generates scratch-like blocks the same way the Scratch forums and wiki does. Uses [this syntax](https://en.scratch-wiki.info/wiki/Block_Plugin/Syntax).\n" - "<:ScratchTeam:1330549427580178472> **/scratchteam** ║ Gets all scratch team members !\n" - "<:tts:1344271467876974602> **/scratchtts** ║ Allows you to use text to speech... Using scratch's text to speech extension !\n" - "<:ScratchCat:1330547949721223238> **/s_profile** ║ Allows you to look at someone's profile easily, with high precision.\n" - "🗂️ **/studio** ║ Allows you to preview a studio.\n" - "🏷️ **/topic** ║ Previews a forum topic, not that useful right now.\n" - "🌟 **/trendscore** ║ Gives a trending score for a specific project, mainly to compare how trendy 2 projects are, can also be used to approximately predict if a project may go on trending.\n" - "🌐 **/webstats** ║ Gets statistics about Scratch, such as total projects count.\n" - "<:youtube:1340017536409927711> **/yttoscratch** ║ Converts any youtube video link into a forum video link. Better for sharing youtube videos on Scratch.\n" - "\n\nNeed assistance, have a suggestion or found a bug? Join our [🔧 support server](https://discord.gg/dgymF2Ye4k)!" - "\nBot profile picture by <:AJustEpic:1368235230749528276>AJustEpic, all rights reserved to them and ScratchOn Network." + "EVENT COMMANDS :\n\n" + "<:SantaCat:1444277069826494557> **/christmas** - Returns the current christmas projects on trending !\n" + "\n---\n\n" + "<:search:1333037655902130247> **/about** - Gives interesting facts and stats about this bot !\n" + " **/bind** - Allows you to bind your scratch account to your discord account.\n" + "<:newscratcher:1330550984971259954> **/check_username** - Tells you if a username is available or not.\n" + " **/embed** - Gives the embed version of a project, useful for websites.\n" + " **/followedby** - Checks if a user is followed by another user!\n" + " **/forums** - Gets all topics in a forum category.\n" + " **/health** - Gets health data about Scratch.\n" + " **/modstatus** - Says if a project is rated FE or NFE.\n" + " **/newestprojects** - Gets newly shared projects.\n" + " **/ontrend** - Checks if a project is on trending.\n" + " **/project** - Gives useful information about a specific project.\n" + " **/randomprojects** - Returns random project titles.\n" + " **/scratchactivity** - Find a user's past activity on scratch.\n" + "<:catblock:1330552768171216897> **/scratchblocks** - Generates scratch-like blocks.\n" + "<:ScratchTeam:1330549427580178472> **/scratchteam** - Gets all scratch team members !\n" + "<:tts:1344271467876974602> **/scratchtts** - Use scratch's Text to Speech in discord !\n" + "<:ScratchCat:1330547949721223238> **/s_profile** - Look at someone's profile.\n" + " **/studio** - Preview a studio.\n" + " **/topic** - Preview a forum topic.\n" + " **/trendscore** - Gives a trending score for a project.\n" + " **/webstats** - Gets statistics about Scratch.\n" + "<:youtube:1340017536409927711> **/yttoscratch** - Converts a youtube video link into a forum link.\n" + "\nNeed assistance, have a suggestion or found a bug? " + "Join our [support server](https://discord.gg/dgymF2Ye4k)!" ), color=scratch_orange, ) @@ -63,86 +61,85 @@ async def help(self, ctx: interactions.SlashContext): name="about", description="Everything you need to know about ScratchOn !", ) - async def about(self, ctx: interactions.SlashContext): + async def about(self, ctx: interactions.SlashContext) -> None: language_counts = Counter() - for guild in self.bot.guilds: language_counts[str(guild.preferred_locale)] += 1 top_languages = language_counts.most_common(2) - - langs = "\n**Top Languages :**\n" - for i, (lang, count) in enumerate(top_languages, 1): - langs = f"{langs}\n{i}. {lang} with {count} servers" + langs = "\n".join( + f"{i}. {lang} with {count} servers" + for i, (lang, count) in enumerate(top_languages, 1) + ) unique_members = set() for guild in self.bot.guilds: unique_members.update(member.id for member in guild.members) - total_unique_members = len(unique_members) bot_name = self.bot.user.username - msg = interactions.Embed(title="🤔 About ScratchOn <:BestBot:1388503205373280337> :") - msg.description = ( + embed = interactions.Embed( + title="About ScratchOn <:BestBot:1388503205373280337> :" + ) + embed.description = ( "<:together:1330551758166036500> **Contributors :**\n\n" - "- <:fluffy:1340009005581598820>** Fluffy**<:separator:1333808735101124668>Basically the bot founder and owner, who coded ScratchOn.\n" - "- <:timmccool:1340009073990701238>** TimMcCool**<:separator:1333808735101124668>Maker of scratchattach, the python library this bot is mainly based on.\n" - "- <:kRxZy_kRxZy:1455522693758849156>** kRxZy_kRxZy**<:separator:1333808735101124668>Very skilled ScratchOn Developer, working on this project for free.\n" - "- <:AJustEpic:1368235230749528276>** A Just Epic**<:separator:1333808735101124668>The amazing artist behind the PFP, who did it for completely free.\n" - f"- 🫵** You**<:separator:1333808735101124668> {bot_name} user, motivating me to continue updating this bot !\n" - f"\n📍 **Where is {bot_name} ?** 🌎\n\n" - f"📈 {bot_name} is in **{len(self.bot.guilds)}** servers, and used by **{total_unique_members}** unique scratchers worldwide. <:together:1330551758166036500>\n" - f"{langs}" - "\n\n🔗 **Links :**\n\n" - f"- [➕ Add {bot_name}](https://discord.com/oauth2/authorize?client_id=1300009645078876170&permissions=274877990912&integration_type=0&scope=bot)\n" - "- [🔧 Support server](https://discord.gg/dgymF2Ye4k)\n\n" - f"**⬆️ Help {bot_name} by upvoting it there ⬆️ :**\n" + "- <:fluffy:1340009005581598820>** Fluffy** - Bot founder and owner.\n" + "- <:timmccool:1340009073990701238>** TimMcCool** - Maker of scratchattach.\n" + "- <:kRxZy_kRxZy:1455522693758849156>** kRxZy_kRxZy** - ScratchOn Developer.\n" + "- <:AJustEpic:1368235230749528276>** A Just Epic** - Artist behind the PFP.\n" + f"- You - {bot_name} user, motivating me to continue !\n" + f"\n**Where is {bot_name} ?**\n\n" + f"{bot_name} is in **{len(self.bot.guilds)}** servers, " + f"and used by **{len(unique_members)}** unique scratchers worldwide.\n" + f"**Top Languages :**\n{langs}\n\n" + "**Links :**\n\n" + f"- [Add {bot_name}](https://discord.com/oauth2/authorize?client_id=1300009645078876170&permissions=274877990912&integration_type=0&scope=bot)\n" + "- [Support server](https://discord.gg/dgymF2Ye4k)\n\n" + f"**Help {bot_name} by upvoting it :**\n" "- [Top.gg](https://top.gg/bot/1300009645078876170)\n" "- [Discordbotlist.com](https://discordbotlist.com/bots/ScratchOn)\n" "- [Discordlist.gg](https://discordlist.gg/bot/1300009645078876170)\n" - "Or you can directly contribute to the code there : https://github.com/Fluffyscratch/ScratchOn" + "Contribute: https://github.com/Fluffyscratch/ScratchOn" ) - msg.color = scratch_orange - await ctx.send(embed=msg) + embed.color = scratch_orange + await ctx.send(embed=embed) @interactions.slash_command( name="webstats", description="Returns statistics about scratch's website.", ) - async def webstats(self, ctx: interactions.SlashContext): + async def webstats(self, ctx: interactions.SlashContext) -> None: stats = scratch.total_site_stats() - embeded_message = interactions.Embed( - title=":bar_chart: Statistics about Scratch :bar_chart:" - ) - embeded_message.description = ( + embed = interactions.Embed(title="Statistics about Scratch :bar_chart:") + embed.description = ( f"**On scratch, there are :**\n\n" - f"- {stats.get('PROJECT_COUNT')} projects 💻\n" - f"- {stats.get('USER_COUNT')} users <:together:1330551758166036500>\n" - f"- {stats.get('STUDIO_COUNT')} studios 🗂️\n\n" - f"**There are {stats.get('COMMENT_COUNT')} comments 💬 :**\n\n" - f"- {stats.get('PROFILE_COMMENT_COUNT')} are profile comments <:together:1330551758166036500>\n" - f"- {stats.get('PROJECT_COMMENT_COUNT')} are project comments 💻\n" - f"- {stats.get('STUDIO_COMMENT_COUNT')} are studio comments 🗂️" + f"- {stats.get('PROJECT_COUNT')} projects\n" + f"- {stats.get('USER_COUNT')} users\n" + f"- {stats.get('STUDIO_COUNT')} studios\n\n" + f"**There are {stats.get('COMMENT_COUNT')} comments :**\n\n" + f"- {stats.get('PROFILE_COMMENT_COUNT')} are profile comments\n" + f"- {stats.get('PROJECT_COMMENT_COUNT')} are project comments\n" + f"- {stats.get('STUDIO_COMMENT_COUNT')} are studio comments" ) - embeded_message.color = scratch_orange - await ctx.send(embed=embeded_message) + embed.color = scratch_orange + await ctx.send(embed=embed) @interactions.slash_command( name="health", description="Gets health data about scratch.", ) - async def scratchstatus(self, ctx: interactions.SlashContext): - original_description = pprint.pformat(scratch.get_health()) - modified_description = re.sub(r"[{},]", "", original_description) - modified_description = re.sub(r"'([^']+)'", r"**\1**", modified_description) + async def scratchstatus(self, ctx: interactions.SlashContext) -> None: + raw = pprint.pformat(scratch.get_health()) + formatted = re.sub(r"[{},]", "", raw) + formatted = re.sub(r"'([^']+)'", r"**\1**", formatted) await ctx.send( embed=interactions.Embed( - title="❤️‍🩹 Scratch's health status :", + title="Scratch's health status :", description=( - f"{modified_description}\n\n" - "## Tip : press control + F (or command + F on mac) and search the health data you're looking for." + f"{formatted}\n\n" + "## Tip : press control + F and search the health data you're looking for." ), color=scratch_orange, ) @@ -158,7 +155,7 @@ async def scratchstatus(self, ctx: interactions.SlashContext): opt_type=interactions.OptionType.STRING, required=True, ) - async def yttoscratch(self, ctx: interactions.SlashContext, link: str): + async def yttoscratch(self, ctx: interactions.SlashContext, link: str) -> None: await ctx.send( embed=interactions.Embed( title="Conversion finished ! Link :", @@ -171,55 +168,55 @@ async def yttoscratch(self, ctx: interactions.SlashContext, link: str): name="tips", description="Gives you tips and tricks for scratch !", ) - async def tips(self, ctx: interactions.SlashContext): - msg = interactions.Embed() - msg.title = "Here are some helpful tips and tricks for scratch !" - msg.color = scratch_orange - msg.description = ( - "**Cool emojis compatible with scratch:**\n\n" - "☠️ ◼️ ◻️ ⚪ ⚫ 🔲 🔳 🔴 🔵 🔶 🔷 🔸 🔹 🟠 🟡 🟢 🔘 🟣 🟤 🔵 🟦 🟩 🟧 🟨 🟩\n" - "🖤 🤖 📞 ⌛ ⏰ ⏱ 🕰 🔋 🔌 🖤 🛠 🛡 📱 📝 🔨 📖 🍵 ⚽ 🏀 ⚾ 🏐 🏆 🎮 🎯 🚗 🏎\n" - "💣 🔑 🔒 🔓 🎁 🏠 🏡 🏢 🔑 🔒 🔒 🔌 🏠 🏡 🔷 🌐 💬 ✉ 🧩 🌟 🕹️ 🎮 🖋️ 🎨\n" - "🎧 🎤 📷 📹 🎬 🏞 🖼 🖊️ 🎞 🎟 🎪 🎭 🎬 🕹️ 🎮 ⌨ 🖱 🔍 📝 🏁 🏆 🏅 🏆\n" - "🏅 🏆 🏁 🏟 ⛹️‍♂️ 🏋️‍♀️ 💆‍♀️ 🚴‍♀️ 🏊‍♂️ 🤾‍♀️ 🤽‍♀️ 🧘‍♀️ 🎾 🏸 🏓 🏒\n" - "🎳 🏏 🎯 ⚽ 🏀 🏐 🏓 🏒 🏆 🏅 🏆 ⛳ 🎯 🏆 🏅 🏅 🔗 📎 🖇 🏷️ 🎀 🎁 🧧\n\n" - "**Scratchmojis :**\n" - "Cats :\n\n" - "<:cat:1330548816843374655> <:separator:1333808735101124668> `_:)_`\n" - "<:awwcat:1330548798841163840> <:separator:1333808735101124668> `_:D_`\n" - "<:coolcat:1330548833209417821> <:separator:1333808735101124668> `_B)_`\n" - "<:tongueoutcat:1330549148155514981> <:separator:1333808735101124668> `_:P_`\n" - "<:winkcat:1330549190169727071> <:separator:1333808735101124668> `_;P_`\n" - "<:lolcat:1330548983873142854> <:separator:1333808735101124668> `_:'P_`\n" - "<:upsidedowncat:1330549166207664209> <:separator:1333808735101124668> `_P:_`\n" - "<:huhcat:1330548916223217747> <:separator:1333808735101124668> `_:3_`\n" - "<:loveitcat:1330549053741731942> <:separator:1333808735101124668> `_<3_`\n" - "<:favitcat:1330548853317042196> <:separator:1333808735101124668> `_**_`\n" - "<:rainbowcat:1330549122855600262> <:separator:1333808735101124668> `_:))_`\n" - "<:pizzacat:1330549104249667655> <:separator:1333808735101124668> `_:D<_`\n\n" - "Other :" + async def tips(self, ctx: interactions.SlashContext) -> None: + embed = interactions.Embed( + title="Here are some helpful tips and tricks for scratch !", + color=scratch_orange, + description=( + "**Cool emojis compatible with scratch:**\n\n" + "Black and white squares/circles: black and white squares\n\n" + "**Scratchmojis :**\n" + "Cats :\n" + "<:cat:1330548816843374655> <:separator:1333808735101124668> `_:)_`\n" + "<:awwcat:1330548798841163840> <:separator:1333808735101124668> `_:D_`\n" + "<:coolcat:1330548833209417821> <:separator:1333808735101124668> `_B)_`\n" + "<:tongueoutcat:1330549148155514981> <:separator:1333808735101124668> `_:P_`\n" + "<:winkcat:1330549190169727071> <:separator:1333808735101124668> `_;P_`\n" + "<:lolcat:1330548983873142854> <:separator:1333808735101124668> `_:'P_`\n" + "<:upsidedowncat:1330549166207664209> <:separator:1333808735101124668> `_P:_`\n" + "<:huhcat:1330548916223217747> <:separator:1333808735101124668> `_:3_`\n" + "<:loveitcat:1330549053741731942> <:separator:1333808735101124668> `_<3_`\n" + "<:favitcat:1330548853317042196> <:separator:1333808735101124668> `_**_`\n" + "<:rainbowcat:1330549122855600262> <:separator:1333808735101124668> `_:))_`\n" + "<:pizzacat:1330549104249667655> <:separator:1333808735101124668> `_:D<_`\n\n" + "Other :" + ), ) - msg.add_field(name="<:meow:1330549076223070269>", value="`_meow_`") - msg.add_field(name="<:Gobo:1330552595390926948>", value="`_gobo_`") - msg.add_field(name="<:10mil:1330548335471493241>", value="`_10mil_`") - msg.add_field(name="<:waffle:1335195624127336451>", value="`_waffle_`") - msg.add_field(name="<:taco:1330548746156638208>", value="`_taco_`") - msg.add_field(name="<:sushi:1330548721355722817>", value="`_sushi_`") - msg.add_field(name="<:apple:1330548350663131247>", value="`_apple_`") - msg.add_field(name="<:broccoli:1330548400214511718>", value="`_broccoli_`") - msg.add_field(name="<:pizza:1330548624215642183>", value="`_pizza_`") - msg.add_field(name="<:candycorn:1330548437753794640>", value="`_candycorn_`") - msg.add_field(name="<:map:1330548598508879922>", value="`_map_`") - msg.add_field(name="<:camera:1330548417684045854>", value="`_camera_`") - msg.add_field(name="<:suitcase:1330548697359974512>", value="`_suitcase_`") - msg.add_field(name="<:compass:1330548457357967360>", value="`_compass_`") - msg.add_field(name="<:binoculars:1330548366064484422>", value="`_binoculars_`") - msg.add_field(name="<:cupcake:1330548475359920141>", value="`_cupcake_`") - msg.add_field(name="<:pride:1330548644859871253>", value="`_pride_`") - msg.add_field(name="<:blm:1330548384007852032>", value="`_blm_`") + other_items = [ + ("<:meow:1330549076223070269>", "`_meow_`"), + ("<:Gobo:1330552595390926948>", "`_gobo_`"), + ("<:10mil:1330548335471493241>", "`_10mil_`"), + ("<:waffle:1335195624127336451>", "`_waffle_`"), + ("<:taco:1330548746156638208>", "`_taco_`"), + ("<:sushi:1330548721355722817>", "`_sushi_`"), + ("<:apple:1330548350663131247>", "`_apple_`"), + ("<:broccoli:1330548400214511718>", "`_broccoli_`"), + ("<:pizza:1330548624215642183>", "`_pizza_`"), + ("<:candycorn:1330548437753794640>", "`_candycorn_`"), + ("<:map:1330548598508879922>", "`_map_`"), + ("<:camera:1330548417684045854>", "`_camera_`"), + ("<:suitcase:1330548697359974512>", "`_suitcase_`"), + ("<:compass:1330548457357967360>", "`_compass_`"), + ("<:binoculars:1330548366064484422>", "`_binoculars_`"), + ("<:cupcake:1330548475359920141>", "`_cupcake_`"), + ("<:pride:1330548644859871253>", "`_pride_`"), + ("<:blm:1330548384007852032>", "`_blm_`"), + ] + for emoji, code in other_items: + embed.add_field(name=emoji, value=code) - await ctx.send(embed=msg) + await ctx.send(embed=embed) @interactions.slash_command( name="scratchtts", @@ -260,7 +257,7 @@ async def scratchtts( text: str, voice: str, language: str, - ): + ) -> None: await ctx.defer() audio_data, playback_rate = scratch.text2speech( @@ -273,7 +270,9 @@ async def scratchtts( ) await ctx.send( - embed=interactions.Embed(title="Done! Here is the output ⬆️", color=scratch_orange), + embed=interactions.Embed( + title="Done! Here is the output", color=scratch_orange + ), file=audio_file, ) @@ -294,7 +293,9 @@ async def scratchtts( required=False, choices=[ interactions.SlashCommandChoice(name="Scratch 3.0", value="scratch3"), - interactions.SlashCommandChoice(name="Scratch 3.0 (high-contrast)", value="scratch3-high-contrast"), + interactions.SlashCommandChoice( + name="Scratch 3.0 (high-contrast)", value="scratch3-high-contrast" + ), interactions.SlashCommandChoice(name="Scratch 2.0", value="scratch2"), ], ) @@ -303,7 +304,7 @@ async def scratchblocks( ctx: interactions.SlashContext, code: str, style: str = "scratch3", - ): + ) -> None: from services import render_blocks_image await ctx.defer() @@ -311,5 +312,5 @@ async def scratchblocks( await ctx.send(file=interactions.File(file=filename)) -def setup(bot: interactions.Client): +def setup(bot: interactions.Client) -> None: UtilityCommands(bot) diff --git a/config.py b/config.py index 7952dc5..f46ace6 100644 --- a/config.py +++ b/config.py @@ -2,18 +2,24 @@ Bot configuration and constants. """ -import interactions -from itertools import cycle -import sys import io +import sys +from itertools import cycle + +import interactions -# Ensures proper utf-8 encoding for prints sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") -# Bot setup +# --------------------------------------------------------------------------- +# Bot instance +# --------------------------------------------------------------------------- + bot = interactions.Client(intents=interactions.Intents.ALL) -# The statuses the bot will cycle through +# --------------------------------------------------------------------------- +# Statuses the bot cycles through +# --------------------------------------------------------------------------- + bot_statuses = cycle( [ "Scratch API", @@ -25,24 +31,33 @@ ] ) -# The bot's theme colours (plain hex integers — interactions.py accepts these directly) -scratch_orange = 0xF6AB3C -scratch_gold = 0xFFBE00 -scratch_blue = 0x4E97FE +# --------------------------------------------------------------------------- +# Theme colours (plain hex integers — interactions.py accepts these directly) +# --------------------------------------------------------------------------- + +scratch_orange: int = 0xF6AB3C +scratch_gold: int = 0xFFBE00 +scratch_blue: int = 0x4E97FE + +# --------------------------------------------------------------------------- +# Reusable embeds +# --------------------------------------------------------------------------- -# Embed for experimental commands betaembed = interactions.Embed( title="Sorry, this command is still in beta ! You cannot use it yet.", color=0xFF0000, ) -# Contributors and developers -contributors = ["EletrixTime", "TimMcCool", "AJustEpic"] -devs = ["Fluffygamer_", "kRxZy_kRxZy"] +# --------------------------------------------------------------------------- +# Contributors / developers +# --------------------------------------------------------------------------- + +contributors: list[str] = ["EletrixTime", "TimMcCool", "AJustEpic"] +devs: list[str] = ["Fluffygamer_", "kRxZy_kRxZy"] -# Global dictionary to store button states (for settings UI) -# Values are interactions.ButtonStyle members -button_states = {} +# --------------------------------------------------------------------------- +# Shared mutable state (settings UI button styles) +# --------------------------------------------------------------------------- -# Memory storage for pending verifications (user_id: Verificator) -pending_verifiers: dict = {} +button_states: dict[str, interactions.ButtonStyle] = {} +pending_verifiers: dict[int, object] = {} diff --git a/database.py b/database.py index 5b98f0e..0284689 100644 --- a/database.py +++ b/database.py @@ -2,46 +2,72 @@ Database configuration and helper functions. """ +import logging + import duckdb -# Database connection +logger = logging.getLogger(__name__) + +VALID_COLUMNS: set[str] = {"language", "ai", "embeds"} + +# --------------------------------------------------------------------------- +# Connection and schema +# --------------------------------------------------------------------------- + db = duckdb.connect("private/ScratchOn.duckdb") -# Initialize table -db.execute("""CREATE TABLE IF NOT EXISTS ScratchOn ( - serverid INTEGER PRIMARY KEY, - language TEXT DEFAULT 'en', - ai BOOLEAN DEFAULT FALSE, - embeds BOOLEAN DEFAULT FALSE -)""") +db.execute( + """ + CREATE TABLE IF NOT EXISTS ScratchOn ( + serverid INTEGER PRIMARY KEY, + language TEXT DEFAULT 'en', + ai BOOLEAN DEFAULT FALSE, + embeds BOOLEAN DEFAULT FALSE + ) + """ +) -def get_server_data(server_id: int, column_name: str): +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + + +def get_server_data(server_id: int, column_name: str) -> object | None: """ - Retrieves specific data from the 'ScratchOn' table in the 'ScratchOn.duckdb' database. + Retrieve a single column value for *server_id*. - :param server_id: The server ID to look for. - :param column_name: The column name whose value is requested. - :return: The value of the requested column for the given server ID, or None if not found. + :param server_id: The Discord server ID to look up. + :param column_name: Column whose value is requested (must be in VALID_COLUMNS). + :return: The value, or ``None`` if not found or on error. """ + if column_name not in VALID_COLUMNS: + logger.error("Rejected invalid column name: %s", column_name) + return None + try: - query = f"SELECT {column_name} FROM ScratchOn WHERE serverid = ?" - result = db.execute(query, [server_id]).fetchone() + result = db.execute( + f"SELECT {column_name} FROM ScratchOn WHERE serverid = ?", + [server_id], + ).fetchone() return result[0] if result else None - except Exception as e: - print(f"Error occurred: {e}") + except Exception: + logger.exception( + "Failed to read column '%s' for server %s", column_name, server_id + ) return None -def add_server(server_id: int): +def add_server(server_id: int) -> None: """ - Adds a new server entry to the database. + Insert a new server entry (idempotent — duplicates are ignored). - :param server_id: The server ID to add. + :param server_id: The Discord server ID to add. """ - db.execute( - """ - INSERT INTO ScratchOn (serverid) VALUES (?) - """, - [server_id], - ) + try: + db.execute( + "INSERT INTO ScratchOn (serverid) VALUES (?)", + [server_id], + ) + except Exception: + logger.exception("Failed to insert server %s", server_id) diff --git a/events/bot_events.py b/events/bot_events.py index be07578..da4abe6 100644 --- a/events/bot_events.py +++ b/events/bot_events.py @@ -2,24 +2,27 @@ Discord bot events. """ -import interactions import logging + +import interactions from interactions.api.events import CommandError -from config import bot, bot_statuses, button_states +from config import bot_statuses, button_states from database import add_server +logger = logging.getLogger(__name__) + class BotEvents(interactions.Extension): - """Extension for core bot lifecycle events.""" + """Core bot lifecycle events and error handling.""" # ------------------------------------------------------------------ # - # Status cycling task # + # Status cycling # # ------------------------------------------------------------------ # @interactions.Task.create(interactions.IntervalTrigger(seconds=10)) async def status_task(self): - """Cycles through bot statuses every 10 seconds.""" + """Cycle through bot statuses every 10 seconds.""" await self.bot.change_presence( activity=interactions.Activity( name=next(bot_statuses), @@ -32,44 +35,37 @@ async def status_task(self): # ------------------------------------------------------------------ # @interactions.listen(interactions.events.Ready) - async def on_ready(self, event: interactions.events.Ready): - print("ScratchOn is ready !") + async def on_ready(self, event: interactions.events.Ready) -> None: + logger.info("ScratchOn is ready!") self.status_task.start() @interactions.listen(interactions.events.GuildJoin) - async def on_guild_join(self, event: interactions.events.GuildJoin): - """When joining a server, register it in the database.""" + async def on_guild_join(self, event: interactions.events.GuildJoin) -> None: + """Register a newly joined server in the database.""" add_server(event.guild.id) # ------------------------------------------------------------------ # - # Component interaction handler (settings buttons) # + # Component interaction handler (settings buttons) # # ------------------------------------------------------------------ # @interactions.listen(interactions.events.Component) - async def on_component(self, event: interactions.events.Component): - """Toggle button states for the /settings UI.""" + async def on_component(self, event: interactions.events.Component) -> None: + """Toggle button states for the ``/settings`` UI.""" ctx = event.ctx button_id = ctx.custom_id - # Only handle the two known settings toggles if button_id not in ("ai_button", "embeds_button"): return - # Default to DANGER (red = disabled) when first seen - if button_id not in button_states: - button_states[button_id] = interactions.ButtonStyle.DANGER - - current_style = button_states[button_id] - - # Toggle: SUCCESS (green) ↔ DANGER (red) + current = button_states.get(button_id, interactions.ButtonStyle.DANGER) new_style = ( interactions.ButtonStyle.SUCCESS - if current_style == interactions.ButtonStyle.DANGER + if current == interactions.ButtonStyle.DANGER else interactions.ButtonStyle.DANGER ) button_states[button_id] = new_style - # Rebuild the full component layout with updated styles + # Rebuild components with updated styles ai_button = interactions.Button( label="AI", style=button_states.get("ai_button", interactions.ButtonStyle.DANGER), @@ -90,34 +86,27 @@ async def on_component(self, event: interactions.events.Component): action_row = interactions.ActionRow(ai_button, embeds_button) select_row = interactions.ActionRow(language_select) - # Edit the original message in-place await ctx.edit_origin(components=[action_row, select_row]) - # Acknowledge the toggle with an ephemeral followup color_name = "green" if new_style == interactions.ButtonStyle.SUCCESS else "red" await ctx.send( - f"The **{button_id}** color changed to **{color_name}**!", - ephemeral=True, + f"The **{button_id}** color changed to **{color_name}**!", ephemeral=True ) - + # ------------------------------------------------------------------ # - # Error handler # + # Error handler # # ------------------------------------------------------------------ # @interactions.listen() - async def on_command_error(event: CommandError): - logging.exception( - f"Error in command {event.ctx.command.name}", - exc_info=event.error + async def on_command_error(event: CommandError) -> None: + logger.exception( + "Error in command %s", event.ctx.command.name, exc_info=event.error ) - try: - await event.ctx.send( - "❌ An internal error occurred.", - ephemeral=True - ) + await event.ctx.send("An internal error occurred.", ephemeral=True) except Exception: pass -def setup(bot: interactions.Client): + +def setup(bot: interactions.Client) -> None: BotEvents(bot) diff --git a/main.py b/main.py index 15edb39..f005700 100644 --- a/main.py +++ b/main.py @@ -1,39 +1,42 @@ """ -ScratchOn Discord Bot - Main Entry Point +ScratchOn Discord Bot — Main Entry Point A Discord bot for the Scratch community, providing various utilities for interacting with the Scratch API. """ +import os + +from dotenv import load_dotenv from interactions.ext.prefixed_commands import setup as setup_prefixed + from config import bot -# Enable prefix command support with the "s " prefix -setup_prefixed(bot, default_prefix="s ") +load_dotenv() -# Load event handlers extension -bot.load_extension("events.bot_events") -# Load all slash-command extensions -bot.load_extension("commands.user_commands") -bot.load_extension("commands.project_commands") -bot.load_extension("commands.studio_forum_commands") -bot.load_extension("commands.search_commands") -bot.load_extension("commands.utility_commands") -# bot.load_extension("commands.experimental_commands") # uncomment when ready -bot.load_extension("commands.prefix_commands") -bot.load_extension("commands.currency_commands") +def main() -> None: + """Run the bot.""" + setup_prefixed(bot, default_prefix="s ") -# Attach Top.gg integration (registers its own listeners on `bot`) -import topGG + bot.load_extension("events.bot_events") -topGG.attach_to_bot(bot) + bot.load_extension("commands.user_commands") + bot.load_extension("commands.project_commands") + bot.load_extension("commands.studio_forum_commands") + bot.load_extension("commands.search_commands") + bot.load_extension("commands.utility_commands") + # bot.load_extension("commands.experimental_commands") + bot.load_extension("commands.prefix_commands") + bot.load_extension("commands.currency_commands") + import topGG -def main(): - """Run the bot.""" - with open("private/token.txt") as f: - token = f.readlines()[0].strip() + topGG.attach_to_bot(bot) + + token = os.getenv("BOT_TOKEN") + if not token: + raise RuntimeError("BOT_TOKEN environment variable is not set") bot.start(token) diff --git a/services/__init__.py b/services/__init__.py index 4ba30b2..ac3c0bf 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -2,7 +2,7 @@ Service modules. """ -from .scratchblocks import render_blocks_image from .blockbits import get_latest_response, request_search +from .scratchblocks import render_blocks_image -__all__ = ["render_blocks_image", "get_latest_response", "request_search"] +__all__: list[str] = ["render_blocks_image", "get_latest_response", "request_search"] diff --git a/services/blockbits.py b/services/blockbits.py index ed8c3f7..77419cb 100644 --- a/services/blockbits.py +++ b/services/blockbits.py @@ -1,33 +1,29 @@ """ -Blockbit communication portal +BlockBit cloud variable communication. """ -import scratchattach as sa -from scratchattach import Encoding - -import websockets import json +import logging +import websockets +from scratchattach import Encoding -# Login to Scratch (temporarily ignored to avoid bugs with Scratch API) -# with open("private/password.txt") as f: -# session = sa.login(username="_Scratch-On_", password=f.readlines()[0]) +logger = logging.getLogger(__name__) -# Connect to the Blockbit project's cloud variables -# cloud = session.connect_cloud(669020072) -latest_value = 1 +latest_value: int | str | None = None -def request_search(username: str): +def request_search(username: str) -> None: """ - Send a request to Scratch asking for a user's balance or data. + Send a request to the Scratch project asking for a user's balance. - The Scratch project listens for messages in the TO_HOST variable. + The Scratch project listens for messages in the ``TO_HOST`` variable. """ # cloud.set_var("TO_HOST", Encoding.encode(f"search&{username}")) -async def get_response(): +async def _fetch_response() -> None: + """Connect to the cloud-data websocket and update *latest_value*.""" global latest_value async with websockets.connect("wss://clouddata.scratch.mit.edu") as ws: await ws.send(json.dumps({"method": "handshake", "project_id": 669020072})) @@ -36,16 +32,20 @@ async def get_response(): if message.get("method") == "variables": variables = Encoding.decode(message.get("variables", {})) if variables: - # Get the last variable value received - last_var = list(variables.values())[-1] - latest_value = last_var + latest_value = list(variables.values())[-1] def get_latest_response() -> int | str | None: """ - Get the most recent response sent back by the Scratch project. + Return the most recent response from the Scratch project. - If the response is a number, return it as an int. Otherwise, return it as a string. + If the response is numeric it is returned as an ``int``. """ - get_response() - return float(latest_value) if latest_value is not None else None + # NOTE: _fetch_response is async and must be started as a background task + # before calling this function; here we simply return the cached value. + if latest_value is None: + return None + try: + return float(latest_value) + except (ValueError, TypeError): + return latest_value diff --git a/services/scratchblocks.py b/services/scratchblocks.py index e2079b5..73fd155 100644 --- a/services/scratchblocks.py +++ b/services/scratchblocks.py @@ -3,36 +3,38 @@ """ import os + from pyppeteer import launch TEMPLATE_PATH = "scratchblocks_template.html" +TEMP_HTML = "private/temp_scratchblocks.html" +CHROMIUM_PATH = "/usr/bin/chromium-browser" async def render_blocks_image( - code: str, style: str, output_path: str = "private/blocks.png" + code: str, + style: str, + output_path: str = "private/blocks.png", ) -> str: """ - Renders scratchblocks code to an image. + Render scratchblocks code to a PNG image. - :param code: Scratchblocks code to render. - :param style: Style to use (scratch3, scratch3-high-contrast, scratch2). - :param output_path: Path to save the rendered image. - :return: Path to the rendered image. + :param code: Scratchblocks source code. + :param style: Rendering style (``scratch3``, ``scratch3-high-contrast``, ``scratch2``). + :param output_path: Destination path for the rendered image. + :return: The *output_path*. """ - # Load the HTML template - with open(TEMPLATE_PATH, "r", encoding="utf-8") as f: - html = f.read() + with open(TEMPLATE_PATH, encoding="utf-8") as fh: + html = fh.read() - # Inject user code - html = html.replace( - "when green flag clicked\nsay [Hello world!]", - code.replace("<", "<").replace(">", ">").replace("\\n", "\n"), - ) + # Inject user code (escape HTML entities first) + safe_code = code.replace("<", "<").replace(">", ">").replace("\\n", "\n") + html = html.replace("when green flag clicked\nsay [Hello world!]", safe_code) - # Add chosen style + # Apply chosen style html = html.replace('style: "scratch3"', f'style: "{style}"') - # Update scratchblocks path + # Rewrite scratchblocks asset paths html = html.replace( "scratchblocks/build/scratchblocks.min.js", "../private/scratchblocks/build/scratchblocks.min.js", @@ -42,33 +44,20 @@ async def render_blocks_image( "../private/scratchblocks/build/translations-all.js", ) - # Save modified HTML to a temporary file - temp_path = "private/temp_scratchblocks.html" - with open(temp_path, "w", encoding="utf-8") as f: - f.write(html) + with open(TEMP_HTML, "w", encoding="utf-8") as fh: + fh.write(html) - # Launch chromium browser using Pyppeteer browser = await launch( - executablePath="/usr/bin/chromium-browser", headless=True, args=["--no-sandbox"] + executablePath=CHROMIUM_PATH, headless=True, args=["--no-sandbox"] ) page = await browser.newPage() - await page.setViewport( - { - "width": 800, - "height": 600, - "deviceScaleFactor": 2, # Improves output resolution - } - ) - - # Navigate to the temporary file - await page.goto(f"file://{os.path.abspath(temp_path)}") + await page.setViewport({"width": 800, "height": 600, "deviceScaleFactor": 2}) + await page.goto(f"file://{os.path.abspath(TEMP_HTML)}") - # Wait for scratchblocks to load await page.waitForFunction("typeof scratchblocks !== 'undefined'") await page.waitForSelector(".scratchblocks") - # Screenshot the result element = await page.querySelector(".preview") await element.screenshot({"path": output_path, "omitBackground": True}) await browser.close() diff --git a/topGG.py b/topGG.py index fc52db0..bcb34c6 100644 --- a/topGG.py +++ b/topGG.py @@ -2,8 +2,6 @@ import logging import os import sys -import time -import traceback from datetime import datetime from pathlib import Path from typing import Dict, List, Optional @@ -22,6 +20,7 @@ # Decorator for excluding commands from Top.gg # --------------------------------------------------------------------------- + def exclude_from_topgg(func): """ Decorator to exclude a command from Top.gg posting. @@ -41,6 +40,7 @@ async def admin_command(ctx: SlashContext): # Logging setup # --------------------------------------------------------------------------- + def setup_logging(): """Configure file + stdout logging.""" logging_path = Path("logs") @@ -62,6 +62,7 @@ def setup_logging(): # Top.gg integration # --------------------------------------------------------------------------- + class TopGGIntegration: """Handles Top.gg API integration for command posting.""" @@ -89,7 +90,9 @@ async def post_commands_to_topgg(self) -> bool: return False async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=commands_data) as resp: + async with session.post( + url, headers=headers, json=commands_data + ) as resp: if resp.status in (200, 204): logging.info( f"✅ Successfully posted {len(commands_data)} commands to Top.gg" @@ -136,10 +139,14 @@ async def _get_bot_commands_for_topgg(self) -> List[Dict]: def _is_command_excluded(self, command) -> bool: """Return True if this command carries the @exclude_from_topgg marker.""" # Check the decorated callback function - if hasattr(command, "callback") and hasattr(command.callback, "_exclude_from_topgg"): + if hasattr(command, "callback") and hasattr( + command.callback, "_exclude_from_topgg" + ): return True # Some internals store it differently - if hasattr(command, "_callback") and hasattr(command._callback, "_exclude_from_topgg"): + if hasattr(command, "_callback") and hasattr( + command._callback, "_exclude_from_topgg" + ): return True # Direct attribute (set manually) if hasattr(command, "_exclude_from_topgg"): @@ -180,7 +187,8 @@ async def _convert_command_to_topgg_format(self, command) -> Optional[Dict]: command_data.update( { "type": 1, # CHAT_INPUT - "description": getattr(command, "description", "") or "No description", + "description": getattr(command, "description", "") + or "No description", } ) @@ -196,7 +204,10 @@ async def _convert_command_to_topgg_format(self, command) -> Optional[Dict]: command_data["options"] = converted_options # Honour default_member_permissions if present - if hasattr(command, "default_member_permissions") and command.default_member_permissions: + if ( + hasattr(command, "default_member_permissions") + and command.default_member_permissions + ): command_data["default_member_permissions"] = str( command.default_member_permissions.value if hasattr(command.default_member_permissions, "value") @@ -222,7 +233,8 @@ def _convert_option(self, option) -> Optional[Dict]: option_data: Dict = { "name": option.name, - "description": getattr(option, "description", "Parameter") or "Parameter", + "description": getattr(option, "description", "Parameter") + or "Parameter", "required": getattr(option, "required", True), "type": type_value, } @@ -243,7 +255,9 @@ def _convert_option(self, option) -> Optional[Dict]: return option_data except Exception as e: - logging.error(f"❌ Error converting option '{getattr(option, 'name', '?')}': {e}") + logging.error( + f"❌ Error converting option '{getattr(option, 'name', '?')}': {e}" + ) return None async def start_periodic_updates(self): @@ -257,13 +271,14 @@ async def _periodic_commands(self): await asyncio.sleep(86_400) # 24 hours except Exception as e: logging.error(f"❌ Error in periodic command update: {e}") - await asyncio.sleep(3_600) # retry after 1 hour + await asyncio.sleep(3_600) # retry after 1 hour # --------------------------------------------------------------------------- # Command syncer # --------------------------------------------------------------------------- + class CommandSyncer: """Wraps interactions.py's command synchronisation.""" @@ -292,6 +307,7 @@ async def sync_commands(self, guild_id: Optional[int] = None) -> int: # attach_to_bot — public entry point # --------------------------------------------------------------------------- + def attach_to_bot(bot: interactions.Client, env_path: str = "TopGG.env"): """Attach Top.gg integration to an existing ``interactions.Client`` instance. @@ -326,9 +342,7 @@ def attach_to_bot(bot: interactions.Client, env_path: str = "TopGG.env"): @bot.listen(interactions.events.Ready) async def _on_ready(event: interactions.events.Ready): - logging.info( - f"🚀 Bot logged in as {bot.user.username} (ID: {bot.user.id})" - ) + logging.info(f"🚀 Bot logged in as {bot.user.username} (ID: {bot.user.id})") logging.info(f"📊 Connected to {len(bot.guilds)} guilds") try: @@ -365,6 +379,7 @@ async def _on_guild_remove(event: interactions.events.GuildLeft): # Helper for ad-hoc posting # --------------------------------------------------------------------------- + async def post_commands_now(bot: interactions.Client) -> bool: """Post commands immediately using the bot's attached integration (if any).""" topgg: Optional[TopGGIntegration] = getattr(bot, "_topgg_integration", None) diff --git a/utils/__init__.py b/utils/__init__.py index 3d4538b..ddd7ecd 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -4,13 +4,13 @@ from .helpers import ( dc2scratch, - replace_last_screenshot, + limiter, remove_line_by_index, + replace_last_screenshot, update_pings, - limiter, ) -__all__ = [ +__all__: list[str] = [ "dc2scratch", "replace_last_screenshot", "remove_line_by_index", diff --git a/utils/helpers.py b/utils/helpers.py index 9bed76f..cf8643c 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -2,120 +2,112 @@ Utility functions for the bot. """ +import logging import os + import scratchattach as scratch from scratchattach import MultiEventHandler -from pyppeteer import launch + +logger = logging.getLogger(__name__) async def dc2scratch(username: str) -> str | None: """ - Converts a Discord username to a Scratch username using stored bindings. + Convert a Discord username to a Scratch username using stored bindings. :param username: Discord username to look up. - :return: Corresponding Scratch username, or None if not found. + :return: Corresponding Scratch username, or ``None`` if not found. """ - with ( - open("private/dcusers.txt") as f1, - open("private/scusers.txt") as f2, - ): - dc_users = [line.strip() for line in f1.readlines()] - sc_users = [line.strip() for line in f2.readlines()] + with open("private/dcusers.txt") as dc_file, open("private/scusers.txt") as sc_file: + dc_users = [line.strip() for line in dc_file] + sc_users = [line.strip() for line in sc_file] - if username in dc_users: - index = dc_users.index(username) - return sc_users[index] - return None + if username in dc_users: + index = dc_users.index(username) + return sc_users[index] + return None -async def replace_last_screenshot(url: str, screenshot_path: str = "screenshot.png"): +async def replace_last_screenshot( + url: str, + screenshot_path: str = "screenshot.png", +) -> None: """ - Takes a screenshot of a URL, replacing any existing screenshot. + Take a screenshot of *url*, replacing any existing file at *screenshot_path*. - :param url: URL to screenshot. - :param screenshot_path: Path to save the screenshot. + :param url: URL to screenshot. + :param screenshot_path: Destination path. """ if os.path.exists(screenshot_path): os.remove(screenshot_path) - print(f"Deleted the previous screenshot: {screenshot_path}") + logger.info("Deleted previous screenshot: %s", screenshot_path) + + from pyppeteer import launch browser = await launch(headless=True, executablePath="/usr/bin/chromium-browser") page = await browser.newPage() await page.goto(url) await page.screenshot({"path": screenshot_path}) - print(f"New screenshot saved as: {screenshot_path}") + logger.info("New screenshot saved: %s", screenshot_path) await browser.close() -def remove_line_by_index(file_path: str, index_to_remove: int): +def remove_line_by_index(file_path: str, index_to_remove: int) -> None: """ - Removes a specific line from a file by its index. + Remove a specific line from *file_path* by its zero-based index. - :param file_path: Path to the file. - :param index_to_remove: Zero-based index of the line to remove. + :param file_path: Path to the file. + :param index_to_remove: Zero-based line index to remove. """ - with open(file_path, "r") as file: - lines = file.readlines() + with open(file_path, "r") as fh: + lines = fh.readlines() if 0 <= index_to_remove < len(lines): del lines[index_to_remove] - with open(file_path, "w") as file: - file.writelines(lines) + with open(file_path, "w") as fh: + fh.writelines(lines) -def update_pings(): +def update_pings() -> None: """ - Updates the message event handlers for users who have ping notifications enabled. + Update the message-event handlers for users with ping notifications enabled. """ - with open("private/users2ping.txt") as file: - whatever = False - temp = ... - for item in file.readlines(): - if item == "\n" or item == "": - break - if whatever: - temp = temp, scratch.get_user(item.strip()).message_events() - else: - whatever = True - temp = scratch.get_user(item.strip()).message_events() - - multievents = len(file.readlines()) - 1 != 1 - - if multievents: - combined = MultiEventHandler(temp) - print(temp) - print(combined) - - @combined.event(function=combined) - def on_ready(): - print("Message trackers are up to date !") + with open("private/users2ping.txt") as fh: + lines = fh.readlines() - @combined.request(function=combined) - def your_request(): ... + event_handler = None + for line in lines: + name = line.strip() + if not name: + break + user_events = scratch.get_user(name).message_events() + event_handler = ( + user_events + if event_handler is None + else MultiEventHandler(event_handler, user_events) + ) - combined.start() - else: - events = temp + if event_handler is None: + return - @events.event() - def on_count_change(old_count, new_count): - print("message count changed from", old_count, "to", new_count) + @event_handler.event + def on_ready(): + logger.info("Message trackers are up to date!") - @events.event() - def on_ready(): - print("Event listener ready!") + @event_handler.request + def on_request(): + pass - events.start() + event_handler.start() def limiter(text: str, limit: int) -> str: """ - Limits text to a certain length and adds ellipsis. + Truncate *text* to *limit* characters and append an ellipsis. - :param text: Text to limit. + :param text: Text to truncate. :param limit: Maximum character length. - :return: Limited text with ellipsis. + :return: Truncated text. """ - result = text[:limit] - return f"{result}..." + return text[:limit] + "..."