|
12 | 12 | from utils.logger import setup_logging, get_logger
|
13 | 13 |
|
14 | 14 | from leetcode import LeetCodeClient, html_to_text
|
| 15 | +from llms import GeminiLLM |
15 | 16 | from utils import SettingsDatabaseManager
|
| 17 | +from utils.database import LLMTranslateDatabaseManager, LLMInspireDatabaseManager |
16 | 18 | from discord.ui import View, Button
|
17 | 19 |
|
18 | 20 | # Set up logging
|
19 | 21 | setup_logging()
|
20 | 22 | logger = get_logger("bot")
|
21 | 23 |
|
| 24 | +# Load environment variables |
22 | 25 | load_dotenv(dotenv_path='.env', verbose=True, override=True)
|
23 | 26 | DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
|
24 | 27 | POST_TIME = os.getenv('POST_TIME', '00:00') # Default to 00:00
|
25 | 28 | TIMEZONE = os.getenv('TIMEZONE', 'UTC') # Default to UTC
|
26 | 29 |
|
27 | 30 | # Initialize the database manager
|
28 | 31 | db = SettingsDatabaseManager()
|
| 32 | +llm_translate_db = LLMTranslateDatabaseManager() |
| 33 | +llm_inspire_db = LLMInspireDatabaseManager() |
29 | 34 |
|
30 | 35 | # Initialize LeetCode client
|
31 | 36 | lcus = LeetCodeClient()
|
|
36 | 41 | intents.message_content = True # Enable message content permission
|
37 | 42 | bot = commands.Bot(command_prefix="!", intents=intents)
|
38 | 43 |
|
| 44 | +# LLM |
| 45 | +try: |
| 46 | + llm = GeminiLLM(model="gemini-2.0-flash") |
| 47 | + llm_pro = GeminiLLM(model="gemini-2.5-pro-preview-03-25") |
| 48 | +except Exception as e: |
| 49 | + logger.error(f"Error while initializing LLM: {e}") |
| 50 | + llm = None |
| 51 | + llm_pro = None |
| 52 | + |
39 | 53 | # Schedule tasks are stored here to be cancelled later
|
40 | 54 | schedule_tasks = {}
|
41 | 55 |
|
42 | 56 | # Define a fixed custom ID prefix
|
43 |
| -LEETCODE_BUTTON_PREFIX = "leetcode_problem_" |
44 |
| - |
| 57 | +# LEETCODE_DISCRIPTION_BUTTON_PREFIX = "leetcode_problem_" |
| 58 | +LEETCODE_DISCRIPTION_BUTTON_PREFIX = "leetcode_problem_" |
| 59 | +LEETCODE_TRANSLATE_BUTTON_PREFIX = "leetcode_translate_" |
| 60 | +LEETCODE_INSPIRE_BUTTON_PREFIX = "leetcode_inspire_" |
45 | 61 | # Global interaction event handler
|
46 | 62 | @bot.event
|
47 | 63 | async def on_interaction(interaction):
|
48 | 64 | # Ignore non-button interactions
|
49 | 65 | if interaction.type != discord.InteractionType.component:
|
50 | 66 | return
|
51 |
| - |
52 |
| - # Check if it's our LeetCode button |
| 67 | + |
53 | 68 | custom_id = interaction.data.get("custom_id", "")
|
54 |
| - if custom_id.startswith(LEETCODE_BUTTON_PREFIX): |
| 69 | + |
| 70 | + # Button for LLM translation |
| 71 | + if custom_id.startswith(LEETCODE_TRANSLATE_BUTTON_PREFIX): |
| 72 | + logger.debug(f"接收到LeetCode LLM翻譯按鈕交互: custom_id={custom_id}") |
| 73 | + try: |
| 74 | + # 先 defer,避免 interaction 過期 |
| 75 | + await interaction.response.defer(ephemeral=True) |
| 76 | + parts = custom_id.split("_") |
| 77 | + # 格式: leetcode_translate_{problem_id}_{domain} |
| 78 | + problem_id = parts[2] |
| 79 | + domain = parts[3] if len(parts) > 3 else "com" |
| 80 | + |
| 81 | + logger.debug(f"嘗試獲取題目並進行LLM翻譯: problem_id={problem_id}, domain={domain}") |
| 82 | + |
| 83 | + client = lcus if domain == "com" else lccn |
| 84 | + |
| 85 | + if problem_id and problem_id.isdigit(): |
| 86 | + # 先查詢 DB cache |
| 87 | + translation = llm_translate_db.get_translation(int(problem_id), domain) |
| 88 | + if translation: |
| 89 | + logger.debug(f"從DB取得LLM翻譯: problem_id={problem_id}") |
| 90 | + await interaction.followup.send(translation, ephemeral=True) |
| 91 | + return |
| 92 | + |
| 93 | + problem_info = await client.get_problem(problem_id=problem_id) |
| 94 | + if problem_info and problem_info.get("content"): |
| 95 | + problem_content = html_to_text(problem_info["content"]) |
| 96 | + # LLM 翻譯 |
| 97 | + try: |
| 98 | + translation = llm.translate(problem_content, "zh-TW") |
| 99 | + # 長度限制 |
| 100 | + if len(translation) > 1900: |
| 101 | + translation = translation[:1900] + "...\n(翻譯內容已截斷)" |
| 102 | + # 寫入 DB |
| 103 | + llm_translate_db.save_translation(int(problem_id), domain, translation) |
| 104 | + await interaction.followup.send(translation, ephemeral=True) |
| 105 | + logger.debug(f"成功發送LLM翻譯並寫入DB: problem_id={problem_id}") |
| 106 | + except Exception as llm_e: |
| 107 | + logger.error(f"LLM 翻譯失敗: {llm_e}", exc_info=True) |
| 108 | + await interaction.followup.send(f"LLM 翻譯失敗:{str(llm_e)}", ephemeral=True) |
| 109 | + else: |
| 110 | + logger.warning(f"題目沒有內容: problem_id={problem_id}") |
| 111 | + await interaction.followup.send("無法獲取題目描述,請前往 LeetCode 網站查看。", ephemeral=True) |
| 112 | + else: |
| 113 | + logger.warning(f"無效的題目ID: {problem_id}") |
| 114 | + await interaction.followup.send("無效的題目ID,無法顯示翻譯。", ephemeral=True) |
| 115 | + except discord.errors.InteractionResponded: |
| 116 | + await interaction.followup.send("已經回應過此交互,請重新點擊按鈕。", ephemeral=True) |
| 117 | + except Exception as e: |
| 118 | + logger.error(f"處理LLM翻譯按鈕交互時發生錯誤: {e}", exc_info=True) |
| 119 | + try: |
| 120 | + await interaction.followup.send(f"LLM 翻譯時發生錯誤:{str(e)}", ephemeral=True) |
| 121 | + except: |
| 122 | + pass |
| 123 | + return |
| 124 | + |
| 125 | + # Button for LLM inspire |
| 126 | + def format_inspire_field(val): |
| 127 | + if isinstance(val, list): |
| 128 | + return '\n'.join(f"- {x}" for x in val) |
| 129 | + return str(val) |
| 130 | + |
| 131 | + INSPIRE_FIELDS = [ |
| 132 | + ("thinking", "🧠 思路"), |
| 133 | + ("traps", "⚠️ 陷阱"), |
| 134 | + ("algorithms", "🛠️ 推薦演算法"), |
| 135 | + ("inspiration", "✨ 其他靈感"), |
| 136 | + ] |
| 137 | + |
| 138 | + if custom_id.startswith(LEETCODE_INSPIRE_BUTTON_PREFIX): |
| 139 | + logger.debug(f"接收到LeetCode 靈感啟發按鈕交互: custom_id={custom_id}") |
| 140 | + try: |
| 141 | + await interaction.response.defer(ephemeral=True) |
| 142 | + parts = custom_id.split("_") |
| 143 | + # 格式: leetcode_inspire_{problem_id}_{domain} |
| 144 | + problem_id = parts[2] |
| 145 | + domain = parts[3] if len(parts) > 3 else "com" |
| 146 | + |
| 147 | + logger.debug(f"嘗試獲取題目並進行LLM靈感啟發: problem_id={problem_id}, domain={domain}") |
| 148 | + |
| 149 | + if not problem_id or not problem_id.isdigit(): |
| 150 | + logger.warning(f"無效的題目ID: {problem_id}") |
| 151 | + await interaction.followup.send("無效的題目ID,無法顯示靈感啟發。", ephemeral=True) |
| 152 | + return |
| 153 | + |
| 154 | + inspire_result = llm_inspire_db.get_inspire(int(problem_id), domain) |
| 155 | + if inspire_result: |
| 156 | + logger.debug(f"Get inspire result from DB: problem_id={problem_id}") |
| 157 | + else: |
| 158 | + client = lcus if domain == "com" else lccn |
| 159 | + problem_info = await client.get_problem(problem_id=problem_id) |
| 160 | + if problem_info and problem_info.get("content"): |
| 161 | + problem_content = html_to_text(problem_info["content"]) |
| 162 | + tags = problem_info.get("tags", []) |
| 163 | + difficulty = problem_info.get("difficulty", "") |
| 164 | + else: |
| 165 | + logger.warning(f"題目沒有內容: problem_id={problem_id}") |
| 166 | + await interaction.followup.send("無法獲取題目資訊。", ephemeral=True) |
| 167 | + return |
| 168 | + |
| 169 | + # Get inspire result from LLM |
| 170 | + try: |
| 171 | + inspire_result = llm_pro.inspire(problem_content, tags, difficulty) |
| 172 | + if not isinstance(inspire_result, dict) or not all(k in inspire_result for k in ["thinking", "traps", "algorithms", "inspiration"]): |
| 173 | + # 回傳原始 LLM 回覆 |
| 174 | + raw = inspire_result.get("raw", inspire_result) |
| 175 | + if len(str(raw)) > 1900: |
| 176 | + raw = str(raw)[:1900] + "...\n(內容已截斷)" |
| 177 | + await interaction.followup.send(str(raw), ephemeral=True) |
| 178 | + logger.debug(f"發送原始 LLM 靈感回覆: problem_id={problem_id}") |
| 179 | + return |
| 180 | + # --- DB cache: save inspire result --- |
| 181 | + formatted_fields = [format_inspire_field(inspire_result[k]) for k, _ in INSPIRE_FIELDS] |
| 182 | + llm_inspire_db.save_inspire( |
| 183 | + int(problem_id), domain, |
| 184 | + *formatted_fields |
| 185 | + ) |
| 186 | + except Exception as llm_e: |
| 187 | + logger.error(f"LLM 靈感啟發失敗: {llm_e}", exc_info=True) |
| 188 | + await interaction.followup.send(f"LLM 靈感啟發失敗:{str(llm_e)}", ephemeral=True) |
| 189 | + return |
| 190 | + |
| 191 | + embed = discord.Embed( |
| 192 | + title="💡 靈感啟發", |
| 193 | + color=0x8e44ad, |
| 194 | + ) |
| 195 | + total_len = 0 |
| 196 | + for key, field_name in INSPIRE_FIELDS: |
| 197 | + val = format_inspire_field(inspire_result.get(key, "")) |
| 198 | + embed.add_field(name=field_name, value=val, inline=False) |
| 199 | + total_len += len(val) |
| 200 | + if total_len > 1800: |
| 201 | + embed.set_footer(text="內容已截斷,請嘗試更精簡提示。") |
| 202 | + await interaction.followup.send(embed=embed, ephemeral=True) |
| 203 | + |
| 204 | + except discord.errors.InteractionResponded: |
| 205 | + await interaction.followup.send("已經回應過此交互,請重新點擊按鈕。", ephemeral=True) |
| 206 | + except Exception as e: |
| 207 | + logger.error(f"處理LLM靈感啟發按鈕交互時發生錯誤: {e}", exc_info=True) |
| 208 | + try: |
| 209 | + await interaction.followup.send(f"LLM 靈感啟發時發生錯誤:{str(e)}", ephemeral=True) |
| 210 | + except: |
| 211 | + pass |
| 212 | + return |
| 213 | + |
| 214 | + # Button for displaying LeetCode problem description |
| 215 | + if custom_id.startswith(LEETCODE_DISCRIPTION_BUTTON_PREFIX): |
55 | 216 | logger.debug(f"接收到LeetCode按鈕交互: custom_id={custom_id}")
|
56 | 217 |
|
57 | 218 | # Parse problem ID and domain
|
@@ -297,21 +458,38 @@ async def send_daily_challenge(channel_id=None, role_id=None, interaction=None,
|
297 | 458 | embed.add_field(name="🔍 Similar Questions", value="\n".join(similar_questions), inline=False)
|
298 | 459 |
|
299 | 460 | embed.set_footer(text=f"LeetCode Daily Challenge | {info['date']}", icon_url="https://leetcode.com/static/images/LeetCode_logo.png")
|
300 |
| - |
301 |
| - # 創建一個自訂ID,包含問題ID和域名 |
302 |
| - custom_id = f"{LEETCODE_BUTTON_PREFIX}{info['id']}_{domain}" |
303 |
| - |
304 |
| - # 創建按鈕組件 |
305 |
| - button = discord.ui.Button( |
| 461 | + |
| 462 | + # Create a view containing the button |
| 463 | + view = discord.ui.View(timeout=None) |
| 464 | + |
| 465 | + # Create a button for displaying the problem description |
| 466 | + description_button = discord.ui.Button( |
306 | 467 | style=discord.ButtonStyle.primary,
|
307 |
| - label="顯示題目描述(僅自己可見)", |
| 468 | + label="題目描述", |
308 | 469 | emoji="📖",
|
309 |
| - custom_id=custom_id |
| 470 | + custom_id=f"{LEETCODE_DISCRIPTION_BUTTON_PREFIX}{info['id']}_{domain}" |
310 | 471 | )
|
311 |
| - |
312 |
| - # 創建包含按鈕的視圖 |
313 |
| - view = discord.ui.View(timeout=None) |
314 |
| - view.add_item(button) |
| 472 | + view.add_item(description_button) |
| 473 | + |
| 474 | + # Add LLM translation button |
| 475 | + translate_custom_id = f"{LEETCODE_TRANSLATE_BUTTON_PREFIX}{info['id']}_{domain}" |
| 476 | + translate_button = discord.ui.Button( |
| 477 | + style=discord.ButtonStyle.success, |
| 478 | + label="LLM 翻譯", |
| 479 | + emoji="🌐", |
| 480 | + custom_id=translate_custom_id |
| 481 | + ) |
| 482 | + view.add_item(translate_button) |
| 483 | + |
| 484 | + # Add LLM inspire button |
| 485 | + inspire_custom_id = f"{LEETCODE_INSPIRE_BUTTON_PREFIX}{info['id']}_{domain}" |
| 486 | + inspire_button = discord.ui.Button( |
| 487 | + style=discord.ButtonStyle.danger, |
| 488 | + label="靈感啟發", |
| 489 | + emoji="💡", |
| 490 | + custom_id=inspire_custom_id |
| 491 | + ) |
| 492 | + view.add_item(inspire_button) |
315 | 493 |
|
316 | 494 | # Determine how to send the message
|
317 | 495 | if interaction:
|
|
0 commit comments