diff --git a/cogs/beatmap_cog.py b/cogs/beatmap_cog.py index 523b76c..d865dcf 100644 --- a/cogs/beatmap_cog.py +++ b/cogs/beatmap_cog.py @@ -40,9 +40,7 @@ def format_length(self, total_seconds: int, user_id_for_l10n: int) -> str: if not total_seconds: return "0:00" try: - return str(datetime.timedelta(seconds=int(total_seconds)))[ - 2: - ] # 去掉小時部分 0: + return str(datetime.timedelta(seconds=int(total_seconds)))[2:] # 去掉小時部分 0: except: return "N/A" @@ -79,9 +77,7 @@ async def on_message(self, message: discord.Message) -> None: if beatmap_id_str: try: beatmap_id = int(beatmap_id_str) - beatmap_detail = await self.osu_api.get_beatmap_details( - beatmap_id=beatmap_id - ) + beatmap_detail = await self.osu_api.get_beatmap_details(beatmap_id=beatmap_id) if beatmap_detail: target_beatmap_from_direct_id = beatmap_detail beatmap_data_list = [ @@ -94,9 +90,7 @@ async def on_message(self, message: discord.Message) -> None: elif beatmapset_id_str: try: beatmapset_id = int(beatmapset_id_str) - beatmapset_data = await self.osu_api.get_beatmapset( - beatmapset_id=beatmapset_id - ) + beatmapset_data = await self.osu_api.get_beatmapset(beatmapset_id=beatmapset_id) # beatmapset_data for API v2 contains a 'beatmaps' key with list of difficulties if ( beatmapset_data @@ -132,9 +126,7 @@ async def on_message(self, message: discord.Message) -> None: # Fallback: if somehow target_beatmap_from_direct_id wasn't set or ID mismatched # This part of the logic might be redundant if target_beatmap_from_direct_id is reliable elif beatmap_data_list: - for bm in ( - beatmap_data_list - ): # Should be a list of one if direct fetch worked + for bm in beatmap_data_list: # Should be a list of one if direct fetch worked if str(bm.get("id")) == beatmap_id_str: # API v2 beatmap id is 'id' target_beatmap = bm break @@ -147,14 +139,10 @@ async def on_message(self, message: discord.Message) -> None: elif beatmap_data_list: # Ensure list is not empty # Try to find osu!standard (ruleset_id 0) for bm in beatmap_data_list: - if ( - bm.get("ruleset_id") == 0 - ): # API v2 uses 'ruleset_id' for mode integer + if bm.get("ruleset_id") == 0: # API v2 uses 'ruleset_id' for mode integer target_beatmap = bm break - if ( - not target_beatmap - ): # If no osu!standard, take the first one in the list + if not target_beatmap: # If no osu!standard, take the first one in the list target_beatmap = beatmap_data_list[0] # If beatmap_data_list was empty, target_beatmap remains None @@ -173,9 +161,7 @@ async def on_message(self, message: discord.Message) -> None: # bpm, total_length, hit_length, max_combo, status (string), url, ruleset_id. # It also contains a nested 'beatmapset' object. - current_beatmapset_data = target_beatmap.get( - "beatmapset" - ) # This should be a nested object + current_beatmapset_data = target_beatmap.get("beatmapset") # This should be a nested object if not current_beatmapset_data: current_beatmapset_data = {} # Fallback to avoid errors, though this indicates an issue logger.warning( @@ -207,9 +193,7 @@ async def on_message(self, message: discord.Message) -> None: ) status_display_string_on_message = get_beatmap_status_display( - raw_status_on_message, - user_id_for_l10n, - lstr, + raw_status_on_message, user_id_for_l10n, lstr ) stars = float(target_beatmap.get("difficulty_rating", 0.0)) @@ -229,22 +213,16 @@ async def on_message(self, message: discord.Message) -> None: b_id = target_beatmap.get("id") # bs_id can be from target_beatmap.beatmapset_id or from current_beatmapset_data.id bs_id = target_beatmap.get("beatmapset_id") - if ( - not bs_id and current_beatmapset_data - ): # Fallback if not directly on beatmap object + if not bs_id and current_beatmapset_data: # Fallback if not directly on beatmap object bs_id = current_beatmapset_data.get("id") beatmap_url = target_beatmap.get("url", f"https://osu.ppy.sh/b/{b_id}") beatmap_cover_url = discord.Embed.Empty if current_beatmapset_data and "covers" in current_beatmapset_data: - beatmap_cover_url = current_beatmapset_data["covers"].get( - "card", discord.Embed.Empty - ) + beatmap_cover_url = current_beatmapset_data["covers"].get("card", discord.Embed.Empty) - mode_int = int( - target_beatmap.get("ruleset_id", 0) - ) # ruleset_id is the integer mode + mode_int = int(target_beatmap.get("ruleset_id", 0)) # ruleset_id is the integer mode mode_name = self.get_mode_name(mode_int, user_id_for_l10n) # For footer: num_diffs_in_set @@ -254,16 +232,12 @@ async def on_message(self, message: discord.Message) -> None: # Or, if we fetched the full set, len(beatmap_data_list) when beatmapset_id_str was used. num_diffs_to_report_in_footer = 0 - if ( - not beatmap_id_str and beatmapset_id_str - ): # It was a query for a full beatmapset + if not beatmap_id_str and beatmapset_id_str: # It was a query for a full beatmapset if ( current_beatmapset_data and "total" in current_beatmapset_data ): # API v2 Beatmapset has 'total' beatmaps num_diffs_to_report_in_footer = current_beatmapset_data["total"] - elif ( - beatmap_data_list - ): # Fallback using the length of the fetched list of diffs + elif beatmap_data_list: # Fallback using the length of the fetched list of diffs num_diffs_to_report_in_footer = len(beatmap_data_list) # If beatmap_id_str was present, we don't show "multiple difficulties" footer usually. @@ -278,9 +252,7 @@ async def on_message(self, message: discord.Message) -> None: embed.add_field( name=lstr(user_id_for_l10n, "beatmap_creator_label"), - value=f"[{creator}](https://osu.ppy.sh/u/{creator_id})" - if creator_id - else creator, + value=f"[{creator}](https://osu.ppy.sh/u/{creator_id})" if creator_id else creator, inline=True, ) embed.add_field( @@ -297,21 +269,15 @@ async def on_message(self, message: discord.Message) -> None: stats_text = f"CS: `{cs}` AR: `{ar}` OD: `{od}` HP: `{hp}`" embed.add_field( - name=lstr(user_id_for_l10n, "beatmap_stats_label"), - value=stats_text, - inline=False, + name=lstr(user_id_for_l10n, "beatmap_stats_label"), value=stats_text, inline=False ) length_formatted = f"{self.format_length(total_length, user_id_for_l10n)} ({self.format_length(hit_length, user_id_for_l10n)} {lstr(user_id_for_l10n, 'short_playable_time_indicator', default_fallback='play')})" embed.add_field( - name=lstr(user_id_for_l10n, "beatmap_length_label"), - value=length_formatted, - inline=True, + name=lstr(user_id_for_l10n, "beatmap_length_label"), value=length_formatted, inline=True ) embed.add_field( - name=lstr(user_id_for_l10n, "beatmap_bpm_label"), - value=f"{bpm:.0f}", - inline=True, + name=lstr(user_id_for_l10n, "beatmap_bpm_label"), value=f"{bpm:.0f}", inline=True ) if max_combo: embed.add_field( @@ -321,9 +287,7 @@ async def on_message(self, message: discord.Message) -> None: ) else: # Create a placeholder field if max_combo is None or 0 embed.add_field( - name=lstr(user_id_for_l10n, "beatmap_max_combo_label"), - value="N/A", - inline=True, + name=lstr(user_id_for_l10n, "beatmap_max_combo_label"), value="N/A", inline=True ) # 頁腳 diff --git a/cogs/copypasta_cog.py b/cogs/copypasta_cog.py index 016f1af..350dcee 100644 --- a/cogs/copypasta_cog.py +++ b/cogs/copypasta_cog.py @@ -43,9 +43,7 @@ def load_copypastas(self) -> None: ) self.copypastas = {} else: - logger.error( - f"[CopypastaCog] {COPASTA_FILE} not found. No copypastas loaded." - ) + logger.error(f"[CopypastaCog] {COPASTA_FILE} not found. No copypastas loaded.") self.copypastas = {} except json.JSONDecodeError: logger.error( @@ -59,8 +57,7 @@ def load_copypastas(self) -> None: self.copypastas = {} @app_commands.command( - name="copypasta", - description="Sends a random copypasta based on your language preference.", + name="copypasta", description="Sends a random copypasta based on your language preference." ) async def send_copypasta(self, interaction: discord.Interaction) -> None: if not self.copypastas: @@ -94,12 +91,8 @@ async def send_copypasta(self, interaction: discord.Interaction) -> None: # 2. If preferred language had no pastas (or lang key didn't exist) AND it's not the copypasta default, try copypasta default if not pastas_to_choose_from and preferred_lang != copypasta_default_lang_key: - if ( - self.copypastas.get(copypasta_default_lang_key) - ): - pastas_to_choose_from = list( - self.copypastas[copypasta_default_lang_key].values() - ) + if self.copypastas.get(copypasta_default_lang_key): + pastas_to_choose_from = list(self.copypastas[copypasta_default_lang_key].values()) logger.debug( f"[CopypastaCog] User {user_id} preferred {preferred_lang} (no pastas), falling back to {copypasta_default_lang_key}. Found {len(pastas_to_choose_from)} pastas." ) diff --git a/cogs/help_cog.py b/cogs/help_cog.py index 6d52e7e..f29442c 100644 --- a/cogs/help_cog.py +++ b/cogs/help_cog.py @@ -31,8 +31,7 @@ def __init__(self, bot: commands.Bot) -> None: self.bot = bot @app_commands.command( - name="help", - description="Displays all available slash commands and their descriptions.", + name="help", description="Displays all available slash commands and their descriptions." ) async def slash_help(self, interaction: discord.Interaction) -> None: logger.debug( @@ -46,12 +45,8 @@ async def slash_help(self, interaction: discord.Interaction) -> None: test_title_key = "help_embed_title" english_fallback_title = "Available Slash Commands (Fallback)" - localized_title_test = lstr( - user_id_for_l10n, test_title_key, english_fallback_title - ) - logger.debug( - f"[HelpCog] Attempted lstr for '{test_title_key}': '{localized_title_test}'" - ) + localized_title_test = lstr(user_id_for_l10n, test_title_key, english_fallback_title) + logger.debug(f"[HelpCog] Attempted lstr for '{test_title_key}': '{localized_title_test}'") try: await interaction.response.defer(ephemeral=True) @@ -75,28 +70,20 @@ def sort_key(cmd): try: return DESIRED_COMMAND_ORDER.index(cmd.name) except ValueError: - return len( - DESIRED_COMMAND_ORDER - ) # Put commands not in the list at the end + return len(DESIRED_COMMAND_ORDER) # Put commands not in the list at the end sorted_commands = sorted(all_app_commands, key=sort_key) - logger.debug( - f"[HelpCog] Sorted commands: {[cmd.name for cmd in sorted_commands]}" - ) + logger.debug(f"[HelpCog] Sorted commands: {[cmd.name for cmd in sorted_commands]}") commands_to_display = [] for i, cmd in enumerate(sorted_commands): - logger.debug( - f"[HelpCog] Processing command {i + 1}/{len(sorted_commands)}: {cmd.name}" - ) + logger.debug(f"[HelpCog] Processing command {i + 1}/{len(sorted_commands)}: {cmd.name}") if isinstance(cmd, app_commands.Command): cmd_name = cmd.name cmd_description = cmd.description if not cmd_description or cmd_description == "...": cmd_description = lstr( - user_id_for_l10n, - "help_no_description", - "No description available.", + user_id_for_l10n, "help_no_description", "No description available." ) localized_desc_key = f"cmd_desc_{cmd_name.lower()}" @@ -130,9 +117,7 @@ def sort_key(cmd): logger.error(f"[HelpCog] Failed to send help embed: {e_send}") # Try to send a simple text message if embed fails try: - fallback_text = ( - "Could not display help commands as an embed. Please check logs." - ) + fallback_text = "Could not display help commands as an embed. Please check logs." if commands_to_display: fallback_text = "\n".join(commands_to_display) await interaction.followup.send(fallback_text, ephemeral=True) diff --git a/cogs/keyword_cog.py b/cogs/keyword_cog.py index b8e036d..d71d948 100644 --- a/cogs/keyword_cog.py +++ b/cogs/keyword_cog.py @@ -26,9 +26,7 @@ class DeleteConfirmView(discord.ui.View): """刪除確認視圖""" def __init__( - self, - message_to_delete: discord.Message, - requester: discord.User | discord.Member, + self, message_to_delete: discord.Message, requester: discord.User | discord.Member ) -> None: """初始化確認視圖 @@ -42,9 +40,7 @@ def __init__( self.value: bool | None = None @discord.ui.button(label="確認刪除", style=discord.ButtonStyle.danger, emoji="🗑️") - async def confirm( - self, interaction: Interaction, button: discord.ui.Button - ) -> None: + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: """確認刪除按鈕 Args: @@ -68,18 +64,12 @@ async def confirm( tracker = get_message_tracker() tracker.remove_message(message_id) - await interaction.response.send_message( - "✅ 已成功刪除訊息!", ephemeral=True - ) + await interaction.response.send_message("✅ 已成功刪除訊息!", ephemeral=True) logger.info(f"🗑️ 用戶 {self.requester} 刪除了機器人訊息 (ID: {message_id})") except discord.NotFound: - await interaction.response.send_message( - "❌ 訊息已被刪除或不存在。", ephemeral=True - ) + await interaction.response.send_message("❌ 訊息已被刪除或不存在。", ephemeral=True) except discord.Forbidden: - await interaction.response.send_message( - "❌ 機器人沒有權限刪除此訊息。", ephemeral=True - ) + await interaction.response.send_message("❌ 機器人沒有權限刪除此訊息。", ephemeral=True) except Exception as e: logger.error(f"❌ 刪除訊息時發生錯誤: {e}", exc_info=True) await interaction.response.send_message( @@ -154,9 +144,7 @@ async def on_submit(self, interaction: Interaction) -> None: response = self.response_input.value.strip() if not interaction.guild: - await interaction.response.send_message( - "❌ 此命令只能在伺服器中使用!", ephemeral=True - ) + await interaction.response.send_message("❌ 此命令只能在伺服器中使用!", ephemeral=True) return # 獲取伺服器關鍵詞 @@ -177,8 +165,7 @@ async def on_submit(self, interaction: Interaction) -> None: self.cog.save_keywords() await interaction.response.send_message( - f"✅ 成功添加關鍵詞!\n**關鍵詞:** `{keyword}`\n**回覆:** {response}", - ephemeral=True, + f"✅ 成功添加關鍵詞!\n**關鍵詞:** `{keyword}`\n**回覆:** {response}", ephemeral=True ) logger.info( @@ -200,9 +187,7 @@ async def on_error(self, interaction: Interaction, error: Exception) -> None: "❌ 處理請求時發生錯誤,請稍後再試。", ephemeral=True ) except discord.InteractionResponded: - await interaction.followup.send( - "❌ 處理請求時發生錯誤,請稍後再試。", ephemeral=True - ) + await interaction.followup.send("❌ 處理請求時發生錯誤,請稍後再試。", ephemeral=True) class KeywordCog(commands.Cog): @@ -221,8 +206,7 @@ def __init__(self, bot: commands.Bot) -> None: # 添加 Message Context Menu(通用刪除功能) self.ctx_menu = app_commands.ContextMenu( - name="刪除此訊息", - callback=self.delete_bot_message, + name="刪除此訊息", callback=self.delete_bot_message ) self.bot.tree.add_command(self.ctx_menu) @@ -233,9 +217,7 @@ def load_keywords(self) -> None: with pathlib.Path(self.keywords_file).open("r", encoding="utf-8") as f: data = json.load(f) # 過濾掉註釋和格式說明 - self.keywords = { - k: v for k, v in data.items() if not k.startswith("_") - } + self.keywords = {k: v for k, v in data.items() if not k.startswith("_")} logger.info(f"✅ 已載入 {len(self.keywords)} 個伺服器的關鍵詞配置") else: self.keywords = {} @@ -302,9 +284,7 @@ def get_guild_keywords(self, guild_id: int) -> dict[str, str]: self.keywords[guild_id_str] = {} return self.keywords[guild_id_str] - async def delete_bot_message( - self, interaction: Interaction, message: discord.Message - ) -> None: + async def delete_bot_message(self, interaction: Interaction, message: discord.Message) -> None: """刪除機器人訊息(Message Context Menu 回調) 支持兩種類型的訊息: @@ -331,9 +311,7 @@ async def delete_bot_message( # 方式 1:檢查是否為回覆(reply)- 用於關鍵詞 if message.reference and message.reference.message_id: try: - original_message = await message.channel.fetch_message( - message.reference.message_id - ) + original_message = await message.channel.fetch_message(message.reference.message_id) trigger_user_id = original_message.author.id message_type = "keyword" original_content = original_message.content @@ -418,13 +396,9 @@ async def on_message(self, message: discord.Message) -> None: logger.error(f"❌ 發送關鍵詞回覆失敗: {e}", exc_info=True) # Slash Commands 群組 - keyword_group = app_commands.Group( - name="keyword", description="關鍵詞管理命令(僅管理員)" - ) + keyword_group = app_commands.Group(name="keyword", description="關鍵詞管理命令(僅管理員)") - @keyword_group.command( - name="add", description="添加新的關鍵詞觸發(使用彈出式表單)" - ) + @keyword_group.command(name="add", description="添加新的關鍵詞觸發(使用彈出式表單)") async def keyword_add(self, interaction: Interaction) -> None: """添加新的關鍵詞(使用 Modal) @@ -439,9 +413,7 @@ async def keyword_add(self, interaction: Interaction) -> None: return if not interaction.guild: - await interaction.response.send_message( - "❌ 此命令只能在伺服器中使用!", ephemeral=True - ) + await interaction.response.send_message("❌ 此命令只能在伺服器中使用!", ephemeral=True) return # 顯示 Modal @@ -456,9 +428,7 @@ async def keyword_list(self, interaction: Interaction) -> None: interaction: Discord 互動對象 """ if not interaction.guild: - await interaction.response.send_message( - "❌ 此命令只能在伺服器中使用!", ephemeral=True - ) + await interaction.response.send_message("❌ 此命令只能在伺服器中使用!", ephemeral=True) return # 獲取伺服器關鍵詞 @@ -466,8 +436,7 @@ async def keyword_list(self, interaction: Interaction) -> None: if not guild_keywords: await interaction.response.send_message( - "📝 此伺服器還沒有設定任何關鍵詞。\n" - "管理員可以使用 `/keyword add` 添加關鍵詞。", + "📝 此伺服器還沒有設定任何關鍵詞。\n管理員可以使用 `/keyword add` 添加關鍵詞。", ephemeral=True, ) return @@ -482,15 +451,11 @@ async def keyword_list(self, interaction: Interaction) -> None: # 添加關鍵詞字段(最多顯示 25 個) for _i, (keyword, response) in enumerate(list(guild_keywords.items())[:25]): # 截斷過長的回覆 - display_response = ( - response if len(response) <= 100 else response[:97] + "..." - ) + display_response = response if len(response) <= 100 else response[:97] + "..." embed.add_field(name=f"🔑 {keyword}", value=display_response, inline=False) if len(guild_keywords) > 25: - embed.set_footer( - text=f"僅顯示前 25 個關鍵詞,共有 {len(guild_keywords)} 個" - ) + embed.set_footer(text=f"僅顯示前 25 個關鍵詞,共有 {len(guild_keywords)} 個") await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/cogs/osu_cog.py b/cogs/osu_cog.py index 76fbb43..e5ef471 100644 --- a/cogs/osu_cog.py +++ b/cogs/osu_cog.py @@ -69,11 +69,7 @@ # NEW VIEW FOR /best COMMAND class PreviousBestButton(discord.ui.Button): def __init__( - self, - user_id_for_l10n: int, - style=discord.ButtonStyle.secondary, - emoji="⬅️", - **kwargs, + self, user_id_for_l10n: int, style=discord.ButtonStyle.secondary, emoji="⬅️", **kwargs ) -> None: super().__init__( label=lstr(user_id_for_l10n, "button_previous_bp", "Previous BP"), @@ -94,11 +90,7 @@ async def callback(self, interaction: discord.Interaction) -> None: class NextBestButton(discord.ui.Button): def __init__( - self, - user_id_for_l10n: int, - style=discord.ButtonStyle.secondary, - emoji="➡️", - **kwargs, + self, user_id_for_l10n: int, style=discord.ButtonStyle.secondary, emoji="➡️", **kwargs ) -> None: super().__init__( label=lstr(user_id_for_l10n, "button_next_bp", "Next BP"), @@ -121,9 +113,7 @@ async def callback(self, interaction: discord.Interaction) -> None: class JumpToBPModal(discord.ui.Modal): def __init__(self, view: BestScoreView, user_id_for_l10n: int) -> None: super().__init__( - title=lstr( - user_id_for_l10n, "modal_jump_to_bp_title", "Jump to Specific BP" - ) + title=lstr(user_id_for_l10n, "modal_jump_to_bp_title", "Jump to Specific BP") ) self.parent_view = view self.user_id_for_l10n = user_id_for_l10n @@ -133,13 +123,9 @@ def __init__(self, view: BestScoreView, user_id_for_l10n: int) -> None: user_id_for_l10n, "modal_bp_rank_label", "BP Rank (1-{max_bp})", # English fallback - max_bp=len(self.parent_view.scores_list) - if self.parent_view.scores_list - else 200, - ), - placeholder=lstr( - user_id_for_l10n, "modal_bp_rank_placeholder", "Enter a number" + max_bp=len(self.parent_view.scores_list) if self.parent_view.scores_list else 200, ), + placeholder=lstr(user_id_for_l10n, "modal_bp_rank_placeholder", "Enter a number"), min_length=1, max_length=3, # Max 100 for BP usually ) @@ -160,12 +146,8 @@ async def on_submit(self, interaction: discord.Interaction) -> None: ) return - self.parent_view.current_index = ( - rank_to_jump - 1 - ) # Convert to 0-based index - await self.parent_view.update_embed( - interaction - ) # This will defer internally and edit + self.parent_view.current_index = rank_to_jump - 1 # Convert to 0-based index + await self.parent_view.update_embed(interaction) # This will defer internally and edit except ValueError: await interaction.response.send_message( lstr( @@ -178,18 +160,13 @@ async def on_submit(self, interaction: discord.Interaction) -> None: except Exception as e: # Catch other potential errors from update_embed logger.error(f"[JumpToBPModal on_submit] Error: {e}", exc_info=True) await interaction.response.send_message( - lstr(self.user_id_for_l10n, "error_generic", "處理跳轉時發生錯誤。"), - ephemeral=True, + lstr(self.user_id_for_l10n, "error_generic", "處理跳轉時發生錯誤。"), ephemeral=True ) class JumpToBPButton(discord.ui.Button): def __init__( - self, - user_id_for_l10n: int, - style=discord.ButtonStyle.secondary, - emoji="\u23f9", - **kwargs, + self, user_id_for_l10n: int, style=discord.ButtonStyle.secondary, emoji="\u23f9", **kwargs ) -> None: # Unicode for :stop_button: super().__init__( label=lstr(user_id_for_l10n, "button_jump_to_bp", "Jump"), @@ -249,9 +226,7 @@ def _update_button_states(self) -> None: # For now, labels are set at init. If dynamic l10n for buttons is needed, it's more complex. async def update_embed(self, interaction: discord.Interaction) -> None: - await ( - interaction.response.defer() - ) # Defer response as embed generation might take time + await interaction.response.defer() # Defer response as embed generation might take time current_score = self.scores_list[self.current_index] # We need to call the cog's embed creation method. @@ -284,11 +259,7 @@ async def on_timeout(self) -> None: # NEW VIEW FOR /recent COMMAND class PreviousRecentButton(discord.ui.Button): def __init__( - self, - user_id_for_l10n: int, - style=discord.ButtonStyle.secondary, - emoji="⬅️", - **kwargs, + self, user_id_for_l10n: int, style=discord.ButtonStyle.secondary, emoji="⬅️", **kwargs ) -> None: super().__init__( label=lstr(user_id_for_l10n, "button_previous_recent", "Previous Play"), @@ -308,11 +279,7 @@ async def callback(self, interaction: discord.Interaction) -> None: class NextRecentButton(discord.ui.Button): def __init__( - self, - user_id_for_l10n: int, - style=discord.ButtonStyle.secondary, - emoji="➡️", - **kwargs, + self, user_id_for_l10n: int, style=discord.ButtonStyle.secondary, emoji="➡️", **kwargs ) -> None: super().__init__( label=lstr(user_id_for_l10n, "button_next_recent", "Next Play"), @@ -421,13 +388,9 @@ def _get_beatmap_attributes_display( if attributes: field_value_parts.append(" | ".join(attributes)) else: # Should not happen if mode_name_for_field is always present - field_name_key = ( - "beatmap_mode_label" # Fallback to just mode label if no attributes - ) + field_name_key = "beatmap_mode_label" # Fallback to just mode label if no attributes - final_field_name = self._get_lstr_with_na_fallback( - user_id_for_l10n, field_name_key - ) + final_field_name = self._get_lstr_with_na_fallback(user_id_for_l10n, field_name_key) final_field_value = "\n".join( field_value_parts ) # Use newline to separate mode name and stats string @@ -437,32 +400,23 @@ def _get_beatmap_attributes_display( def get_na_value(self, user_id_for_l10n: int) -> str: # Determine language for N/A value current_lang = get_user_language(str(user_id_for_l10n)) - return _translations.get(current_lang, {}).get( - "value_not_available", "N/A" - ) + return _translations.get(current_lang, {}).get("value_not_available", "N/A") # No need for complex checks if we fetch directly and have a hardcoded fallback def _get_lstr_with_na_fallback(self, user_id_for_l10n: int, key: str, *args) -> str: raw_translation = lstr(user_id_for_l10n, key, *args) - if ( - " str: + def get_mode_name(self, mode_int: int, user_id_for_l10n: int, name_only: bool = False) -> str: current_lang = get_user_language(str(user_id_for_l10n)) logger.debug( f"[OSU_COG get_mode_name] Called with mode_int: {mode_int}, user_id: {user_id_for_l10n}, determined_lang: {current_lang}, name_only: {name_only}" ) key_map = OSU_MODES_NAME_ONLY_L10N_KEYS if name_only else OSU_MODES_L10N_KEYS - l10n_key = key_map.get( - mode_int, "mode_name_only_unknown" if name_only else "mode_unknown" - ) + l10n_key = key_map.get(mode_int, "mode_name_only_unknown" if name_only else "mode_unknown") # Directly use _translations with current_lang localized_name = _translations.get(current_lang, {}).get(l10n_key) @@ -471,9 +425,7 @@ def get_mode_name( not localized_name or localized_name == l10n_key ): # If key not found in current_lang translations # Try fallback to default language for the key - localized_name = _translations.get(config.DEFAULT_LANGUAGE, {}).get( - l10n_key - ) + localized_name = _translations.get(config.DEFAULT_LANGUAGE, {}).get(l10n_key) if ( not localized_name or localized_name == l10n_key @@ -490,9 +442,7 @@ async def _determine_game_mode( ) -> int: """Determines the actual game mode to use based on user input and player defaults.""" if requested_mode_int is not None: - logger.debug( - f"[OSU_COG /{command_name}] User provided mode: {requested_mode_int}" - ) + logger.debug(f"[OSU_COG /{command_name}] User provided mode: {requested_mode_int}") return requested_mode_int user_api_default_mode_str = player_data.get("playmode") if user_api_default_mode_str: @@ -500,9 +450,7 @@ async def _determine_game_mode( v: k for k, v in OSU_MODES_INT_TO_STRING.items() } # Stays local for now if user_api_default_mode_str in REVERSE_OSU_MODES_INT_TO_STRING: - determined_mode_int = REVERSE_OSU_MODES_INT_TO_STRING[ - user_api_default_mode_str - ] + determined_mode_int = REVERSE_OSU_MODES_INT_TO_STRING[user_api_default_mode_str] logger.debug( f"[OSU_COG /{command_name}] Using user API default mode: {user_api_default_mode_str} -> {determined_mode_int}" ) @@ -517,14 +465,10 @@ async def _determine_game_mode( return config.DEFAULT_OSU_MODE async def _get_user_data(self, user_identifier: str, user_id_for_l10n: int): - logger.debug( - f"[OSU_COG _get_user_data] Attempting to get user: {user_identifier}" - ) + logger.debug(f"[OSU_COG _get_user_data] Attempting to get user: {user_identifier}") user_data = await self.osu_api.get_user(user_identifier=user_identifier) if not user_data: - error_message = lstr( - user_id_for_l10n, "error_user_not_found", user_identifier - ) + error_message = lstr(user_id_for_l10n, "error_user_not_found", user_identifier) logger.debug( f"[OSU_COG _get_user_data] User not found. Returning error: {error_message}" ) @@ -532,9 +476,7 @@ async def _get_user_data(self, user_identifier: str, user_id_for_l10n: int): logger.debug("[OSU_COG _get_user_data] User found. Returning data.") return user_data, None - @app_commands.command( - name="recent", description="Shows the most recent osu! score for a user." - ) + @app_commands.command(name="recent", description="Shows the most recent osu! score for a user.") @app_commands.describe( osu_user="osu! username (optional)", osu_id="osu! user ID (optional)", @@ -568,9 +510,7 @@ async def recent( elif osu_user: user_identifier = osu_user.strip() else: - bound_osu_user = await user_data_manager.get_user_binding( - user_id_for_l10n - ) + bound_osu_user = await user_data_manager.get_user_binding(user_id_for_l10n) if bound_osu_user: user_identifier = str(bound_osu_user) else: @@ -578,27 +518,19 @@ async def recent( lstr(user_id_for_l10n, "error_osu_user_not_provided_or_bound") ) return - player_data, error_msg = await self._get_user_data( - user_identifier, user_id_for_l10n - ) + player_data, error_msg = await self._get_user_data(user_identifier, user_id_for_l10n) if error_msg: await interaction.followup.send( - lstr( - user_id_for_l10n, "error_user_not_found", str(user_identifier) - ), + lstr(user_id_for_l10n, "error_user_not_found", str(user_identifier)), ephemeral=True, ) return numeric_user_id = player_data.get("id") - player_name_for_embed = ( - player_data.get("username") or str(user_identifier).strip() - ) + player_name_for_embed = player_data.get("username") or str(user_identifier).strip() player_avatar_url = player_data.get("avatar_url", None) - actual_mode_int = await self._determine_game_mode( - mode, player_data, "recent" - ) + actual_mode_int = await self._determine_game_mode(mode, player_data, "recent") actual_mode_str = OSU_MODES_INT_TO_STRING.get(actual_mode_int) if actual_mode_str is None: @@ -624,9 +556,7 @@ async def recent( ephemeral=True, ) return - logger.debug( - f"[OSU_COG DEBUG /recent] USER_INPUT_PROCESSED: '{processed_user_input}'" - ) + logger.debug(f"[OSU_COG DEBUG /recent] USER_INPUT_PROCESSED: '{processed_user_input}'") logger.debug( f"[OSU_COG DEBUG /recent] GET_USER_DATA_START: Calling _get_user_data for '{processed_user_input}'" @@ -650,9 +580,7 @@ async def recent( ) error_message_key = "error_no_recent_plays" await interaction.followup.send( - lstr( - user_id_for_l10n, error_message_key, "", player_name_for_embed - ), + lstr(user_id_for_l10n, error_message_key, "", player_name_for_embed), ephemeral=True, ) return @@ -699,9 +627,7 @@ async def recent( else str(e_fetch_recent)[:100] + "..." ) # Ensure fallback for lstr - error_report_msg = lstr( - user_id_for_l10n, "error_generic_command", error_detail - ) + error_report_msg = lstr(user_id_for_l10n, "error_generic_command", error_detail) if " str: class ModSelect(discord.ui.Select): def __init__(self, parent_view, available_mods) -> None: self.parent_view = parent_view - options = [ - discord.SelectOption(label=mod.upper(), value=mod) for mod in available_mods - ] + options = [discord.SelectOption(label=mod.upper(), value=mod) for mod in available_mods] # Add "No Mods" option - options.insert( - 0, discord.SelectOption(label="-- No Mods --", value="_no_mods_") - ) + options.insert(0, discord.SelectOption(label="-- No Mods --", value="_no_mods_")) if not options: options = [ - discord.SelectOption( - label="No Mods Available", value="_disabled", default=True - ) + discord.SelectOption(label="No Mods Available", value="_disabled", default=True) ] super().__init__( placeholder="No mods applicable...", @@ -97,18 +91,14 @@ def __init__( if self.all_maps_in_set and self.current_difficulty_index is not None: # If pagination is active, add pagination buttons - prev_label = lstr( - self.user_id_for_l10n, "button_prev_difficulty", "⬅️ Prev Diff" - ) + prev_label = lstr(self.user_id_for_l10n, "button_prev_difficulty", "⬅️ Prev Diff") self.prev_difficulty_button = discord.ui.Button( label=prev_label, style=discord.ButtonStyle.secondary, row=1 ) self.prev_difficulty_button.callback = self.prev_difficulty_callback self.add_item(self.prev_difficulty_button) - next_label = lstr( - self.user_id_for_l10n, "button_next_difficulty", "Next Diff ➡️" - ) + next_label = lstr(self.user_id_for_l10n, "button_next_difficulty", "Next Diff ➡️") self.next_difficulty_button = discord.ui.Button( label=next_label, style=discord.ButtonStyle.secondary, row=1 ) @@ -326,20 +316,12 @@ async def _generate_pp_embed( # lstr is already available in the calling scope of pp command, let's ensure it's passed or accessible # User ID for l10n is already available as user_id_for_l10n - status_display_string = get_beatmap_status_display( - raw_status, - user_id_for_l10n, - lstr, - ) + status_display_string = get_beatmap_status_display(raw_status, user_id_for_l10n, lstr) stars_raw = attributes_data.get("star_rating") pp_100_raw = attributes_data.get("pp") - max_combo_from_api_attrs = attributes_data.get( - "max_combo" - ) # From /attributes POST - if ( - max_combo_from_api_attrs is None - ): # Fallback to beatmap details if not in /attributes + max_combo_from_api_attrs = attributes_data.get("max_combo") # From /attributes POST + if max_combo_from_api_attrs is None: # Fallback to beatmap details if not in /attributes max_combo_from_api_attrs = target_beatmap.get("max_combo") osu_file_path = None # To store path of downloaded .osu file @@ -362,9 +344,7 @@ async def _generate_pp_embed( # No need to check if osu_file_path is None, as download_osu_file now raises on error. # Parse metadata from .osu file and use it - parsed_metadata = beatmap_utils.parse_osu_file_metadata( - osu_file_path - ) + parsed_metadata = beatmap_utils.parse_osu_file_metadata(osu_file_path) artist = parsed_metadata.get("artist", artist) title = parsed_metadata.get("title", title) version = parsed_metadata.get("version", version) @@ -373,11 +353,7 @@ async def _generate_pp_embed( ) rosu_pp_result = await beatmap_utils.calculate_pp_with_rosu( - osu_file_path, - selected_mods_list, - accuracy=100.0, - combo=None, - misses=0, + osu_file_path, selected_mods_list, accuracy=100.0, combo=None, misses=0 ) # No need to check if rosu_pp_result is None, as calculate_pp_with_rosu now raises on error. @@ -395,9 +371,7 @@ async def _generate_pp_embed( except beatmap_utils.RosuPpCalculationError as e: logger.error(f"[PpCog] Error calculating PP with rosu-pp: {e}") rosu_pp_error_message_key = "error_rosupp_calculation_failed" - except ( - Exception - ) as e: # Catch any other unexpected errors during this block + except Exception as e: # Catch any other unexpected errors during this block logger.error( f"[PpCog] Unexpected error during rosu-pp processing: {type(e).__name__} - {e}" ) @@ -406,9 +380,7 @@ async def _generate_pp_embed( if osu_file_path and pathlib.Path(osu_file_path).exists(): beatmap_utils.delete_osu_file(osu_file_path) else: - logger.warning( - "[PpCog] osu_api.session not available for .osu download." - ) + logger.warning("[PpCog] osu_api.session not available for .osu download.") # This case might also warrant a user-facing message if it implies rosu-pp cannot be attempted. # For now, it only logs. @@ -435,7 +407,9 @@ async def _generate_pp_embed( key_display_line = f"Key: `{cs}` " cs_display = "N/A" - attributes_line = f"CS: `{cs_display}` AR: `{ar_display}` HP: `{hp_display}` OD: `{od_display}`".strip() + attributes_line = ( + f"CS: `{cs_display}` AR: `{ar_display}` HP: `{hp_display}` OD: `{od_display}`".strip() + ) if key_display_line: attributes_line = f"{key_display_line}{attributes_line}" @@ -504,9 +478,7 @@ async def _generate_pp_embed( estimated_suffix = lstr(user_id_for_l10n, "pp_estimated_suffix", "(估算值)") pp_100_field_name += f" {estimated_suffix}" # Add to field name for clarity - embed.add_field( - name=pp_100_field_name, value=f"`{pp_value_string}`", inline=False - ) + embed.add_field(name=pp_100_field_name, value=f"`{pp_value_string}`", inline=False) # If there was an error during rosu-pp processing, send a follow-up message if rosu_pp_error_message_key: @@ -551,9 +523,7 @@ async def _generate_pp_embed( return embed - @app_commands.command( - name="pp", description="Shows PP information for an osu! beatmap." - ) + @app_commands.command(name="pp", description="Shows PP information for an osu! beatmap.") @app_commands.describe( url="The URL of the osu! beatmap (either beatmapset or specific difficulty)." ) @@ -598,19 +568,14 @@ async def pp(self, interaction: discord.Interaction, url: str) -> None: def sort_key(bm): mode_priority = {"osu": 0, "taiko": 1, "fruits": 2, "mania": 3} # Sort by mode, then by difficulty_rating ASCENDING (Easy -> Hard) - return ( - mode_priority.get(bm.get("mode"), 4), - float(bm.get("difficulty_rating", 0)), - ) + return (mode_priority.get(bm.get("mode"), 4), float(bm.get("difficulty_rating", 0))) if beatmap_id: # Specific difficulty URL if not beatmapset_id: temp_beatmap_details = await self.osu_api.get_beatmap_details( beatmap_id=beatmap_id ) - if not temp_beatmap_details or not temp_beatmap_details.get( - "beatmapset_id" - ): + if not temp_beatmap_details or not temp_beatmap_details.get("beatmapset_id"): await interaction.followup.send( lstr( user_id_for_l10n, @@ -621,9 +586,7 @@ def sort_key(bm): return beatmapset_id = temp_beatmap_details.get("beatmapset_id") - beatmapset_data = await self.osu_api.get_beatmapset( - beatmapset_id=beatmapset_id - ) + beatmapset_data = await self.osu_api.get_beatmapset(beatmapset_id=beatmapset_id) if not beatmapset_data or not beatmapset_data.get("beatmaps"): await interaction.followup.send( lstr( @@ -658,24 +621,18 @@ def sort_key(bm): logger.warning( f"[PpCog] beatmap_id {beatmap_id} not found in its own sorted beatmapset (ascending sort). Defaulting to index 0." ) - initial_difficulty_index = ( - 0 # Fallback, though ideally should always be found - ) + initial_difficulty_index = 0 # Fallback, though ideally should always be found if initial_difficulty_index >= len( all_maps_in_set_for_pagination ): # Should not happen with StopIteration logic initial_difficulty_index = 0 - target_beatmap = all_maps_in_set_for_pagination[ - initial_difficulty_index - ] + target_beatmap = all_maps_in_set_for_pagination[initial_difficulty_index] # beatmap_id is the one user requested, ensure target_beatmap reflects this specific one. elif beatmapset_id: # Beatmapset URL (no specific difficulty initially) - beatmapset_data = await self.osu_api.get_beatmapset( - beatmapset_id=beatmapset_id - ) + beatmapset_data = await self.osu_api.get_beatmapset(beatmapset_id=beatmapset_id) if not beatmapset_data or not beatmapset_data.get("beatmaps"): await interaction.followup.send( lstr( @@ -712,9 +669,7 @@ def sort_key(bm): initial_difficulty_index = ( 0 # For beatmapset URL, start with the first (easiest after sort) ) - target_beatmap = all_maps_in_set_for_pagination[ - initial_difficulty_index - ] + target_beatmap = all_maps_in_set_for_pagination[initial_difficulty_index] beatmap_id = target_beatmap.get( "id" ) # Update beatmap_id to reflect the initial target @@ -747,10 +702,7 @@ def sort_key(bm): beatmap_id=beatmap_id, mods=[], ruleset_id=current_ruleset_id ) - if ( - not initial_beatmap_attributes - or "attributes" not in initial_beatmap_attributes - ): + if not initial_beatmap_attributes or "attributes" not in initial_beatmap_attributes: await interaction.followup.send( lstr( user_id_for_l10n, diff --git a/cogs/user_cog.py b/cogs/user_cog.py index 1973607..3493103 100644 --- a/cogs/user_cog.py +++ b/cogs/user_cog.py @@ -40,12 +40,7 @@ } # New: Fallback text for modes if localized name is problematic, to accompany emoji -MODE_FALLBACK_TEXT = { - 0: "osu!", - 1: "Taiko", - 2: "Catch", - 3: "Mania", -} +MODE_FALLBACK_TEXT = {0: "osu!", 1: "Taiko", 2: "Catch", 3: "Mania"} # 用於將國家代碼轉換為旗幟 Emoji @@ -74,7 +69,9 @@ def get_country_name(country_code: str, lang: str = "en") -> str: ): # common_name might be better for Chinese # Try to get specific chinese name if available, else official try: - return country.name # pycountry might have some chinese names in .name for some countries + return ( + country.name + ) # pycountry might have some chinese names in .name for some countries except KeyError: pass # fall through return country.name @@ -103,9 +100,7 @@ def get_na_value(self, user_id_for_l10n: int) -> str: logger.warning( "[USER_COG] lstr returned unexpected placeholder for 'value_not_available' (matched exact error string). Forced to '無法取得'." ) - return ( - "無法取得" # Forcing zh_TW N/A as a temporary fix for the user's case - ) + return "無法取得" # Forcing zh_TW N/A as a temporary fix for the user's case # Original checks for other error conditions if ( @@ -127,9 +122,7 @@ def _get_lstr_with_na_fallback(self, user_id_for_l10n: int, key: str, *args) -> placeholder_if_key_truly_missing = f"__LSTR_KEY_ERROR__{key}__" # Attempt to get the translation - raw_translation = lstr( - user_id_for_l10n, key, placeholder_if_key_truly_missing, *args - ) + raw_translation = lstr(user_id_for_l10n, key, placeholder_if_key_truly_missing, *args) # Check if the placeholder was returned (meaning key was not found by lstr) # or if lstr indicated a missing translation or formatting error. @@ -138,9 +131,7 @@ def _get_lstr_with_na_fallback(self, user_id_for_l10n: int, key: str, *args) -> or " str: @@ -152,13 +143,9 @@ def get_mode_name(self, mode_int: int, user_id_for_l10n: int) -> str: localized_name = self._get_lstr_with_na_fallback(user_id_for_l10n, l10n_key) # If localized name is N/A or key is unknown, try to use a more generic English fallback - if ( - localized_name == self.get_na_value(user_id_for_l10n) - or l10n_key == "mode_unknown" - ): + if localized_name == self.get_na_value(user_id_for_l10n) or l10n_key == "mode_unknown": base_name = MODE_FALLBACK_TEXT.get( - mode_int, - self._get_lstr_with_na_fallback(user_id_for_l10n, "mode_unknown"), + mode_int, self._get_lstr_with_na_fallback(user_id_for_l10n, "mode_unknown") ) final_display_name = base_name else: @@ -168,10 +155,7 @@ def get_mode_name(self, mode_int: int, user_id_for_l10n: int) -> str: return final_display_name def format_datetime_obj( - self, - dt_obj: datetime.datetime, - user_id_for_l10n: int, - format_key: str = "date_format", + self, dt_obj: datetime.datetime, user_id_for_l10n: int, format_key: str = "date_format" ) -> str: if not dt_obj: return "N/A" # Should use self.get_na_value(user_id_for_l10n) ideally @@ -190,9 +174,7 @@ def format_datetime_obj( format_str = "%Y-%m-%d %H:%M:%S" return dt_obj.strftime(format_str.strip()) except Exception as e: - logger.error( - f"[USER_COG format_datetime_obj] Error formatting datetime: {e}" - ) + logger.error(f"[USER_COG format_datetime_obj] Error formatting datetime: {e}") return str(dt_obj) # Fallback to simple string conversion def time_since( @@ -218,9 +200,7 @@ def time_since( formatted_parts = [] for comp in potential_components: if comp["value"] > 0: - unit_str = lstr( - user_id_for_l10n, comp["unit_key"], comp["default_unit"] - ) + unit_str = lstr(user_id_for_l10n, comp["unit_key"], comp["default_unit"]) formatted_parts.append(f"{comp['value']}{unit_str}") # Handle seconds if no other larger units were added, or if all are zero until seconds @@ -267,6 +247,7 @@ def _build_profile_detail_section( def l(k, *a): return self._get_lstr_with_na_fallback(user_id_for_l10n, k, *a) + na = self.get_na_value(user_id_for_l10n) lines = [] @@ -274,7 +255,9 @@ def l(k, *a): mode_emoji = MODE_EMOJI_STRINGS.get(current_mode_int, "") mode_name = self.get_mode_name(current_mode_int, user_id_for_l10n) game_mode_label = l("user_profile_game_mode", "Game Mode") - lines.extend((f"**{game_mode_label}:** {mode_emoji} {mode_name}", "")) # Add a blank line for spacing + lines.extend( + (f"**{game_mode_label}:** {mode_emoji} {mode_name}", "") + ) # Add a blank line for spacing # 1. Status 區塊 # Variable definitions (most are already in place, ensure all needed are here before building status_item_lines) @@ -284,9 +267,7 @@ def l(k, *a): pp_country_rank = mode_stats.get("country_rank") if mode_stats else None rank_str = f"`#{pp_rank:,}`" if pp_rank is not None else na country_rank_str = ( - f"{country_flag} `#{pp_country_rank:,}`" - if pp_country_rank is not None - else na + f"{country_flag} `#{pp_country_rank:,}`" if pp_country_rank is not None else na ) level = mode_stats.get("level", {}) if mode_stats else {} @@ -319,18 +300,14 @@ def l(k, *a): avg_score_val = None if total_score and playcount and playcount > 0: avg_score_val = total_score / playcount - avg_score_display = ( - f"`{avg_score_val:,.2f}`" if avg_score_val is not None else na - ) + avg_score_display = f"`{avg_score_val:,.2f}`" if avg_score_val is not None else na ranked_score = mode_stats.get("ranked_score") if mode_stats else None ranked_score_display = f"`{ranked_score:,}`" if ranked_score is not None else na avg_ranked_val = None if ranked_score and playcount and playcount > 0: avg_ranked_val = ranked_score / playcount - avg_ranked_display = ( - f"`{avg_ranked_val:,.2f}`" if avg_ranked_val is not None else na - ) + avg_ranked_display = f"`{avg_ranked_val:,.2f}`" if avg_ranked_val is not None else na total_hits = mode_stats.get("total_hits") if mode_stats else None total_hits_display = f"`{total_hits:,}`" if total_hits is not None else na @@ -347,7 +324,24 @@ def l(k, *a): status_item_lines = [] # Order based on user request - status_item_lines.extend((f"**{l('user_profile_global_rank')}:** {rank_str} ({country_rank_str})", f"**{l('user_profile_level')}:** {level_str}", f"**PP:** {pp_str} {l('user_profile_accuracy')}: {acc_str}", f"**{l('user_profile_grades')}:** {grades}", f"**{l('user_profile_accuracy')}:** {acc_str}", f"**{l('user_profile_play_count')}:** {playcount_str}", f"**{l('user_profile_total_score')}:** {total_score_display}", f"**{l('user_profile_avg_score', 'Avg. Score')}:** {avg_score_display}/{l('user_profile_play_short', 'Play')}", f"**{l('user_profile_ranked_score')}:** {ranked_score_display}", f"**{l('user_profile_avg_ranked_score', 'Avg. Ranked Score')}:** {avg_ranked_display}/{l('user_profile_play_short', 'Play')}", f"**{l('user_profile_total_hits')}:** {total_hits_display}", f"**{l('user_profile_avg_hits', 'Avg. Hits')}:** {avg_hits_display}/{l('user_profile_play_short', 'Play')}", f"**{l('user_profile_max_combo')}:** {max_combo_str}", f"**{l('user_profile_replays_watched')}:** {replays_str}")) + status_item_lines.extend( + ( + f"**{l('user_profile_global_rank')}:** {rank_str} ({country_rank_str})", + f"**{l('user_profile_level')}:** {level_str}", + f"**PP:** {pp_str} {l('user_profile_accuracy')}: {acc_str}", + f"**{l('user_profile_grades')}:** {grades}", + f"**{l('user_profile_accuracy')}:** {acc_str}", + f"**{l('user_profile_play_count')}:** {playcount_str}", + f"**{l('user_profile_total_score')}:** {total_score_display}", + f"**{l('user_profile_avg_score', 'Avg. Score')}:** {avg_score_display}/{l('user_profile_play_short', 'Play')}", + f"**{l('user_profile_ranked_score')}:** {ranked_score_display}", + f"**{l('user_profile_avg_ranked_score', 'Avg. Ranked Score')}:** {avg_ranked_display}/{l('user_profile_play_short', 'Play')}", + f"**{l('user_profile_total_hits')}:** {total_hits_display}", + f"**{l('user_profile_avg_hits', 'Avg. Hits')}:** {avg_hits_display}/{l('user_profile_play_short', 'Play')}", + f"**{l('user_profile_max_combo')}:** {max_combo_str}", + f"**{l('user_profile_replays_watched')}:** {replays_str}", + ) + ) lines.append(f"{BLACK_CIRCLE} {l('profile_section_status') or 'Status'}") for i, item_content in enumerate(status_item_lines): @@ -358,9 +352,7 @@ def l(k, *a): lines.append(f"\n{BLACK_CIRCLE} {l('profile_section_other') or '其他'}") # 以前的名字 prev_names = player_data.get("previous_usernames") - prev_names_str = ( - ", ".join(prev_names) if prev_names else na - ) # Not numerical, no backticks + prev_names_str = ", ".join(prev_names) if prev_names else na # Not numerical, no backticks lines.append(f"{TREE} **{l('user_profile_previous_names')}:** {prev_names_str}") # 好友/追蹤者 followers = player_data.get("follower_count") @@ -368,18 +360,14 @@ def l(k, *a): lines.append(f"{TREE} **{l('user_profile_followers')}:** {followers_str}") # 慣用 playstyle = player_data.get("playstyle") - playstyle_str = ( - ", ".join(playstyle) if playstyle else na - ) # Not numerical, no backticks + playstyle_str = ", ".join(playstyle) if playstyle else na # Not numerical, no backticks lines.append(f"{TREE} **{l('user_profile_playstyle')}:** {playstyle_str}") # 成就 achievements_raw = player_data.get("user_achievements") logger.debug( f"[USER_COG _build_profile_detail_section] Raw achievements data: {achievements_raw}" ) - achievements_str = ( - f"`{len(achievements_raw)}`" if achievements_raw is not None else na - ) + achievements_str = f"`{len(achievements_raw)}`" if achievements_raw is not None else na lines.append(f"{TREE} **{l('user_profile_achievements')}:** {achievements_str}") # 總遊玩時間 total_seconds = mode_stats.get("play_time") if mode_stats else None @@ -395,16 +383,12 @@ def l(k, *a): join_date_display_str = na # Default to N/A if join_date_api_val: try: - join_dt_obj = datetime.datetime.fromisoformat( - join_date_api_val - ) + join_dt_obj = datetime.datetime.fromisoformat(join_date_api_val) formatted_date = self.format_datetime_obj(join_dt_obj, user_id_for_l10n) relative_time = self.time_since( join_dt_obj, user_id_for_l10n ) # short=True by default - logger.debug( - f"Join date: Formatted='{formatted_date}', Relative='{relative_time}'" - ) + logger.debug(f"Join date: Formatted='{formatted_date}', Relative='{relative_time}'") if ( formatted_date and relative_time @@ -412,16 +396,18 @@ def l(k, *a): ): join_date_display_str = f"{formatted_date} ({relative_time})" else: - join_date_display_str = formatted_date # Fallback to just date if relative time is weird + join_date_display_str = ( + formatted_date # Fallback to just date if relative time is weird + ) except Exception as e: logger.warning( f"Could not parse or format join_date for detail view: {join_date_api_val}. Error: {e}" ) - join_date_display_str = join_date_api_val # Fallback to raw string from API if parsing/formatting fails + join_date_display_str = ( + join_date_api_val # Fallback to raw string from API if parsing/formatting fails + ) - lines.append( - f"{END} **{l('user_profile_join_date')}:** {join_date_display_str}" - ) + lines.append(f"{END} **{l('user_profile_join_date')}:** {join_date_display_str}") # 個人連結 - New Independent Section link_detail_lines = [] @@ -494,28 +480,20 @@ async def profile( elif osu_user: user_identifier = osu_user.strip() identifier_type_for_api = "username" - logger.debug( - f"Profile lookup: osu_user='{user_identifier}', type='username'" - ) + logger.debug(f"Profile lookup: osu_user='{user_identifier}', type='username'") else: - bound_osu_id_val = await user_data_manager.get_user_binding( - interaction.user.id - ) + bound_osu_id_val = await user_data_manager.get_user_binding(interaction.user.id) if bound_osu_id_val: user_identifier = str(bound_osu_id_val) identifier_type_for_api = "id" - logger.debug( - f"Profile lookup: bound_user_id='{user_identifier}', type='id'" - ) + logger.debug(f"Profile lookup: bound_user_id='{user_identifier}', type='id'") else: await interaction.followup.send( lstr(user_id_for_l10n, "error_osu_user_not_provided_or_bound") ) return - actual_mode_int = ( - mode.value if mode and hasattr(mode, "value") else config.DEFAULT_OSU_MODE - ) + actual_mode_int = mode.value if mode and hasattr(mode, "value") else config.DEFAULT_OSU_MODE api_mode_for_get_user = ( OSU_MODES_INT_TO_STRING.get(actual_mode_int) if mode is not None else None ) @@ -548,13 +526,9 @@ async def profile( if mode is None and player_data.get("playmode"): returned_mode_str_from_api = player_data.get("playmode") - REVERSE_OSU_MODES_INT_TO_STRING = { - v: k for k, v in OSU_MODES_INT_TO_STRING.items() - } + REVERSE_OSU_MODES_INT_TO_STRING = {v: k for k, v in OSU_MODES_INT_TO_STRING.items()} if returned_mode_str_from_api in REVERSE_OSU_MODES_INT_TO_STRING: - overridden_mode_int = REVERSE_OSU_MODES_INT_TO_STRING[ - returned_mode_str_from_api - ] + overridden_mode_int = REVERSE_OSU_MODES_INT_TO_STRING[returned_mode_str_from_api] if overridden_mode_int != actual_mode_int: logger.debug( f"Mode override: User didn't specify mode. API playmode='{returned_mode_str_from_api}'. Overriding actual_mode_int from {actual_mode_int} to {overridden_mode_int}." @@ -565,9 +539,7 @@ async def profile( f"API returned unrecognized playmode: '{returned_mode_str_from_api}'. Sticking with default/initial mode: {actual_mode_int}" ) - self.get_mode_name( - actual_mode_int, user_id_for_l10n - ) + self.get_mode_name(actual_mode_int, user_id_for_l10n) player_data.get("username") or self.get_na_value(user_id_for_l10n) user_id_from_api = player_data.get("id") @@ -584,12 +556,8 @@ async def profile( ): # Fallback for older API or different structure playcount = player_data.get("play_count") - level_current = ( - mode_stats.get("level", {}).get("current") if mode_stats else None - ) - level_progress = ( - mode_stats.get("level", {}).get("progress", 0.0) if mode_stats else 0.0 - ) + level_current = mode_stats.get("level", {}).get("current") if mode_stats else None + level_progress = mode_stats.get("level", {}).get("progress", 0.0) if mode_stats else 0.0 level_display = ( f"{level_current}.{int(level_progress):02d}" if level_current is not None @@ -607,9 +575,7 @@ async def profile( if actual_player_username and actual_player_username != "N/A": english_title_format = f"{actual_player_username}'s OSU! Profile" template_key = "user_profile_title" - localized_template = lstr( - user_id_for_l10n, template_key, english_title_format - ) + localized_template = lstr(user_id_for_l10n, template_key, english_title_format) if ( localized_template != english_title_format and "{}" in localized_template @@ -626,13 +592,9 @@ async def profile( else: embed_title = english_title_format else: - embed_title = lstr( - user_id_for_l10n, "user_profile_title_na", "OSU! Profile" - ) + embed_title = lstr(user_id_for_l10n, "user_profile_title_na", "OSU! Profile") - embed_url = ( - f"https://osu.ppy.sh/users/{user_id_from_api}" if user_id_from_api else None - ) + embed_url = f"https://osu.ppy.sh/users/{user_id_from_api}" if user_id_from_api else None group_colour_hex = player_data.get("profile_colour") if not group_colour_hex and player_data.get("is_supporter", False): @@ -663,24 +625,18 @@ async def profile( f"Raw rank_history for graph decision: {'Exists and has data' if rank_history_from_player_data and rank_history_from_player_data.get('data') else 'Missing or no data'}" ) - rank_has_data = ( - rank_history_from_player_data - and rank_history_from_player_data.get("data") + rank_has_data = rank_history_from_player_data and rank_history_from_player_data.get( + "data" ) if rank_has_data: # Only rank graph is generated now try: - combined_graph_buf, generated_rank = ( - self._generate_profile_combined_graph( - rank_history_from_player_data, user_id_for_l10n - ) + combined_graph_buf, generated_rank = self._generate_profile_combined_graph( + rank_history_from_player_data, user_id_for_l10n ) has_rank_data_for_graph = generated_rank except Exception as e: - logger.error( - f"Error during profile graph generation call: {e}", - exc_info=True, - ) + logger.error(f"Error during profile graph generation call: {e}", exc_info=True) detail_text = self._build_profile_detail_section( player_data, @@ -693,9 +649,7 @@ async def profile( files_to_send = [] if combined_graph_buf: - graph_file = discord.File( - combined_graph_buf, filename="profile_graph.png" - ) + graph_file = discord.File(combined_graph_buf, filename="profile_graph.png") embed.set_image(url="attachment://profile_graph.png") files_to_send.append(graph_file) @@ -709,9 +663,7 @@ async def profile( embed.set_image(url=cover_url) pp_display = ( - f"{pp_raw:,.2f}pp" - if pp_raw is not None - else self.get_na_value(user_id_for_l10n) + f"{pp_raw:,.2f}pp" if pp_raw is not None else self.get_na_value(user_id_for_l10n) ) embed.add_field( name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_pp"), @@ -720,35 +672,25 @@ async def profile( ) accuracy_display = ( - f"{accuracy:,.2f}%" - if accuracy is not None - else self.get_na_value(user_id_for_l10n) + f"{accuracy:,.2f}%" if accuracy is not None else self.get_na_value(user_id_for_l10n) ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "user_profile_accuracy" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_accuracy"), value=accuracy_display, inline=True, ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "user_profile_level" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_level"), value=level_display, inline=True, ) global_rank_display = ( - f"#{pp_rank:,}" - if pp_rank is not None - else self.get_na_value(user_id_for_l10n) + f"#{pp_rank:,}" if pp_rank is not None else self.get_na_value(user_id_for_l10n) ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "user_profile_global_rank" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_global_rank"), value=global_rank_display, inline=True, ) @@ -759,22 +701,16 @@ async def profile( else self.get_na_value(user_id_for_l10n) ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "user_profile_country_rank" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_country_rank"), value=country_rank_display, inline=True, ) playcount_display = ( - f"{playcount:,}" - if playcount is not None - else self.get_na_value(user_id_for_l10n) + f"{playcount:,}" if playcount is not None else self.get_na_value(user_id_for_l10n) ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "user_profile_play_count" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_play_count"), value=playcount_display, inline=True, ) @@ -782,9 +718,7 @@ async def profile( join_date_display = self.get_na_value(user_id_for_l10n) if join_date_str: try: - join_dt = datetime.datetime.fromisoformat( - join_date_str - ) + join_dt = datetime.datetime.fromisoformat(join_date_str) join_date_display = ( self.format_datetime_obj(join_dt, user_id_for_l10n) + f" ({self.time_since(join_dt, user_id_for_l10n)})" @@ -793,9 +727,7 @@ async def profile( logger.warning(f"Could not parse join_date_str: {join_date_str}") join_date_display = join_date_str embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "user_profile_join_date" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_join_date"), value=join_date_display, inline=False, ) @@ -803,9 +735,7 @@ async def profile( mode_emoji = MODE_EMOJI_STRINGS.get(actual_mode_int, "") mode_display_name = self.get_mode_name(actual_mode_int, user_id_for_l10n) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "user_profile_game_mode" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "user_profile_game_mode"), value=f"{mode_emoji} {mode_display_name}".strip(), inline=False, ) @@ -837,26 +767,19 @@ def _generate_profile_combined_graph(self, rank_history_full, user_id_for_l10n): fig.patch.set_facecolor("#23272A") - title_rank = lstr( - user_id_for_l10n, "graph_title_rank_history", "Rank History (90 days)" - ) + title_rank = lstr(user_id_for_l10n, "graph_title_rank_history", "Rank History (90 days)") logger.debug(f"Graph title_rank for legend: '{title_rank}'") - ax_rank.plot( - list(range(1, len(rank_data) + 1)), rank_data, color="#bfaaff", linewidth=2 - ) + ax_rank.plot(list(range(1, len(rank_data) + 1)), rank_data, color="#bfaaff", linewidth=2) ax_rank.set_title(title_rank, fontsize=12, color="white", pad=10) - ax_rank.set_ylabel( - lstr(user_id_for_l10n, "graph_ylabel_rank", "Rank"), color="white" - ) + ax_rank.set_ylabel(lstr(user_id_for_l10n, "graph_ylabel_rank", "Rank"), color="white") ax_rank.invert_yaxis() ax_rank.tick_params(axis="x", colors="white") ax_rank.tick_params(axis="y", colors="white") ax_rank.grid(alpha=0.3) ax_rank.set_xlabel( - lstr(user_id_for_l10n, "graph_xlabel_days", "Days (Most Recent)"), - color="white", + lstr(user_id_for_l10n, "graph_xlabel_days", "Days (Most Recent)"), color="white" ) ax_rank.set_facecolor("#23272A") @@ -865,24 +788,19 @@ def _generate_profile_combined_graph(self, rank_history_full, user_id_for_l10n): plt.tight_layout(pad=2.0) buf = io.BytesIO() - plt.savefig( - buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor() - ) + plt.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor()) buf.seek(0) plt.close(fig) - logger.info( - f"Successfully generated profile rank graph. Has rank data: {has_rank_data}" - ) + logger.info(f"Successfully generated profile rank graph. Has rank data: {has_rank_data}") return buf, has_rank_data - @app_commands.command( - name="mapper", description="Shows osu! mapping statistics for a user." - ) - @app_commands.describe( - osu_user="osu! username (optional)", osu_id="osu! user ID (optional)" - ) + @app_commands.command(name="mapper", description="Shows osu! mapping statistics for a user.") + @app_commands.describe(osu_user="osu! username (optional)", osu_id="osu! user ID (optional)") async def mapper( - self, interaction: discord.Interaction, osu_user: str | None = None, osu_id: int | None = None + self, + interaction: discord.Interaction, + osu_user: str | None = None, + osu_id: int | None = None, ) -> None: await interaction.response.defer() user_id_for_l10n = interaction.user.id @@ -901,9 +819,7 @@ async def mapper( if user_identifier.isdigit(): identifier_type = "username" else: - bound_osu_user = await user_data_manager.get_user_binding( - interaction.user.id - ) + bound_osu_user = await user_data_manager.get_user_binding(interaction.user.id) if bound_osu_user: user_identifier = str(bound_osu_user) else: @@ -934,18 +850,10 @@ async def mapper( f"[USER_COG /mapper] Fetched user: ID {actual_user_id}, Username: {actual_username}" ) - beatmap_types_to_fetch = [ - "ranked", - "loved", - "graveyard", - "pending", - "nominated", - ] + beatmap_types_to_fetch = ["ranked", "loved", "graveyard", "pending", "nominated"] all_beatmapsets = {} - logger.debug( - f"[USER_COG /mapper] Starting to fetch beatmapsets for user {actual_user_id}" - ) + logger.debug(f"[USER_COG /mapper] Starting to fetch beatmapsets for user {actual_user_id}") for bs_type in beatmap_types_to_fetch: logger.debug(f"[USER_COG /mapper] Fetching type: {bs_type}") offset = 0 @@ -960,10 +868,7 @@ async def mapper( f"[USER_COG /mapper] Fetching {bs_type} - offset: {offset}, current_fetches_for_type: {current_fetches_for_type}" ) beatmapsets_page = await self.osu_api.get_user_beatmapsets( - user_id=actual_user_id, - beatmap_type=bs_type, - limit=limit, - offset=offset, + user_id=actual_user_id, beatmap_type=bs_type, limit=limit, offset=offset ) if beatmapsets_page is None: @@ -997,9 +902,7 @@ async def mapper( ) break - logger.debug( - f"[USER_COG /mapper] Total unique beatmapsets fetched: {len(all_beatmapsets)}" - ) + logger.debug(f"[USER_COG /mapper] Total unique beatmapsets fetched: {len(all_beatmapsets)}") hosted_mapsets_list = list(all_beatmapsets.values()) @@ -1008,9 +911,7 @@ async def mapper( total_favourites = 0 # Get additional stats from the player_data (user object) - kudosu_total = player_data.get("kudosu", {}).get( - "total" - ) # Can be None if not present + kudosu_total = player_data.get("kudosu", {}).get("total") # Can be None if not present followers_count = player_data.get("follower_count") # Can be None guest_diffs_count = player_data.get("guest_beatmapset_count") # Can be None # also player_data.get('ranked_beatmapset_count') and player_data.get('loved_beatmapset_count') exist @@ -1040,9 +941,7 @@ async def mapper( if date_str_to_parse: try: # API v2 dates are ISO 8601 with Z (UTC) e.g. "2023-01-15T10:30:00+00:00" or "2023-01-15T10:30:00Z" - current_bm_date = datetime.datetime.fromisoformat( - date_str_to_parse - ) + current_bm_date = datetime.datetime.fromisoformat(date_str_to_parse) if ( latest_submission_date_obj is None or current_bm_date > latest_submission_date_obj @@ -1055,9 +954,7 @@ async def mapper( ): earliest_submission_date_obj = current_bm_date except ValueError: - logger.warning( - f"[USER_COG /mapper] Could not parse date: {date_str_to_parse}" - ) + logger.warning(f"[USER_COG /mapper] Could not parse date: {date_str_to_parse}") mapping_duration_str = self._get_lstr_with_na_fallback( user_id_for_l10n, "value_not_available" @@ -1084,9 +981,7 @@ async def mapper( ) ): try: - embed_title_to_use = localized_template_candidate.format( - actual_username - ) + embed_title_to_use = localized_template_candidate.format(actual_username) except Exception as e: logger.error( f"[USER_COG /mapper] Formatting localized mapper title ('{localized_template_candidate}') failed: {e}. Falling back to English title." @@ -1103,9 +998,7 @@ async def mapper( embed = discord.Embed(color=discord.Color.purple()) # Set the author with the mapper's name, profile link, and avatar - embed.set_author( - name=author_name, url=author_profile_url, icon_url=author_icon_display_url - ) + embed.set_author(name=author_name, url=author_profile_url, icon_url=author_icon_display_url) # Thumbnail is no longer needed as avatar is in author icon. # Old embed.url (to beatmapsets/extra) is also removed. @@ -1117,23 +1010,17 @@ async def mapper( inline=True, ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "mapper_ranked_loved" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_ranked_loved"), value=str(ranked_loved_sets_count), inline=True, ) guest_diffs_display = ( str(guest_diffs_count) if guest_diffs_count is not None - else self._get_lstr_with_na_fallback( - user_id_for_l10n, "value_not_available" - ) + else self._get_lstr_with_na_fallback(user_id_for_l10n, "value_not_available") ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "mapper_guest_difficulties" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_guest_difficulties"), value=guest_diffs_display, inline=True, ) @@ -1144,24 +1031,18 @@ async def mapper( else self._get_lstr_with_na_fallback(user_id_for_l10n, "never_uploaded") ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "mapper_first_upload" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_first_upload"), value=first_upload_display, inline=True, ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "mapper_mapping_duration" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_mapping_duration"), value=mapping_duration_str, inline=True, ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "mapper_total_favourites" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_total_favourites"), value=f"{total_favourites:,}", inline=True, ) @@ -1169,9 +1050,7 @@ async def mapper( followers_display = ( f"{followers_count:,}" if followers_count is not None - else self._get_lstr_with_na_fallback( - user_id_for_l10n, "value_not_available" - ) + else self._get_lstr_with_na_fallback(user_id_for_l10n, "value_not_available") ) embed.add_field( name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_followers"), @@ -1187,9 +1066,7 @@ async def mapper( kudosu_display = ( f"{kudosu_total:,}" if kudosu_total is not None - else self._get_lstr_with_na_fallback( - user_id_for_l10n, "value_not_available" - ) + else self._get_lstr_with_na_fallback(user_id_for_l10n, "value_not_available") ) embed.add_field( name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_kudosu"), @@ -1213,7 +1090,9 @@ async def mapper( latest_submission_display_value = "" if lb_url: - latest_submission_display_value = f"[{lb_artist} - {lb_title}]({lb_url})\n" # CORRECTED to single backslash + latest_submission_display_value = ( + f"[{lb_artist} - {lb_title}]({lb_url})\n" # CORRECTED to single backslash + ) else: latest_submission_display_value = ( f"{lb_artist} - {lb_title}\n" # CORRECTED to single backslash @@ -1225,9 +1104,7 @@ async def mapper( ) embed.add_field( - name=self._get_lstr_with_na_fallback( - user_id_for_l10n, "mapper_latest_submission" - ), + name=self._get_lstr_with_na_fallback(user_id_for_l10n, "mapper_latest_submission"), value=latest_submission_display_value, inline=False, ) @@ -1263,7 +1140,10 @@ async def mapper( osu_user="Your osu! username (optional)", osu_id="Your osu! user ID (optional)" ) async def setuser( - self, interaction: discord.Interaction, osu_user: str | None = None, osu_id: int | None = None + self, + interaction: discord.Interaction, + osu_user: str | None = None, + osu_id: int | None = None, ) -> None: user_id_for_l10n = str(interaction.user.id) await interaction.response.defer(ephemeral=True) @@ -1290,9 +1170,7 @@ async def setuser( if not identifier_to_use: # Check if user has an existing binding to display - existing_binding = await user_data_manager.get_user_binding( - interaction.user.id - ) + existing_binding = await user_data_manager.get_user_binding(interaction.user.id) if existing_binding: # Attempt to get the osu! user object to display the current official username try: @@ -1365,9 +1243,7 @@ async def setuser( ) # Store ID for consistency if possible, or username if ID fetch fails # Use osu_id_to_store for binding as it's more reliable, but display official_osu_username - await user_data_manager.set_user_binding( - interaction.user.id, osu_id_to_store - ) + await user_data_manager.set_user_binding(interaction.user.id, osu_id_to_store) await interaction.followup.send( lstr( user_id_for_l10n, @@ -1391,8 +1267,7 @@ async def setuser( ) @app_commands.command( - name="unsetuser", - description="Unbind your Discord account from your osu! account.", + name="unsetuser", description="Unbind your Discord account from your osu! account." ) async def unsetuser(self, interaction: discord.Interaction) -> None: user_id_for_l10n = str(interaction.user.id) diff --git a/cogs/utility_cog.py b/cogs/utility_cog.py index 8804bef..5c46386 100644 --- a/cogs/utility_cog.py +++ b/cogs/utility_cog.py @@ -28,10 +28,7 @@ def get_language_display_name(lang_code: str, target_lang_code: str) -> str: "en": {"en": "English", "zh_TW": "英語"}, "zh_TW": {"en": "Traditional Chinese", "zh_TW": "繁體中文"}, } - if ( - lang_code in predefined_names - and target_lang_code in predefined_names[lang_code] - ): + if lang_code in predefined_names and target_lang_code in predefined_names[lang_code]: return predefined_names[lang_code][target_lang_code] return lang_code # Fallback to code @@ -41,13 +38,14 @@ def __init__(self, bot: commands.Bot) -> None: self.bot = bot @app_commands.command( - name="lang", - description="Sets or shows your preferred language for bot responses.", + name="lang", description="Sets or shows your preferred language for bot responses." ) @app_commands.describe( language_code="The language code to set (e.g., en, zh_TW). Leave empty to see current." ) - async def lang(self, interaction: discord.Interaction, language_code: str | None = None) -> None: + async def lang( + self, interaction: discord.Interaction, language_code: str | None = None + ) -> None: user_id = interaction.user.id current_user_lang = get_user_language(user_id) @@ -59,29 +57,21 @@ async def lang(self, interaction: discord.Interaction, language_code: str | None if language_code is None: # Display current language and available languages - current_lang_display = get_language_display_name( - current_user_lang, current_user_lang - ) + current_lang_display = get_language_display_name(current_user_lang, current_user_lang) # Directly use the translations for the current user's language for this response. response_message = "Failed to construct current language message." try: translations_for_current_lang = _translations.get(current_user_lang, {}) - message_template = translations_for_current_lang.get( - "lang_no_code_provided" - ) + message_template = translations_for_current_lang.get("lang_no_code_provided") if message_template: response_message = message_template.format( current_lang_display, available_langs_str ) else: # Fallback if the template itself is missing - default_lang_translations = _translations.get( - config.DEFAULT_LANGUAGE, {} - ) - fallback_template = default_lang_translations.get( - "lang_no_code_provided" - ) + default_lang_translations = _translations.get(config.DEFAULT_LANGUAGE, {}) + fallback_template = default_lang_translations.get("lang_no_code_provided") if fallback_template: response_message = fallback_template.format( current_lang_display, available_langs_str @@ -89,9 +79,7 @@ async def lang(self, interaction: discord.Interaction, language_code: str | None else: # Ultimate fallback response_message = f"No language code provided. Current: {current_lang_display}. Available: {available_langs_str}" except Exception as e: - logger.error( - f"[UtilityCog] Error formatting lang_no_code_provided: {e}" - ) + logger.error(f"[UtilityCog] Error formatting lang_no_code_provided: {e}") response_message = f"No language code provided. Your current language is **{current_lang_display}**. Available languages: {available_langs_str}" # Fallback to English structure await interaction.response.send_message(response_message, ephemeral=True) @@ -129,25 +117,17 @@ async def lang(self, interaction: discord.Interaction, language_code: str | None # This ensures the confirmation message itself is in the new language. response_message = "Language setting failed to construct message." try: - translations_for_new_lang = _translations.get( - final_code_to_check, {} - ) + translations_for_new_lang = _translations.get(final_code_to_check, {}) message_template = translations_for_new_lang.get("lang_set_success") if message_template: response_message = message_template.format(new_lang_display) else: # Fallback if the template itself is missing in the new language # Try default language as a secondary fallback for the template - default_lang_translations = _translations.get( - config.DEFAULT_LANGUAGE, {} - ) - fallback_template = default_lang_translations.get( - "lang_set_success" - ) + default_lang_translations = _translations.get(config.DEFAULT_LANGUAGE, {}) + fallback_template = default_lang_translations.get("lang_set_success") if fallback_template: - response_message = fallback_template.format( - new_lang_display - ) + response_message = fallback_template.format(new_lang_display) else: # Ultimate fallback if no template is found anywhere response_message = ( f"Language set to: {new_lang_display}" # Non-localized @@ -155,13 +135,9 @@ async def lang(self, interaction: discord.Interaction, language_code: str | None except Exception as e: logger.error(f"[UtilityCog] Error formatting lang_set_success: {e}") # Fallback to a simple English message if formatting fails - response_message = ( - f"Your language has been set to: **{new_lang_display}**." - ) + response_message = f"Your language has been set to: **{new_lang_display}**." - await interaction.response.send_message( - response_message, ephemeral=True - ) + await interaction.response.send_message(response_message, ephemeral=True) else: # This case should ideally not be reached if final_code_to_check in SUPPORTED_LANGUAGES # but set_user_language might have other internal checks in the future. @@ -189,17 +165,14 @@ async def lang_autocomplete( display_lang_for_choices = get_user_language(interaction.user.id) for lang_code_supported in config.SUPPORTED_LANGUAGES: - display_name = get_language_display_name( - lang_code_supported, display_lang_for_choices - ) + display_name = get_language_display_name(lang_code_supported, display_lang_for_choices) if ( current.lower() in lang_code_supported.lower() or current.lower() in display_name.lower() ): choices.append( app_commands.Choice( - name=f"{display_name} ({lang_code_supported})", - value=lang_code_supported, + name=f"{display_name} ({lang_code_supported})", value=lang_code_supported ) ) return choices[:25] # Autocomplete can show max 25 choices diff --git a/private/config.sample.py b/private/config.sample.py index 3b7d8c3..c0a4fd3 100644 --- a/private/config.sample.py +++ b/private/config.sample.py @@ -3,9 +3,7 @@ DISCORD_BOT_TOKEN = "YOUR_DISCORD_BOT_TOKEN_HERE" DEFAULT_LANGUAGE = "en" -DEFAULT_OSU_MODE = ( - 0 # 0 for osu!standard, 1 for Taiko, 2 for CatchTheBeat, 3 for osu!mania -) +DEFAULT_OSU_MODE = 0 # 0 for osu!standard, 1 for Taiko, 2 for CatchTheBeat, 3 for osu!mania # 支援的語言列表,對應 locales 文件夾中的文件名 (不含 .json) SUPPORTED_LANGUAGES = {"en": "English", "zh_TW": "繁體中文"} diff --git a/utils/beatmap_utils.py b/utils/beatmap_utils.py index a1638b8..93378a4 100644 --- a/utils/beatmap_utils.py +++ b/utils/beatmap_utils.py @@ -115,12 +115,8 @@ def get_beatmap_status_display(status_input, user_id_for_l10n: int, lstr_func) - normalized_status_str in BEATMAP_STATUS_API_MAP ): # Direct match for string keys like "ranked" status_key = normalized_status_str - elif ( - normalized_status_str == "approved" - ): # API v2 uses "approved" string, map it. - status_key = ( - "approved" # Ensure approved has its own key if distinct from qualified - ) + elif normalized_status_str == "approved": # API v2 uses "approved" string, map it. + status_key = "approved" # Ensure approved has its own key if distinct from qualified elif normalized_status_str == "qualified": status_key = "qualified" # Add more explicit string mappings if needed @@ -149,18 +145,14 @@ def get_beatmap_status_display(status_input, user_id_for_l10n: int, lstr_func) - return f"{emoji} {status_text_localized}" -async def download_osu_file( - beatmap_id: int, session: aiohttp.ClientSession -) -> str | None: +async def download_osu_file(beatmap_id: int, session: aiohttp.ClientSession) -> str | None: """Downloads an .osu file for a given beatmap_id. Returns the path to the downloaded file. Raises BeatmapDownloadError if the download failed or temp directory doesn't exist. The file is saved in the TEMP_OSU_DIR. """ if not pathlib.Path(TEMP_OSU_DIR).exists(): - err_msg = ( - f"Temporary directory {TEMP_OSU_DIR} does not exist. Please create it." - ) + err_msg = f"Temporary directory {TEMP_OSU_DIR} does not exist. Please create it." logger.error(f"[BeatmapUtils] Error: {err_msg}") raise BeatmapDownloadError(err_msg) @@ -179,9 +171,7 @@ async def download_osu_file( error_detail = f"HTTP {response.status}" try: # Try to get more error details from response text_response = await response.text() - error_detail += ( - f": {text_response[:200]}" # Limit length of error message - ) + error_detail += f": {text_response[:200]}" # Limit length of error message except Exception: pass # Ignore if cannot get text err_msg = f"Error downloading .osu file: {error_detail} for URL {url}" @@ -192,9 +182,7 @@ async def download_osu_file( logger.error(f"[BeatmapUtils] {err_msg}") raise BeatmapDownloadError(err_msg) from e except Exception as e: # Catch any other unexpected errors - err_msg = ( - f"Unexpected error during .osu download for {url}: {type(e).__name__} - {e}" - ) + err_msg = f"Unexpected error during .osu download for {url}: {type(e).__name__} - {e}" logger.error(f"[BeatmapUtils] {err_msg}") raise BeatmapDownloadError(err_msg) from e @@ -221,15 +209,11 @@ def parse_osu_file_metadata(osu_file_path: str) -> dict: metadata["title"] = line[len("TitleUnicode:") :].strip() elif line.startswith("Artist:") and metadata["artist"] is None: metadata["artist"] = line[len("Artist:") :].strip() - elif ( - line.startswith("ArtistUnicode:") and metadata["artist"] is None - ): + elif line.startswith("ArtistUnicode:") and metadata["artist"] is None: metadata["artist"] = line[len("ArtistUnicode:") :].strip() elif line.startswith("Version:") and metadata["version"] is None: metadata["version"] = line[len("Version:") :].strip() - elif line.startswith("[") and line.endswith( - "]" - ): # Reached another section + elif line.startswith("[") and line.endswith("]"): # Reached another section break # If any metadata is still None, default them to avoid issues, or they can be handled upstream if metadata["title"] is None: @@ -240,9 +224,7 @@ def parse_osu_file_metadata(osu_file_path: str) -> dict: metadata["version"] = "Unknown Version" logger.debug(f"[BeatmapUtils] Parsed metadata: {metadata} from {osu_file_path}") except Exception as e: - logger.error( - f"[BeatmapUtils] Error parsing .osu file metadata for {osu_file_path}: {e}" - ) + logger.error(f"[BeatmapUtils] Error parsing .osu file metadata for {osu_file_path}: {e}") if metadata["title"] is None: metadata["title"] = "Error Parsing Title" if metadata["artist"] is None: @@ -252,9 +234,7 @@ def parse_osu_file_metadata(osu_file_path: str) -> dict: return metadata -def get_mods_bitmask_and_clock_rate( - selected_mods: list[str], -) -> tuple[int, float | None]: +def get_mods_bitmask_and_clock_rate(selected_mods: list[str]) -> tuple[int, float | None]: """Converts a list of mod acronyms to a bitmask and determines clock rate.""" bitmask = 0 clock_rate = 1.0 # Default clock rate @@ -402,9 +382,7 @@ def delete_osu_file(osu_file_path: str) -> None: """Deletes the specified .osu file from the temp directory.""" try: if ( - osu_file_path - and pathlib.Path(osu_file_path).exists() - and TEMP_OSU_DIR in osu_file_path + osu_file_path and pathlib.Path(osu_file_path).exists() and TEMP_OSU_DIR in osu_file_path ): # Safety check pathlib.Path(osu_file_path).unlink() logger.debug(f"[BeatmapUtils] Deleted temporary file: {osu_file_path}") @@ -413,9 +391,7 @@ def delete_osu_file(osu_file_path: str) -> None: f"[BeatmapUtils] File not deleted (not found or invalid path): {osu_file_path}" ) except Exception as e: - logger.error( - f"[BeatmapUtils] Error deleting temporary file {osu_file_path}: {e}" - ) + logger.error(f"[BeatmapUtils] Error deleting temporary file {osu_file_path}: {e}") # Example usage and if __name__ == "__main__" block will be removed. diff --git a/utils/delete_view.py b/utils/delete_view.py index 42e8feb..ffe8221 100644 --- a/utils/delete_view.py +++ b/utils/delete_view.py @@ -64,9 +64,7 @@ def is_admin(self, member: discord.Member) -> bool: return bool(member.guild_permissions.administrator) @discord.ui.button(label="刪除", style=discord.ButtonStyle.danger, emoji="🗑️") - async def delete_button( - self, interaction: Interaction, button: discord.ui.Button - ) -> None: + async def delete_button(self, interaction: Interaction, button: discord.ui.Button) -> None: """刪除按鈕回調 Args: @@ -85,8 +83,7 @@ async def delete_button( "❌ 只有觸發此指令的用戶或管理員才能刪除此訊息!", ephemeral=True ) logger.debug( - f"🚫 用戶 {interaction.user} 嘗試刪除訊息但無權限 " - f"(觸發者: {self.trigger_user_id})" + f"🚫 用戶 {interaction.user} 嘗試刪除訊息但無權限 (觸發者: {self.trigger_user_id})" ) return @@ -101,18 +98,12 @@ async def delete_button( ) # 發送確認訊息(因為原訊息已刪除,所以用 ephemeral) - await interaction.response.send_message( - "✅ 已成功刪除訊息!", ephemeral=True - ) + await interaction.response.send_message("✅ 已成功刪除訊息!", ephemeral=True) except discord.NotFound: - await interaction.response.send_message( - "❌ 訊息已被刪除或不存在。", ephemeral=True - ) + await interaction.response.send_message("❌ 訊息已被刪除或不存在。", ephemeral=True) logger.warning(f"⚠️ 用戶 {interaction.user} 嘗試刪除訊息但訊息不存在") except discord.Forbidden: - await interaction.response.send_message( - "❌ 機器人沒有權限刪除此訊息。", ephemeral=True - ) + await interaction.response.send_message("❌ 機器人沒有權限刪除此訊息。", ephemeral=True) logger.error(f"❌ 機器人沒有權限刪除訊息 (ID: {interaction.message.id})") except Exception as e: logger.error(f"❌ 刪除訊息時發生錯誤: {e}", exc_info=True) @@ -126,6 +117,4 @@ async def on_timeout(self) -> None: if isinstance(item, discord.ui.Button): item.disabled = True - logger.debug( - f"⏱️ DeleteMessageView 超時,按鈕已禁用 (觸發者: {self.trigger_user_id})" - ) + logger.debug(f"⏱️ DeleteMessageView 超時,按鈕已禁用 (觸發者: {self.trigger_user_id})") diff --git a/utils/localization.py b/utils/localization.py index 559597e..8a9594d 100644 --- a/utils/localization.py +++ b/utils/localization.py @@ -20,15 +20,11 @@ def _load_user_preferences() -> None: """從 JSON 文件加載用戶語言偏好到內存""" global _user_lang_preferences if not pathlib.Path(USER_PREFS_FILE).exists(): - logger.info( - f"'{USER_PREFS_FILE}' not found. Starting with empty user preferences." - ) + logger.info(f"'{USER_PREFS_FILE}' not found. Starting with empty user preferences.") _user_lang_preferences = {} return try: - with ( - _prefs_lock - ): # Ensure thread-safe reading, though less critical than writing + with _prefs_lock: # Ensure thread-safe reading, though less critical than writing with pathlib.Path(USER_PREFS_FILE).open(encoding="utf-8") as f: content = f.read() if not content: # File is empty @@ -39,9 +35,7 @@ def _load_user_preferences() -> None: else: _user_lang_preferences = json.loads(content) # Ensure keys are strings if they were stored as ints from interaction.user.id - _user_lang_preferences = { - str(k): v for k, v in _user_lang_preferences.items() - } + _user_lang_preferences = {str(k): v for k, v in _user_lang_preferences.items()} logger.info(f"已成功從 '{USER_PREFS_FILE}' 加載用戶語言偏好。") except json.JSONDecodeError: logger.error(f"錯誤:解析 '{USER_PREFS_FILE}' 失敗。將使用空的用戶偏好。") @@ -88,17 +82,13 @@ def load_language(lang_code: str) -> None: logger.error(f"[L10N] Failed to parse language file {lang_code}.json.") _translations[lang_code] = {} else: - logger.debug( - f"[L10N] Language {lang_code} already in _translations. Skipping load." - ) + logger.debug(f"[L10N] Language {lang_code} already in _translations. Skipping load.") # DEBUG: Print current state of _translations cache after any load attempt or skip logger.debug(f"[L10N] Current _translations keys: {list(_translations.keys())}") for lc, trans_dict in _translations.items(): test_key_val = trans_dict.get("user_profile_game_mode", "") - logger.debug( - f"[L10N] _translations['{lc}']['user_profile_game_mode']: '{test_key_val}'" - ) + logger.debug(f"[L10N] _translations['{lc}']['user_profile_game_mode']: '{test_key_val}'") def get_user_language(user_id: int | str) -> str: @@ -106,17 +96,13 @@ def get_user_language(user_id: int | str) -> str: # Ensure all keys in the global preferences are strings before attempting to get. # This is a safeguard against potential pollution from other parts of the code. global _user_lang_preferences # Explicitly state we are working with the global - current_prefs_copy = dict( - _user_lang_preferences - ) # Work on a copy to iterate and modify safely + current_prefs_copy = dict(_user_lang_preferences) # Work on a copy to iterate and modify safely cleaned_prefs = {str(k): v for k, v in current_prefs_copy.items()} if len(cleaned_prefs) != len(current_prefs_copy): logger.warning( f"[L10N] Cleaned _user_lang_preferences due to mixed key types. Original count: {len(current_prefs_copy)}, Cleaned count: {len(cleaned_prefs)}" ) - _user_lang_preferences = ( - cleaned_prefs # Update the global with the cleaned version - ) + _user_lang_preferences = cleaned_prefs # Update the global with the cleaned version logger.debug(f"[L10N] Called for user_id: '{user_id}'") # Now _user_lang_preferences should only have string keys. @@ -148,11 +134,7 @@ def set_user_language(user_id: int | str, lang_code: str) -> bool: def get_localized_string( - user_id_or_lang_code: int | str | None, - key: str, - default_fallback: str = "", - *args, - **kwargs, + user_id_or_lang_code: int | str | None, key: str, default_fallback: str = "", *args, **kwargs ) -> str: """根據用戶的語言偏好或預設語言獲取翻譯後的文本。 @@ -184,27 +166,20 @@ def get_localized_string( "[L10N] Reload failed, _translations still empty. Returning raw key or fallback." ) # Cannot format if translations are missing. Return unformatted key or fallback. - return ( - default_fallback or f"" - ) + return default_fallback or f"" localized_string = _translations.get(lang_code, {}).get(key) if localized_string is None: # Try fallback to default language (e.g., English) if not already using it - if ( - lang_code != config.DEFAULT_LANGUAGE - and config.DEFAULT_LANGUAGE in _translations - ): + if lang_code != config.DEFAULT_LANGUAGE and config.DEFAULT_LANGUAGE in _translations: localized_string = _translations[config.DEFAULT_LANGUAGE].get(key) # If still not found, use the provided default_fallback if localized_string is None: localized_string = default_fallback # If default_fallback was also empty, it means the key is truly missing. - if ( - not localized_string - ): # Checks if default_fallback was also empty or None + if not localized_string: # Checks if default_fallback was also empty or None logger.warning( f"[L10N] Key '{key}' not found in lang '{lang_code}' or default '{config.DEFAULT_LANGUAGE}', and no fallback string provided. Returning placeholder." ) @@ -220,15 +195,9 @@ def get_localized_string( if args or kwargs: # Only call format if there are args or kwargs return localized_string.format(*args, **kwargs) return localized_string - except ( - IndexError, - KeyError, - TypeError, - ) as e: # Added TypeError for bad keyword args + except (IndexError, KeyError, TypeError) as e: # Added TypeError for bad keyword args # logger.error(f"[L10N] Formatting key='{key}', raw_string='{localized_string}', args={args}, kwargs={kwargs} FAILED: {e}") - return ( - f"" # Include error type - ) + return f"" # Include error type # 初始加載預設語言 和用戶偏好 diff --git a/utils/message_tracker.py b/utils/message_tracker.py index 0ef0656..2688e06 100644 --- a/utils/message_tracker.py +++ b/utils/message_tracker.py @@ -74,9 +74,7 @@ def _cleanup_old_messages(self) -> None: for message_id in old_messages: del self._messages[message_id] - logger.info( - f"🧹 清理了 {cleanup_count} 條舊訊息記錄 (剩餘: {len(self._messages)})" - ) + logger.info(f"🧹 清理了 {cleanup_count} 條舊訊息記錄 (剩餘: {len(self._messages)})") def get_stats(self) -> dict[str, int]: """獲取統計信息 diff --git a/utils/osu_api.py b/utils/osu_api.py index f993a74..4d249de 100644 --- a/utils/osu_api.py +++ b/utils/osu_api.py @@ -81,14 +81,16 @@ async def _ensure_token(self) -> bool: return True async def _request( - self, method: str, endpoint: str, params: dict | None = None, json_payload: dict | None = None + self, + method: str, + endpoint: str, + params: dict | None = None, + json_payload: dict | None = None, ) -> dict | list | None: """發送異步請求到 osu! API v2""" logger.debug(f"[OSU_API] Preparing request: {method} {endpoint}") if not await self._ensure_token(): - logger.error( - "[OSU_API] Failed to ensure valid access token. Aborting request." - ) + logger.error("[OSU_API] Failed to ensure valid access token. Aborting request.") return None await self.setup() # 確保 session 已初始化 @@ -115,19 +117,13 @@ async def _request( logger.debug(f"[OSU_API] Request sent. URL: {response.url}") logger.debug(f"[OSU_API] Request Headers: {headers}") if response.status == 204: - logger.debug( - f"[OSU_API] Response Status: 204 No Content for URL: {url}" - ) + logger.debug(f"[OSU_API] Response Status: 204 No Content for URL: {url}") return {} response_text = await response.text() - logger.debug( - f"[OSU_API] Response Status: {response.status} for URL: {url}" - ) + logger.debug(f"[OSU_API] Response Status: {response.status} for URL: {url}") # Limit logging large responses in debug, show first 500 chars - logger.debug( - f"[OSU_API] Response Text (first 500 chars): {response_text[:500]}" - ) + logger.debug(f"[OSU_API] Response Text (first 500 chars): {response_text[:500]}") if response.status >= 400: # Error already logged in the previous log, this one is more for raising the exception @@ -136,12 +132,8 @@ async def _request( ) response.raise_for_status() - if ( - not response_text and response.status == 200 - ): # Empty success response - logger.debug( - f"[OSU_API] Empty successful response (200 OK) for URL: {url}" - ) + if not response_text and response.status == 200: # Empty success response + logger.debug(f"[OSU_API] Empty successful response (200 OK) for URL: {url}") return {} # Or None, depending on expectation for empty 200s return await response.json() @@ -214,9 +206,7 @@ async def get_user_recent( # 示例,需要根據 v2 文檔調整參數 endpoint = f"/users/{user_id}/scores/recent" params = {"limit": limit, "include_fails": "1" if include_fails else "0"} - if ( - mode - ): # osu! API v2 scores endpoints usually require mode as a query parameter + if mode: # osu! API v2 scores endpoints usually require mode as a query parameter params["mode"] = mode if offset is not None: params["offset"] = offset @@ -224,7 +214,11 @@ async def get_user_recent( return await self._request("GET", endpoint, params=params) async def get_user_best( - self, user_id: int | str, mode: str | None = None, limit: int = 100, offset: int | None = None + self, + user_id: int | str, + mode: str | None = None, + limit: int = 100, + offset: int | None = None, ) -> list | None: """ 獲取使用者的最佳表現。支援自動分頁獲取最多實際請求的 limit 數量。 @@ -250,9 +244,7 @@ async def get_user_best( total_limit_to_fetch = limit while len(all_scores) < total_limit_to_fetch: - actual_request_limit = min( - page_limit, total_limit_to_fetch - len(all_scores) - ) + actual_request_limit = min(page_limit, total_limit_to_fetch - len(all_scores)) if ( actual_request_limit <= 0 ): # Should not happen if loop condition is correct, but as a safeguard @@ -304,9 +296,7 @@ async def get_user_best( logger.debug( f"[get_user_best] Fetched a total of {len(all_scores)} scores for user {user_id}." ) - return ( - all_scores or [] - ) # Return empty list if nothing found, or None if error earlier + return all_scores or [] # Return empty list if nothing found, or None if error earlier async def get_user_beatmapsets( self, user_id: int | str, beatmap_type: str, limit: int = 50, offset: int = 0 @@ -494,9 +484,7 @@ def calculate_accuracy(self, statistics: dict, mode: str = "osu") -> float: accuracy = ((c300 * 300 + c100 * 100 + c50 * 50) / (total_hits * 300)) * 100 return round(accuracy, 2) if mode == "taiko": - total_hits = ( - c300 + c100 + cmiss - ) # c50 is not used, geki/katu are part of c300/c100 + total_hits = c300 + c100 + cmiss # c50 is not used, geki/katu are part of c300/c100 if total_hits == 0: return 0.0 # Taiko accuracy: ( (greats * 1) + (goods * 0.5) ) / total_notes @@ -539,11 +527,7 @@ def calculate_accuracy(self, statistics: dict, mode: str = "osu") -> float: # geki = MAX, katu = 300s in some contexts, but API stats are count_geki, count_300, count_katu, count_100, count_50, count_miss # For mania: count_geki (MAX/300g), count_300 (300), count_katu (200/!200), count_100 (100), count_50 (50), count_miss (Miss) total_score_points = ( - (c_geki * 320) - + (c300 * 300) - + (c_katu * 200) - + (c100 * 100) - + (c50 * 50) + (c_geki * 320) + (c300 * 300) + (c_katu * 200) + (c100 * 100) + (c50 * 50) ) total_possible_points = (c_geki + c300 + c_katu + c100 + c50 + cmiss) * 320 if total_possible_points == 0: @@ -553,14 +537,10 @@ def calculate_accuracy(self, statistics: dict, mode: str = "osu") -> float: # If API v2 provides accuracy directly in the score object, prefer that. # score_data.get('accuracy') might be e.g. 0.9876, so multiply by 100. return ( - statistics.get("accuracy", 0.0) * 100 - if statistics.get("accuracy") is not None - else 0.0 + statistics.get("accuracy", 0.0) * 100 if statistics.get("accuracy") is not None else 0.0 ) - async def get_score_v1( - self, beatmap_id: int, user_id: int | str, mode: int = 0 - ) -> dict | None: + async def get_score_v1(self, beatmap_id: int, user_id: int | str, mode: int = 0) -> dict | None: """ 從 osu! API v1 獲取指定 beatmap 和用戶的最高分數。 API v1 端點: /get_scores @@ -568,9 +548,7 @@ async def get_score_v1( 只獲取用戶在該圖上的最佳成績 (limit=1)。 """ if not self.api_v1_key: - logger.warning( - "[get_score_v1] API v1 key is not configured. Skipping fallback." - ) + logger.warning("[get_score_v1] API v1 key is not configured. Skipping fallback.") return None endpoint = f"{OSU_API_V1_BASE_URL}/get_scores" @@ -591,9 +569,7 @@ async def get_score_v1( f"[get_score_v1] Successfully fetched score from API v1: {data[0]} for beatmap {beatmap_id}, user {user_id}, mode {mode}" ) return data[0] # Return the first (and only) score - if ( - isinstance(data, list) and not data - ): # Successfully fetched an empty list + if isinstance(data, list) and not data: # Successfully fetched an empty list logger.info( f"[get_score_v1] API v1 returned an empty list for beatmap {beatmap_id}, user {user_id}, mode {mode}. No score data found." ) @@ -612,8 +588,7 @@ async def get_score_v1( return None except Exception as e: logger.error( - f"[get_score_v1] Unexpected error during API v1 request: {e}", - exc_info=True, + f"[get_score_v1] Unexpected error during API v1 request: {e}", exc_info=True ) return None