mikubot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. mikubot-0.1.0/PKG-INFO +57 -0
  2. mikubot-0.1.0/README.md +21 -0
  3. mikubot-0.1.0/pyproject.toml +48 -0
  4. mikubot-0.1.0/src/mikubot/__init__.py +16 -0
  5. mikubot-0.1.0/src/mikubot/commands/__init__.py +21 -0
  6. mikubot-0.1.0/src/mikubot/commands/avatar.py +22 -0
  7. mikubot-0.1.0/src/mikubot/commands/clap.py +19 -0
  8. mikubot-0.1.0/src/mikubot/commands/code.py +56 -0
  9. mikubot-0.1.0/src/mikubot/commands/edenpatch.py +20 -0
  10. mikubot-0.1.0/src/mikubot/commands/gamebanana.py +115 -0
  11. mikubot-0.1.0/src/mikubot/commands/goofy_translate.py +75 -0
  12. mikubot-0.1.0/src/mikubot/commands/guess.py +202 -0
  13. mikubot-0.1.0/src/mikubot/commands/it.py +29 -0
  14. mikubot-0.1.0/src/mikubot/commands/list_brazil.py +14 -0
  15. mikubot-0.1.0/src/mikubot/commands/purge.py +28 -0
  16. mikubot-0.1.0/src/mikubot/commands/rename_channel.py +215 -0
  17. mikubot-0.1.0/src/mikubot/commands/reply_commands.py +23 -0
  18. mikubot-0.1.0/src/mikubot/commands/role.py +20 -0
  19. mikubot-0.1.0/src/mikubot/commands/settings.py +26 -0
  20. mikubot-0.1.0/src/mikubot/commands/stop.py +30 -0
  21. mikubot-0.1.0/src/mikubot/commands/sync.py +19 -0
  22. mikubot-0.1.0/src/mikubot/commands/toggle_bot_response.py +21 -0
  23. mikubot-0.1.0/src/mikubot/commands/user_count.py +20 -0
  24. mikubot-0.1.0/src/mikubot/commands/utils.py +28 -0
  25. mikubot-0.1.0/src/mikubot/commands/uwu.py +32 -0
  26. mikubot-0.1.0/src/mikubot/commands/you_are_going_to_brazil.py +28 -0
  27. mikubot-0.1.0/src/mikubot/commands/zoe.py +138 -0
  28. mikubot-0.1.0/src/mikubot/config.py +336 -0
  29. mikubot-0.1.0/src/mikubot/discord.py +351 -0
  30. mikubot-0.1.0/src/mikubot/logger.py +65 -0
  31. mikubot-0.1.0/src/mikubot/modals/__init__.py +8 -0
  32. mikubot-0.1.0/src/mikubot/modals/base.py +35 -0
  33. mikubot-0.1.0/src/mikubot/modals/brazil.py +45 -0
  34. mikubot-0.1.0/src/mikubot/modals/gamebanana.py +34 -0
  35. mikubot-0.1.0/src/mikubot/modals/general.py +13 -0
  36. mikubot-0.1.0/src/mikubot/modals/rename_chat.py +114 -0
  37. mikubot-0.1.0/src/mikubot/modals/trigger_words.py +50 -0
  38. mikubot-0.1.0/src/mikubot/modals/uwufy.py +52 -0
  39. mikubot-0.1.0/src/mikubot/modals/zoe.py +40 -0
  40. mikubot-0.1.0/src/mikubot/stocks/__init__.py +12 -0
  41. mikubot-0.1.0/src/mikubot/stocks/command/__init__.py +880 -0
  42. mikubot-0.1.0/src/mikubot/stocks/command/embeds.py +207 -0
  43. mikubot-0.1.0/src/mikubot/stocks/command/modals.py +141 -0
  44. mikubot-0.1.0/src/mikubot/stocks/command/utils.py +11 -0
  45. mikubot-0.1.0/src/mikubot/stocks/command/views.py +225 -0
  46. mikubot-0.1.0/src/mikubot/stocks/core.py +311 -0
  47. mikubot-0.1.0/src/mikubot/stocks/derivative_rules/__init__.py +6 -0
  48. mikubot-0.1.0/src/mikubot/stocks/derivative_rules/asymmetric.py +30 -0
  49. mikubot-0.1.0/src/mikubot/stocks/derivative_rules/base.py +32 -0
  50. mikubot-0.1.0/src/mikubot/stocks/derivative_rules/decay.py +40 -0
  51. mikubot-0.1.0/src/mikubot/stocks/derivative_rules/knock_out.py +33 -0
  52. mikubot-0.1.0/src/mikubot/stocks/graphs.py +195 -0
  53. mikubot-0.1.0/src/mikubot/stocks/history.py +163 -0
  54. mikubot-0.1.0/src/mikubot/stocks/intervals.py +64 -0
  55. mikubot-0.1.0/src/mikubot/stocks/links.py +128 -0
  56. mikubot-0.1.0/src/mikubot/stocks/metrics/__init__.py +3 -0
  57. mikubot-0.1.0/src/mikubot/stocks/metrics/liquidity.py +104 -0
  58. mikubot-0.1.0/src/mikubot/stocks/metrics/prices.py +45 -0
  59. mikubot-0.1.0/src/mikubot/stocks/metrics/volatility.py +105 -0
  60. mikubot-0.1.0/src/mikubot/stocks/models.py +375 -0
  61. mikubot-0.1.0/src/mikubot/stocks/polymorphic.py +68 -0
  62. mikubot-0.1.0/src/mikubot/stocks/securities.py +52 -0
  63. mikubot-0.1.0/src/mikubot/stocks/store.py +90 -0
  64. mikubot-0.1.0/src/mikubot/stocks/transactions.py +164 -0
  65. mikubot-0.1.0/src/mikubot/stocks/utils.py +20 -0
  66. mikubot-0.1.0/src/mikubot/utils.py +14 -0
