diff --git a/docs/roles-reactions.rst b/docs/roles-reactions.rst index 49e10e21d..897287548 100644 --- a/docs/roles-reactions.rst +++ b/docs/roles-reactions.rst @@ -60,4 +60,4 @@ The second argument 'changeDescription' can be used when you don't want Axobot t You can also use the third argument, a list of emojis, if you want your embed to contain only specific roles/emojis. Thus you can create different roles-reactions embeds with the same system. -.. note:: Note that there are two criteria for the bot to recognize the embed as its own: it must be sent by itself, and the footer text must be the same as in the official embeds. This means that you can use the `embed` command to send a custom embed, it will still work. +.. note:: Note that there are two criteria for the bot to recognize the embed as its own: it must be sent by itself, and the footer text must be the same as in the official embeds (i.e. :code:`Axobot roles reactions`). This means that you can use the `embed` command to send a custom embed, it will still work. diff --git a/events-list.json b/events-list.json index c06abebad..61636ec4d 100644 --- a/events-list.json +++ b/events-list.json @@ -202,5 +202,57 @@ ], "probability": 0.05 } + }, + "christmas-2023": { + "begin": "2023-11-01", + "end": "2023-12-31", + "type": "christmas", + "icon": "https://zrunner.me/cdn/halloween_2023.png", + "color": 4886759, + "objectives": [ + { + "points": 300, + "reward_type": "role", + "role_id": 1159531747877330994 + }, + { + "points": 600, + "reward_type": "rankcard", + "rank_card": "christmas23", + "min_date": "2023-10-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.91 + } } } \ No newline at end of file diff --git a/fcts/bot_events.py b/fcts/bot_events.py index ea36ffdbc..4bab018b4 100644 --- a/fcts/bot_events.py +++ b/fcts/bot_events.py @@ -1,20 +1,15 @@ -from collections import defaultdict import datetime import json -import random -from random import randint, choices, lognormvariate -from typing import Any, Literal, Optional, Union +from typing import Any, Generator, Literal, Optional, Union import discord from discord.ext import commands, tasks from libs.bot_classes import SUPPORT_GUILD_ID, Axobot, MyContext -from libs.bot_events import (EventData, EventRewardRole, EventType, - get_events_translations, EventItem, EventItemWithCount) +from libs.bot_events import (AbstractSubcog, ChristmasSubcog, EventData, + EventRewardRole, EventType) from libs.checks.checks import database_connected from libs.formatutils import FormatUtils -from libs.tips import generate_random_tip -from utils import OUTAGE_REASON class BotEvents(commands.Cog): @@ -23,11 +18,6 @@ class BotEvents(commands.Cog): def __init__(self, bot: Axobot): self.bot = bot self.file = "bot_events" - self.collect_reward = [-8, 25] - self.collect_cooldown = 60*60 # (1h) time in seconds between 2 collects - self.collect_max_strike_period = 3600 * 2 # (2h) time in seconds after which the strike level is reset to 0 - self.collect_bonus_per_strike = 1.05 # the amount of points is multiplied by this number for each strike level - self.translations_data = get_events_translations() self.current_event: Optional[EventType] = None self.current_event_data: EventData = {} @@ -38,6 +28,17 @@ def __init__(self, bot: Axobot): self.coming_event_id: Optional[str] = None self.update_current_event() + self._subcog: AbstractSubcog = ChristmasSubcog( + self.bot, self.current_event, self.current_event_data, self.current_event_id) + + @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.bot.log.info("[BotEvents] Updating subcog with new data") + self._subcog = ChristmasSubcog(self.bot, self.current_event, self.current_event_data, self.current_event_id) + return self._subcog + async def cog_load(self): if self.bot.internal_loop_enabled: self._update_event_loop.start() # pylint: disable=no-member @@ -88,15 +89,7 @@ async def get_specific_objectives(self, reward_type: Literal["rankcard", "role", objective for objective in self.current_event_data["objectives"] if objective["reward_type"] == reward_type - ] - - async def _is_fun_enabled(self, message: discord.Message): - "Check if fun is enabled in a given context" - if message.guild is None: - return True - if not self.bot.database_online and not message.author.guild_permissions.manage_guild: - return False - return await self.bot.get_config(message.guild.id, "enable_fun") + ] @tasks.loop(time=[ datetime.time(hour=0, tzinfo=datetime.timezone.utc), @@ -125,13 +118,16 @@ async def on_message(self, msg: discord.Message): if msg.guild and await self.bot.check_axobot_presence(guild=msg.guild): # If axobot is already there, don't do anything return - if self.current_event and (data := self.current_event_data.get("emojis")): - if not await self._is_fun_enabled(msg): - # don't react if fun is disabled for this guild - return - if random.random() < data["probability"] and any(trigger in msg.content for trigger in data["triggers"]): - react = random.choice(data["reactions_list"]) - await msg.add_reaction(react) + if self.current_event: + await self.subcog.on_message(msg) + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + "Give points to the user if they reacted to a message with the right emoji" + if self.bot.zombie_mode: + return + if self.current_event: + await self.subcog.on_raw_reaction_add(payload) @commands.hybrid_group("event", aliases=["botevents", "botevent", "events"]) @commands.check(database_connected) @@ -147,13 +143,13 @@ async def event_info(self, ctx: MyContext): current_event = self.current_event_id lang = await self.bot._(ctx.channel, '_used_locale') lang = 'en' if lang not in ('en', 'fr') else lang - events_desc = self.translations_data[lang]["events_desc"] + events_desc = self.subcog.translations_data[lang]["events_desc"] if current_event in events_desc: event_desc = events_desc[current_event] # Title try: - title = self.translations_data[lang]["events_title"][current_event] + title = self.subcog.translations_data[lang]["events_title"][current_event] except KeyError: title = self.current_event # Begin/End dates @@ -172,14 +168,11 @@ async def event_info(self, ctx: MyContext): value=end ) # Prices to win - prices = self.translations_data[lang]["events_prices"] - if current_event in prices: - points = await self.bot._(ctx.channel, "bot_events.points") - prices = [f"- **{k} {points}:** {v}" for k, - v in prices[current_event].items()] + prices_translations = self.subcog.translations_data[lang]["events_prices"] + if current_event in prices_translations: emb.add_field( name=await self.bot._(ctx.channel, "bot_events.events-price-title"), - value="\n".join(prices), + value=await self.get_info_prices_field(ctx.channel), inline=False ) await ctx.send(embed=emb) @@ -198,269 +191,98 @@ async def event_info(self, ctx: MyContext): if current_event: self.bot.dispatch("error", ValueError(f"'{current_event}' has no event description"), ctx) + async def get_info_prices_field(self, channel): + "Get the prices field text for the current event" + prices_translations = self.subcog.translations_data["en"]["events_prices"] + if self.current_event_id not in prices_translations: + return await self.bot._(channel, "bot_events.nothing-desc") + points = await self.bot._(channel, "bot_events.points") + prices: list[str] = [] + for required_points, description in prices_translations[self.current_event_id].items(): + related_objective = [ + objective + for objective in self.current_event_data["objectives"] + if str(objective["points"]) == required_points + ] + if related_objective and (min_date := related_objective[0].get("min_date")): + parsed_date = datetime.datetime.strptime(min_date, "%Y-%m-%d").replace(tzinfo=datetime.timezone.utc) + format_date = await FormatUtils.date(parsed_date, hour=False, seconds=False) + description += f" ({await self.bot._(channel, 'bot_events.available-starting', date=format_date)})" + prices.append(f"- **{required_points} {points}:** {description}") + return "\n".join(prices) + @events_main.command(name="profile") @commands.check(database_connected) async def event_profile(self, ctx: MyContext, user: discord.User = None): """Take a look at your progress in the event and your global ranking Event points are reset after each event""" - lang = await self.bot._(ctx.channel, '_used_locale') - lang = 'en' if lang not in ('en', 'fr') else lang - events_desc = self.translations_data[lang]["events_desc"] - - # if no event - if not self.current_event_id in events_desc: - await ctx.send(await self.bot._(ctx.channel, "bot_events.nothing-desc")) - if self.current_event_id: - self.bot.dispatch("error", ValueError(f"'{self.current_event_id}' has no event description"), ctx) - return - # if current event has no objectives - if not self.current_event_data["objectives"]: - cmd_mention = await self.bot.get_command_mention("event info") - await ctx.send(await self.bot._(ctx.channel, "bot_events.no-objectives", cmd=cmd_mention)) - return - - await ctx.defer() - - title = await self.bot._(ctx.channel, "bot_events.rank-title") - desc = await self.bot._(ctx.channel, "bot_events.xp-howto") - - if not self.bot.database_online: - lang = await self.bot._(ctx.channel, '_used_locale') - reason = OUTAGE_REASON.get(lang, OUTAGE_REASON['en']) - emb = discord.Embed(title=title, description=desc, color=self.current_event_data["color"]) - emb.add_field(name="OUTAGE", value=reason) - await ctx.send(embed=emb) - return - if user is None: user = ctx.author - - emb = discord.Embed(title=title, description=desc, color=self.current_event_data["color"]) - user: discord.User - emb.set_author(name=user, icon_url=user.display_avatar.replace(static_format="png", size=32)) - for field in await self.generate_user_profile_rank_fields(ctx, lang, user): - emb.add_field(**field) - emb.add_field(**await self.generate_user_profile_collection_field(ctx, user)) - await ctx.send(embed=emb) - - async def generate_user_profile_rank_fields(self, ctx: MyContext, lang: Literal["fr", "en"], user: discord.User): - "Compute the texts to display in the /event profile command" - user_rank_query = await self.db_get_event_rank(user.id) - if user_rank_query is None: - user_rank = await self.bot._(ctx.channel, "bot_events.unclassed") - points = 0 - else: - total_ranked = await self.db_get_participants_count() - if user_rank_query['rank'] <= total_ranked: - user_rank = f"{user_rank_query['rank']}/{total_ranked}" - else: - user_rank = await self.bot._(ctx.channel, "bot_events.unclassed") - points: int = user_rank_query["points"] - prices: dict[str, dict[str, str]] = self.translations_data[lang]["events_prices"] - if self.current_event_id in prices: - emojis = self.bot.emojis_manager.customs["green_check"], self.bot.emojis_manager.customs["red_cross"] - prices_list = [] - for price, desc in prices[self.current_event_id].items(): - emoji = emojis[0] if int(price) <= points else emojis[1] - prices_list.append(f"{emoji}{min(points, int(price))}/{price}: {desc}") - prices = "\n".join(prices_list) - objectives_title = await self.bot._(ctx.channel, "bot_events.objectives") - else: - prices = "" - objectives_title = "" - - _points_total = await self.bot._(ctx.channel, "bot_events.points-total") - _position_global = await self.bot._(ctx.channel, "bot_events.position-global") - _rank_global = await self.bot._(ctx.channel, "bot_events.leaderboard-global", count=5) - - fields: list[dict[str, Any]] = [ - {"name": objectives_title, "value": prices, "inline": False}, - {"name": _points_total, "value": str(points)}, - {"name": _position_global, "value": user_rank}, - ] - if top_5 := await self.get_top_5(): - fields.append({"name": _rank_global, "value": top_5, "inline": False}) - return fields - - async def generate_user_profile_collection_field(self, ctx: MyContext, user: discord.User): - "Compute the texts to display in the /event profile command" - if ctx.author == user: - title = await self.bot._(ctx.channel, "bot_events.collection-title.user") - else: - title = await self.bot._(ctx.channel, "bot_events.collection-title.other", user=user.display_name) - items = await self.db_get_user_collected_items(user.id) - if len(items) == 0: - if ctx.author == user: - _empty_collection = await self.bot._(ctx.channel, "bot_events.collection-empty.user") - else: - _empty_collection = await self.bot._(ctx.channel, "bot_events.collection-empty.other", user=user.display_name) - return {"name": title, "value": _empty_collection, "inline": True} - lang = await self.bot._(ctx.channel, '_used_locale') - name_key = "french_name" if lang in ("fr", "fr2") else "english_name" - items.sort(key=lambda item: item["frequency"], reverse=True) - items_list: list[str] = [] - more_count = 0 - for item in items: - if len(items_list) >= 32: - more_count += item['count'] - continue - item_name = item["emoji"] + " " + item[name_key] - items_list.append(f"{item_name} x{item['count']}") - if more_count: - items_list.append(await self.bot._(ctx.channel, "bot_events.collection-more", count=more_count)) - return {"name": title, "value": "\n".join(items_list), "inline": True} + await self.subcog.profile_cmd(ctx, user) @events_main.command(name="collect") @commands.check(database_connected) @commands.cooldown(3, 60, commands.BucketType.user) async def event_collect(self, ctx: MyContext): "Get some event points every hour" - current_event = self.current_event_id - lang = await self.bot._(ctx.channel, '_used_locale') - lang = 'en' if lang not in ('en', 'fr') else lang - events_desc = self.translations_data[lang]["events_desc"] - # if no event - if not current_event in events_desc: - await ctx.send(await self.bot._(ctx.channel, "bot_events.nothing-desc")) - if current_event: - self.bot.dispatch("error", ValueError(f"'{current_event}' has no event description"), ctx) + await self.subcog.collect_cmd(ctx) + + async def get_user_unlockable_rankcards(self, user: discord.User, points: Optional[int]=None) -> Generator[str, Any, None]: + "Get a list of event rank cards that the user can unlock" + if (users_cog := self.bot.get_cog("Users")) is None: return - # if current event has no objectives - if not self.current_event_data["objectives"]: - cmd_mention = await self.bot.get_command_mention("event info") - await ctx.send(await self.bot._(ctx.channel, "bot_events.no-objectives", cmd=cmd_mention)) + if self.current_event is None or len(rewards := await self.get_specific_objectives("rankcard")) == 0: return - await ctx.defer() - - # check last collect from this user - seconds_since_last_collect = await self.db_get_seconds_since_last_collect(ctx.author.id) - can_collect, is_strike = await self.check_user_collect_availability(ctx.author.id, seconds_since_last_collect) - if not can_collect: - # cooldown error - time_remaining = self.collect_cooldown - seconds_since_last_collect - remaining = await FormatUtils.time_delta(time_remaining, lang=lang) - txt = await self.bot._(ctx.channel, "bot_events.collect.too-quick", time=remaining) - else: - # grant points - items = await self.get_random_items() - strike_level = (await self.db_get_user_strike_level(ctx.author.id) + 1) if is_strike else 0 - if len(items) == 0: - points = randint(*self.collect_reward) - bonus = 0 - else: - points = sum(item["points"] for item in items) - bonus = max(0, await self.adjust_points_to_strike(points, strike_level) - points) - await self.db_add_user_items(ctx.author.id, [item["item_id"] for item in items]) - txt = await self.generate_collect_message(ctx.channel, items, points + bonus) - if strike_level and bonus != 0: - txt += f"\n\n{await self.bot._(ctx.channel, 'bot_events.collect.strike-bonus', bonus=bonus, level=strike_level+1)}" - await self.db_add_collect(ctx.author.id, points + bonus, increase_strike=is_strike) - # send result - if ctx.can_send_embed: - title = self.translations_data[lang]["events_title"][current_event] - emb = discord.Embed(title=title, description=txt, color=self.current_event_data["color"]) - emb.add_field(**await self.get_random_tip_field(ctx.channel)) - await ctx.send(embed=emb) - else: - await ctx.send(txt) - - async def generate_collect_message(self, channel, items: list[EventItem], points: int): - "Generate the message to send after a /collect command" - items_count = len(items) - # no item collected - if items_count == 0: - if points < 0: - return await self.bot._(channel, "bot_events.collect.lost-points", points=-points) - if points == 0: - return await self.bot._(channel, "bot_events.collect.nothing") - return await self.bot._(channel, "bot_events.collect.got-points", points=points) - language = await self.bot._(channel, "_used_locale") - name_key = "french_name" if language in ("fr", "fr2") else "english_name" - # 1 item collected - if items_count == 1: - item_name = items[0]["emoji"] + " " + items[0][name_key] - return await self.bot._(channel, "bot_events.collect.got-items", count=1, item=item_name, points=points) - # more than 1 item - f_points = str(points) if points <= 0 else "+" + str(points) - text = await self.bot._(channel, "bot_events.collect.got-items", count=items_count, points=f_points) - items_group: dict[int, int] = defaultdict(int) - for item in items: - items_group[item["item_id"]] += 1 - for item_id, count in items_group.items(): - item = next(item for item in items if item["item_id"] == item_id) - item_name = item["emoji"] + " " + item[name_key] - item_points = ('+' if item["points"] >= 0 else '') + str(item["points"] * count) - text += f"\n**{item_name}** x{count} ({item_points} points)" - return text - - async def adjust_points_to_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) - - async def get_random_items(self) -> list[EventItem]: - "Get some random items to win during an event" - if self.current_event is None: - return [] - items_count = min(round(lognormvariate(1.1, 0.9)), 8) # random number between 0 and 8 - if items_count <= 0: - return [] - items = await self.db_get_event_items(self.current_event) - if len(items) == 0: - return [] - return choices( - items, - weights=[item["frequency"] for item in items], - k=items_count - ) - - async def get_random_tip_field(self, channel): - return { - "name": await self.bot._(channel, "bot_events.tip-title"), - "value": await generate_random_tip(self.bot, channel), - "inline": False - } - - async def get_top_5(self) -> str: - "Get the list of the 5 users with the most event points" - top_5 = await self.db_get_event_top(number=5) - if top_5 is None: - return await self.bot._(self.bot.get_channel(0), "bot_events.nothing-desc") - top_5_f: list[str] = [] - for i, row in enumerate(top_5): - if user := self.bot.get_user(row['user_id']): - username = user.display_name - elif user := await self.bot.fetch_user(row['user_id']): - username = user.display_name - else: - username = f"user {row['user_id']}" - top_5_f.append(f"{i+1}. {username} ({row['points']} points)") - return "\n".join(top_5_f) - - async def reload_event_rankcard(self, user: Union[discord.User, int], points: int = None): + cards = await users_cog.get_rankcards(user) + if points is None: + points = await self.subcog.db_get_event_rank(user.id) + points = 0 if (points is None) else points["points"] + for reward in rewards: + if reward["rank_card"] not in cards and points >= reward["points"]: + if min_date := reward.get("min_date"): + parsed_date = datetime.datetime.strptime(min_date, "%Y-%m-%d").replace(tzinfo=datetime.timezone.utc) + if self.bot.utcnow() < parsed_date: + continue + yield reward["rank_card"] + + async def check_and_send_card_unlocked_notif(self, channel, user: Union[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) + if user is None: + return + cards = [card async for card in self.get_user_unlockable_rankcards(user)] + if cards: + title = await self.bot._(channel, "bot_events.rankcard-unlocked.title") + profile_card_cmd = await self.bot.get_command_mention("profile card") + desc = await self.bot._(channel, "bot_events.rankcard-unlocked.desc", + cards=", ".join(cards), + profile_card_cmd=profile_card_cmd, + count=len(cards) + ) + emb = discord.Embed(title=title, description=desc, color=discord.Color.brand_green()) + emb.set_author(name=user.global_name, icon_url=user.display_avatar) + await channel.send(embed=emb) + + async def reload_event_rankcard(self, user: Union[discord.User, int], points: Optional[int] = None): """Grant the current event rank card to the provided user, if they have enough points 'points' argument can be provided to avoid re-fetching the database""" if (users_cog := self.bot.get_cog("Users")) is None: return - if self.current_event is None or len(rewards := await self.get_specific_objectives("rankcard")) == 0: + if self.current_event is None: return if isinstance(user, int): user = self.bot.get_user(user) if user is None: return - cards = await users_cog.get_rankcards(user) - if points is None: - points = await self.db_get_event_rank(user.id) - points = 0 if (points is None) else points["points"] - for reward in rewards: - if reward["rank_card"] not in cards and points >= reward["points"]: - await users_cog.set_rankcard(user, reward["rank_card"], True) - # send internal log - embed = discord.Embed( - description=f"{user} ({user.id}) has been granted the rank card **{reward['rank_card']}**", - color=discord.Color.brand_green() - ) - await self.bot.send_embed(embed) + async for card in self.get_user_unlockable_rankcards(user, points): + await users_cog.set_rankcard(user, card, True) + # send internal log + embed = discord.Embed( + description=f"{user} ({user.id}) has been granted the rank card **{card}**", + color=discord.Color.brand_green() + ) + await self.bot.send_embed(embed) async def reload_event_special_role(self, user: Union[discord.User, int], points: int = None): """Grant the current event special role to the provided user, if they have enough points @@ -474,105 +296,16 @@ async def reload_event_special_role(self, user: Union[discord.User, int], points if (member := support_guild.get_member(user_id)) is None: return if points is None: - points = await self.db_get_event_rank(user_id) + points = await self.subcog.db_get_event_rank(user_id) points = 0 if (points is None) else points["points"] for reward in rewards: if points >= reward["points"]: + if min_date := reward.get("min_date"): + parsed_date = datetime.datetime.strptime(min_date, "%Y-%m-%d").replace(tzinfo=datetime.timezone.utc) + if self.bot.utcnow() < parsed_date: + continue await member.add_roles(discord.Object(reward["role_id"])) - - async def check_user_collect_availability(self, user_id: int, seconds_since_last_collect: Optional[int] = None): - "Check if a user can collect points, and if they are in a strike period" - if not self.bot.database_online or self.bot.current_event is None: - return False, False - if not seconds_since_last_collect: - seconds_since_last_collect = await self.db_get_seconds_since_last_collect(user_id) - if seconds_since_last_collect is None: - return True, False - if seconds_since_last_collect < self.collect_cooldown: - return False, False - if seconds_since_last_collect < self.collect_max_strike_period: - return True, True - return True, False - - async def db_add_collect(self, user_id: int, points: int, increase_strike: bool): - """Add collect points to a user - if increase_strike is True, the strike level will be increased by 1, else it will be reset to 0""" - try: - if not self.bot.database_online or self.bot.current_event is None: - return True - if increase_strike: - query = "INSERT INTO `event_points` (`user_id`, `collect_points`, `strike_level`, `beta`) VALUES (%s, %s, 1, %s) \ - ON DUPLICATE KEY UPDATE collect_points = collect_points + VALUE(`collect_points`), \ - strike_level = strike_level + 1, \ - last_collect = CURRENT_TIMESTAMP();" - else: - query = "INSERT INTO `event_points` (`user_id`, `collect_points`, `beta`) VALUES (%s, %s, %s) \ - ON DUPLICATE KEY UPDATE collect_points = collect_points + VALUE(`collect_points`), \ - strike_level = 0, \ - last_collect = CURRENT_TIMESTAMP();" - async with self.bot.db_query(query, (user_id, points, self.bot.beta)): - pass - try: - await self.reload_event_rankcard(user_id) - await self.reload_event_special_role(user_id) - except Exception as err: - self.bot.dispatch("error", err) - return True - except Exception as err: - self.bot.dispatch("error", err) - return False - - async def db_get_seconds_since_last_collect(self, user_id: int): - "Get the last collect datetime from a user" - if not self.bot.database_online: - return None - query = "SELECT `last_collect` FROM `event_points` WHERE `user_id` = %s AND `beta` = %s;" - async with self.bot.db_query(query, (user_id, self.bot.beta), fetchone=True, astuple=True) as query_result: - if not query_result: - return None - query_result: tuple[datetime.datetime] - # if no last collect, return a very high number - if query_result[0] is None: - return 1e9 - # apply utc offset - last_collect = query_result[0].replace(tzinfo=datetime.timezone.utc) - return (self.bot.utcnow() - last_collect).total_seconds() - - async def db_get_user_strike_level(self, user_id: int) -> int: - "Get the strike level of a user" - if not self.bot.database_online: - return 0 - query = "SELECT `strike_level` FROM `event_points` WHERE `user_id` = %s AND `beta` = %s;" - async with self.bot.db_query(query, (user_id, self.bot.beta), fetchone=True) as query_result: - return query_result["strike_level"] if query_result else 0 - - async def db_get_event_rank(self, user_id: int): - "Get the ranking of a user" - if not self.bot.database_online: - return None - query = "SELECT `user_id`, `points`, FIND_IN_SET( `points`, \ - ( SELECT GROUP_CONCAT( `points` ORDER BY `points` DESC ) FROM `event_points` WHERE `beta` = %(beta)s ) ) AS rank \ - FROM `event_points` WHERE `user_id` = %(user)s AND `beta` = %(beta)s" - async with self.bot.db_query(query, {'user': user_id, 'beta': self.bot.beta}, fetchone=True) as query_results: - return query_results or None - - async def db_get_event_top(self, number: int): - "Get the event points leaderboard containing at max the given number of users" - if not self.bot.database_online: - return None - query = "SELECT `user_id`, `points` FROM `event_points` WHERE `points` != 0 AND `beta` = %s ORDER BY `points` DESC LIMIT %s" - async with self.bot.db_query(query, (self.bot.beta, number)) as query_results: - return query_results - - async def db_get_participants_count(self) -> int: - "Get the number of users who have at least 1 event point" - if not self.bot.database_online: - return 0 - query = "SELECT COUNT(*) as count FROM `event_points` WHERE `points` > 0 AND `beta` = %s;" - async with self.bot.db_query(query, (self.bot.beta,), fetchone=True) as query_results: - return query_results['count'] - async def db_add_user_points(self, user_id: int, points: int): "Add some 'other' events points to a user" try: @@ -592,33 +325,6 @@ async def db_add_user_points(self, user_id: int, points: int): self.bot.dispatch("error", err) return False - async def db_get_event_items(self, event_type: EventType) -> list[EventItem]: - "Get the items to win during a specific event" - if not self.bot.database_online: - return [] - query = "SELECT * FROM `event_available_items` WHERE `event_type` = %s;" - async with self.bot.db_query(query, (event_type, )) as query_results: - return query_results - - async def db_add_user_items(self, user_id: int, items_ids: list[int]): - "Add some items to a user's collection" - if not self.bot.database_online: - return - query = "INSERT INTO `event_collected_items` (`user_id`, `item_id`, `beta`) VALUES " + \ - ", ".join(["(%s, %s, %s)"] * len(items_ids)) + ';' - async with self.bot.db_query(query, [arg for item_id in items_ids for arg in (user_id, item_id, self.bot.beta)]): - pass - - async def db_get_user_collected_items(self, user_id: int) -> list[EventItemWithCount]: - "Get the items collected by a user" - if not self.bot.database_online: - return [] - query = """SELECT COUNT(*) AS 'count', a.* - FROM `event_collected_items` c LEFT JOIN `event_available_items` a ON c.`item_id` = a.`item_id` - WHERE c.`user_id` = %s AND c.`beta` = %s - GROUP BY c.`item_id`""" - async with self.bot.db_query(query, (user_id, self.bot.beta)) as query_results: - return query_results async def setup(bot): await bot.add_cog(BotEvents(bot)) diff --git a/fcts/fun.py b/fcts/fun.py index 662553e3d..860b1fb27 100644 --- a/fcts/fun.py +++ b/fcts/fun.py @@ -114,12 +114,12 @@ async def roll(self, ctx: MyContext, *, options: str): ..Example roll Play Minecraft, play Star Citizens, do homeworks ..Doc fun.html#roll""" - liste = list(set([x for x in [x.strip() for x in options.split(',')] if len(x) > 0])) - if len(liste) == 0: + possibilities = list({x for x in [x.strip() for x in options.split(',')] if len(x) > 0}) + if len(possibilities) == 0: return await ctx.send(await self.bot._(ctx.channel,"fun.no-roll")) - elif len(liste) == 1: + elif len(possibilities) == 1: return await ctx.send(await self.bot._(ctx.channel,"fun.not-enough-roll")) - choosen = random.choice(liste) + choosen = random.choice(possibilities) await ctx.send(choosen) @commands.command(name="cookie", aliases=['cookies', 'crustulum'], hidden=True) @@ -261,14 +261,14 @@ async def blame(self, ctx: MyContext, name: str): if await self.is_on_guild(ctx.author, 523525264517496834): # Benny Support await ctx.send(file=await self.utilities.find_img(f'blame-{name}.png')) elif name in ['help','list']: - liste = l1 + available_names = l1 if await self.is_on_guild(ctx.author, 391968999098810388): # fr-minecraft - liste += l2 + available_names += l2 if await self.is_on_guild(ctx.author, SUPPORT_GUILD_ID.id): # Axobot server - liste += l3 + available_names += l3 if await self.is_on_guild(ctx.author, 523525264517496834): # Benny Support - liste += l4 - txt = "- "+"\n- ".join(sorted(liste)) + available_names += l4 + txt = "- "+"\n- ".join(sorted(available_names)) title = await self.bot._(ctx.channel, "fun.blame-0", user=ctx.author) if ctx.can_send_embed: emb = discord.Embed(title=title, description=txt, color=self.bot.get_cog("Help").help_color) @@ -291,17 +291,18 @@ async def kill(self, ctx: MyContext, *, name: typing.Optional[str]=None): victime = name ex = victime.replace(" ", "_") author = ctx.author.mention - liste = await self.bot._(ctx.channel, "fun.kills-list") - msg = random.choice(liste) + possibilities = await self.bot._(ctx.channel, "fun.kills-list") + msg = random.choice(possibilities) tries = 0 while '{attacker}' in msg and name is None and tries<50: - msg = random.choice(liste) + msg = random.choice(possibilities) tries += 1 await ctx.send(msg.format(attacker=author, victim=victime, ex=ex)) - @commands.command(name="arapproved",aliases=['arapprouved'],hidden=True) + @commands.command(name="arapproved",aliases=['arapprouved'], hidden=True) @commands.check(lambda ctx: ctx.author.id in [375598088850505728,279568324260528128]) async def arapproved(self, ctx: MyContext): + "If you don't know why this exists, it's probably not for you" await ctx.send(file=await self.utilities.find_img("arapproved.png")) @commands.command(name='party',hidden=True) @@ -324,6 +325,7 @@ async def party(self, ctx: MyContext): await ctx.send(file=await self.utilities.find_img('cameleon.gif')) @commands.command(name="cat", hidden=True) + @commands.cooldown(5, 7, commands.BucketType.user) @commands.check(is_fun_enabled) async def cat_gif(self, ctx: MyContext): """Wow... So cuuuute ! @@ -334,7 +336,21 @@ async def cat_gif(self, ctx: MyContext): 'https://25.media.tumblr.com/7774fd7794d99b5998318ebd5438ba21/tumblr_n2r7h35U211rudcwro1_400.gif', 'https://tenor.com/view/seriously-seriously-cat-cat-really-cat-really-look-cat-look-gif-22182662', 'http://coquelico.c.o.pic.centerblog.net/chat-peur.gif', - 'https://tenor.com/view/nope-bye-cat-leave-done-gif-12387359' + 'https://tenor.com/view/nope-bye-cat-leave-done-gif-12387359', + 'https://tenor.com/view/cute-cat-kitten-kitty-pussy-cat-gif-16577050', + 'https://tenor.com/view/cat-box-gif-18395469', + 'https://tenor.com/view/pile-cats-cute-silly-meowtain-gif-5791255', + 'https://tenor.com/view/cat-fight-cats-cat-love-pet-lover-pelea-gif-13002823369159732311', + 'https://tenor.com/view/cat-disapear-cat-snow-cat-jump-fail-cat-fun-jump-cats-gif-17569677', + 'https://tenor.com/view/black-cat-tiny-cat-smol-kitten-airplane-ears-cutie-pie-gif-23391953', + 'https://tenor.com/view/cat-cats-catsoftheinternet-biting-tale-cat-bite-gif-23554005', + 'https://tenor.com/view/on-my-way-cat-run-cat-on-my-way-cat-cat-on-my-way-gif-26471384', + 'https://tenor.com/view/cat-cat-activity-goober-goober-cat-silly-cat-gif-186256394908832033', + 'https://tenor.com/view/cat-stacked-kittens-kitty-pussy-cats-gif-16220908', + 'https://tenor.com/view/cute-cat-cats-cats-of-the-internet-cattitude-gif-17600906', + 'https://tenor.com/view/cat-scared-hide-terrified-frightened-gif-17023981', + 'https://tenor.com/view/cat-running-away-escape-getaway-bye-gif-16631286', + 'https://tenor.com/view/bye-cat-box-tight-face-bored-cat-gif-7986182' ])) @commands.command(name="happy-birthday", hidden=True, aliases=['birthday', 'hb']) diff --git a/fcts/info.py b/fcts/info.py index 1d6e634ca..3c16158fd 100644 --- a/fcts/info.py +++ b/fcts/info.py @@ -947,15 +947,15 @@ async def snowflake_info(self, interaction: discord.Interaction, snowflake: args async def find_user(self, interaction: discord.Interaction, user: discord.User): "Find any user visible by the bot" # Servers list - servers_in = list() + servers_in: list[str] = [] owned, membered = 0, 0 if hasattr(user, "mutual_guilds"): for s in user.mutual_guilds: if s.owner==user: - servers_in.append(":crown: "+s.name) + servers_in.append(f":crown: {s.name} ({s.id})") owned += 1 else: - servers_in.append("- "+s.name) + servers_in.append(f"- {s.name} ({s.id})") membered += 1 if len("\n".join(servers_in)) > 1020: servers_in = [f"{owned} owned servers, member of {membered} others"] @@ -978,7 +978,7 @@ async def find_user(self, interaction: discord.Interaction, user: discord.User): disp_lang = list() if hasattr(user, "mutual_guilds"): for lang in await self.bot.get_cog('Utilities').get_languages(user): - disp_lang.append('{} ({}%)'.format(lang[0], round(lang[1]*100))) + disp_lang.append(f"{lang[0]} ({lang[1]*100:.0f}%)") if len(disp_lang) == 0: disp_lang = ["Unknown"] # User name @@ -990,9 +990,9 @@ async def find_user(self, interaction: discord.Interaction, user: discord.User): user_name = user.name # XP sus xp_sus = "Unknown" - if Xp := self.bot.get_cog("Xp"): - if Xp.sus is not None: - xp_sus = str(user.id in Xp.sus) + if xp_cog := self.bot.get_cog("Xp"): + if xp_cog.sus is not None: + xp_sus = str(user.id in xp_cog.sus) # ---- if interaction.guild is None: color = None diff --git a/fcts/roles_react.py b/fcts/roles_react.py index 3b3c67f78..2324ddc55 100644 --- a/fcts/roles_react.py +++ b/fcts/roles_react.py @@ -1,6 +1,6 @@ import importlib import re -from typing import Any, Optional, Tuple, Union +from typing import Any, Tuple, Union import discord from discord.ext import commands @@ -14,6 +14,8 @@ class RolesReact(commands.Cog): + "Allow members to get new roles by clicking on reactions" + def __init__(self, bot: Axobot): self.bot = bot self.file = 'roles_react' @@ -21,13 +23,14 @@ def __init__(self, bot: Axobot): self.guilds_which_have_roles = set() self.cache_initialized = False self.embed_color = 12118406 - self.footer_txt = 'ZBot roles reactions' + self.footer_texts = ("Axobot roles reactions", "ZBot roles reactions") @commands.Cog.listener() async def on_ready(self): self.table = 'roles_react_beta' if self.bot.beta else 'roles_react' async def prepare_react(self, payload: discord.RawReactionActionEvent) -> Tuple[discord.Message, discord.Role]: + "Handle new added/removed reactions and check if they are roles reactions" if payload.guild_id is None or payload.user_id == self.bot.user.id: return None, None if not self.cache_initialized: @@ -42,11 +45,16 @@ async def prepare_react(self, payload: discord.RawReactionActionEvent) -> Tuple[ except discord.NotFound: # we don't care about those return None, 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}") + self.bot.log.warning( + f"Could not fetch roles-reactions message {payload.message_id} in guild {payload.guild_id}: {err}" + ) return None, None - if len(msg.embeds) == 0 or msg.embeds[0].footer.text != self.footer_txt: + if len(msg.embeds) == 0 or msg.embeds[0].footer.text not in self.footer_texts: return None, None - temp = await self.rr_list_role(payload.guild_id, payload.emoji.id if payload.emoji.is_custom_emoji() else payload.emoji.name) + temp = await self.rr_list_role( + payload.guild_id, + payload.emoji.id if payload.emoji.is_custom_emoji() else payload.emoji.name + ) if len(temp) == 0: return None, None role = self.bot.get_guild(payload.guild_id).get_role(temp[0]["role"]) @@ -79,7 +87,7 @@ async def on_raw_reaction_event(self, payload: discord.RawReactionActionEvent, i async def rr_get_guilds(self) -> set: """Get the list of guilds which have roles reactions""" - query = "SELECT `guild` FROM `{}`;".format(self.table) + query = f"SELECT `guild` FROM `{self.table}`;" async with self.bot.db_query(query) as query_results: self.guilds_which_have_roles = {x['guild'] for x in query_results} self.cache_initialized = True @@ -89,21 +97,21 @@ async def rr_add_role(self, guild: int, role: int, emoji: str, desc: str): """Add a role reaction in the database""" if isinstance(emoji, discord.Emoji): emoji = emoji.id - query = ("INSERT INTO `{}` (`guild`,`role`,`emoji`,`description`) VALUES (%(g)s,%(r)s,%(e)s,%(d)s);".format(self.table)) + query = f"INSERT INTO `{self.table}` (`guild`,`role`,`emoji`,`description`) VALUES (%(g)s,%(r)s,%(e)s,%(d)s);" async with self.bot.db_query(query, {'g': guild, 'r': role, 'e': emoji, 'd': desc}): pass return True - async def rr_list_role(self, guild: int, emoji: str = None): + async def rr_list_role(self, guild_id: int, emoji: str = None): """List role reaction in the database""" if isinstance(emoji, discord.Emoji): emoji = emoji.id if emoji is None: - query = "SELECT * FROM `{}` WHERE guild={} ORDER BY added_at;".format(self.table, guild) + query = f"SELECT * FROM `{self.table}` WHERE guild=%(g)s ORDER BY added_at;" else: - query = "SELECT * FROM `{}` WHERE guild={} AND emoji='{}' ORDER BY added_at;".format(self.table, guild, emoji) + query = f"SELECT * FROM `{self.table}` WHERE guild=%(g)s AND emoji=%(e)s ORDER BY added_at;" liste: list[dict[str, Any]] = [] - async with self.bot.db_query(query) as query_results: + async with self.bot.db_query(query, {"g": guild_id, "e": emoji}) as query_results: for row in query_results: if emoji is None or row['emoji'] == str(emoji): liste.append(row) @@ -111,8 +119,8 @@ async def rr_list_role(self, guild: int, emoji: str = None): async def rr_remove_role(self, rr_id: int): """Remove a role reaction from the database""" - query = ("DELETE FROM `{}` WHERE `ID`={};".format(self.table, rr_id)) - async with self.bot.db_query(query): + query = f"DELETE FROM `{self.table}` WHERE `ID`=%s;" + async with self.bot.db_query(query, (rr_id,)): pass return True @@ -246,7 +254,7 @@ async def rr_get(self, ctx: MyContext): des, emojis = await self.create_list_embed(roles_list, ctx.guild) title = await self.bot._(ctx.guild.id, "roles_react.rr-embed") emb = discord.Embed(title=title, description=des, color=self.embed_color, timestamp=ctx.message.created_at) - emb.set_footer(text=self.footer_txt) + emb.set_footer(text=self.footer_texts[0]) msg = await ctx.send(embed=emb) for emoji in emojis: try: @@ -319,11 +327,13 @@ async def give_remove_role(self, user: discord.Member, role: discord.Role, guild self.bot.dispatch("error", err) else: if not ignore_success: - await channel.send(await self.bot._(guild.id, "roles_react.role-given" if give else "roles_react.role-lost", r=role.name)) + await channel.send( + await self.bot._(guild.id, "roles_react.role-given" if give else "roles_react.role-lost", r=role.name) + ) @rr_main.command(name='update') @commands.check(checks.database_connected) - async def rr_update(self, ctx: MyContext, embed: discord.Message, change_description: Optional[bool] = True, + async def rr_update(self, ctx: MyContext, embed: discord.Message, change_description: bool = True, emojis: commands.Greedy[Union[discord.Emoji, args.UnicodeEmoji]] = None): """Update an Axobot message to refresh roles/reactions If you don't want to update the embed content (for example if it's a custom embed) then you can use 'False' as a second argument, and I will only check the reactions @@ -336,10 +346,10 @@ async def rr_update(self, ctx: MyContext, embed: discord.Message, change_descrip ..Doc roles-reactions.html#update-your-embed""" if embed.author != ctx.guild.me: return await ctx.send(await self.bot._(ctx.guild, "roles_react.not-zbot-msg")) - if len(embed.embeds) != 1 or embed.embeds[0].footer.text != self.footer_txt: + if len(embed.embeds) != 1 or embed.embeds[0].footer.text not in self.footer_texts: return await ctx.send(await self.bot._(ctx.guild, "roles_react.not-zbot-embed")) if not embed.channel.permissions_for(embed.guild.me).add_reactions: - return await ctx.send(await self.bot._(ctx.guild, 'fun', "cant-react")) + return await ctx.send(await self.bot._(ctx.guild, "poll.cant-react")) emb = embed.embeds[0] try: full_list: dict[str, dict[str, Any]] = {x['emoji']: x for x in await self.rr_list_role(ctx.guild.id)} diff --git a/fcts/morpions.py b/fcts/tictactoe.py similarity index 80% rename from fcts/morpions.py rename to fcts/tictactoe.py index 3b01ffe6f..e6223d9f2 100644 --- a/fcts/morpions.py +++ b/fcts/tictactoe.py @@ -11,12 +11,12 @@ from libs.serverconfig.options_list import options -class Morpions(commands.Cog): +class TicTacToe(commands.Cog): "Allow users to play PvP tic-tac-toe" def __init__(self, bot: Axobot): self.bot = bot - self.file = 'morpions' + self.file = 'tictactoe' self.in_game = {} self.types: tuple[str] = options['ttt_display']['values'] @@ -37,13 +37,13 @@ async def main(self, ctx: MyContext, leave: Literal['leave'] = None): """ if leave == 'leave': if ctx.author.id not in self.in_game: - await ctx.send(await self.bot._(ctx.channel, 'morpion.not-playing')) + await ctx.send(await self.bot._(ctx.channel, 'tictactoe.not-playing')) else: self.in_game.pop(ctx.author.id) - await ctx.send(await self.bot._(ctx.channel, 'morpion.game-removed')) + await ctx.send(await self.bot._(ctx.channel, 'tictactoe.game-removed')) return if ctx.author.id in self.in_game: - await ctx.send(await self.bot._(ctx.channel, 'morpion.already-playing')) + await ctx.send(await self.bot._(ctx.channel, 'tictactoe.already-playing')) return self.in_game[ctx.author.id] = time.time() game = self.Game(ctx, self, await self.get_ttt_mode(ctx)) @@ -54,7 +54,7 @@ async def main(self, ctx: MyContext, leave: Literal['leave'] = None): class Game(): "An actual tictactoe game running" - def __init__(self, ctx: MyContext, cog: 'Morpions', mode: Literal["disabled", "short", "normal"]): + def __init__(self, ctx: MyContext, cog: 'TicTacToe', mode: Literal["disabled", "short", "normal"]): self.cog = cog self.ctx = ctx self.bot = ctx.bot @@ -129,8 +129,8 @@ async def start(self): try: grille = list(range(1, 10)) tour = await self.player_starts() - u_begin = await self.bot._(ctx.channel, 'morpion.user-begin' if tour else 'morpion.bot-begin') - tip = await self.bot._(ctx.channel, 'morpion.tip', symb1=self.emojis[0], symb2=self.emojis[1]) + u_begin = await self.bot._(ctx.channel, 'tictactoe.user-begin' if tour else 'tictactoe.bot-begin') + tip = await self.bot._(ctx.channel, 'tictactoe.tip', symb1=self.emojis[0], symb2=self.emojis[1]) await ctx.send(u_begin.format(ctx.author.mention) + tip) match_nul = True @@ -154,7 +154,7 @@ def check(msg: discord.Message): try: msg: discord.Message = await self.bot.wait_for('message', check=check, timeout=50) except asyncio.TimeoutError: - await ctx.channel.send(await self.bot._(ctx.channel, 'morpion.too-late')) + await ctx.channel.send(await self.bot._(ctx.channel, 'tictactoe.too-late')) return saisie = msg.content if msg.content in self.entrees_valides: @@ -164,13 +164,13 @@ def check(msg: discord.Message): if self.use_short: await msg.delete(delay=0.1) else: # cell is not empty - await ctx.send(await self.bot._(ctx.channel, 'morpion.pion-1')) + await ctx.send(await self.bot._(ctx.channel, 'tictactoe.pion-1')) display_grille = False continue elif msg.content.endswith("leave"): # user leaves the game return else: # invalid cell number - await ctx.send(await self.bot._(ctx.channel, 'morpion.pion-2')) + await ctx.send(await self.bot._(ctx.channel, 'tictactoe.pion-2')) display_grille = False continue ### @@ -203,18 +203,37 @@ def check(msg: discord.Message): if self.use_short and last_grid: await last_grid.delete() if match_nul: - await self.bot.get_cog("BotEvents").db_add_user_points(ctx.author.id, 2) - resultat = await self.bot._(ctx.channel, 'morpion.nul') + resultat = await self.bot._(ctx.channel, 'tictactoe.nul') else: if tour: # Le bot a gagné - resultat = await self.bot._(ctx.channel, 'morpion.win-bot') + resultat = await self.bot._(ctx.channel, 'tictactoe.win-bot') else: # L'utilisateur a gagné - resultat = await self.bot._(ctx.channel, 'morpion.win-user', user=ctx.author.mention) - await self.bot.get_cog("BotEvents").db_add_user_points(ctx.author.id, 8) + resultat = await self.bot._(ctx.channel, 'tictactoe.win-user', user=ctx.author.mention) await ctx.send(await self.display_grid(grille)+'\n'+resultat) + if not match_nul and not tour: + # give event points if user won + await self.cog.give_event_points(ctx.channel, ctx.author, 8) except Exception as err: self.bot.dispatch("command_error", ctx, err) + async def give_event_points(self, channel, 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._(channel, 'bot_events.tictactoe.reward-title'), + description=await self.bot._(channel, '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 channel.send(embed=emb) + # send card unlocked notif + await cog.check_and_send_card_unlocked_notif(channel, user) + # give points + await cog.db_add_user_points(user.id, points) + async def setup(bot): - await bot.add_cog(Morpions(bot)) + await bot.add_cog(TicTacToe(bot)) diff --git a/fcts/timers.py b/fcts/timers.py index f98af3fe5..e185cc06a 100644 --- a/fcts/timers.py +++ b/fcts/timers.py @@ -361,9 +361,6 @@ async def remind_clear(self, ctx: MyContext): """Remove every pending reminder ..Doc miscellaneous.html#clear-every-reminders""" - if not (ctx.guild is None or ctx.channel.permissions_for(ctx.guild.me).add_reactions): - await ctx.send(await self.bot._(ctx.channel, "fun.cant-react")) - return count = await self.db_get_user_reminders_count(ctx.author.id) if count == 0: await ctx.send(await self.bot._(ctx.channel, "timers.rmd.empty")) diff --git a/fcts/users.py b/fcts/users.py index f9786dee2..17317c916 100644 --- a/fcts/users.py +++ b/fcts/users.py @@ -1,7 +1,5 @@ import importlib -import json -import time -from typing import Any, Optional, Union +from typing import Any, Optional import discord from cachingutils import acached @@ -233,12 +231,6 @@ async def profile_card(self, ctx: MyContext, style: args.CardStyle): await ctx.defer() await self.db_edit_user_xp_card(ctx.author.id, style) await ctx.send(await self.bot._(ctx.channel, 'users.changed-card', style=style)) - last_update = self.get_last_rankcard_update(ctx.author.id) - if last_update is None: - await self.bot.get_cog("BotEvents").db_add_user_points(ctx.author.id, 15) - elif last_update < time.time()-86400: - await self.bot.get_cog("BotEvents").db_add_user_points(ctx.author.id, 2) - self.set_last_rankcard_update(ctx.author.id) @profile_card.autocomplete("style") async def card_autocomplete_style(self, inter: discord.Interaction, current: str): @@ -284,25 +276,6 @@ async def user_config(self, ctx: MyContext, option: str, enable: Optional[bool]= await self.db_edit_user_config(ctx.author.id, option, enable) await ctx.send(await self.bot._(ctx.channel, 'users.config_success', opt=option)) - def get_last_rankcard_update(self, user_id: int): - "Get the timestamp of the last rank card change for a user" - try: - with open("rankcards_update.json", 'r', encoding="ascii") as file: - records: dict[str, int] = json.load(file) - except FileNotFoundError: - return None - return records.get(str(user_id)) - - def set_last_rankcard_update(self, user_id: int): - "Set the timestamp of the last rank card change for a user as now" - try: - with open("rankcards_update.json", 'r', encoding="ascii") as file: - old: dict[str, int] = json.load(file) - except FileNotFoundError: - old: dict[str, int] = {} - old[str(user_id)] = round(time.time()) - with open("rankcards_update.json", 'w', encoding="ascii") as file: - json.dump(old, file) async def setup(bot): await bot.add_cog(Users(bot)) diff --git a/lang/bot_events/en.json b/lang/bot_events/en.json index 49a6c3227..52edd88ba 100644 --- a/lang/bot_events/en.json +++ b/lang/bot_events/en.json @@ -36,11 +36,22 @@ "points-total": "Total points", "position-global": "Global position", "rank-title": "Event points", + "rankcard-unlocked": { + "title": "New card unlocked!", + "desc": { + "one": "Thanks to your participation in the event, you have unlocked a new rank card: **%{cards}**!\nYou can use it with the command %{profile_card_cmd}.", + "many": "Thanks to your participation in the event, you have unlocked the following rank cards: **%{cards}**!\nYou can use them with the command %{profile_card_cmd}." + } + }, "reaction": { "positive": "Congrats %{user}, you have found one %{item}! It has been added to your collection, and you earned **%{points} event points**!", "negative": "Oh no %{user}, you have found one %{item}! It has been added to your collection, but you lost **%{points} event points**..." }, "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**!" + }, "tip-title": "Random tip", "unclassed": "unclassed", "xp-howto": "You can earn event points by winning tic-tac-toe games, using event commands when available, or by performing various other secret tasks. Don't hesitate to come to the support server to ask for information!" diff --git a/lang/bot_events/fr.json b/lang/bot_events/fr.json index 65579ebe3..e8ce8a636 100644 --- a/lang/bot_events/fr.json +++ b/lang/bot_events/fr.json @@ -1,4 +1,11 @@ { + "available-starting": "disponible à partir du %{date}", + "calendar": { + "collected-all": "Tu as récupéré tous les cadeaux du calendrier de l'avent !", + "collected-day": "Il n'y a plus de cadeaux pour toi aujourd'hui, reviens demain !", + "today-gifts": "Voici les cadeaux d'aujourd'hui (**%{points} points**) :", + "today-gifts-late": "Voici les cadeaux du jour, ainsi que des %{jours} jours manqués (**%{points} points**) :" + }, "collect": { "got-points": "Vous n'avez pas trouvé d'objet intéressant cette fois-ci, mais vous avez tout de même réussi à **gagner %{points} points d'événement** !", "lost-points": "Il semble que vous n'ayez pas trouvé d'objet à collectionner sur votre chemin, et que vous ayez inexplicablement **perdu %{points} points d'événement**...", @@ -29,7 +36,22 @@ "points-total": "Total des points", "position-global": "Position mondiale", "rank-title": "Points d'événement", + "rankcard-unlocked": { + "title": "Nouvelle carte débloquée !", + "desc": { + "one": "Grâce à ta participation à l'événement, tu as débloqué une nouvelle carte d'xp : **%{cards}**!\nTu peux l'utiliser dès maintenant avec la commande %{profile_card_cmd}.", + "many": "Grâce à ta participation à l'événement, tu as débloqué ces nouvelles cartes d'xp : **%{cards}**!\nTu peux les utiliser dès maintenant avec la commande %{profile_card_cmd}." + } + }, + "reaction": { + "positive": "Félicitations %{user}, tu as trouvé 1 %{item} ! Il a été ajouté à ta collection et t'a fait remporter **%{points} points d'événement** !", + "negative": "Oh non, %{user} tu as trouvé 1 %{item} ! Il a été ajouté à ta collection, mais t'a fait perdre **%{points} points d'événement**..." + }, "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, vous avez gagné **%{points} points d'événement** !" + }, "tip-title": "Astuce aléatoire", "unclassed": "Non classé", "xp-howto": "Tu peux gagner des points d'événements en remportant des parties de morpion, en utilisant les commandes événementielles quand il y en a, ou en accomplissant divers autres tâches secrètes. N'hésite pas à venir sur le serveur de support pour demander des renseignements !" diff --git a/lang/commands/fr.json b/lang/commands/fr.json index 47e05d071..fdba0c8d7 100644 --- a/lang/commands/fr.json +++ b/lang/commands/fr.json @@ -44,7 +44,7 @@ "rank": "rang", "reminders clear": "tout-annuler", "reminders create": "ajouter", - "reminders delete": "anuler", + "reminders delete": "annuler", "reminders list": "liste", "remindme": "rappelle-moi", "role grant": "donner", diff --git a/lang/morpion/cs.json b/lang/morpion/cs.json deleted file mode 100644 index f8f516c92..000000000 --- a/lang/morpion/cs.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "already-playing": "Již máte hru!", - "bot-begin": "Pojďme na to začít!", - "game-removed": "Správně jste opustili hru", - "not-playing": "Nemáte žádnou hru v procesu", - "nul": "Remíza, nikdo nevyhrál...", - "pion-1": "Na této buňce je již pěšec!", - "pion-2": "Neplatný vstup", - "tip": "\n*Chcete-li hrát, zadejte číslo mezi 1 a 9, odpovídající vybranému případu. Hraj %{symb1}, hraješ %{symb2}*", - "too-late": "Rozhodovali jste se příliš dlouho. Hra skončila!", - "user-begin": "{}, začínáte!", - "win-bot": "Vyhrát! Konec hry!", - "win-user": "Dobrá práce, %{user} vyhrál!" -} \ No newline at end of file diff --git a/lang/morpion/de.json b/lang/morpion/de.json deleted file mode 100644 index b3e2e617b..000000000 --- a/lang/morpion/de.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "already-playing": "Du kannst nicht zwei Spiele gleichzeitig spielen!", - "bot-begin": "Lass uns loslegen, ich bin drann!", - "game-removed": "Du hast das aktuelle Spiel verlassen", - "not-playing": "Du bist nicht in einem Spielprozess", - "nul": "Unentschieden, keiner gewinnt...", - "pion-1": "Da ist schon eine Figur auf diesem Feld!", - "pion-2": "Keine passende Antwort", - "tip": "\n*Um zu spielen gib einfach eine Zahl zwischen 1 und 9 ein. Ich spiele %{symb1}, du %{symb2}*", - "too-late": "Du hast zu lange gebraucht, Game over!", - "user-begin": "{}, du bist frann!", - "win-bot": "Ich habe gewonnen!", - "win-user": "Gut gemacht, %{user} hat gewonnen!" -} \ No newline at end of file diff --git a/lang/morpion/fi.json b/lang/morpion/fi.json deleted file mode 100644 index bb37819f3..000000000 --- a/lang/morpion/fi.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "already-playing": "Sinulla on jo peli menossa!", - "bot-begin": "Mennään, minä aloitan!", - "game-removed": "Olet poistunut pelistä", - "not-playing": "Et ole pelaamassa peliä", - "nul": "Tasapeli, kukaan ei voittanut...", - "pion-1": "Siinä on jo pelinappula!", - "pion-2": "Pätemätön syöte tapaus", - "tip": "\n*Näin peli toimii, kirjoita numero yhden (1) ja yhdeksän (9) välistä, vastaavana valitsevaan tapaukseen. Minä pelaan %{symb1}, sinä %{symb2}!*", - "too-late": "Sinulla kesti liian kauan valita. Peli pelattu!", - "user-begin": "{}, aloita sinä!", - "win-bot": "Minä voitin! Peli päättyi!", - "win-user": "Hyvin tehty, %{user} voitti!" -} \ No newline at end of file diff --git a/lang/morpion/fr2.json b/lang/morpion/fr2.json deleted file mode 100644 index 3d13cc56c..000000000 --- a/lang/morpion/fr2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "already-playing": "Tu es déjà en pleine partie !", - "bot-begin": "Me first x) !", - "game-removed": "Tu as correctement quitté la partie", - "not-playing": "Tu n'as aucune partie en cours", - "nul": "Match nul, personne n'a gagné...", - "pion-1": "Il y a déjà un pion sur cette case !", - "pion-2": "Case saisie invalide !", - "tip": "\n*Pour jouer, il suffit de taper un nombre entre 1 et 9, correspondant à la case choisie. Je joue les %{symb1}, toi les %{symb2}*", - "too-late": "Tu as réfléchi *beaucoup* trop longtemps ! Fin de la partie, dsl !", - "user-begin": "Commence, {} !", - "win-bot": "J'ai gagné ! Fin du match ! :fireworks:", - "win-user": "Bien joué, %{user} est notre grand gagnant :blobdance: !" -} \ No newline at end of file diff --git a/lang/morpion/lolcat.json b/lang/morpion/lolcat.json deleted file mode 100644 index 91feaa68f..000000000 --- a/lang/morpion/lolcat.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "already-playing": "U already are ingame!", - "bot-begin": "Hop, I'll start!", - "game-removed": "U have correctly let the game", - "not-playing": "U dont have any play in progress", - "nul": "Draw, nobody won... rip", - "pion-1": "There's already a pawn on dat cell!", - "pion-2": "Invalid input!", - "tip": "\n*To play, simply type a nbr beetween 1 - 9, corresponding 2 teh chosen case. I play the %{symb1}, U play the %{symb2}*", - "too-late": "U was too long! End of the game!", - "user-begin": "{}, u begin!", - "win-bot": "I won! Game over!", - "win-user": "GG, %{user} won!" -} \ No newline at end of file diff --git a/lang/morpion/en.json b/lang/tictactoe/en.json similarity index 100% rename from lang/morpion/en.json rename to lang/tictactoe/en.json diff --git a/lang/morpion/fr.json b/lang/tictactoe/fr.json similarity index 100% rename from lang/morpion/fr.json rename to lang/tictactoe/fr.json diff --git a/libs/bot_events/__init__.py b/libs/bot_events/__init__.py index 6f734ccb3..21a51267e 100644 --- a/libs/bot_events/__init__.py +++ b/libs/bot_events/__init__.py @@ -1,15 +1,9 @@ -import json -import os -from typing import Literal - -from .dict_types import (EventData, EventItem, EventRewardRole, - EventsTranslation, EventType, EventItemWithCount) - - -def get_events_translations() -> dict[Literal["fr", "en"], EventsTranslation]: - """Get the translations for the events""" - with open(os.path.dirname(__file__)+"/events-translations.json", "r", encoding="utf-8") as file: - return json.load(file) +from .dict_types import (EventData, EventItem, EventItemWithCount, + EventRewardRole, EventType) +from .get_translations import get_events_translations +from .subcogs.abstract_subcog import AbstractSubcog +from .subcogs.christmas_subcog import ChristmasSubcog +from .subcogs.random_collect_subcog import RandomCollectSubcog __all__ = ( "get_events_translations", @@ -18,4 +12,7 @@ def get_events_translations() -> dict[Literal["fr", "en"], EventsTranslation]: "EventRewardRole", "EventType", "EventItemWithCount", + "AbstractSubcog", + "RandomCollectSubcog", + "ChristmasSubcog", ) diff --git a/libs/bot_events/dict_types.py b/libs/bot_events/dict_types.py index 163cda663..1d35f438b 100644 --- a/libs/bot_events/dict_types.py +++ b/libs/bot_events/dict_types.py @@ -1,8 +1,7 @@ import datetime from typing import Literal, Optional, TypedDict, Union - -EventType = Literal["blurple", "halloween", "fish"] +EventType = Literal["blurple", "halloween", "fish", "christmas"] class EventsTranslation(TypedDict): "Represents the translations for the bot events in one language" @@ -31,12 +30,14 @@ class EventRewardCard(TypedDict): points: int reward_type: Literal["rankcard"] rank_card: str + min_date: Optional[str] class EventRewardRole(TypedDict): "Represents the special role reward for an event" points: int reward_type: Literal["role"] role_id: int + min_date: Optional[str] class EventRewardCustom(TypedDict): "Represents a custom reward for an event" diff --git a/libs/bot_events/events-translations.json b/libs/bot_events/events-translations.json index 16ef5106e..acf7a5d42 100644 --- a/libs/bot_events/events-translations.json +++ b/libs/bot_events/events-translations.json @@ -7,6 +7,7 @@ "christmas-2022": "La période des fêtes de fin d'année est là ! C'est l'occasion rêvée de retrouver ses amis et sa famille, de partager de bons moments ensemble, et de s'offrir tout plein de somptueux cadeaux !\n\nPour cet événement de rassemblement, nulle compétition, il vous suffit d'utiliser la commande `event collect` pour récupérer votre carte d'XP spécial Noël 2022 !\nVous pourrez ensuite utiliser cette carte d'XP via la commande `profile card`.\n\nBonne fêtes de fin d'année à tous !", "blurple-2023": "Nous célébrons en ce moment le 8e anniversaire de Discord ! Pour l'occasion, Axobot se met aux couleurs de Discord, le célèbre blurple, et vous propose de récupérer une carte d'XP spéciale anniversaire !\n\nPour cela, il vous suffit de récupérer des points d'événements, en utilisant la commande `event collect` régulièrement ou en redécorant votre serveur avec la commande `blurple`.\n\nJoyeux anniversaire Discord !", "halloween-2023": "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.\nProfitez-en pour redécorer votre serveur aux couleurs d'Halloween avec la commande `/halloween lightfy` et ses dérivées, vérifiez que votre avatar soit bien conforme avec la commande `/halloween check`, et récupérez des points d'événements le plus régulièrement possible avec la commande `/event collect`.\n\nLes plus téméraires d'entre vous réussirons peut-être à débloquer la carte d'xp spécial Halloween 2023, que vous pourrez utiliser via la commande `/profile card` !", + "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` !", "test-2022": "Test event!" }, "events_prices": { @@ -27,6 +28,10 @@ "halloween-2023": { "400": "Débloquez la carte d'xp Halloween 2023, obtenable uniquement pendant cet événement !", "600": "Venez réclamer votre rôle spécial Halloween 2023 sur le serveur officiel d'Axobot !" + }, + "christmas-2023": { + "300": "Venez réclamer votre rôle spécial Noël 2023 sur le serveur officiel d'Axobot !", + "600": "Débloquez la carte d'xp Noël 2023 le 25 décembre avec `/event collect` !" } }, "events_title": { @@ -36,6 +41,7 @@ "christmas-2022": "Joyeuses fêtes de fin d'année !", "blurple-2023": "Joyeux anniversaire Discord !", "halloween-2023": "Le temps des citrouilles est arrivé !", + "christmas-2023": "Joyeux Noël !", "test-2022": "Test event!" } }, @@ -47,6 +53,7 @@ "christmas-2022": "The holiday season is here! It's the perfect opportunity to get together with friends and family, share good times together, and get all sorts of wonderful gifts!\n\nFor this gathering event, no competition, just use the `event collect` command to get your Christmas 2022 XP card!\nYou can then use this XP card via the `profile card` command.\n\nMerry Christmas to all!", "blurple-2023": "We are currently celebrating Discord's 8th anniversary! For the occasion, Axobot is turning Discord's famous blurple color, and offers you to get a special anniversary XP card!\n\nTo do so, all you have to do is collect event points, by using the `event collect` command regularly or by redecorating your server with the `blurple` command.\n\nHappy birthday Discord!", "halloween-2023": "October is upon us! Until November 1st, you'll be able to enjoy an atmosphere of darkness, full of bats, skeletons and pumpkins.\nTake advantage of this time to redecorate your server in Halloween colors with the `/halloween lightfy` command and its derivatives, check that your avatar is in tune with the `/halloween check` command, and collect event points as regularly as possible with the `/event collect` command. \nThe most daring among you may even manage to unlock the special Halloween 2023 xp card, which you will be able to use via the `/profile card` command!", + "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`!", "test-2022": "Test event!" }, "events_prices": { @@ -67,6 +74,10 @@ "halloween-2023": { "400": "Unlock the Halloween 2023 xp card, obtainable only during this event!", "600": "Come claim your special Halloween 2023 role on the official Axobot server!" + }, + "christmas-2023": { + "300": "Come claim your special Christmas 2023 role on the official Axobot server!", + "600": "Unlock the Christmas 2023 xp card on December 25 with `/event collect`!" } }, "events_title": { @@ -76,6 +87,7 @@ "christmas-2022": "Merry Christmas!", "blurple-2023": "Happy birthday Discord!", "halloween-2023": "It's pumpkin time!", + "christmas-2023": "Merry Christmas!", "test-2022": "Test event!" } } diff --git a/libs/bot_events/get_translations.py b/libs/bot_events/get_translations.py new file mode 100644 index 000000000..4067c9566 --- /dev/null +++ b/libs/bot_events/get_translations.py @@ -0,0 +1,10 @@ +import json +import os +from typing import Literal + +from libs.bot_events.dict_types import EventsTranslation + +def get_events_translations() -> dict[Literal["fr", "en"], EventsTranslation]: + """Get the translations for the events""" + with open(os.path.dirname(__file__)+"/events-translations.json", "r", encoding="utf-8") as file: + return json.load(file) \ No newline at end of file diff --git a/libs/bot_events/subcogs/abstract_subcog.py b/libs/bot_events/subcogs/abstract_subcog.py new file mode 100644 index 000000000..55160b365 --- /dev/null +++ b/libs/bot_events/subcogs/abstract_subcog.py @@ -0,0 +1,246 @@ +import datetime +from abc import ABC, abstractmethod +from typing import Any, Literal, Optional, TypedDict + +import discord + +from libs.bot_classes import Axobot, MyContext +from libs.bot_events.dict_types import (EventData, EventItem, + EventItemWithCount, EventType) +from libs.bot_events.get_translations import get_events_translations +from libs.formatutils import FormatUtils +from libs.tips import generate_random_tip + + +class DBUserRank(TypedDict): + "Type for the result of db_get_event_rank" + user_id: int + points: int + rank: int + + +class AbstractSubcog(ABC): + "Abstract class for the subcogs used by BotEvents" + + def __init__(self, bot: Axobot, + current_event: Optional[EventType], current_event_data: EventData, current_event_id: Optional[str]): + self.bot = bot + self.current_event = current_event + self.current_event_data = current_event_data + self.current_event_id = current_event_id + self.translations_data = get_events_translations() + + @abstractmethod + async def on_message(self, msg: discord.Message): + "Called when a message is sent" + + @abstractmethod + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + "Called when a reaction is added" + + @abstractmethod + async def profile_cmd(self, ctx: MyContext, user: discord.User): + "Displays the profile of the user" + + @abstractmethod + async def collect_cmd(self, ctx: MyContext): + "Collects the daily/hourly reward" + + async def generate_user_profile_rank_fields(self, ctx: MyContext, lang: Literal["fr", "en"], user: discord.User): + "Compute the texts to display in the /event profile command" + user_rank_query = await self.db_get_event_rank(user.id) + if user_rank_query is None: + user_rank = await self.bot._(ctx.channel, "bot_events.unclassed") + points = 0 + else: + total_ranked = await self.db_get_participants_count() + if user_rank_query['rank'] <= total_ranked: + user_rank = f"{user_rank_query['rank']}/{total_ranked}" + else: + user_rank = await self.bot._(ctx.channel, "bot_events.unclassed") + points: int = user_rank_query["points"] + + _points_total = await self.bot._(ctx.channel, "bot_events.points-total") + _position_global = await self.bot._(ctx.channel, "bot_events.position-global") + _rank_global = await self.bot._(ctx.channel, "bot_events.leaderboard-global", count=5) + + fields: list[dict[str, Any]] = [] + if prices_field := await self.generate_prices_field(ctx, lang, points): + fields.append(prices_field) + fields += [ + {"name": _points_total, "value": str(points)}, + {"name": _position_global, "value": user_rank}, + ] + if top_5 := await self.get_top_5(): + fields.append({"name": _rank_global, "value": top_5, "inline": False}) + return fields + + async def generate_prices_field(self, ctx: MyContext, lang: Literal["fr", "en"], user_points: int): + "Generate an embed field to display the current event prices for the user" + prices_translations: dict[str, dict[str, str]] = self.translations_data[lang]["events_prices"] + if self.current_event_id not in prices_translations: + return None + prices = [] + for required_points, desc in prices_translations[self.current_event_id].items(): + # check for a min_date + related_objective = [ + objective + for objective in self.current_event_data["objectives"] + if str(objective["points"]) == required_points + ] + parsed_date = None + if related_objective and (min_date := related_objective[0].get("min_date")): + parsed_date = datetime.datetime.strptime(min_date, "%Y-%m-%d").replace(tzinfo=datetime.timezone.utc) + format_date = await FormatUtils.date(parsed_date, hour=False, seconds=False) + desc += f" (**{await self.bot._(ctx.channel, 'bot_events.available-starting', date=format_date)}**)" + # assign correct emoji + if parsed_date and parsed_date > self.bot.utcnow(): + emoji = self.bot.emojis_manager.customs["gray_check"] + elif int(required_points) > user_points: + emoji =self.bot.emojis_manager.customs["red_cross"] + else: + emoji = self.bot.emojis_manager.customs["green_check"] + prices.append(f"{emoji}{min(user_points, int(required_points))}/{required_points}: {desc}") + return { + "name": await self.bot._(ctx.channel, "bot_events.objectives"), + "value": "\n".join(prices), + "inline": False + } + + async def generate_user_profile_collection_field(self, ctx: MyContext, user: discord.User): + "Compute the texts to display in the /event profile command" + if ctx.author == user: + title = await self.bot._(ctx.channel, "bot_events.collection-title.user") + else: + title = await self.bot._(ctx.channel, "bot_events.collection-title.other", user=user.display_name) + items = await self.db_get_user_collected_items(user.id, self.current_event) + if len(items) == 0: + if ctx.author == user: + _empty_collection = await self.bot._(ctx.channel, "bot_events.collection-empty.user") + else: + _empty_collection = await self.bot._(ctx.channel, "bot_events.collection-empty.other", user=user.display_name) + return {"name": title, "value": _empty_collection, "inline": True} + lang = await self.bot._(ctx.channel, '_used_locale') + name_key = "french_name" if lang in ("fr", "fr2") else "english_name" + items.sort(key=lambda item: item["frequency"], reverse=True) + items_list: list[str] = [] + more_count = 0 + for item in items: + if len(items_list) >= 32: + more_count += item['count'] + continue + item_name = item["emoji"] + " " + item[name_key] + items_list.append(f"{item_name} x{item['count']}") + if more_count: + items_list.append(await self.bot._(ctx.channel, "bot_events.collection-more", count=more_count)) + return {"name": title, "value": "\n".join(items_list), "inline": True} + + async def get_top_5(self) -> str: + "Get the list of the 5 users with the most event points" + top_5 = await self.db_get_event_top(number=5) + if top_5 is None: + return await self.bot._(self.bot.get_channel(0), "bot_events.nothing-desc") + top_5_f: list[str] = [] + for i, row in enumerate(top_5): + if user := self.bot.get_user(row['user_id']): + username = user.display_name + elif user := await self.bot.fetch_user(row['user_id']): + username = user.display_name + else: + username = f"user {row['user_id']}" + top_5_f.append(f"{i+1}. {username} ({row['points']} points)") + return "\n".join(top_5_f) + + async def is_fun_enabled(self, message: discord.Message): + "Check if fun is enabled in a given context" + if message.guild is None: + return True + if not self.bot.database_online and not message.author.guild_permissions.manage_guild: + return False + return await self.bot.get_config(message.guild.id, "enable_fun") + + async def get_random_tip_field(self, channel): + return { + "name": await self.bot._(channel, "bot_events.tip-title"), + "value": await generate_random_tip(self.bot, channel), + "inline": False + } + + async def get_seconds_since_last_collect(self, user_id: int): + "Get the seconds since the last collect from a user" + last_collect = await self.db_get_last_user_collect(user_id) + if last_collect is None: + return 1e9 + return (self.bot.utcnow() - last_collect).total_seconds() + + async def db_get_event_top(self, number: int): + "Get the event points leaderboard containing at max the given number of users" + if not self.bot.database_online: + return None + query = "SELECT `user_id`, `points` FROM `event_points` WHERE `points` != 0 AND `beta` = %s \ + ORDER BY `points` DESC LIMIT %s" + async with self.bot.db_query(query, (self.bot.beta, number)) as query_results: + return query_results + + async def db_get_participants_count(self) -> int: + "Get the number of users who have at least 1 event point" + if not self.bot.database_online: + return 0 + query = "SELECT COUNT(*) as count FROM `event_points` WHERE `points` > 0 AND `beta` = %s;" + async with self.bot.db_query(query, (self.bot.beta,), fetchone=True) as query_results: + return query_results['count'] + + async def db_get_event_rank(self, user_id: int) -> Optional[DBUserRank]: + "Get the ranking of a user" + if not self.bot.database_online: + return None + query = "SELECT `user_id`, `points`, FIND_IN_SET( `points`, \ + ( SELECT GROUP_CONCAT( `points` ORDER BY `points` DESC ) FROM `event_points` WHERE `beta` = %(beta)s ) ) AS rank \ + FROM `event_points` WHERE `user_id` = %(user)s AND `beta` = %(beta)s" + async with self.bot.db_query(query, {'user': user_id, 'beta': self.bot.beta}, fetchone=True) as query_results: + return query_results or None + + async def db_get_user_collected_items(self, user_id: int, event_type: EventType) -> list[EventItemWithCount]: + "Get the items collected by a user" + if not self.bot.database_online: + return [] + query = """SELECT COUNT(*) AS 'count', a.* + FROM `event_collected_items` c LEFT JOIN `event_available_items` a ON c.`item_id` = a.`item_id` + WHERE c.`user_id` = %s + AND c.`beta` = %s + AND a.`event_type` = %s + GROUP BY c.`item_id`""" + async with self.bot.db_query(query, (user_id, self.bot.beta, event_type)) as query_results: + return query_results + + async def db_get_last_user_collect(self, user_id: int) -> datetime.datetime: + "Get the last collect datetime from a user" + if not self.bot.database_online: + return None + query = "SELECT `last_collect` FROM `event_points` WHERE `user_id` = %s AND `beta` = %s;" + async with self.bot.db_query(query, (user_id, self.bot.beta), fetchone=True, astuple=True) as query_result: + if not query_result: + return None + query_result: tuple[datetime.datetime] + if query_result[0] is None: + return None + # apply utc offset + last_collect = query_result[0].replace(tzinfo=datetime.timezone.utc) + return last_collect + + async def db_add_user_items(self, user_id: int, items_ids: list[int]): + "Add some items to a user's collection" + if not self.bot.database_online: + return + query = "INSERT INTO `event_collected_items` (`user_id`, `item_id`, `beta`) VALUES " + \ + ", ".join(["(%s, %s, %s)"] * len(items_ids)) + ';' + async with self.bot.db_query(query, [arg for item_id in items_ids for arg in (user_id, item_id, self.bot.beta)]): + pass + + async def db_get_event_items(self, event_type: EventType) -> list[EventItem]: + "Get the items to win during a specific event" + if not self.bot.database_online: + return [] + query = "SELECT * FROM `event_available_items` WHERE `event_type` = %s;" + async with self.bot.db_query(query, (event_type, )) as query_results: + return query_results diff --git a/libs/bot_events/subcogs/christmas_subcog.py b/libs/bot_events/subcogs/christmas_subcog.py new file mode 100644 index 000000000..d6039211b --- /dev/null +++ b/libs/bot_events/subcogs/christmas_subcog.py @@ -0,0 +1,275 @@ +import datetime as dt +from collections import defaultdict +from random import choices, lognormvariate, random +from typing import Optional + +import discord +import emoji +from cachingutils import acached + +from libs.bot_classes import Axobot +from libs.bot_events.dict_types import EventData, EventItem, EventType +from libs.bot_events.subcogs.abstract_subcog import AbstractSubcog + +# list of the advent calendar items IDs per day between 1 and 24 +# on december 25th the card will be unlocked +ADVENT_CALENDAR: dict[int, list[int]] = { + 1: [41, 61], + 2: [46, 49], + 3: [42, 61], + 4: [46, 52], + 5: [36, 47, 49], + 6: [32, 47, 57, 39], + 7: [42, 53, 61, 61], + 8: [32, 32, 49, 59, 61], + 9: [45, 47, 48, 51, 55], + 10: [39, 39, 44, 51], + 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], + 18: [33, 48, 56, 58], + 19: [36, 45, 45, 50], + 20: [35, 39, 42, 43, 57], + 21: [37, 43, 55, 57], + 22: [40, 48, 49], + 23: [33, 46, 58], + 24: [54], +} + + +class ChristmasSubcog(AbstractSubcog): + "Utility class for the BotEvents cog when the event is Christmas" + + def __init__(self, bot: Axobot, + current_event: Optional[EventType], current_event_data: EventData, current_event_id: Optional[str]): + super().__init__(bot, current_event, current_event_data, current_event_id) + self.pending_reactions: dict[int, EventItem] = {} # map of MessageID => EventItem + + async def on_message(self, msg): + "Add random reaction to some messages" + if self.current_event and (data := self.current_event_data.get("emojis")): + if not await self.is_fun_enabled(msg): + # don't react if fun is disabled for this guild + return + if random() < data["probability"] and await self.check_trigger_words(msg.content): + if item := await self.get_random_item_for_reaction(): + try: + await msg.add_reaction(item["emoji"]) + except discord.HTTPException as err: + self.bot.dispatch("error", err, f"When trying to add event reaction {item['emoji']}") + return + self.pending_reactions[msg.id] = item + + async def on_raw_reaction_add(self, payload): + if payload.message_id not in self.pending_reactions: + return + item = self.pending_reactions[payload.message_id] + if payload.emoji.name != item["emoji"]: + print("wrong emoji") + return + del self.pending_reactions[payload.message_id] + # add item to user collection + await self.db_add_user_items(payload.user_id, [item["item_id"]]) + # prepare the notification embed + translation_source = payload.guild_id or payload.user_id + lang = await self.bot._(translation_source, '_used_locale') + title = self.translations_data[lang]["events_title"][self.current_event_id] + desc_key = "bot_events.reaction.positive" if item["points"] >= 0 else "bot_events.reaction.negative" + name_key = "french_name" if lang in ("fr", "fr2") else "english_name" + item_name = item["emoji"] + " " + item[name_key] + user_mention = f"<@{payload.user_id}>" + desc = await self.bot._(translation_source, desc_key, user=user_mention, item=item_name, points=abs(item["points"])) + embed = discord.Embed(title=title, description=desc, color=self.current_event_data["color"]) + if self.current_event_data["icon"]: + embed.set_image(url=self.current_event_data["icon"]) + if destination := (self.bot.get_channel(payload.channel_id) or self.bot.get_user(payload.user_id)): + # send the notification, auto delete after 12 seconds + await destination.send(embed=embed, delete_after=12) + # send the rank card notification if needed + await self.bot.get_cog("BotEvents").check_and_send_card_unlocked_notif(destination, payload.user_id) + # add points (and potentially grant reward rank card) + await self.db_add_collect(payload.user_id, item["points"]) + + async def profile_cmd(self, ctx, user): + "Displays the profile of the user" + lang = await self.bot._(ctx.channel, '_used_locale') + lang = 'en' if lang not in ('en', 'fr') else lang + events_desc = self.translations_data[lang]["events_desc"] + + # if no event + if not self.current_event_id in events_desc: + await ctx.send(await self.bot._(ctx.channel, "bot_events.nothing-desc")) + if self.current_event_id: + self.bot.dispatch("error", ValueError(f"'{self.current_event_id}' has no event description"), ctx) + return + # if current event has no objectives + if not self.current_event_data["objectives"]: + cmd_mention = await self.bot.get_command_mention("event info") + await ctx.send(await self.bot._(ctx.channel, "bot_events.no-objectives", cmd=cmd_mention)) + return + + await ctx.defer() + + title = await self.bot._(ctx.channel, "bot_events.rank-title") + desc = await self.bot._(ctx.channel, "bot_events.xp-howto") + + emb = discord.Embed(title=title, description=desc, color=self.current_event_data["color"]) + emb.set_author(name=user.global_name, icon_url=user.display_avatar.replace(static_format="png", size=32)) + for field in await self.generate_user_profile_rank_fields(ctx, lang, user): + emb.add_field(**field) + emb.add_field(**await self.generate_user_profile_collection_field(ctx, user)) + await ctx.send(embed=emb) + + async def collect_cmd(self, ctx): + "Collect your daily reward" + current_event = self.current_event_id + lang = await self.bot._(ctx.channel, '_used_locale') + lang = 'en' if lang not in ('en', 'fr') else lang + events_desc = self.translations_data[lang]["events_desc"] + # if no event + if not current_event in events_desc: + await ctx.send(await self.bot._(ctx.channel, "bot_events.nothing-desc")) + if current_event: + self.bot.dispatch("error", ValueError(f"'{current_event}' has no event description"), ctx) + return + # if current event has no objectives + if not self.current_event_data["objectives"]: + cmd_mention = await self.bot.get_command_mention("event info") + await ctx.send(await self.bot._(ctx.channel, "bot_events.no-objectives", cmd=cmd_mention)) + return + await ctx.defer() + + # check last collect from this user + last_collect_day = await self.get_last_user_collect(ctx.author.id) + gifts = await self.get_calendar_gifts_from_date(last_collect_day) + if gifts: + await self.db_add_user_items(ctx.author.id, [item["item_id"] for item in gifts]) + txt = await self.generate_collect_message(ctx.channel, gifts, last_collect_day) + # send result + if ctx.can_send_embed: + title = self.translations_data[lang]["events_title"][current_event] + emb = discord.Embed(title="✨ "+title, description=txt, color=self.current_event_data["color"]) + emb.add_field(**await self.get_random_tip_field(ctx.channel)) + emb.set_thumbnail(url="https://cdn-icons-png.flaticon.com/512/4213/4213958.png") + await ctx.send(embed=emb) + # send the rank card notification if needed + await self.bot.get_cog("BotEvents").check_and_send_card_unlocked_notif(ctx.channel, ctx.author) + else: + await ctx.send(txt) + # add points (and potentially grant reward rank card) + if gifts: + await self.db_add_collect(ctx.author.id, sum(item["points"] for item in gifts)) + + async def today(self): + return dt.datetime.now(dt.timezone.utc).date() + # return dt.date(2023, 12, 1) + + async def check_trigger_words(self, message: str): + "Check if a word in the message triggers the event" + if self.current_event and (data := self.current_event_data.get("emojis")): + message = message.lower() + return any(trigger in message for trigger in data["triggers"]) + return False + + async def get_calendar_gifts_from_date(self, last_collect_day: dt.date) -> list[EventItem]: + "Get the list of the gifts from the advent calendar from a given date" + today = await self.today() + if today.month != 12: + return [] + gifts_ids = [] + # make sure user can't get more than 3 gifts in the past + min_past_day = max(today.day - 3, last_collect_day.day) + for day in range(min_past_day, today.day + 1): + gifts_ids += ADVENT_CALENDAR[day] + if not gifts_ids: + return [] + items = await self.db_get_event_items(self.current_event) + gifts: list[EventItem] = [] + for item in items: + if item["item_id"] in gifts_ids: + gifts.append(item) + return gifts + + async def is_past_christmas(self): + today = await self.today() + return today.month == 12 and today.day >= 25 + + async def generate_collect_message(self, channel, 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: + return await self.bot._(channel, "bot_events.calendar.collected-all") + return await self.bot._(channel, "bot_events.calendar.collected-day") + # 1 item collected + language = await self.bot._(channel, "_used_locale") + name_key = "french_name" if language in ("fr", "fr2") else "english_name" + today = await self.today() + total_points = sum(item["points"] for item in items) + text = "### " + if today == last_collect_day: + text += await self.bot._(channel, "bot_events.calendar.today-gifts", points=total_points) + else: + missed_days = min(today.day - last_collect_day.day, 3) + text = await self.bot._(channel, "bot_events.calendar.today-gifts-late", days=missed_days, points=total_points) + items_group: dict[int, int] = defaultdict(int) + for item in items: + items_group[item["item_id"]] += 1 + for item_id, count in items_group.items(): + item = next(item for item in items if item["item_id"] == item_id) + item_name = item["emoji"] + " " + item[name_key] + item_points = ('+' if item["points"] >= 0 else '') + str(item["points"] * count) + text += f"\n**{item_name}** x{count} ({item_points} points)" + return text + + async def get_last_user_collect(self, user_id: int): + "Get the UTC date of the last collect from a user, or December 1st if never collected" + last_collect_date = await self.db_get_last_user_collect(user_id) + if last_collect_date is None: + return dt.date(2023, 12, 1) + today = await self.today() + last_collect_day = last_collect_date.date() + if last_collect_day.year != today.year or last_collect_day.month != today.month: + return dt.date(2023, 12, 1) + return last_collect_day + + @acached(60*60*24) + async def _get_suitable_reaction_items(self): + "Get the list of items usable in reactions" + if self.current_event is None: + return [] + items_count = min(round(lognormvariate(1.1, 0.9)), 8) # random number between 0 and 8 + if items_count <= 0: + return [] + items = await self.db_get_event_items(self.current_event) + if len(items) == 0: + return [] + return [item for item in items if emoji.emoji_count(item["emoji"]) == 1] + + async def get_random_item_for_reaction(self): + "Get some random items to win during an event" + items = await self._get_suitable_reaction_items() + return choices( + items, + weights=[item["frequency"] for item in items], + )[0] + + async def db_add_collect(self, user_id: int, points: int): + """Add collect points to a user""" + if not self.bot.database_online or self.bot.current_event is None: + return + query = "INSERT INTO `event_points` (`user_id`, `collect_points`, `beta`) VALUES (%s, %s, %s) \ + ON DUPLICATE KEY UPDATE collect_points = collect_points + VALUE(`collect_points`), \ + last_collect = CURRENT_TIMESTAMP();" + async with self.bot.db_query(query, (user_id, points, self.bot.beta)): + pass + if cog := self.bot.get_cog("BotEvents"): + try: + await cog.reload_event_rankcard(user_id) + await cog.reload_event_special_role(user_id) + except Exception as err: + self.bot.dispatch("error", err) diff --git a/libs/bot_events/subcogs/random_collect_subcog.py b/libs/bot_events/subcogs/random_collect_subcog.py new file mode 100644 index 000000000..e777c5179 --- /dev/null +++ b/libs/bot_events/subcogs/random_collect_subcog.py @@ -0,0 +1,228 @@ +from collections import defaultdict +from random import choice, choices, lognormvariate, randint, random +from typing import Optional + +import discord + +from libs.bot_classes import Axobot +from libs.bot_events.dict_types import EventData, EventItem, EventType +from libs.bot_events.subcogs.abstract_subcog import AbstractSubcog +from libs.formatutils import FormatUtils +from utils import OUTAGE_REASON + + +class RandomCollectSubcog(AbstractSubcog): + "Utility class for the BotEvents cog when the event is about collecting random items" + + def __init__(self, bot: Axobot, + current_event: Optional[EventType], current_event_data: EventData, current_event_id: Optional[str]): + super().__init__(bot, current_event, current_event_data, current_event_id) + + self.collect_reward = [-8, 25] + self.collect_cooldown = 60*60 # (1h) time in seconds between 2 collects + self.collect_max_strike_period = 3600 * 2 # (2h) time in seconds after which the strike level is reset to 0 + self.collect_bonus_per_strike = 1.05 # the amount of points is multiplied by this number for each strike level + + async def on_message(self, msg): + "Add random reaction to some messages" + if self.current_event and (data := self.current_event_data.get("emojis")): + if not await self.is_fun_enabled(msg): + # don't react if fun is disabled for this guild + return + if random() < data["probability"] and any(trigger in msg.content for trigger in data["triggers"]): + react = choice(data["reactions_list"]) + await msg.add_reaction(react) + + async def on_raw_reaction_add(self, payload): + pass + + async def profile_cmd(self, ctx, user): + "Displays the profile of the user" + lang = await self.bot._(ctx.channel, '_used_locale') + lang = 'en' if lang not in ('en', 'fr') else lang + events_desc = self.translations_data[lang]["events_desc"] + + # if no event + if not self.current_event_id in events_desc: + await ctx.send(await self.bot._(ctx.channel, "bot_events.nothing-desc")) + if self.current_event_id: + self.bot.dispatch("error", ValueError(f"'{self.current_event_id}' has no event description"), ctx) + return + # if current event has no objectives + if not self.current_event_data["objectives"]: + cmd_mention = await self.bot.get_command_mention("event info") + await ctx.send(await self.bot._(ctx.channel, "bot_events.no-objectives", cmd=cmd_mention)) + return + + await ctx.defer() + + title = await self.bot._(ctx.channel, "bot_events.rank-title") + desc = await self.bot._(ctx.channel, "bot_events.xp-howto") + + if not self.bot.database_online: + lang = await self.bot._(ctx.channel, '_used_locale') + reason = OUTAGE_REASON.get(lang, OUTAGE_REASON['en']) + emb = discord.Embed(title=title, description=desc, color=self.current_event_data["color"]) + emb.add_field(name="OUTAGE", value=reason) + await ctx.send(embed=emb) + return + + emb = discord.Embed(title=title, description=desc, color=self.current_event_data["color"]) + emb.set_author(name=user, icon_url=user.display_avatar.replace(static_format="png", size=32)) + for field in await self.generate_user_profile_rank_fields(ctx, lang, user): + emb.add_field(**field) + emb.add_field(**await self.generate_user_profile_collection_field(ctx, user)) + await ctx.send(embed=emb) + + async def collect_cmd(self, ctx): + "Get some event points every hour" + current_event = self.current_event_id + lang = await self.bot._(ctx.channel, '_used_locale') + lang = 'en' if lang not in ('en', 'fr') else lang + events_desc = self.translations_data[lang]["events_desc"] + # if no event + if not current_event in events_desc: + await ctx.send(await self.bot._(ctx.channel, "bot_events.nothing-desc")) + if current_event: + self.bot.dispatch("error", ValueError(f"'{current_event}' has no event description"), ctx) + return + # if current event has no objectives + if not self.current_event_data["objectives"]: + cmd_mention = await self.bot.get_command_mention("event info") + await ctx.send(await self.bot._(ctx.channel, "bot_events.no-objectives", cmd=cmd_mention)) + return + await ctx.defer() + + # check last collect from this user + seconds_since_last_collect = await self.get_seconds_since_last_collect(ctx.author.id) + can_collect, is_strike = await self.check_user_collect_availability(ctx.author.id, seconds_since_last_collect) + if not can_collect: + # cooldown error + time_remaining = self.collect_cooldown - seconds_since_last_collect + remaining = await FormatUtils.time_delta(time_remaining, lang=lang) + txt = await self.bot._(ctx.channel, "bot_events.collect.too-quick", time=remaining) + else: + # grant points + items = await self.get_random_items() + strike_level = (await self.db_get_user_strike_level(ctx.author.id) + 1) if is_strike else 0 + if len(items) == 0: + points = randint(*self.collect_reward) + bonus = 0 + else: + points = sum(item["points"] for item in items) + bonus = max(0, await self.adjust_points_to_strike(points, strike_level) - points) + await self.db_add_user_items(ctx.author.id, [item["item_id"] for item in items]) + txt = await self.generate_collect_message(ctx.channel, items, points + bonus) + if strike_level and bonus != 0: + txt += "\n\n" + \ + await self.bot._(ctx.channel, 'bot_events.collect.strike-bonus', bonus=bonus, level=strike_level+1) + await self.db_add_collect(ctx.author.id, points + bonus, increase_strike=is_strike) + # send result + if ctx.can_send_embed: + title = self.translations_data[lang]["events_title"][current_event] + emb = discord.Embed(title=title, description=txt, color=self.current_event_data["color"]) + emb.add_field(**await self.get_random_tip_field(ctx.channel)) + await ctx.send(embed=emb) + else: + await ctx.send(txt) + + async def generate_collect_message(self, channel, items: list[EventItem], points: int): + "Generate the message to send after a /collect command" + items_count = len(items) + # no item collected + if items_count == 0: + if points < 0: + return await self.bot._(channel, "bot_events.collect.lost-points", points=-points) + if points == 0: + return await self.bot._(channel, "bot_events.collect.nothing") + return await self.bot._(channel, "bot_events.collect.got-points", points=points) + language = await self.bot._(channel, "_used_locale") + name_key = "french_name" if language in ("fr", "fr2") else "english_name" + # 1 item collected + if items_count == 1: + item_name = items[0]["emoji"] + " " + items[0][name_key] + return await self.bot._(channel, "bot_events.collect.got-items", count=1, item=item_name, points=points) + # more than 1 item + f_points = str(points) if points <= 0 else "+" + str(points) + text = await self.bot._(channel, "bot_events.collect.got-items", count=items_count, points=f_points) + items_group: dict[int, int] = defaultdict(int) + for item in items: + items_group[item["item_id"]] += 1 + for item_id, count in items_group.items(): + item = next(item for item in items if item["item_id"] == item_id) + item_name = item["emoji"] + " " + item[name_key] + item_points = ('+' if item["points"] >= 0 else '') + str(item["points"] * count) + text += f"\n**{item_name}** x{count} ({item_points} points)" + return text + + async def get_random_items(self) -> list[EventItem]: + "Get some random items to win during an event" + if self.current_event is None: + return [] + items_count = min(round(lognormvariate(1.1, 0.9)), 8) # random number between 0 and 8 + if items_count <= 0: + return [] + items = await self.db_get_event_items(self.current_event) + if len(items) == 0: + return [] + return choices( + items, + weights=[item["frequency"] for item in items], + k=items_count + ) + + async def check_user_collect_availability(self, user_id: int, seconds_since_last_collect: Optional[int] = None): + "Check if a user can collect points, and if they are in a strike period" + if not self.bot.database_online or self.bot.current_event is None: + return False, False + if not seconds_since_last_collect: + seconds_since_last_collect = await self.get_seconds_since_last_collect(user_id) + if seconds_since_last_collect is None: + return True, False + if seconds_since_last_collect < self.collect_cooldown: + return False, False + if seconds_since_last_collect < self.collect_max_strike_period: + return True, True + return True, False + + async def adjust_points_to_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) + + async def db_get_user_strike_level(self, user_id: int) -> int: + "Get the strike level of a user" + if not self.bot.database_online: + return 0 + query = "SELECT `strike_level` FROM `event_points` WHERE `user_id` = %s AND `beta` = %s;" + async with self.bot.db_query(query, (user_id, self.bot.beta), fetchone=True) as query_result: + return query_result["strike_level"] if query_result else 0 + + async def db_add_collect(self, user_id: int, points: int, increase_strike: bool): + """Add collect points to a user + if increase_strike is True, the strike level will be increased by 1, else it will be reset to 0""" + try: + if not self.bot.database_online or self.bot.current_event is None: + return True + if increase_strike: + query = "INSERT INTO `event_points` (`user_id`, `collect_points`, `strike_level`, `beta`) VALUES (%s, %s, 1, %s) \ + ON DUPLICATE KEY UPDATE collect_points = collect_points + VALUE(`collect_points`), \ + strike_level = strike_level + 1, \ + last_collect = CURRENT_TIMESTAMP();" + else: + query = "INSERT INTO `event_points` (`user_id`, `collect_points`, `beta`) VALUES (%s, %s, %s) \ + ON DUPLICATE KEY UPDATE collect_points = collect_points + VALUE(`collect_points`), \ + strike_level = 0, \ + last_collect = CURRENT_TIMESTAMP();" + async with self.bot.db_query(query, (user_id, points, self.bot.beta)): + pass + if cog := self.bot.get_cog("BotEvents"): + try: + await cog.reload_event_rankcard(user_id) + await cog.reload_event_special_role(user_id) + except Exception as err: + self.bot.dispatch("error", err) + return True + except Exception as err: + self.bot.dispatch("error", err) + return False diff --git a/libs/emojis_manager.py b/libs/emojis_manager.py index d48cfe071..83825c0a9 100644 --- a/libs/emojis_manager.py +++ b/libs/emojis_manager.py @@ -965,7 +965,7 @@ def __init__(self, bot: "Axobot"): 'minecraft': '<:minecraft:958305433439834152>'} try: - resp = requests.get("https://www.unicode.org/Public/emoji/latest/emoji-test.txt") + resp = requests.get("https://www.unicode.org/Public/emoji/latest/emoji-test.txt", timeout=5) self.unicode_set: set[str] = set() for character in resp.text: if character not in string.printable: diff --git a/libs/rss/rss_web.py b/libs/rss/rss_web.py index 208f0519a..7df4ea3a5 100644 --- a/libs/rss/rss_web.py +++ b/libs/rss/rss_web.py @@ -2,10 +2,11 @@ import datetime as dt import re -from typing import TYPE_CHECKING, Optional, Literal +from typing import TYPE_CHECKING, Literal, Optional import aiohttp import discord +from feedparser import CharacterEncodingOverride from feedparser.util import FeedParserDict from .convert_post_to_text import get_text_from_entry @@ -30,7 +31,10 @@ def is_web_url(self, string: str): async def _get_feed(self, url: str, session: Optional[aiohttp.ClientSession]=None) -> FeedParserDict: "Get a list of feeds from a web URL" feed = await feed_parse(self.bot, url, 9, session) - if feed is None or 'bozo_exception' in feed or not feed.entries: + if feed is None or not feed.entries: + return None + if 'bozo_exception' in feed and not isinstance(feed['bozo_exception'], CharacterEncodingOverride): + # CharacterEncodingOverride exceptions are ignored return None date_field_key = await self._get_feed_date_key(feed.entries[0]) if date_field_key is not None and len(feed.entries) > 1: