easterobot 1.0.0__py3-none-any.whl

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 (48) hide show
  1. easterobot/__init__.py +19 -0
  2. easterobot/__main__.py +6 -0
  3. easterobot/bot.py +584 -0
  4. easterobot/cli.py +127 -0
  5. easterobot/commands/__init__.py +28 -0
  6. easterobot/commands/base.py +171 -0
  7. easterobot/commands/basket.py +99 -0
  8. easterobot/commands/disable.py +29 -0
  9. easterobot/commands/edit.py +68 -0
  10. easterobot/commands/enable.py +35 -0
  11. easterobot/commands/help.py +33 -0
  12. easterobot/commands/reset.py +121 -0
  13. easterobot/commands/search.py +127 -0
  14. easterobot/commands/top.py +105 -0
  15. easterobot/config.py +401 -0
  16. easterobot/info.py +18 -0
  17. easterobot/logger.py +16 -0
  18. easterobot/models.py +58 -0
  19. easterobot/py.typed +1 -0
  20. easterobot/resources/config.example.yml +226 -0
  21. easterobot/resources/credits.txt +1 -0
  22. easterobot/resources/eggs/egg_01.png +0 -0
  23. easterobot/resources/eggs/egg_02.png +0 -0
  24. easterobot/resources/eggs/egg_03.png +0 -0
  25. easterobot/resources/eggs/egg_04.png +0 -0
  26. easterobot/resources/eggs/egg_05.png +0 -0
  27. easterobot/resources/eggs/egg_06.png +0 -0
  28. easterobot/resources/eggs/egg_07.png +0 -0
  29. easterobot/resources/eggs/egg_08.png +0 -0
  30. easterobot/resources/eggs/egg_09.png +0 -0
  31. easterobot/resources/eggs/egg_10.png +0 -0
  32. easterobot/resources/eggs/egg_11.png +0 -0
  33. easterobot/resources/eggs/egg_12.png +0 -0
  34. easterobot/resources/eggs/egg_13.png +0 -0
  35. easterobot/resources/eggs/egg_14.png +0 -0
  36. easterobot/resources/eggs/egg_15.png +0 -0
  37. easterobot/resources/eggs/egg_16.png +0 -0
  38. easterobot/resources/eggs/egg_17.png +0 -0
  39. easterobot/resources/eggs/egg_18.png +0 -0
  40. easterobot/resources/eggs/egg_19.png +0 -0
  41. easterobot/resources/eggs/egg_20.png +0 -0
  42. easterobot/resources/logging.conf +47 -0
  43. easterobot/resources/logo.png +0 -0
  44. easterobot-1.0.0.dist-info/METADATA +242 -0
  45. easterobot-1.0.0.dist-info/RECORD +48 -0
  46. easterobot-1.0.0.dist-info/WHEEL +4 -0
  47. easterobot-1.0.0.dist-info/entry_points.txt +2 -0
  48. easterobot-1.0.0.dist-info/licenses/LICENSE +21 -0