mikubot-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: mikubot
3
+ Version: 0.1.0
4
+ Summary: Discord bot used for the Eden-Project Discord server.
5
+ Keywords: discord,eden,miku,vocaloid,project diva
6
+ Author: Jay184
7
+ Author-email: Jay184 <me@jay0.mozmail.com>
8
+ License-Expression: 0BSD
9
+ Requires-Dist: capstone>=5.0.6
10
+ Requires-Dist: discord-py>=2.6.3
11
+ Requires-Dist: emoji>=2.14.1
12
+ Requires-Dist: googletrans>=4.0.2
13
+ Requires-Dist: jinja2>=3.1.6
14
+ Requires-Dist: loguru>=0.7.3
15
+ Requires-Dist: matplotlib>=3.10.6
16
+ Requires-Dist: msgpack>=1.1.1
17
+ Requires-Dist: pandas>=2.3.2
18
+ Requires-Dist: pefile>=2024.8.26
19
+ Requires-Dist: pillow>=11.3.0
20
+ Requires-Dist: pydantic>=2.11.9
21
+ Requires-Dist: pydantic-settings>=2.10.1
22
+ Requires-Dist: requests>=2.32.5
23
+ Requires-Dist: requests-sse>=0.5.2
24
+ Requires-Dist: rich>=14.1.0
25
+ Requires-Dist: sqlitedict>=2.1.0
26
+ Requires-Dist: sqlmodel>=0.0.27
27
+ Requires-Dist: typer>=0.19.2
28
+ Requires-Dist: uwuipy>=0.1.10
29
+ Requires-Dist: yfinance>=0.2.66
30
+ Maintainer: Jay184
31
+ Maintainer-email: Jay184 <me@jay0.mozmail.com>
32
+ Requires-Python: >=3.11
33
+ Project-URL: Issues, https://github.com/Jay184/mikubot/issues
34
+ Project-URL: Repository, https://github.com/Jay184/mikubot.git
35
+ Description-Content-Type: text/markdown
36
+
37
+ # MikuBot
38
+
39
+ Bot used for the Eden Project discord server.
40
+
41
+ ## Prerequisites
42
+
43
+ * Python 3.11 or above
44
+
45
+ ## Usage
46
+
47
+ ### Windows
48
+
49
+ 1. Create a `token.txt` which contains the bot's token.
50
+ 1. Copy the `settings.json.template` and rename it to `settings.json` and fill it out with the necessary data.
51
+ 1. run the `run.cmd`.
52
+
53
+ _Note: Make sure `py` starts a Python interpreter or change the `run.cmd` to use `python` instead._
54
+
55
+ ## Acknowledgements
56
+
57
+ * [Bot Avatar](https://cdn.discordapp.com/app-icons/1078530934766321744/65ae221617e928f3ff928e535885d66d.png?size=256)
@@ -0,0 +1,21 @@
1
+ # MikuBot
2
+
3
+ Bot used for the Eden Project discord server.
4
+
5
+ ## Prerequisites
6
+
7
+ * Python 3.11 or above
8
+
9
+ ## Usage
10
+
11
+ ### Windows
12
+
13
+ 1. Create a `token.txt` which contains the bot's token.
14
+ 1. Copy the `settings.json.template` and rename it to `settings.json` and fill it out with the necessary data.
15
+ 1. run the `run.cmd`.
16
+
17
+ _Note: Make sure `py` starts a Python interpreter or change the `run.cmd` to use `python` instead._
18
+
19
+ ## Acknowledgements
20
+
21
+ * [Bot Avatar](https://cdn.discordapp.com/app-icons/1078530934766321744/65ae221617e928f3ff928e535885d66d.png?size=256)
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "mikubot"
3
+ version = "0.1.0"
4
+ description = "Discord bot used for the Eden-Project Discord server."
5
+ keywords = ["discord", "eden", "miku", "vocaloid", "project diva"]
6
+ authors = [
7
+ {name = "Jay184", email = "me@jay0.mozmail.com"},
8
+ ]
9
+ maintainers = [
10
+ {name = "Jay184", email = "me@jay0.mozmail.com"},
11
+ ]
12
+ license = "0BSD"
13
+ readme = "README.md"
14
+ requires-python = ">=3.11"
15
+ dependencies = [
16
+ "capstone>=5.0.6",
17
+ "discord-py>=2.6.3",
18
+ "emoji>=2.14.1",
19
+ "googletrans>=4.0.2",
20
+ "jinja2>=3.1.6",
21
+ "loguru>=0.7.3",
22
+ "matplotlib>=3.10.6",
23
+ "msgpack>=1.1.1",
24
+ "pandas>=2.3.2",
25
+ "pefile>=2024.8.26",
26
+ "pillow>=11.3.0",
27
+ "pydantic>=2.11.9",
28
+ "pydantic-settings>=2.10.1",
29
+ "requests>=2.32.5",
30
+ "requests-sse>=0.5.2",
31
+ "rich>=14.1.0",
32
+ "sqlitedict>=2.1.0",
33
+ "sqlmodel>=0.0.27",
34
+ "typer>=0.19.2",
35
+ "uwuipy>=0.1.10",
36
+ "yfinance>=0.2.66",
37
+ ]
38
+
39
+ [project.scripts]
40
+ mikubot = "mikubot:main"
41
+
42
+ [project.urls]
43
+ Repository = "https://github.com/Jay184/mikubot.git"
44
+ Issues = "https://github.com/Jay184/mikubot/issues"
45
+
46
+ [build-system]
47
+ requires = ["uv_build>=0.8.12,<0.9.0"]
48
+ build-backend = "uv_build"
@@ -0,0 +1,16 @@
1
+ from .config import Settings
2
+ from .discord import Bot
3
+ from .logger import setup_logger
4
+
5
+
6
+ def main() -> None:
7
+ settings = Settings.load()
8
+
9
+ if settings.discord_key:
10
+ setup_logger(settings.logging)
11
+ client = Bot(settings)
12
+ client.run(settings.discord_key)
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
@@ -0,0 +1,21 @@
1
+ from .sync import *
2
+ from .stop import *
3
+ from .reply_commands import *
4
+ from .settings import *
5
+ from .code import *
6
+ from .avatar import *
7
+ from .clap import *
8
+ from .edenpatch import *
9
+ from .gamebanana import *
10
+ from .it import *
11
+ from .list_brazil import *
12
+ from .purge import *
13
+ from .rename_channel import *
14
+ from .role import *
15
+ from .toggle_bot_response import *
16
+ from .user_count import *
17
+ from .uwu import *
18
+ from .you_are_going_to_brazil import *
19
+ from .zoe import *
20
+ from .guess import *
21
+ from .goofy_translate import *
@@ -0,0 +1,22 @@
1
+ from discord import Interaction, Embed, Member
2
+ from discord.app_commands import describe, checks
3
+ from mikubot import Bot
4
+
5
+
6
+ def register(bot: Bot):
7
+ @bot.tree.command(name="avatar", description="Get the avatar of a user.")
8
+ @checks.bot_has_permissions(send_messages=True)
9
+ @describe(user="The user to retrieve the avatar of.")
10
+ async def handler(interaction: Interaction, user: Member):
11
+ embed = Embed(
12
+ title=f"{user.name}'s Avatar",
13
+ color=0x0099ff,
14
+ )
15
+
16
+ embed.set_footer(text=bot.user.display_name)
17
+ embed.set_image(url=user.display_avatar.url)
18
+
19
+ await interaction.response.send_message( # noqa
20
+ embed=embed,
21
+ ephemeral=True,
22
+ )
@@ -0,0 +1,19 @@
1
+ from discord import Interaction, Message
2
+ from discord.app_commands import describe, checks
3
+ from mikubot import Bot
4
+ import re
5
+
6
+
7
+ def register(bot: Bot):
8
+ @bot.tree.command(name="clap", description="Adds 👏 Clap 👏 Emoji 👏 in 👏 between 👏 each 👏 word.")
9
+ @checks.bot_has_permissions(send_messages=True)
10
+ @describe(text="The text you want to add claps to.")
11
+ async def handler(interaction: Interaction, text: str):
12
+ text = re.sub(r"\s+", " 👏 ", text)
13
+ await interaction.response.send_message(text[:2000]) # noqa
14
+
15
+ @bot.tree.context_menu(name="clap")
16
+ @checks.bot_has_permissions(send_messages=True)
17
+ async def context_handler(interaction: Interaction, message: Message):
18
+ text = re.sub(r"\s+", " 👏 ", message.content)
19
+ await interaction.response.send_message(text[:2000], allowed_mentions=None) # noqa
@@ -0,0 +1,56 @@
1
+ from discord import Interaction
2
+ from discord.app_commands import describe, checks, choices, Choice
3
+ from capstone import Cs as Capstone, CS_ARCH_X86, CS_MODE_64, CS_OPT_SYNTAX_INTEL
4
+ from pefile import PE
5
+ from mikubot import Bot
6
+
7
+
8
+ def register(bot: Bot):
9
+ def format_bytes(data: bytes, offset: int = 0) -> str:
10
+ lines = []
11
+
12
+ for i in range(0, len(data), 16):
13
+ chunk = data[i:i + 16]
14
+ hex_part = " ".join(f"{b:02X}" for b in chunk)
15
+ ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
16
+ lines.append(f"{(offset + i):08X} {hex_part:<47} |{ascii_part}|")
17
+
18
+ return "\n".join(lines)
19
+
20
+ @bot.tree.command(name="code", description="Display binary code of MM+.")
21
+ @checks.bot_has_permissions(send_messages=True)
22
+ @checks.has_role(1023036859883999272)
23
+ @describe(
24
+ version="The version of the game.",
25
+ address="The address to start reading.",
26
+ length="The length of the code to read.",
27
+ assembly="If the code should be displayed as assembly code.",
28
+ )
29
+ @choices(version=[
30
+ Choice(name="1.00", value=0),
31
+ Choice(name="1.01", value=1),
32
+ Choice(name="1.02", value=2),
33
+ Choice(name="1.03", value=3),
34
+ ])
35
+ async def handler(interaction: Interaction, address: int, length: int = 0x20, assembly: bool = True, version: Choice[int] = 3):
36
+ md = Capstone(CS_ARCH_X86, CS_MODE_64)
37
+ md.syntax = CS_OPT_SYNTAX_INTEL
38
+
39
+ length = max(0x10, min(bot.settings.mm_code.max_length, length))
40
+
41
+ file_path = bot.settings.mm_code.binaries[version.value]
42
+ pe = PE(file_path, fast_load=True)
43
+
44
+ image_base = pe.OPTIONAL_HEADER.ImageBase
45
+ rva = address - image_base
46
+ data = pe.get_data(rva, length)
47
+
48
+ if assembly:
49
+ instructions = md.disasm(data, address, length)
50
+ message = "\n".join(f"{i.address:08X}: {i.mnemonic} {i.op_str}" for i in instructions)
51
+ await interaction.response.send_message(f"```x86asm\n{message}\n```") # noqa
52
+ else:
53
+ message = format_bytes(data, address)
54
+ await interaction.response.send_message(f"```properties\n{message}\n```") # noqa
55
+
56
+ pe.close()
@@ -0,0 +1,20 @@
1
+ from discord import Interaction, Embed
2
+ from discord.app_commands import checks
3
+ from mikubot import Bot
4
+
5
+
6
+ def register(bot: Bot):
7
+ @bot.tree.command(name="edenpatch", description="Info on songlimit patch, song id patch, etc.")
8
+ @checks.bot_has_permissions(send_messages=True)
9
+ async def handler(interaction: Interaction):
10
+ embed = Embed(
11
+ description=r"The Eden \_\_\_\_\_\_\_ Patch series of mods are public releases of mods that are included within Eden Project and as a result can cause some unintended side effects when enabled with Eden Project, as the mods are effectively running twice.",
12
+ color=0x86cecb,
13
+ )
14
+
15
+ embed.set_author(name="Eden Patch", icon_url="https://images.gamebanana.com/img/ico/games/6296031c71087.png")
16
+ embed.set_footer(text=bot.user.display_name)
17
+
18
+ await interaction.response.send_message( # noqa
19
+ embed=embed,
20
+ )
@@ -0,0 +1,115 @@
1
+ from discord import Interaction, Embed
2
+ from discord.app_commands import describe, checks
3
+ from urllib.parse import urljoin
4
+ from datetime import datetime, timezone
5
+ from mikubot import Bot
6
+ import requests
7
+
8
+
9
+ def register(bot: Bot):
10
+ def create_mod_embed(data: dict, full: bool = False) -> Embed:
11
+ embed = Embed(
12
+ title=data.get("_sName"),
13
+ url=data.get("_sProfileUrl"),
14
+ timestamp=datetime.fromtimestamp(data.get("_tsDateAdded"), timezone.utc),
15
+ color=0x86cecb,
16
+ )
17
+
18
+ # Mark obselete
19
+ if data.get("_bIsObsolete", False):
20
+ embed.title = f"~~{embed.title}~~"
21
+
22
+ embed.set_author(
23
+ name=data.get("_aSubmitter", {}).get("_sName"),
24
+ icon_url=data.get("_aRootCategory", {}).get("_sIconUrl")
25
+ )
26
+
27
+ first_image = data.get("_aPreviewMedia").get("_aImages")[0]
28
+ first_image_url = urljoin(first_image.get("_sBaseUrl", ""), first_image.get("_sFile", ""))
29
+
30
+ embed.set_thumbnail(url=first_image_url)
31
+ embed.set_footer(text=bot.user.display_name)
32
+
33
+ embed.add_field(
34
+ name="Submitter",
35
+ value=data.get("_aSubmitter", {}).get("_sName"),
36
+ inline=True,
37
+ )
38
+
39
+ embed.add_field(
40
+ name="Likes",
41
+ value=data.get("_nLikeCount", 0),
42
+ inline=True,
43
+ )
44
+
45
+ embed.add_field(
46
+ name="Views",
47
+ value=data.get("_nViewCount", 0),
48
+ inline=True,
49
+ )
50
+
51
+ if "_aAdditionalInfo" in data and "_sversion" in data.get("_aAdditionalInfo"):
52
+ embed.add_field(
53
+ name="Version",
54
+ value=data.get("_aAdditionalInfo", {}).get("_sversion", "Unknown version"),
55
+ inline=True,
56
+ )
57
+
58
+ # The following require a new API call
59
+ if full:
60
+ mod_id = data.get("_idRow")
61
+ profile_url = f"https://gamebanana.com/apiv10/Mod/{mod_id}/ProfilePage"
62
+ response = requests.get(profile_url)
63
+
64
+ if response.status_code == 200:
65
+ profile_data = response.json()
66
+
67
+ if "_aContentRatings" in profile_data and len(profile_data.get("_aContentRatings", {})):
68
+ embed.add_field(
69
+ name="Content Warnings",
70
+ value=", ".join(profile_data.get("_aContentRatings", {}).values()),
71
+ inline=True,
72
+ )
73
+
74
+ if "_sDescription" in profile_data:
75
+ embed.description = profile_data.get("_sDescription")
76
+
77
+ return embed
78
+
79
+ @bot.tree.command(name="gamebanana", description="Searches for a mod in the MegaMix+ section of Gamebanana.")
80
+ @checks.bot_has_permissions(send_messages=True)
81
+ @describe(query="The mod name or submission ID to search for.")
82
+ async def handler(interaction: Interaction, query: str):
83
+ settings = bot.settings.gamebanana_search
84
+
85
+ await interaction.response.defer() # noqa
86
+
87
+ url = f"https://gamebanana.com/apiv10/Game/16522/Subfeed?_nPage=1&_nPerpage=10&_sSort=default&_sName={query}"
88
+ response = requests.get(url)
89
+
90
+ if response.status_code != 200:
91
+ return
92
+
93
+ results = response.json().get("_aRecords") or []
94
+
95
+ # Limit results to N entries
96
+ result_count = min(len(results), settings.limit)
97
+
98
+ if result_count:
99
+ for i in range(0, result_count, 10):
100
+ embeds = [create_mod_embed(result, settings.full) for result in results[i:i+10]]
101
+
102
+ await interaction.followup.send( # noqa
103
+ f"Showing {result_count} our of {len(results)} results...",
104
+ embeds=embeds
105
+ )
106
+ else:
107
+ embed = Embed(
108
+ title="No results found.",
109
+ color=0xffc526,
110
+ )
111
+
112
+ embed.set_author(name="GameBanana Search", icon_url="https://images.gamebanana.com/static/img/mascots/detective.png")
113
+ embed.set_footer(text=bot.user.display_name)
114
+
115
+ await interaction.followup.send(embed=embed) # noqa
@@ -0,0 +1,75 @@
1
+ from discord import Interaction, Message
2
+ from discord.app_commands import describe, checks
3
+ from googletrans import Translator
4
+ from googletrans.constants import LANGUAGES
5
+
6
+ from mikubot import Bot
7
+ import random
8
+ import asyncio
9
+
10
+
11
+ async def safe_translate(translator: Translator, text: str, src: str, dest: str, retries: int = 3) -> str:
12
+ """Translate text with retries and delay on failure."""
13
+ for attempt in range(retries):
14
+ try:
15
+ translated = await translator.translate(text, src=src, dest=dest)
16
+ return translated.text
17
+ except Exception as e: # noqa
18
+ if attempt < retries - 1:
19
+ await asyncio.sleep(1 + attempt) # wait longer each retry
20
+ else:
21
+ # Give up, return what we have so far
22
+ return text
23
+ return text
24
+
25
+
26
+ async def mass_translate(text: str, hops: int = 10, initial_lang: str = "en", final_lang: str = "en") -> str:
27
+ current_text = text
28
+ hops = max(0, hops)
29
+
30
+ available_langs = list(LANGUAGES.keys())
31
+
32
+ if initial_lang in available_langs:
33
+ available_langs.remove(initial_lang)
34
+
35
+ if final_lang in available_langs:
36
+ available_langs.remove(final_lang)
37
+
38
+ async with Translator() as translator:
39
+ for _ in range(hops):
40
+ random_lang = random.choice(available_langs)
41
+ current_text = await safe_translate(translator, current_text, src=initial_lang, dest=random_lang)
42
+ initial_lang = random_lang
43
+ await asyncio.sleep(random.uniform(0.5, 1.5)) # small delay to reduce rate limit risk
44
+
45
+ current_text = await safe_translate(translator, current_text, src=initial_lang, dest=final_lang)
46
+
47
+ return current_text
48
+
49
+
50
+ def register(bot: Bot):
51
+ @bot.tree.command(name="jumble", description="Google translates text through random languages before returning to the original language.")
52
+ @checks.cooldown(1, 10, key=lambda i: (i.guild_id,))
53
+ @checks.bot_has_permissions(send_messages=True)
54
+ @describe(
55
+ text="The text to translate",
56
+ hops="How many languages to translate to",
57
+ initial_lang="Language to start with",
58
+ final_lang="Language to end on",
59
+ )
60
+ async def handler(interaction: Interaction, text: str, hops: int = 10, initial_lang: str = "en", final_lang: str = "en"):
61
+ await interaction.response.defer() # noqa
62
+ text = await mass_translate(text, hops, initial_lang, final_lang)
63
+ await interaction.followup.send(text[:2000]) # noqa
64
+
65
+ @bot.tree.context_menu(name="jumble")
66
+ @checks.bot_has_permissions(send_messages=True)
67
+ async def context_handler(interaction: Interaction, message: Message):
68
+ # We could use discord.py's modals to query for hops and languages if we need to.
69
+ hops = 10
70
+ initial_lang = "en"
71
+ final_lang = "en"
72
+
73
+ await interaction.response.defer() # noqa
74
+ text = await mass_translate(message.content, hops, initial_lang, final_lang)
75
+ await interaction.followup.send(text[:2000], allowed_mentions=None) # noqa
@@ -0,0 +1,202 @@
1
+ from collections import Counter
2
+ from datetime import datetime, timezone
3
+ from discord import Interaction
4
+ from discord.app_commands import describe, checks
5
+ from pydantic import BaseModel, Field
6
+ from sqlitedict import SqliteDict
7
+ from mikubot import Bot
8
+ import random
9
+
10
+
11
+ class Guess(BaseModel):
12
+ user_id: int
13
+ guess: list[str]
14
+ feedback: list[str]
15
+
16
+
17
+ class Puzzle(BaseModel):
18
+ solution: list[str]
19
+ pool: list[str]
20
+ guesses: list[Guess] = Field(default_factory=list)
21
+ solved_by: int | None = None
22
+ last_guess: datetime | None = None
23
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
24
+ is_public: bool = False
25
+
26
+ def is_solved(self) -> bool:
27
+ return self.solved_by is not None
28
+
29
+ def can_guess(self) -> bool:
30
+ return not self.is_solved() or self.is_public
31
+
32
+ def is_expired(self, max_age_minutes: int = 1440):
33
+ return (datetime.now(timezone.utc) - self.created_at).total_seconds() > (max_age_minutes * 60)
34
+
35
+
36
+ def generate_sequence(pool: list[str], length: int, max_duplicates: int = 2) -> list[str]:
37
+ while True:
38
+ seq = [random.choice(pool) for _ in range(length)]
39
+ emoji_counts = {emoji: seq.count(emoji) for emoji in set(seq)}
40
+ if all(count <= max_duplicates for count in emoji_counts.values()):
41
+ if len(set(seq)) > 1: # Avoid boring ones
42
+ return seq
43
+
44
+
45
+ def compare_guess(answer: list[str], guess: list[str]) -> list[str]:
46
+ feedback = [""] * len(guess)
47
+ answer_counter = Counter(answer)
48
+
49
+ # First pass: check for exact matches
50
+ for i in range(len(guess)):
51
+ if guess[i] == answer[i]:
52
+ feedback[i] = "✅"
53
+ answer_counter[guess[i]] -= 1 # Reserve matched emoji
54
+
55
+ # Second pass: check for partial matches
56
+ for i in range(len(guess)):
57
+ if feedback[i]: # Already matched
58
+ continue
59
+ if answer_counter[guess[i]] > 0:
60
+ feedback[i] = "⚠️"
61
+ answer_counter[guess[i]] -= 1
62
+ else:
63
+ feedback[i] = "❌"
64
+
65
+ return feedback
66
+
67
+
68
+ def render_guess_history(puzzle: Puzzle) -> str:
69
+ if not puzzle.guesses:
70
+ return "_No guesses yet._"
71
+
72
+ lines = []
73
+ for guess in puzzle.guesses:
74
+ feedback = compare_guess(puzzle.solution, guess.guess)
75
+ guess_str = " ".join(guess.guess)
76
+ result_str = " ".join(feedback)
77
+ lines.append(f"- {guess_str} → {result_str}")
78
+ return "\n".join(lines)
79
+
80
+
81
+ def extract_emojis(text: str, emoji_pool: list[str]) -> list[str]:
82
+ result = []
83
+ i = 0
84
+ pool_sorted = sorted(emoji_pool, key=len, reverse=True) # match longer first
85
+
86
+ while i < len(text):
87
+ match = None
88
+ for emoji in pool_sorted:
89
+ if text.startswith(emoji, i):
90
+ match = emoji
91
+ break
92
+ if match:
93
+ result.append(match)
94
+ i += len(match)
95
+ else:
96
+ i += 1 # skip non-matching character
97
+
98
+ return result
99
+
100
+
101
+ def register(bot: Bot):
102
+ def create_puzzle(user_id: int) -> Puzzle:
103
+ sequence = generate_sequence(
104
+ bot.settings.guessing_game.emoji_pool,
105
+ bot.settings.guessing_game.sequence_length,
106
+ bot.settings.guessing_game.max_duplicates,
107
+ )
108
+
109
+ puzzle = Puzzle(solution=sequence, pool=bot.settings.guessing_game.emoji_pool.copy())
110
+
111
+ with SqliteDict(
112
+ bot.settings.storage_file,
113
+ tablename="guessgame",
114
+ outer_stack=False,
115
+ ) as db:
116
+ db[str(user_id)] = puzzle.model_dump()
117
+ db.commit()
118
+
119
+ return puzzle
120
+
121
+ def get_puzzle(user_id: int) -> Puzzle:
122
+ with SqliteDict(
123
+ bot.settings.storage_file,
124
+ tablename="guessgame",
125
+ outer_stack=False,
126
+ ) as db:
127
+ puzzle_data = db.get(str(user_id))
128
+
129
+ if puzzle_data:
130
+ puzzle = Puzzle.model_validate(puzzle_data)
131
+
132
+ if puzzle.is_solved() or puzzle.is_expired():
133
+ return create_puzzle(user_id)
134
+
135
+ return puzzle
136
+ else:
137
+ return create_puzzle(user_id)
138
+
139
+ @bot.tree.command(name="codebreaker", description="Start a codebreaker game or continue your current one with a guess.")
140
+ @checks.bot_has_permissions(send_messages=True)
141
+ @describe(guess="Your guess. Leave empty to see current state or generate a new puzzle.")
142
+ async def handler(interaction: Interaction, guess: str = ""):
143
+ puzzle = get_puzzle(interaction.user.id)
144
+
145
+ if not guess:
146
+ history = render_guess_history(puzzle)
147
+ emoji_list = " ".join(puzzle.pool)
148
+
149
+ await interaction.response.send_message( # noqa
150
+ f"Here is your puzzle:\n\n"
151
+ f"Guess the sequence of {len(puzzle.solution)} emojis!\n"
152
+ f"Use _/codebreaker (your guess)_ to solve.\n"
153
+ f"Available emojis: {emoji_list}\n\n"
154
+ f"**Your guesses so far:**\n{history}",
155
+ ephemeral=True,
156
+ )
157
+
158
+ return
159
+
160
+ guess_emojis = extract_emojis(guess, puzzle.pool)
161
+
162
+ if len(guess_emojis) != len(puzzle.solution):
163
+ await interaction.response.send_message( # noqa
164
+ f"You must enter exactly {len(puzzle.solution)} emojis from the allowed pool: {" ".join(puzzle.pool)}",
165
+ ephemeral=True
166
+ )
167
+ return
168
+
169
+ feedback = compare_guess(puzzle.solution, guess_emojis)
170
+ is_correct = all(symbol == "✅" for symbol in feedback)
171
+
172
+ puzzle.guesses.append(Guess(user_id=interaction.user.id, guess=guess_emojis, feedback=feedback))
173
+ puzzle.last_guess = datetime.now(timezone.utc)
174
+
175
+ with SqliteDict(
176
+ bot.settings.storage_file,
177
+ tablename="guessgame",
178
+ outer_stack=False,
179
+ ) as db:
180
+ if is_correct:
181
+ puzzle.solved_by = interaction.user.id
182
+
183
+ await interaction.response.send_message( # noqa
184
+ f"Correct!\n\n"
185
+ f"Solution: {" ".join(guess_emojis)} → {" ".join(feedback)}\n\n"
186
+ f"**Your guess history:**\n{render_guess_history(puzzle)}",
187
+ ephemeral=True
188
+ )
189
+
190
+ del db[str(interaction.user.id)]
191
+ db[f"{interaction.user.id}:{hash(puzzle.created_at)}"] = puzzle.model_dump()
192
+ else:
193
+ await interaction.response.send_message( # noqa
194
+ f"Not quite!\n"
195
+ f"Your guess: {" ".join(guess_emojis)} → {" ".join(feedback)}\n\n"
196
+ f"**Your guess history:**\n{render_guess_history(puzzle)}",
197
+ ephemeral=True
198
+ )
199
+
200
+ db[str(interaction.user.id)] = puzzle.model_dump()
201
+
202
+ db.commit()