diff --git a/core/bot_classes/axobot.py b/core/bot_classes/axobot.py index 36600c364..b6408419e 100644 --- a/core/bot_classes/axobot.py +++ b/core/bot_classes/axobot.py @@ -246,13 +246,20 @@ async def get_config(self, guild_id: discord.Guild | int, option: str): options_list = await self.get_options_list() return options_list.get(option, {"default": None})["default"] - async def get_guilds_with_config(self, option_name: str, option_value: str): + async def get_guilds_with_value(self, option_name: str, option_value: str): """Get a list of guilds with a specific config option set to a specific value""" cog = self.get_cog("ServerConfig") if cog and self.database_online: return await cog.db_get_guilds_with_value(option_name, option_value) return [] + async def db_get_guilds_with_option(self, option_name: str): + """Get a list of guilds with a specific config option set to anything but None""" + cog = self.get_cog("ServerConfig") + if cog and self.database_online: + return await cog.db_get_guilds_with_option(option_name) + return {} + async def get_recipient(self, channel: discord.DMChannel) -> discord.User | None: """Get the recipient of the given DM channel diff --git a/core/database/query/db_abstract_query.py b/core/database/query/db_abstract_query.py index e0d437a86..cc01f724b 100644 --- a/core/database/query/db_abstract_query.py +++ b/core/database/query/db_abstract_query.py @@ -33,7 +33,7 @@ async def __aexit__(self, exc_type, value, traceback): async def _format_query(self): "Create a formatted query string from the query and its arguments." - await format_query(self.cursor, self.query, self.args) + return await format_query(self.cursor, self.query, self.args) async def _save_execution_time(self, start_time: float): await save_execution_time(self.bot, start_time) diff --git a/core/emojis_manager.py b/core/emojis_manager.py index 3bb095254..bd52934d3 100644 --- a/core/emojis_manager.py +++ b/core/emojis_manager.py @@ -1012,5 +1012,6 @@ def get_emoji(self, name: str) -> discord.Emoji | None: "minecraft": 958305433439834152, "github": 1130174138267480244, "readthedocs": 484841075001786368, + "bluesky": 1312561135794393232, } return self.bot.get_emoji(ids[name]) diff --git a/core/enums.py b/core/enums.py index f953c3286..c5d1d88cd 100644 --- a/core/enums.py +++ b/core/enums.py @@ -36,6 +36,7 @@ class RankCardsFlag(_BaseFlagClass): 1 << 14: "christmas23", 1 << 15: "april24", 1 << 16: "halloween24", + 1 << 17: "christmas24", } class UserFlag(_BaseFlagClass): diff --git a/docs/conf.py b/docs/conf.py index 2edb39ce7..a0dc77096 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,7 +35,7 @@ # The short X.Y version version = "5.0" # The full version, including alpha/beta/rc tags -release = "5.0.2" +release = "5.0.3" _documentation_name = "Axobot Documentation" diff --git a/docs/rss.rst b/docs/rss.rst index 52e456ab7..6f0308bd1 100644 --- a/docs/rss.rst +++ b/docs/rss.rst @@ -12,9 +12,9 @@ To manage this plugin (add, edit or remove feeds), you will need at least the Ma See the last post ----------------- -**Syntax:** :code:`last-post [youtube|twitch|deviant|web]` +**Syntax:** :code:`last-post [bluesky|deviantart|twitch|youtube|web]` -This command allows you to see the last post of a youtube channel, a user on Twitch or DeviantArt, or from any valid RSS feed. If you provide a full URL, the bot will automatically detect the type of feed. If you only provide the name of the channel, you will have to specify the type of feed. +This command allows you to see the last post of a YouTube channel, a user on Twitch/DeviantArt/Bluesky, or from any valid RSS feed. If you provide a full URL, the bot will automatically detect the type of feed. If you only provide the name of the channel, you will have to specify the type of feed. .. note:: No specific permission is required for this command. Remember to allow the use of external emojis to get a prettier look. @@ -27,7 +27,7 @@ Follow a feed If you want to automatically track an rss feed, this command should be used. You can only track a maximum feeds, which will be reloaded every 20 minutes. Note that Minecraft server tracing also counts as an rss feed, and therefore will cost you a slot (which are currently limited to 10 per server). -For YouTube channels, simply give the link of the channel, so that the bot automatically detects the type and name of the channel. If no type is recognized, the 'web' type will be selected. +For YouTube, Twitch or Bluesky channels, simply give the link of the channel, so that the bot automatically detects the type and name of the channel. If no type is recognized, the 'web' type will be selected. .. note:: To post a message, the bot does not need any specific permission. But if it's a Minecraft server feed (see the `corresponding section `__), don't forget the "`Read message history `__" permission! diff --git a/events-list.json b/events-list.json index 1846ac5b2..363974321 100644 --- a/events-list.json +++ b/events-list.json @@ -331,5 +331,51 @@ ], "probability": 0.05 } + }, + "christmas-2024": { + "begin": "2024-12-01", + "end": "2024-12-31", + "type": "christmas", + "icon": "https://zrunner.me/cdn/christmas_2024.png", + "color": 4886759, + "objectives": [ + { + "points": 300, + "reward_type": "role", + "role_id": 1312417584456532090 + }, + { + "points": 600, + "reward_type": "rankcard", + "rank_card": "christmas24", + "min_date": "2023-12-25" + } + ], + "emojis": { + "reactions_list": [], + "triggers": [ + "christmas", + "xmas", + "present", + "noël", + "santa", + "gift", + "cadeau", + "snow", + "neige", + "ice", + "glace", + "ho ho ho", + "reinder", + "sleight", + "traîneau", + "candy cane", + "sucre d'orge", + "elf ", + "lutin", + "cookie" + ], + "probability": 0.1 + } } } \ No newline at end of file diff --git a/lang/bot_events/cs.json b/lang/bot_events/cs.json index 537193490..d1971d6b5 100644 --- a/lang/bot_events/cs.json +++ b/lang/bot_events/cs.json @@ -51,8 +51,10 @@ }, "soon": "Událost se blíží! Sledujte informace, protože začne %{date}", "tictactoe": { - "reward-title": "Tic-tac-toe vítezství!", - "reward-desc": "Tím, že jsi vyhrál tuto hru, jsi získal ** body%{points} události**!" + "won": { + "title": "Tic-tac-toe vítezství!", + "desc": "Tím, že jsi vyhrál tuto hru, jsi získal ** body%{points} události**!" + } }, "tip-title": "Náhodný tip", "unclassed": "nezařazené", diff --git a/lang/bot_events/en.json b/lang/bot_events/en.json index b3eab73d5..7b40d22db 100644 --- a/lang/bot_events/en.json +++ b/lang/bot_events/en.json @@ -51,8 +51,14 @@ }, "soon": "An event is coming soon! Watch for information as it will begin on %{date}", "tictactoe": { - "reward-title": "Tic-tac-toe victory!", - "reward-desc": "By winning this game, you earned **%{points} event points**!" + "lost": { + "title": "Tic-tac-toe defeat", + "desc": "By losing this game, you lost **%{points} event points**..." + }, + "won": { + "title": "Tic-tac-toe victory!", + "desc": "By winning this game, you earned **%{points} event points**!" + } }, "tip-title": "Random tip", "unclassed": "unclassed", diff --git a/lang/bot_events/fr.json b/lang/bot_events/fr.json index feb3c7611..34f77331c 100644 --- a/lang/bot_events/fr.json +++ b/lang/bot_events/fr.json @@ -51,8 +51,14 @@ }, "soon": "Un événement arrive bientôt ! Surveillez les informations, car il débutera le %{date}", "tictactoe": { - "reward-title": "Victoire au morpion !", - "reward-desc": "En remportant ce match, tu as gagné **%{points} points d'événement** !" + "lost": { + "title": "Défaite au morpion", + "desc": "En perdant ce match, tu as perdu **%{points} points d'événement**..." + }, + "won": { + "title": "Victoire au morpion !", + "desc": "En remportant ce match, tu as gagné **%{points} points d'événement** !" + } }, "tip-title": "Astuce aléatoire", "unclassed": "Non classé", diff --git a/lang/bot_events/lolcat.json b/lang/bot_events/lolcat.json index 169ef5511..8372aeb99 100644 --- a/lang/bot_events/lolcat.json +++ b/lang/bot_events/lolcat.json @@ -51,8 +51,10 @@ }, "soon": "Hey hey hey! ur too soon, try checking on %{date}! :hype: ", "tictactoe": { - "reward-title": "WOW, u won against the most supremely smartest tic-tac-toe AI ever!", - "reward-desc": "So uh, as a reward, I've gave u **%{points} event points**, check 'em with `/event profile`!" + "won": { + "title": "WOW, u won against the most supremely smartest tic-tac-toe AI ever!", + "desc": "So uh, as a reward, I've gave u **%{points} event points**, check 'em with `/event profile`!" + } }, "tip-title": "Cool tip", "unclassed": "unclassd", diff --git a/lang/rss/en.json b/lang/rss/en.json index 0b6ae143c..1db9e0fd7 100644 --- a/lang/rss/en.json +++ b/lang/rss/en.json @@ -4,6 +4,9 @@ "many": "Type the roles to mention for these feeds (either names, IDs or mentions), separated by spaces." }, "ask-roles-hint-example": "For example:\n> Members \"Super VIP\" @Ping roles", + "bluesky": "Bluesky", + "bluesky-default-flow": "{logo} | New post of {author}! ({date})\n\n{title}\n\nLink: {link}\n\n{mentions}", + "bluesky-form-last": "{logo} | Here is the last post of {author}:\nWritten on {date}\n\n{title}\n\nLink: {url}", "change-txt": { "title": "Edit your feed text", "label": "Message template", diff --git a/lang/rss/fr.json b/lang/rss/fr.json index d749326e0..487cb9f3b 100644 --- a/lang/rss/fr.json +++ b/lang/rss/fr.json @@ -4,6 +4,9 @@ "many": "Entrez les rôles à mentionner pour ces flux (au choix leurs noms, IDs ou mentions), séparés par des espaces." }, "ask-roles-hint-example": "Par exemple:\n> Membres \"Super VIP\" @Notifs", + "bluesky": "Bluesky", + "bluesky-default-flow": "{logo} | Nouveau post de {author} ! ({date})\n\n{title}\n\nLien : {link}\n\n{mentions}", + "bluesky-form-last": "{logo} | Voici le dernier post de {author}:\nÉcrit le {date}\n\n{title}\n\nLien : {url}", "change-txt": { "title": "Modifiez le texte de votre flux", "label": "Modèle de message", diff --git a/modules/bot_events/bot_events.py b/modules/bot_events/bot_events.py index 5ba193b4b..1d3258b13 100644 --- a/modules/bot_events/bot_events.py +++ b/modules/bot_events/bot_events.py @@ -1,6 +1,8 @@ import datetime import json import logging +import time +from collections import defaultdict from typing import AsyncGenerator, Literal import discord @@ -12,7 +14,7 @@ from core.formatutils import FormatUtils from .data import EventData, EventRewardRole, EventType -from .subcogs import AbstractSubcog, RandomCollectSubcog +from .subcogs import AbstractSubcog, ChristmasSubcog class BotEvents(commands.Cog): @@ -32,15 +34,17 @@ def __init__(self, bot: Axobot): self.coming_event_id: str | None = None self.update_current_event() - self._subcog: AbstractSubcog = RandomCollectSubcog( + self._subcog: AbstractSubcog = ChristmasSubcog( self.bot, self.current_event, self.current_event_data, self.current_event_id) + self.min_delay_between_ttt_wins = 30 # seconds between 2 tictactoe wins + self.last_ttt_win: dict[int, int] = defaultdict(int) # map of user_id -> timestamp @property def subcog(self) -> AbstractSubcog: "Return the subcog populated with the current event data" if self._subcog.current_event != self.current_event or self._subcog.current_event_data != self.current_event_data: self.log.debug("Updating subcog with new data") - self._subcog = RandomCollectSubcog(self.bot, self.current_event, self.current_event_data, self.current_event_id) + self._subcog = ChristmasSubcog(self.bot, self.current_event, self.current_event_data, self.current_event_id) return self._subcog async def cog_load(self): @@ -130,6 +134,62 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): if self.current_event: await self.subcog.on_raw_reaction_add(payload) + @commands.Cog.listener() + async def on_tictactoe_win(self, interaction: discord.Interaction): + "Grant points to the user if they won a game of tictactoe" + if not self.current_event: + return + now = time.time() + # limit to 1 win per minute + if self.last_ttt_win[interaction.user.id] + self.min_delay_between_ttt_wins > now: + return + self.last_ttt_win[interaction.user.id] = now + points = 7 + user = interaction.user + # send win reward embed + emb = discord.Embed( + title=await self.bot._(interaction, "bot_events.tictactoe.won.title"), + description=await self.bot._(interaction, "bot_events.tictactoe.won.desc", points=points), + color=self.current_event_data["color"], + ) + emb.set_author(name=user.global_name, icon_url=user.display_avatar) + await interaction.followup.send(embed=emb) + # send card unlocked notif + await self.check_and_send_card_unlocked_notif(interaction, user) + # give points + await self.db_add_user_points(user.id, points) + + @commands.Cog.listener() + async def on_tictactoe_lose(self, interaction: discord.Interaction): + "Grant points to the user if they lost a game of tictactoe" + if not self.current_event: + return + user = interaction.user + # check if user points is high enough to add some difficulty (ie. remove points on loss) + if not self.current_event_data["objectives"]: + return + user_points = await self.db_get_user_points(user.id) + first_cap = self.current_event_data["objectives"][0]["points"] + last_cap = self.current_event_data["objectives"][-1]["points"] + if user_points < first_cap * 1.1: + return + if first_cap == last_cap or user_points < last_cap * 1.1: + # remove 3 points if user has more than 110% of the first objective + points = -3 + else: + # remove 5 points if user has more than 110% of the max objective + points = -5 + # send loss reward embed + emb = discord.Embed( + title=await self.bot._(interaction, "bot_events.tictactoe.lost.title"), + description=await self.bot._(interaction, "bot_events.tictactoe.lost.desc", points=-points), + color=self.current_event_data["color"], + ) + emb.set_author(name=user.global_name, icon_url=user.display_avatar) + await interaction.followup.send(embed=emb) + # remove points + await self.db_add_user_points(user.id, points) + events_main = app_commands.Group( name="event", description="Participate in bot special events!", @@ -217,7 +277,7 @@ async def event_profile(self, interaction: discord.Interaction, user: discord.Us await self.subcog.profile_cmd(interaction, user) @events_main.command(name="collect") - @app_commands.checks.cooldown(1, 60) + @app_commands.checks.cooldown(2, 60) @app_commands.check(database_connected) async def event_collect(self, interaction: discord.Interaction): "Get some event points every hour" @@ -240,8 +300,11 @@ async def get_user_unlockable_rankcards(self, user: discord.User, points: int | continue yield reward["rank_card"] - async def check_and_send_card_unlocked_notif(self, - interaction: discord.Interaction | discord.TextChannel, user: discord.User | int): + async def check_and_send_card_unlocked_notif( + self, + interaction: discord.Interaction | discord.TextChannel, + user: discord.User | int + ): "Check if the user meets the requirements to unlock the event rank card, and send a notification if so" if isinstance(user, int): user = self.bot.get_user(user) @@ -313,6 +376,16 @@ def _check_reward_date(self, reward_date: str | None): parsed_date = datetime.datetime.strptime(reward_date, "%Y-%m-%d").replace(tzinfo=datetime.UTC) return self.bot.utcnow() < parsed_date + async def db_get_user_points(self, user_id: int) -> int | None: + "Get the user's event points" + if not self.bot.database_online or self.bot.current_event is None: + return None + query = "SELECT `points` FROM `event_points` WHERE `user_id` = %s AND `beta` = %s;" + async with self.bot.db_main.read(query, (user_id, self.bot.beta), fetchone=True) as query_result: + if query_result: + return query_result["points"] + return None + async def db_add_user_points(self, user_id: int, points: int): "Add some 'other' events points to a user" try: diff --git a/modules/bot_events/data/events-translations.json b/modules/bot_events/data/events-translations.json index 7ae000f29..367323198 100644 --- a/modules/bot_events/data/events-translations.json +++ b/modules/bot_events/data/events-translations.json @@ -10,6 +10,7 @@ "christmas-2023": "Noël approche ! Pendant le mois de décembre, participez aux festivités en collectant votre récompense chaque jour du calendrier de l'avent, jouez au morpion pour gagner des points d'événement, et récupérez la carte d'XP spécial Noël 2023 le 25 décembre avec `/event collect` !\nLes trois meilleurs participants (hors équipe du bot) gagneront même un **mois d'abonnement Nitro !**", "april-2024": "La journée internationale des poissons fait son retour ! Pendant toute la journée, Axobot fêtera le 1er avril avec des réactions aléatoires sur le thème des poissons, des commandes uniques ainsi que d'autres choses trop cool.\nAvec un peu de chance, vous trouverez peut-être la carte d'xp collector cachée dans les réactions !\n\nPour rappel, les cartes d'xp sont accessibles via ma commande `profile card`", "halloween-2024": "Le mois d'octobre est là ! Profitez jusqu'au 1er novembre d'une atmosphère ténébreuse, remplie de chauve-souris, de squelettes et de citrouilles.\nRécupérez des points d'événements le plus régulièrement possible avec la commande `/event collect` : les plus téméraires d'entre vous réussirons peut-être à débloquer la carte d'xp spécial Halloween 2024, que vous pourrez utiliser via la commande `/profile card` !", + "christmas-2024": "Noël approche ! Pendant le mois de décembre, participez aux festivités en collectant votre récompense chaque jour du calendrier de l'avent, jouez au morpion pour gagner des points d'événement, et récupérez la carte d'XP spécial Noël 2024 le 25 décembre avec `/event collect` !", "test-2022": "Test event!" }, "events_prices": { @@ -41,6 +42,10 @@ "halloween-2024": { "300": "Débloquez la carte d'xp Halloween 2024, obtenable uniquement pendant cet événement !", "500": "Venez réclamer votre rôle spécial Halloween 2024 sur le serveur officiel d'Axobot !" + }, + "christmas-2024": { + "300": "Venez réclamer votre rôle spécial Noël 2024 sur le serveur officiel d'Axobot !", + "600": "Débloquez la carte d'xp Noël 2024 le 25 décembre avec `/event collect` !" } }, "events_title": { @@ -53,6 +58,7 @@ "christmas-2023": "Joyeux Noël !", "april-2024": "Joyeux 1er avril !", "halloween-2024": "Le temps des citrouilles est arrivé !", + "christmas-2024": "Joyeux Noël !", "test-2022": "Test event!" } }, @@ -67,6 +73,7 @@ "christmas-2023": "Christmas is coming! During the month of December, take part in the festivities by collecting your reward each day of the advent calendar, play tic-tac-toe to earn event points, and collect the special Christmas 2023 XP card on December 25 with `/event collect`!\nThe three best participants (excluding the bot staff) will even win **a month's Nitro subscription!**", "april-2024": "International Fish Day is back! Throughout the day, Axobot will be celebrating April 1st with random fish-themed reactions, unique commands and other cool stuff.\nWith a little luck, you might find the hidden collector's XP card in the reactions!\n\nAs a reminder, the XP cards are accessible via my `profile card` command", "halloween-2024": "October is here! Enjoy a dark atmosphere full of bats, skeletons and pumpkins until November 1st.\nCollect event points as regularly as possible with the `/event collect` command: the most daring among you may succeed in unlocking the special Halloween 2024 xp card, which you will be able to use via the `/profile card` command!", + "christmas-2024": "Christmas is coming! During the month of December, take part in the festivities by collecting your reward each day of the advent calendar, play tic-tac-toe to earn event points, and collect the special Christmas 2024 XP card on December 25 with `/event collect`!", "test-2022": "Test event!" }, "events_prices": { @@ -98,6 +105,10 @@ "halloween-2024": { "300": "Unlock the Halloween 2024 xp card, obtainable only during this event!", "500": "Come claim your special Halloween 2024 role on the official Axobot server!" + }, + "christmas-2024": { + "300": "Come claim your special Christmas 2024 role on the official Axobot server!", + "600": "Unlock the Christmas 2024 xp card on December 25 with `/event collect`!" } }, "events_title": { @@ -110,6 +121,7 @@ "christmas-2023": "Merry Christmas!", "april-2024": "Happy April 1st!", "halloween-2024": "It's pumpkin time!", + "christmas-2024": "Merry Christmas!", "test-2022": "Test event!" } } diff --git a/modules/bot_events/subcogs/abstract_subcog.py b/modules/bot_events/subcogs/abstract_subcog.py index 9f2bec7ae..e2accce0c 100644 --- a/modules/bot_events/subcogs/abstract_subcog.py +++ b/modules/bot_events/subcogs/abstract_subcog.py @@ -289,6 +289,7 @@ async def db_add_collect(self, user_id: int, points: int, with_strike: bool): query = "INSERT INTO `event_points` (`user_id`, `collect_points`, `last_collect`, `beta`) \ VALUES (%s, %s, CURRENT_TIMESTAMP(), %s) \ ON DUPLICATE KEY UPDATE collect_points = collect_points + VALUE(`collect_points`), \ + strike_level = 0, \ last_collect = CURRENT_TIMESTAMP();" else: return diff --git a/modules/bot_events/subcogs/christmas_subcog.py b/modules/bot_events/subcogs/christmas_subcog.py index 3836a7f88..ed1807050 100644 --- a/modules/bot_events/subcogs/christmas_subcog.py +++ b/modules/bot_events/subcogs/christmas_subcog.py @@ -28,10 +28,10 @@ 11: [36, 41, 43, 49], 12: [32, 33, 35, 46, 53, 55], 13: [36, 40, 42, 58], - 14: [38], - 15: [42, 50], - 16: [39, 60, 62], - 17: [45, 25, 25, 53], + 14: [42, 50], + 15: [38], + 16: [34], + 17: [39, 60, 62], 18: [33, 48, 56, 58], 19: [36, 45, 45, 50], 20: [35, 39, 42, 43, 57], @@ -221,9 +221,8 @@ async def is_past_christmas(self): async def generate_collect_message(self, interaction: discord.Interaction, items: list[EventItem], last_collect_day: dt.date): "Generate the message to send after a /collect command" - past_christmas = await self.is_past_christmas() if not items: - if past_christmas: + if await self.is_past_christmas(): return await self.bot._(interaction, "bot_events.calendar.collected-all") return await self.bot._(interaction, "bot_events.calendar.collected-day") # 1 item collected diff --git a/modules/bot_events/subcogs/random_collect_subcog.py b/modules/bot_events/subcogs/random_collect_subcog.py index b3b56c088..bbeca61ed 100644 --- a/modules/bot_events/subcogs/random_collect_subcog.py +++ b/modules/bot_events/subcogs/random_collect_subcog.py @@ -99,7 +99,7 @@ async def collect_cmd(self, interaction): bonus = 0 else: points = sum(item["points"] for item in items) - bonus = max(0, await self.adjust_points_to_strike(points, strike_level) - points) + bonus = await self.get_bonus_from_strike(points, strike_level) await self.db_add_user_items(interaction.user.id, [item["item_id"] for item in items]) txt = await self.generate_collect_message(interaction, items, points + bonus) if strike_level and bonus != 0: @@ -174,10 +174,11 @@ async def check_user_collect_availability(self, user_id: int, seconds_since_last return True, True return True, False - async def adjust_points_to_strike(self, points: int, strike_level: int): + async def get_bonus_from_strike(self, points: int, strike_level: int): "Get a random amount of points for the /collect command, depending on the strike level" - strike_coef = self.collect_bonus_per_strike ** strike_level - return round(points * strike_coef) + strike_coef = self.collect_bonus_per_strike ** strike_level - 1 + bonus = round(min(points, 50) * strike_coef) + return max(0, bonus) async def db_get_user_strike_level(self, user_id: int) -> int: "Get the strike level of a user" diff --git a/modules/bot_stats/bot_stats.py b/modules/bot_stats/bot_stats.py index a5a22f3f5..2a302bfd5 100644 --- a/modules/bot_stats/bot_stats.py +++ b/modules/bot_stats/bot_stats.py @@ -66,6 +66,7 @@ def __init__(self, bot: Axobot): self.open_files: dict[str, int] = defaultdict(int) self.role_reactions = {"added": 0, "removed": 0} self.serverlogs_audit_search: tuple[int, int] | None = None + self.invite_tracker_search: tuple[int, int] | None = None self.snooze_events: dict[tuple[int, int], int] = defaultdict(int) self.stream_events: dict[str, int] = defaultdict(int) self.voice_transcript_events: dict[tuple[float, float], int] = defaultdict(int) @@ -273,6 +274,13 @@ async def on_serverlogs_audit_search(self, success: bool): else: self.serverlogs_audit_search = (1, success) + async def on_invite_tracker_search(self, success: bool): + "Called when an invite tracker search is done" + if prev := self.invite_tracker_search: + self.invite_tracker_search = (prev[0]+1, prev[1]+success) + else: + self.invite_tracker_search = (1, success) + async def db_get_disabled_rss(self) -> int: "Count the number of disabled RSS feeds in any guild" table = "rss_feed_beta" if self.bot.beta else "rss_feed" @@ -539,6 +547,11 @@ async def sql_loop(self): audit_search_percent = round(self.serverlogs_audit_search[1] / self.serverlogs_audit_search[0] * 100, 1) cursor.execute(query, (now, "logs.audit_search", audit_search_percent, 1, '%', False, self.bot.entity_id)) self.serverlogs_audit_search = None + # Invites tracker + if self.invite_tracker_search is not None: + invite_search_percent = round(self.invite_tracker_search[1] / self.invite_tracker_search[0] * 100, 1) + cursor.execute(query, (now, "logs.invite_search", invite_search_percent, 1, '%', False, self.bot.entity_id)) + self.invite_tracker_search = None # Last backup save if self.last_backup_size: cursor.execute(query, (now, "backup.size", self.last_backup_size, 1, "Gb", False, self.bot.entity_id)) diff --git a/modules/invites_tracker/invites_tracker.py b/modules/invites_tracker/invites_tracker.py index 08dc78773..1c32a4d17 100644 --- a/modules/invites_tracker/invites_tracker.py +++ b/modules/invites_tracker/invites_tracker.py @@ -33,7 +33,7 @@ async def db_get_invites(self, guild_id: int) -> list[TrackedInvite]: async with self.bot.db_main.read(query, (guild_id, self.bot.beta)) as query_result: return query_result - async def db_add_invite(self, guild_id: int, invite_id: str, user_id: int | None, creation_date: datetime | None, + async def db_upsert_invite(self, guild_id: int, invite_id: str, user_id: int | None, creation_date: datetime | None, usage_count: int): "Insert a tracked invite in the database, or update it if it already exists" query = ( @@ -81,7 +81,7 @@ async def sync_guild_invites(self, guild: discord.Guild): # add/update existing invitations for invite in guild_invites: user_id = invite.inviter.id if invite.inviter else None - await self.db_add_invite(guild.id, invite.code, user_id, invite.created_at, invite.uses) + await self.db_upsert_invite(guild.id, invite.code, user_id, invite.created_at, invite.uses) count += 1 # delete removed invitations for tracked_invite in await self.db_get_invites(guild.id): @@ -120,7 +120,7 @@ async def is_tracker_enabled(self, guild_id: int) -> bool: async def sync_all_guilds_invites(self): "Sync the tracked invites of all guilds" synced_invites = 0 - for guild_id in await self.bot.get_guilds_with_config("enable_invites_tracking", str(True)): + for guild_id in await self.bot.get_guilds_with_value("enable_invites_tracking", str(True)): try: guild = self.bot.get_guild(guild_id) if guild and guild.me.guild_permissions.manage_guild: @@ -150,7 +150,7 @@ async def on_invite_create(self, invite: discord.Invite): if invite.guild is None or not await self.is_tracker_enabled(invite.guild.id): return inviter_id = invite.inviter.id if invite.inviter else None - await self.db_add_invite(invite.guild.id, invite.code, inviter_id, invite.created_at, invite.uses or 0) + await self.db_upsert_invite(invite.guild.id, invite.code, inviter_id, invite.created_at, invite.uses or 0) @commands.Cog.listener() async def on_invite_delete(self, invite: discord.Invite): @@ -162,11 +162,10 @@ async def on_invite_delete(self, invite: discord.Invite): @commands.Cog.listener() async def on_member_join(self, member: discord.Member): "Detect which invite was used when a member joins the server" - if not member.guild.me.guild_permissions.manage_guild: + if not member.guild.me.guild_permissions.manage_guild or not await self.is_tracker_enabled(member.guild.id): return await asyncio.sleep(1) # Wait for the invite to be updated - used_invite = await self.check_invites_usage(member.guild) - if used_invite: + if used_invite := await self.check_invites_usage(member.guild): discord_invite, tracked_invite = used_invite tracked_invite["last_count"] = discord_invite.uses tracked_invite["max_uses"] = discord_invite.max_uses @@ -175,6 +174,7 @@ async def on_member_join(self, member: discord.Member): await self.db_update_invite_count(member.guild.id, discord_invite.code, discord_invite.uses) else: self.bot.log.warning(f"Could not detect the invite used in guild {member.guild.id}") + self.bot.dispatch("invite_tracker_search", used_invite is not None) invites_main = app_commands.Group( diff --git a/modules/partners/partners.py b/modules/partners/partners.py index d7b675947..a4f5982d4 100644 --- a/modules/partners/partners.py +++ b/modules/partners/partners.py @@ -218,13 +218,10 @@ async def get_bot_owners(self, bot_id: int, session: aiohttp.ClientSession) -> l owners.append(owner_id) return owners - async def get_partners_channels(self): + async def get_partners_channels(self) -> list[discord.abc.GuildChannel]: """Return every partners channels""" - channels: list[discord.abc.GuildChannel] = [] - for guild in self.bot.guilds: - if channel := await self.bot.get_config(guild.id, "partner_channel"): - channels.append(channel) - return channels + guilds_map: dict[int, discord.abc.GuildChannel] = await self.bot.db_get_guilds_with_option("partner_channel") + return list(guilds_map.values()) async def update_partners(self, channel: discord.TextChannel, color: int | None = None) -> int: """Update every partners of a channel""" diff --git a/modules/roles_react/roles_react.py b/modules/roles_react/roles_react.py index 74e786440..b3aa59b40 100644 --- a/modules/roles_react/roles_react.py +++ b/modules/roles_react/roles_react.py @@ -55,15 +55,16 @@ async def prepare_react(self, payload: discord.RawReactionActionEvent) -> tuple[ chan = self.bot.get_channel(payload.channel_id) if chan is None or isinstance(chan, discord.abc.PrivateChannel): return None - try: - msg = await chan.fetch_message(payload.message_id) - except discord.NotFound: # we don't care about those - return None - except Exception as err: - self.bot.log.warning( - f"Could not fetch roles-reactions message {payload.message_id} in guild {payload.guild_id}: {err}" - ) - return None + if (msg := self.bot.cached_messages[payload.message_id]) is None: + try: + msg = await chan.fetch_message(payload.message_id) + except discord.NotFound: # we don't care about those + return None + except Exception as err: + self.bot.log.warning( + f"Could not fetch roles-reactions message {payload.message_id} in guild {payload.guild_id}: {err}" + ) + return None if len(msg.embeds) == 0 or msg.embeds[0].footer.text not in self.footer_texts: return None temp = await self.db_get_role_from_emoji( diff --git a/modules/rss/rss.py b/modules/rss/rss.py index b400e0ffc..9792856b4 100644 --- a/modules/rss/rss.py +++ b/modules/rss/rss.py @@ -24,6 +24,7 @@ from core.paginator import PaginatedSelectView, Paginator from core.tips import GuildTip from core.views import ConfirmView, TextInputModal +from modules.rss.src.rss_bluesky import BlueskyRSS from .src import (FeedEmbedData, FeedObject, FeedType, RssMessage, YoutubeRSS, feed_parse) @@ -72,6 +73,7 @@ def __init__(self, bot: Axobot): self.web_rss = WebRSS(self.bot) self.deviant_rss = DeviantartRSS(self.bot) self.twitch_rss = TwitchRSS(self.bot) + self.bluesky_rss = BlueskyRSS(self.bot) self.cache: dict[str, list[RssMessage]] = {} # launch rss loop @@ -92,7 +94,7 @@ async def cog_unload(self): @app_commands.rename(feed_type="type") @app_commands.checks.cooldown(3, 20) async def rss_last_post(self, interaction: discord.Interaction, url: str, - feed_type: Literal["youtube", "twitter", "twitch", "deviantart", "web"] | None): + feed_type: Literal["bluesky", "deviantart", "twitch", "youtube", "web"] | None): """Search the last post of a feed ..Example rss last-post https://www.youtube.com/channel/UCZ5XnGb-3t7jCkXdawN2tkA @@ -116,6 +118,8 @@ async def rss_last_post(self, interaction: discord.Interaction, url: str, await self.last_post_twitch(interaction, url) elif feed_type == "deviantart": await self.last_post_deviant(interaction, url) + elif feed_type == "bluesky": + await self.last_post_bluesky(interaction, url) elif feed_type == "web": await self.last_post_web(interaction, url) else: @@ -131,6 +135,8 @@ async def get_feed_type_from_url(self, url: str): return "twitch" if self.deviant_rss.is_deviantart_url(url): return "deviantart" + if self.bluesky_rss.is_bluesky_url(url): + return "bluesky" if self.web_rss.is_web_url(url): return "web" return None @@ -193,6 +199,21 @@ async def last_post_deviant(self, interaction: discord.Interaction, user: str): else: await interaction.followup.send(obj) + async def last_post_bluesky(self, interaction: discord.Interaction, user: str): + "Search for the last post of a bluesky user" + if extracted_user := await self.bluesky_rss.get_username_by_url(user): + user = extracted_user + text = await self.bluesky_rss.get_last_post(interaction.channel, user, filter_config=None) + if isinstance(text, str): + await interaction.followup.send(text) + else: + form = await self.bot._(interaction, "rss.bluesky-form-last") + obj = await text.create_msg(form) + if isinstance(obj, discord.Embed): + await interaction.followup.send(embed=obj) + else: + await interaction.followup.send(obj) + async def last_post_web(self, interaction: discord.Interaction, link: str): "Search for the last post of a web feed" try: @@ -259,6 +280,11 @@ async def system_add(self, interaction: discord.Interaction, link: str, if identifiant is not None: feed_type = "deviant" display_type = "deviantart" + if identifiant is None: + identifiant = await self.bluesky_rss.get_username_by_url(link) + if identifiant is not None: + feed_type = "bluesky" + display_type = "bluesky" if identifiant is not None and not link.startswith("https://"): link = "https://"+link if identifiant is None and link.startswith("https"): @@ -1132,6 +1158,8 @@ async def check_rss_url(self, url: str): return True if self.deviant_rss.is_deviantart_url(url): return True + if self.bluesky_rss.is_bluesky_url(url): + return True # check web feed feed = await feed_parse(url, 8) if feed is None: @@ -1153,6 +1181,8 @@ async def create_id(self, feed_type: FeedType): numb = int("50"+numb) elif feed_type == "twitch": numb = int("60"+numb) + elif feed_type == "bluesky": + numb = int("70"+numb) else: numb = int("66"+numb) return numb @@ -1339,6 +1369,11 @@ async def check_feed(self, feed: FeedObject, session: ClientSession = None, shou objs = await self.twitch_rss.get_last_post(chan, feed.link, feed.filter_config, session) else: objs = await self.twitch_rss.get_new_posts(chan, feed.link, feed.date, feed.filter_config, session) + elif feed.type == "bluesky": + if feed.date is None: + objs = await self.bluesky_rss.get_last_post(chan, feed.link, feed.filter_config, session) + else: + objs = await self.bluesky_rss.get_new_posts(chan, feed.link, feed.date, feed.filter_config, session) else: self.bot.dispatch("error", RuntimeError(f"Unknown feed type {feed.type}")) return False diff --git a/modules/rss/src/rss_bluesky.py b/modules/rss/src/rss_bluesky.py new file mode 100644 index 000000000..ca955649d --- /dev/null +++ b/modules/rss/src/rss_bluesky.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import datetime as dt +import re +from typing import TYPE_CHECKING + +import aiohttp +import discord +from feedparser.util import FeedParserDict + +from .convert_post_to_text import get_text_from_entry +from .rss_general import (FeedFilterConfig, FeedObject, RssMessage, + check_filter, feed_parse) + +if TYPE_CHECKING: + from core.bot_classes import Axobot + +class BlueskyRSS: + "Utilities class for any Bluesky RSS action" + + def __init__(self, bot: Axobot): + self.bot = bot + self.min_time_between_posts = 60 # seconds + self.url_pattern = r"^https://(?:www\.)?bsky.app/profile/([\w._:-]+)$" + + def is_bluesky_url(self, string: str): + "Check if an url is a valid Bluesky URL" + matches = re.match(self.url_pattern, string) + return bool(matches) + + async def get_username_by_url(self, url: str) -> str | None: + "Extract the Bluesky username from a URL" + matches = re.match(self.url_pattern, url) + if not matches: + return None + return matches.group(1) + + async def _get_feed(self, username: str, filter_config: FeedFilterConfig | None=None, + session: aiohttp.ClientSession | None=None) -> FeedParserDict: + "Get a list of feeds from a Bluesky username" + url = f"https://bsky.app/profile/{username}/rss" + feed = await feed_parse(url, 9, session) + if feed is None or "bozo_exception" in feed or not feed.entries: + return None + if filter_config is not None: + feed.entries = [entry for entry in feed.entries[:50] if await check_filter(entry, filter_config)] + return feed + + async def _parse_entry(self, entry: FeedParserDict, feed: FeedParserDict, url: str, channel: discord.TextChannel): + "Parse a feed entry to get the relevant information and return a RssMessage object" + full_author = feed["feed"]["title"] + author = re.search(r"^@[\w._-]+ - (\S+)$", full_author).group(1) + post_text = await get_text_from_entry(entry) + return RssMessage( + bot=self.bot, + feed=FeedObject.unrecorded("bluesky", channel.guild.id if channel.guild else None, link=url), + url=entry["link"], + date=entry["published_parsed"], + entry_id=entry["id"], + title=post_text, + author=author, + channel=full_author, + post_text=post_text + ) + + async def get_last_post(self, channel: discord.TextChannel, username: str, + filter_config: FeedFilterConfig | None, + session: aiohttp.ClientSession | None=None) -> RssMessage | str: + "Get the last post from a Bluesky user" + feed = await self._get_feed(username, filter_config, session) + if feed is None or not feed.entries: + return await self.bot._(channel.guild, "rss.nothing") + entry = feed.entries[0] + url = f"https://bsky.app/profile/{username}/rss" + return await self._parse_entry(entry, feed, url, channel) + + async def get_new_posts(self, channel: discord.TextChannel, username: str, date: dt.datetime, + filter_config: FeedFilterConfig | None, + session: aiohttp.ClientSession | None=None) -> list[RssMessage]: + "Get all new posts from a Bluesky user" + feed = await self._get_feed(username, filter_config, session) + if feed is None or not feed.entries: + return [] + posts_list: list[RssMessage] = [] + url = f"https://bsky.app/profile/{username}/rss" + for entry in feed.entries: + # don't return more than 10 posts + if len(posts_list) > 10: + break + # don't return posts older than the date + if (dt.datetime(*entry["published_parsed"][:6], tzinfo=dt.UTC) - date).total_seconds() < self.min_time_between_posts: + break + posts_list.append(await self._parse_entry(entry, feed, url, channel)) + posts_list.reverse() + return posts_list diff --git a/modules/rss/src/rss_deviantart.py b/modules/rss/src/rss_deviantart.py index a0deefff7..30ded6eae 100644 --- a/modules/rss/src/rss_deviantart.py +++ b/modules/rss/src/rss_deviantart.py @@ -61,7 +61,7 @@ async def _parse_entry(self, entry: FeedParserDict, feed: FeedParserDict, url: s async def get_last_post(self, channel: discord.TextChannel, username: str, filter_config: FeedFilterConfig | None, - session: aiohttp.ClientSession | None= None): + session: aiohttp.ClientSession | None = None): "Get the last post from a DeviantArt user" feed = await self._get_feed(username, filter_config, session) if feed is None: @@ -80,7 +80,7 @@ async def get_new_posts(self, channel: discord.TextChannel, username: str, date: posts_list: list[RssMessage] = [] url = "https://www.deviantart.com/" + username for entry in feed.entries: - if dt.datetime(*entry["published_parsed"][:6], tzinfo=dt.UTC) <= date: + if (dt.datetime(*entry["published_parsed"][:6], tzinfo=dt.UTC) - date).total_seconds() <= self.min_time_between_posts: break obj = await self._parse_entry(entry, feed, url, channel) posts_list.append(obj) diff --git a/modules/rss/src/rss_general.py b/modules/rss/src/rss_general.py index cda590e29..4649dbd0b 100644 --- a/modules/rss/src/rss_general.py +++ b/modules/rss/src/rss_general.py @@ -15,7 +15,7 @@ from core.formatutils import FormatUtils from core.safedict import SafeDict -FeedType = Literal["tw", "yt", "twitch", "reddit", "mc", "deviant", "web"] +FeedType = Literal["tw", "yt", "twitch", "reddit", "mc", "deviant", "bluesky", "web"] if TYPE_CHECKING: from core.bot_classes import Axobot @@ -386,6 +386,8 @@ def get_emoji(self, cog: "EmojisManager") -> discord.Emoji | str: return cog.get_emoji("minecraft") if self.type == "deviant": return cog.get_emoji("deviant") + if self.type == "bluesky": + return cog.get_emoji("bluesky") if self.link is not None: if self.link.startswith("https://github.com/"): return cog.get_emoji("github") diff --git a/modules/serverconfig/serverconfig.py b/modules/serverconfig/serverconfig.py index cc6a12b08..5b5cb2b9c 100644 --- a/modules/serverconfig/serverconfig.py +++ b/modules/serverconfig/serverconfig.py @@ -242,6 +242,28 @@ async def db_get_guilds_with_value(self, option_name: str, value: str) -> list[i async with self.bot.db_main.read(query, (option_name, value, self.bot.beta)) as query_results: return [row["guild_id"] for row in query_results] + async def db_get_guilds_with_option(self, option_name: str): + "Get a list of guilds with a specific option" + if option_name not in (await self.get_options_list()): + raise ValueError(f"Option {option_name} does not exist") + if not self.bot.database_online: + raise RuntimeError("Database is offline") + query = "SELECT `guild_id`, `value` FROM `serverconfig` WHERE `option_name` = %s AND `beta` = %s" + result: dict[int, Any] = {} + async with self.bot.db_main.read(query, (option_name, self.bot.beta)) as query_results: + for row in query_results: + guild_id, raw_value = row["guild_id"], row["value"] + if self.enable_caching and (cache_value := self.cache.get((guild_id, option_name), None)): + result[guild_id] = cache_value + elif guild := self.bot.get_guild(guild_id): + value = await from_raw(option_name, raw_value, guild, self.bot) + if value is None: + continue + if self.enable_caching: + self.cache[(guild_id, option_name)] = value + result[guild_id] = value + return result + async def db_get_guild(self, guild_id: int) -> dict[str, str] | None: "Get a guild from the database" if not self.bot.database_online: diff --git a/modules/tictactoe/tictactoe.py b/modules/tictactoe/tictactoe.py index d5fe127eb..de92e57d3 100644 --- a/modules/tictactoe/tictactoe.py +++ b/modules/tictactoe/tictactoe.py @@ -32,8 +32,8 @@ async def main(self, interaction: discord.Interaction): await interaction.response.defer() self.in_game[interaction.user.id] = time.time() game = self.Game(interaction, self) - await game.init_game() u_begin = await self.bot._(interaction, "tictactoe.user-begin" if game.is_user_turn else "tictactoe.bot-begin") + await game.init_game() tip = await self.bot._(interaction, "tictactoe.tip", symb1=game.emojis[0], symb2=game.emojis[1]) await interaction.edit_original_response(content=u_begin.format(interaction.user.mention) + tip, view=game) await game.wait() @@ -43,7 +43,7 @@ class Game(discord.ui.View): "An actual tictactoe game running" def __init__(self, interaction: discord.Interaction, cog: "TicTacToe"): - super().__init__(timeout=60) + super().__init__(timeout=120) self.cog = cog self.interaction = interaction self.bot = cog.bot @@ -132,7 +132,7 @@ async def on_click(self, interaction: discord.Interaction): await interaction.response.defer() case_id = int(interaction.data["custom_id"].split("_")[1]) if not await self.test_valid_cell(case_id): - self.bot.dispatch("error", ValueError("Invalid cell"), "During a tictactoe game") + self.bot.dispatch("error", ValueError(f"Invalid cell: {case_id}"), "During a tictactoe game") return self.grid = await self.replace_cell(self.is_user_turn, case_id) self.interaction = interaction @@ -143,24 +143,31 @@ async def on_click(self, interaction: discord.Interaction): async def bot_turn(self): "Make the bot play its turn" - # Prepare the fallback answer as a random empty cell - case_id = random.choice([i for i, x in enumerate(self.grid) if isinstance(x, int)]) - # Check if the user is about to win, or if the bot can win - for k in range(0, 9): - if await self.test_valid_cell(k): - for i in [True, False]: - grid_copy = await self.replace_cell(i, k) - if await self.test_victory(grid_copy): - case_id = k - break + chosen_cell = await self._find_optimal_cell() + if chosen_cell is None: + # Fallback to a random empty cell + chosen_cell = random.choice([i for i, x in enumerate(self.grid) if isinstance(x, int)]) # Update the game state - self.grid = await self.replace_cell(self.is_user_turn, case_id) + self.grid = await self.replace_cell(self.is_user_turn, chosen_cell) if await self.check_game_end(): return self.is_user_turn = True # Edit the message await self.update_grid() + async def _find_optimal_cell(self): + possible_cells: list[int] = [] + # Check if the user is about to win, or if the bot can win + for cell in range(0, 9): + if await self.test_valid_cell(cell): + for is_user in [True, False]: + grid_copy = await self.replace_cell(is_user, cell) + if await self.test_victory(grid_copy): + possible_cells.append(cell) + if possible_cells: + return random.choice(possible_cells) + return None + async def check_game_end(self): "Check if anyone won the game, or if no cell is empty" if await self.test_victory(self.grid): # someone won @@ -176,28 +183,15 @@ async def check_game_end(self): return False self.stop() await self.update_grid(result) - if not is_draw and self.is_user_turn: - # give event points if user won - await self.cog.give_event_points(self.interaction, self.interaction.user, 8) + if not is_draw: + if self.is_user_turn: + self.bot.dispatch("tictactoe_win", self.interaction) + else: + self.bot.dispatch("tictactoe_lose", self.interaction) return True - async def give_event_points(self, interaction: discord.Interaction, user: discord.User, points: int): - "Give points to a user and check if they had unlocked a card" - if cog := self.bot.get_cog("BotEvents"): - if not cog.current_event: - return - # send win reward embed - emb = discord.Embed( - title=await self.bot._(interaction, "bot_events.tictactoe.reward-title"), - description=await self.bot._(interaction, "bot_events.tictactoe.reward-desc", points=points), - color=cog.current_event_data["color"], - ) - emb.set_author(name=user.global_name, icon_url=user.display_avatar) - await interaction.followup.send(embed=emb) - # send card unlocked notif - await cog.check_and_send_card_unlocked_notif(interaction, user) - # give points - await cog.db_add_user_points(user.id, points) + async def on_error(self, interaction, error, item, /): + await self.bot.dispatch("error", error, interaction) async def setup(bot): diff --git a/modules/xp/cards/cards_metadata.py b/modules/xp/cards/cards_metadata.py index 2c6f881df..c9911c54e 100644 --- a/modules/xp/cards/cards_metadata.py +++ b/modules/xp/cards/cards_metadata.py @@ -39,6 +39,7 @@ "christmas23", "april24", "halloween24", + "christmas24", } JSON_DATA_FILE = os.path.dirname(__file__) + "/cards_data.json"