easterobot/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ """Module for command line interface."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from typing import NoReturn, Optional
8
+
9
+ from .bot import DEFAULT_CONFIG_PATH, Easterobot
10
+ from .info import __issues__, __summary__, __version__
11
+
12
+ LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class HelpArgumentParser(argparse.ArgumentParser):
17
+ def error(self, message: str) -> NoReturn: # pragma: no cover
18
+ """Handle error from argparse.ArgumentParser."""
19
+ self.print_help(sys.stderr)
20
+ self.exit(2, f"{self.prog}: error: {message}\n")
21
+
22
+
23
+ def get_parser() -> argparse.ArgumentParser:
24
+ """Prepare ArgumentParser."""
25
+ parser = HelpArgumentParser(
26
+ prog="easterobot",
27
+ description=__summary__,
28
+ formatter_class=argparse.RawTextHelpFormatter,
29
+ )
30
+ parser.add_argument(
31
+ "--version",
32
+ action="version",
33
+ version=f"%(prog)s, version {__version__}",
34
+ )
35
+
36
+ # Add subparsers
37
+ subparsers = parser.add_subparsers(
38
+ help="desired action to perform",
39
+ dest="action",
40
+ required=True,
41
+ )
42
+
43
+ # Add parent parser with common arguments
44
+ parent_parser = HelpArgumentParser(add_help=False)
45
+ parent_parser.add_argument(
46
+ "-v",
47
+ "--verbose",
48
+ help="verbose mode, enable DEBUG messages.",
49
+ action="store_true",
50
+ required=False,
51
+ )
52
+ parent_parser.add_argument(
53
+ "-t",
54
+ "--token",
55
+ help="run using a token and the default configuration.",
56
+ )
57
+ parent_parser.add_argument(
58
+ "-e",
59
+ "--env",
60
+ help="load token from DISCORD_TOKEN environnement variable.",
61
+ action="store_true",
62
+ )
63
+
64
+ # Parser of hello command
65
+ run_parser = subparsers.add_parser(
66
+ "run",
67
+ parents=[parent_parser],
68
+ help="start the bot.",
69
+ )
70
+ run_parser.add_argument(
71
+ "-c",
72
+ "--config",
73
+ help=f"path to configuration, default to {DEFAULT_CONFIG_PATH}.",
74
+ default=DEFAULT_CONFIG_PATH,
75
+ )
76
+
77
+ # Parser of hello command
78
+ run_parser = subparsers.add_parser(
79
+ "generate",
80
+ parents=[parent_parser],
81
+ help="generate a configuration.",
82
+ )
83
+ run_parser.add_argument(
84
+ "-i",
85
+ "--interactive",
86
+ help="ask questions for create a ready to use config.",
87
+ action="store_true",
88
+ )
89
+ run_parser.add_argument("destination", default=".")
90
+ return parser
91
+
92
+
93
+ def setup_logging(verbose: Optional[bool] = None) -> None:
94
+ """Do setup logging."""
95
+ # Setup logging
96
+ logging.basicConfig(
97
+ level=logging.DEBUG if verbose else logging.WARNING,
98
+ format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
99
+ )
100
+
101
+
102
+ def entrypoint(argv: Optional[Sequence[str]] = None) -> None:
103
+ """Entrypoint for command line interface."""
104
+ try:
105
+ parser = get_parser()
106
+ args = parser.parse_args(argv)
107
+ setup_logging(args.verbose)
108
+ if args.action == "run":
109
+ bot = Easterobot.from_config(
110
+ args.config,
111
+ token=args.token,
112
+ env=args.env,
113
+ )
114
+ bot.auto_run()
115
+ elif args.action == "generate":
116
+ Easterobot.generate(
117
+ destination=args.destination,
118
+ token=args.token,
119
+ env=args.env,
120
+ interactive=args.interactive,
121
+ )
122
+ else:
123
+ parser.error("No command specified") # pragma: no cover
124
+ except Exception as err: # NoQA: BLE001 # pragma: no cover
125
+ logger.critical("Unexpected error", exc_info=err)
126
+ logger.critical("Please, report this error to %s.", __issues__)
127
+ sys.exit(1)
@@ -0,0 +1,28 @@
1
+ """Init package."""
2
+
3
+ from easterobot.bot import Easterobot
4
+ from easterobot.commands.base import egg_command_group
5
+ from easterobot.commands.basket import basket_command
6
+ from easterobot.commands.disable import disable_command
7
+ from easterobot.commands.edit import edit_command
8
+ from easterobot.commands.enable import enable_command
9
+ from easterobot.commands.help import help_command
10
+ from easterobot.commands.reset import reset_command
11
+ from easterobot.commands.search import search_command
12
+ from easterobot.commands.top import top_command
13
+
14
+ __all__ = [
15
+ "basket_command",
16
+ "disable_command",
17
+ "edit_command",
18
+ "egg_command_group",
19
+ "enable_command",
20
+ "help_command",
21
+ "reset_command",
22
+ "search_command",
23
+ "top_command",
24
+ ]
25
+
26
+
27
+ async def setup(bot: Easterobot) -> None:
28
+ bot.tree.add_command(egg_command_group)
@@ -0,0 +1,171 @@
1
+ """Base module for command."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import logging
6
+ from collections.abc import Coroutine
7
+ from time import time
8
+ from typing import Any, Callable, TypeVar, Union, cast
9
+
10
+ import discord
11
+ from discord import app_commands
12
+ from sqlalchemy import and_, delete
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+ from typing_extensions import Concatenate, ParamSpec
15
+
16
+ from easterobot.bot import Easterobot
17
+ from easterobot.models import Cooldown
18
+
19
+ egg_command_group = app_commands.Group(
20
+ name="egg",
21
+ description="Commandes en lien avec Pâque",
22
+ guild_only=True,
23
+ )
24
+ logger = logging.getLogger(__name__)
25
+ cooldown_lock = asyncio.Lock()
26
+
27
+
28
+ class InterruptedCommandError(Exception):
29
+ pass
30
+
31
+
32
+ InteractionChannel = Union[
33
+ discord.VoiceChannel,
34
+ discord.StageChannel,
35
+ discord.TextChannel,
36
+ discord.ForumChannel,
37
+ discord.CategoryChannel,
38
+ discord.Thread,
39
+ discord.DMChannel,
40
+ discord.GroupChannel,
41
+ ]
42
+
43
+
44
+ class Context(discord.Interaction[Easterobot]):
45
+ user: discord.Member
46
+ command: "app_commands.Command[Any, ..., Any]" # type: ignore[assignment]
47
+ channel: InteractionChannel
48
+ channel_id: int
49
+ guild: discord.Guild
50
+ guild_id: int
51
+
52
+
53
+ P = ParamSpec("P")
54
+ T = TypeVar("T")
55
+ Interaction = discord.Interaction[Easterobot]
56
+ Coro = Coroutine[Any, Any, T]
57
+
58
+
59
+ def controlled_command(
60
+ *, cooldown: bool = True, **perms: bool
61
+ ) -> Callable[
62
+ [Callable[Concatenate[Context, P], Coro[None]]],
63
+ Callable[Concatenate[Interaction, P], Coro[None]],
64
+ ]:
65
+ """Add a cooldown and permission check."""
66
+
67
+ def decorator(
68
+ f: Callable[Concatenate[Context, P], Coro[None]],
69
+ ) -> Callable[Concatenate[Interaction, P], Coro[None]]:
70
+ @functools.wraps(f)
71
+ async def decorated(
72
+ interaction: Interaction, *args: P.args, **kwargs: P.kwargs
73
+ ) -> None:
74
+ # Check if interaction is valid
75
+ if not isinstance(interaction.command, app_commands.Command):
76
+ logger.warning("No command provided %s", interaction)
77
+ return
78
+ if interaction.channel is None or interaction.channel_id is None:
79
+ logger.warning("No channel provided %s", interaction)
80
+ return
81
+
82
+ # Preformat event repr
83
+ event_repr = (
84
+ f"/{interaction.command.qualified_name} by {interaction.user} "
85
+ f"({interaction.user.id}) in {interaction.channel.jump_url}"
86
+ )
87
+
88
+ # Check if in guild
89
+ if interaction.guild is None or interaction.guild_id is None:
90
+ logger.warning("Must be use in a guild !")
91
+ return
92
+ if not isinstance(interaction.user, discord.Member):
93
+ logger.warning("No channel provided %s", interaction)
94
+ return
95
+
96
+ # Compute needed permissions
97
+ needed_perms = discord.Permissions(**perms)
98
+ have_perms = interaction.user.guild_permissions.is_superset(
99
+ needed_perms
100
+ )
101
+ admin_ids = interaction.client.config.admins
102
+ is_super_admin = (
103
+ interaction.user.id in admin_ids
104
+ or interaction.user.id == interaction.client.owner_id
105
+ )
106
+ if not have_perms and not is_super_admin:
107
+ logger.warning("%s failed for wrong permissions", event_repr)
108
+ await interaction.response.send_message(
109
+ "Vous n'avez pas la permission",
110
+ ephemeral=True,
111
+ )
112
+ return
113
+
114
+ # Check command cooldown
115
+ cmd = interaction.command.name
116
+ if cooldown:
117
+ available_at = None
118
+ async with (
119
+ AsyncSession(interaction.client.engine) as session,
120
+ cooldown_lock, # We must use lock for avoid race condition
121
+ ):
122
+ cd_user = await session.get(
123
+ Cooldown,
124
+ (interaction.user.id, interaction.guild_id, cmd),
125
+ )
126
+ cd_cmd = interaction.client.config.commands[cmd].cooldown
127
+ now = time()
128
+ if cd_user is None or now > cd_cmd + cd_user.timestamp:
129
+ await session.merge(
130
+ Cooldown(
131
+ user_id=interaction.user.id,
132
+ guild_id=interaction.guild_id,
133
+ command=cmd,
134
+ timestamp=now,
135
+ )
136
+ )
137
+ await session.commit()
138
+ else:
139
+ available_at = cd_cmd + cd_user.timestamp
140
+ if available_at:
141
+ logger.warning("%s failed for cooldown", event_repr)
142
+ await interaction.response.send_message(
143
+ "Vous devez encore attendre "
144
+ f"<t:{available_at + 1:.0f}:R>",
145
+ ephemeral=True,
146
+ )
147
+ return
148
+ logger.info("%s", event_repr)
149
+ try:
150
+ await f(cast(Context, interaction), *args, **kwargs)
151
+ except InterruptedCommandError:
152
+ logger.exception("InterruptedCommandError occur")
153
+ async with (
154
+ AsyncSession(interaction.client.engine) as session,
155
+ cooldown_lock,
156
+ ):
157
+ # This is unsafe
158
+ await session.execute(
159
+ delete(Cooldown).where(
160
+ and_(
161
+ Cooldown.guild_id == interaction.guild_id,
162
+ Cooldown.user_id == interaction.user.id,
163
+ Cooldown.command == cmd,
164
+ )
165
+ )
166
+ )
167
+ await session.commit()
168
+
169
+ return decorated
170
+
171
+ return decorator
@@ -0,0 +1,99 @@
1
+ """Command basket."""
2
+
3
+ from typing import Optional
4
+
5
+ import discord
6
+ from discord import app_commands
7
+ from sqlalchemy import and_, func, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from easterobot.bot import embed
11
+ from easterobot.commands.base import (
12
+ Context,
13
+ controlled_command,
14
+ egg_command_group,
15
+ )
16
+ from easterobot.config import agree
17
+ from easterobot.models import Egg
18
+
19
+
20
+ @egg_command_group.command(
21
+ name="basket",
22
+ description="Regarder le contenu d'un panier",
23
+ )
24
+ @app_commands.describe(
25
+ user="Membre possèdant le panier à inspecter",
26
+ )
27
+ @controlled_command(cooldown=True)
28
+ async def basket_command(
29
+ ctx: Context, user: Optional[discord.Member] = None
30
+ ) -> None:
31
+ """Show current user basket."""
32
+ # Delay the response
33
+ await ctx.response.defer(ephemeral=True)
34
+
35
+ # Set the user of the basket
36
+ hunter = user or ctx.user
37
+
38
+ # Util for accord in some language
39
+ you = ctx.user == hunter
40
+
41
+ async with AsyncSession(ctx.client.engine) as session:
42
+ morsels = []
43
+ missing = []
44
+ user_egg_count = await ctx.client.get_rank(
45
+ session, ctx.guild_id, hunter.id
46
+ )
47
+ rank = user_egg_count[1] if user_egg_count else None
48
+ res = await session.execute(
49
+ select(
50
+ Egg.emoji_id,
51
+ func.count().label("count"),
52
+ )
53
+ .where(
54
+ and_(
55
+ Egg.guild_id == ctx.guild.id,
56
+ Egg.user_id == hunter.id,
57
+ )
58
+ )
59
+ .group_by(Egg.emoji_id)
60
+ )
61
+ egg_counts: dict[int, int] = dict(res.all()) # type: ignore[arg-type]
62
+ egg_count = 0
63
+ for emoji in ctx.client.app_emojis.choices:
64
+ try:
65
+ type_count = egg_counts.pop(emoji.id)
66
+ egg_count += type_count
67
+ morsels.append(f"{emoji} \xd7 {type_count}")
68
+ except KeyError: # noqa: PERF203
69
+ missing.append(emoji)
70
+
71
+ absent_count = sum(egg_counts.values())
72
+ if absent_count:
73
+ egg_count += absent_count
74
+ morsels.insert(0, f"🥚 \xd7 {absent_count}")
75
+
76
+ if rank is not None:
77
+ il = ctx.client.config.conjugate("{Iel} est", hunter)
78
+ morsels.insert(
79
+ 0,
80
+ f"**{'Tu es' if you else il} au rang** {rank}\n",
81
+ )
82
+
83
+ their = "te" if you else "lui"
84
+ if missing:
85
+ morsels.append(
86
+ f"\nIl {their} manque : {''.join(map(str, missing))}"
87
+ )
88
+ text = "\n".join(morsels).strip()
89
+ await ctx.followup.send(
90
+ embed=embed(
91
+ title=f"Contenu du panier de {hunter.display_name}",
92
+ description=text,
93
+ footer=(
94
+ f"Cela {their} fait un total de "
95
+ + agree("{0} œuf", "{0} œufs", egg_count)
96
+ ),
97
+ ),
98
+ ephemeral=True,
99
+ )
@@ -0,0 +1,29 @@
1
+ """Module for disable hunt."""
2
+
3
+ from sqlalchemy import delete, select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from easterobot.models import Hunt
7
+
8
+ from .base import Context, controlled_command, egg_command_group
9
+
10
+
11
+ @egg_command_group.command(
12
+ name="disable", description="Désactiver la chasse aux œufs dans le salon"
13
+ )
14
+ @controlled_command(cooldown=True, manage_channels=True)
15
+ async def disable_command(ctx: Context) -> None:
16
+ """Disable the hunt."""
17
+ await ctx.response.defer(ephemeral=True)
18
+ async with AsyncSession(ctx.client.engine) as session:
19
+ old = await session.scalar(
20
+ select(Hunt).where(Hunt.channel_id == ctx.channel.id)
21
+ )
22
+ if old:
23
+ await session.execute(
24
+ delete(Hunt).where(Hunt.channel_id == ctx.channel.id)
25
+ )
26
+ await session.commit()
27
+ await ctx.followup.send(
28
+ f"Chasse aux œufs{'' if old else ' déjà'} désactivée", ephemeral=True
29
+ )
@@ -0,0 +1,68 @@
1
+ """Module for edit command."""
2
+
3
+ import discord
4
+ from sqlalchemy import and_, delete, select
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from easterobot.bot import embed
8
+ from easterobot.config import RAND, agree
9
+ from easterobot.models import Egg
10
+
11
+ from .base import Context, controlled_command, egg_command_group
12
+
13
+
14
+ @egg_command_group.command(
15
+ name="edit", description="Editer le nombre d'œufs d'un membre"
16
+ )
17
+ @controlled_command(cooldown=True, administrator=True)
18
+ async def edit_command(
19
+ ctx: Context,
20
+ user: discord.Member,
21
+ oeufs: int,
22
+ ) -> None:
23
+ """Edit command."""
24
+ oeufs = min(max(oeufs, 0), 100_000)
25
+ await ctx.response.defer(ephemeral=True)
26
+ async with AsyncSession(ctx.client.engine) as session:
27
+ eggs: list[Egg] = list(
28
+ (
29
+ await session.scalars(
30
+ select(Egg).where(
31
+ and_(
32
+ Egg.guild_id == ctx.guild_id,
33
+ Egg.user_id == user.id,
34
+ )
35
+ )
36
+ )
37
+ ).all()
38
+ )
39
+ diff = len(eggs) - oeufs
40
+ if diff > 0:
41
+ to_delete = []
42
+ for _ in range(diff):
43
+ egg = RAND.choice(eggs)
44
+ eggs.remove(egg)
45
+ to_delete.append(egg.id)
46
+ await session.execute(delete(Egg).where(Egg.id.in_(to_delete)))
47
+ await session.commit()
48
+ elif diff < 0:
49
+ for _ in range(-diff):
50
+ session.add(
51
+ Egg(
52
+ guild_id=ctx.guild_id,
53
+ channel_id=ctx.channel_id,
54
+ user_id=user.id,
55
+ emoji_id=ctx.client.app_emojis.rand().id,
56
+ )
57
+ )
58
+ await session.commit()
59
+ await ctx.followup.send(
60
+ embed=embed(
61
+ title="Edition terminée",
62
+ description=(
63
+ f"{user.mention} a maintenant "
64
+ f"{agree('{0} œuf', '{0} œufs', oeufs)}"
65
+ ),
66
+ ),
67
+ ephemeral=True,
68
+ )
@@ -0,0 +1,35 @@
1
+ """Command enable."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from easterobot.models import Hunt
7
+
8
+ from .base import Context, controlled_command, egg_command_group
9
+
10
+
11
+ @egg_command_group.command(
12
+ name="enable", description="Activer la chasse dans le salon"
13
+ )
14
+ @controlled_command(cooldown=True, manage_channels=True)
15
+ async def enable_command(
16
+ ctx: Context,
17
+ ) -> None:
18
+ """Enable hunt in a channel."""
19
+ await ctx.response.defer(ephemeral=True)
20
+ async with AsyncSession(ctx.client.engine) as session:
21
+ old = await session.scalar(
22
+ select(Hunt).where(Hunt.channel_id == ctx.channel.id)
23
+ )
24
+ if not old:
25
+ session.add(
26
+ Hunt(
27
+ channel_id=ctx.channel.id,
28
+ guild_id=ctx.guild_id,
29
+ next_egg=0,
30
+ )
31
+ )
32
+ await session.commit()
33
+ await ctx.followup.send(
34
+ f"Chasse aux œufs{' déjà' if old else ''} activée", ephemeral=True
35
+ )
@@ -0,0 +1,33 @@
1
+ """Module for help command."""
2
+
3
+ from discord.app_commands import AppCommandGroup
4
+
5
+ from easterobot.bot import embed
6
+
7
+ from .base import Context, controlled_command, egg_command_group
8
+
9
+
10
+ @egg_command_group.command(name="help", description="Obtenir de l'aide")
11
+ @controlled_command(cooldown=True)
12
+ async def help_command(ctx: Context) -> None:
13
+ """Help command."""
14
+ emb = embed(
15
+ title="Liste des commandes",
16
+ description=ctx.client.description,
17
+ thumbnail=(
18
+ ctx.client.user.display_avatar.url if ctx.client.user else None
19
+ ),
20
+ footer="Crée par dashstrom",
21
+ )
22
+ for command in ctx.client.app_commands:
23
+ for option in command.options:
24
+ if not isinstance(option, AppCommandGroup):
25
+ continue
26
+ emb.add_field(
27
+ name=(
28
+ f"</{egg_command_group.name} {option.name}:{command.id}>"
29
+ ),
30
+ value=f"{option.description}",
31
+ inline=False,
32
+ )
33
+ await ctx.response.send_message(embed=emb, ephemeral=True)