Developer Interface

easterobot

Init module of easterobot.

Easterobot

Bases: Bot

Main Easterobot Discord bot class.

Source code in easterobot/bot.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
class Easterobot(discord.ext.commands.Bot):
    """Main Easterobot Discord bot class."""

    owner: discord.User
    game: GameCog
    hunt: HuntCog
    init_finished: asyncio.Event

    def __init__(self, config: MConfig) -> None:
        """Initialize the Easterobot instance.

        Args:
            config: Loaded bot configuration.
        """
        intents = discord.Intents.default()
        if config.message_content:
            intents.message_content = True

        # Suppress NaCl warnings for voice
        discord.VoiceClient.warn_nacl = False

        super().__init__(
            command_prefix=".",
            description="Bot Discord pour faire la chasse aux œufs",
            activity=discord.Game(name="rechercher des œufs"),
            intents=INTENTS,
        )

        self.app_commands: list[discord.app_commands.AppCommand] = []
        self.app_emojis: dict[str, discord.Emoji] = {}
        self.config = config
        self.config.configure_logging()

        # Ensure database schema is up-to-date
        upgrade(self.config.alembic_config(), "head")

        logger.info("Opening database %s", self.config.database_uri)
        self.engine = create_async_engine(
            self.config.database_uri,
            echo=False,
        )

    @classmethod
    def from_config(
        cls,
        path: Union[str, Path] = DEFAULT_CONFIG_PATH,
        *,
        token: Optional[str] = None,
        env: bool = False,
    ) -> "Easterobot":
        """Create an instance from a configuration file.

        Args:
            path: Path to the configuration file.
            token: Bot token override.
            env: If True, load configuration from environment variables.

        Returns:
            An initialized `Easterobot` instance.
        """
        config = load_config_from_path(path, token=token, env=env)
        return Easterobot(config)

    @classmethod
    def generate(
        cls,
        destination: Union[Path, str],
        *,
        token: Optional[str] = None,
        env: bool = False,
        interactive: bool = False,
    ) -> "Easterobot":
        """Generate a new bot configuration and resources.

        Args:
            destination: Directory where the bot's data will be created.
            token: Bot token override.
            env: If True, load configuration from environment variables.
            interactive: If True, prompt user for the bot token.

        Returns:
            An initialized `Easterobot` instance.
        """
        destination = Path(destination).resolve()
        destination.mkdir(parents=True, exist_ok=True)
        config_data = EXAMPLE_CONFIG_PATH.read_bytes()
        config = load_config_from_buffer(config_data, token=token, env=env)
        config.attach_default_working_directory(destination)

        if interactive:
            while True:
                try:
                    config.verified_token()
                    break
                except (ValueError, TypeError):
                    config.token = getpass("Token: ")

        # Create resources directory
        config._resources = pathlib.Path("resources")  # noqa: SLF001
        shutil.copytree(
            RESOURCES, destination / "resources", dirs_exist_ok=True
        )

        # Save configuration
        config_path = destination / "config.yml"
        config_path.write_bytes(dump_yaml(config))
        (destination / ".gitignore").write_bytes(b"*\n")
        return Easterobot(config)

    def is_super_admin(
        self,
        user: Union[discord.User, discord.Member],
    ) -> bool:
        """Check whether a user is a super admin.

        Args:
            user: The Discord user or member to check.

        Returns:
            True if the user is a super admin, False otherwise.
        """
        return (
            user.id in self.config.admins
            or user.id in (self.owner.id, self.owner_id)
            or (self.owner_ids is not None and user.id in self.owner_ids)
        )

    async def resolve_channel(
        self,
        channel_id: int,
    ) -> Optional[discord.TextChannel]:
        """Get a text channel by its ID.

        Args:
            channel_id: ID of the channel to fetch.

        Returns:
            The corresponding text channel, or None if unavailable.
        """
        channel = self.get_channel(channel_id)
        if channel is None:
            try:
                channel = await self.fetch_channel(channel_id)
            except (discord.NotFound, discord.Forbidden):
                return None
        if not isinstance(channel, discord.TextChannel):
            return None
        return channel

    async def setup_hook(self) -> None:
        """Load bot extensions (commands, games, hunts)."""
        await self.load_extension(
            "easterobot.commands", package="easterobot.commands.__init__"
        )
        await self.load_extension(
            "easterobot.games", package="easterobot.games.__init__"
        )
        await self.load_extension(
            "easterobot.hunts", package="easterobot.hunts.__init__"
        )

    def auto_run(self) -> None:
        """Start the bot using the verified token."""
        self.run(token=self.config.verified_token())

    async def start(self, token: str, *, reconnect: bool = True) -> None:
        """Start the bot and initialize the ready event.

        Args:
            token: Bot authentication token.
            reconnect: Whether to automatically reconnect on disconnect.
        """
        self.init_finished = asyncio.Event()
        await super().start(token=token, reconnect=reconnect)

    async def on_ready(self) -> None:
        """Handle the bot ready event.

        This may trigger multiple times if the bot reconnects.
        """
        logger.info("Syncing commands...")
        await self.tree.sync()
        self.app_commands = await self.tree.fetch_commands()

        # Sync bot owner
        app_info = await self.application_info()
        self.owner = app_info.owner
        logger.info("Owner is %s (%s)", self.owner.display_name, self.owner.id)

        # Load emojis
        await self._load_emojis()

        # Load eggs
        eggs_path = (self.config.resources / "emotes" / "eggs").resolve()
        self.egg_emotes = RandomItem(
            [self.app_emojis[path.stem] for path in eggs_path.glob("**/*")]
        )

        # Log all available guilds
        async for guild in self.fetch_guilds():
            logger.info("Guild %s (%s)", guild, guild.id)

        # Log user
        logger.info(
            "Logged in as %s (%s)",
            self.user,
            getattr(self.user, "id", "unknown"),
        )

        # Set init event as finished
        self.init_finished.set()

    async def _load_emojis(self) -> None:
        """Load or create application emojis from resource files."""
        emojis = {
            emoji.name: emoji
            for emoji in await self.fetch_application_emojis()
        }
        emotes_path = (self.config.resources / "emotes").resolve()

        # TODO(dashstrom): Remove outdated emojis.
        # TODO(dashstrom): Implement emoji caching.
        self.app_emojis = {}
        for emote in emotes_path.glob("**/*"):
            if not emote.is_file():
                continue
            name = emote.stem
            if name not in emojis:
                logger.info(
                    "Missing emoji %s, creating on application...",
                    name,
                )
                image_data = emote.read_bytes()
                emoji = await self.create_application_emoji(
                    name=name,
                    image=image_data,
                )
                self.app_emojis[name] = emoji
            else:
                logger.info("Loaded emoji %s", name)
                self.app_emojis[name] = emojis[name]

__init__

__init__(config: MConfig) -> None

Initialize the Easterobot instance.

Parameters:
  • config (MConfig) –

    Loaded bot configuration.

Source code in easterobot/bot.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def __init__(self, config: MConfig) -> None:
    """Initialize the Easterobot instance.

    Args:
        config: Loaded bot configuration.
    """
    intents = discord.Intents.default()
    if config.message_content:
        intents.message_content = True

    # Suppress NaCl warnings for voice
    discord.VoiceClient.warn_nacl = False

    super().__init__(
        command_prefix=".",
        description="Bot Discord pour faire la chasse aux œufs",
        activity=discord.Game(name="rechercher des œufs"),
        intents=INTENTS,
    )

    self.app_commands: list[discord.app_commands.AppCommand] = []
    self.app_emojis: dict[str, discord.Emoji] = {}
    self.config = config
    self.config.configure_logging()

    # Ensure database schema is up-to-date
    upgrade(self.config.alembic_config(), "head")

    logger.info("Opening database %s", self.config.database_uri)
    self.engine = create_async_engine(
        self.config.database_uri,
        echo=False,
    )

auto_run

auto_run() -> None

Start the bot using the verified token.

Source code in easterobot/bot.py
205
206
207
def auto_run(self) -> None:
    """Start the bot using the verified token."""
    self.run(token=self.config.verified_token())

from_config classmethod

from_config(
    path: Union[str, Path] = DEFAULT_CONFIG_PATH,
    *,
    token: Optional[str] = None,
    env: bool = False,
) -> Easterobot

Create an instance from a configuration file.

Parameters:
  • path (Union[str, Path], default: DEFAULT_CONFIG_PATH ) –

    Path to the configuration file.

  • token (Optional[str], default: None ) –

    Bot token override.

  • env (bool, default: False ) –

    If True, load configuration from environment variables.

Returns:
  • Easterobot

    An initialized Easterobot instance.

Source code in easterobot/bot.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
@classmethod
def from_config(
    cls,
    path: Union[str, Path] = DEFAULT_CONFIG_PATH,
    *,
    token: Optional[str] = None,
    env: bool = False,
) -> "Easterobot":
    """Create an instance from a configuration file.

    Args:
        path: Path to the configuration file.
        token: Bot token override.
        env: If True, load configuration from environment variables.

    Returns:
        An initialized `Easterobot` instance.
    """
    config = load_config_from_path(path, token=token, env=env)
    return Easterobot(config)

generate classmethod

generate(
    destination: Union[Path, str],
    *,
    token: Optional[str] = None,
    env: bool = False,
    interactive: bool = False,
) -> Easterobot

Generate a new bot configuration and resources.

Parameters:
  • destination (Union[Path, str]) –

    Directory where the bot's data will be created.

  • token (Optional[str], default: None ) –

    Bot token override.

  • env (bool, default: False ) –

    If True, load configuration from environment variables.

  • interactive (bool, default: False ) –

    If True, prompt user for the bot token.

Returns:
  • Easterobot

    An initialized Easterobot instance.

Source code in easterobot/bot.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
@classmethod
def generate(
    cls,
    destination: Union[Path, str],
    *,
    token: Optional[str] = None,
    env: bool = False,
    interactive: bool = False,
) -> "Easterobot":
    """Generate a new bot configuration and resources.

    Args:
        destination: Directory where the bot's data will be created.
        token: Bot token override.
        env: If True, load configuration from environment variables.
        interactive: If True, prompt user for the bot token.

    Returns:
        An initialized `Easterobot` instance.
    """
    destination = Path(destination).resolve()
    destination.mkdir(parents=True, exist_ok=True)
    config_data = EXAMPLE_CONFIG_PATH.read_bytes()
    config = load_config_from_buffer(config_data, token=token, env=env)
    config.attach_default_working_directory(destination)

    if interactive:
        while True:
            try:
                config.verified_token()
                break
            except (ValueError, TypeError):
                config.token = getpass("Token: ")

    # Create resources directory
    config._resources = pathlib.Path("resources")  # noqa: SLF001
    shutil.copytree(
        RESOURCES, destination / "resources", dirs_exist_ok=True
    )

    # Save configuration
    config_path = destination / "config.yml"
    config_path.write_bytes(dump_yaml(config))
    (destination / ".gitignore").write_bytes(b"*\n")
    return Easterobot(config)

is_super_admin

is_super_admin(user: Union[User, Member]) -> bool

Check whether a user is a super admin.

Parameters:
  • user (Union[User, Member]) –

    The Discord user or member to check.

Returns:
  • bool

    True if the user is a super admin, False otherwise.

Source code in easterobot/bot.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def is_super_admin(
    self,
    user: Union[discord.User, discord.Member],
) -> bool:
    """Check whether a user is a super admin.

    Args:
        user: The Discord user or member to check.

    Returns:
        True if the user is a super admin, False otherwise.
    """
    return (
        user.id in self.config.admins
        or user.id in (self.owner.id, self.owner_id)
        or (self.owner_ids is not None and user.id in self.owner_ids)
    )

on_ready async

on_ready() -> None

Handle the bot ready event.

This may trigger multiple times if the bot reconnects.

Source code in easterobot/bot.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
async def on_ready(self) -> None:
    """Handle the bot ready event.

    This may trigger multiple times if the bot reconnects.
    """
    logger.info("Syncing commands...")
    await self.tree.sync()
    self.app_commands = await self.tree.fetch_commands()

    # Sync bot owner
    app_info = await self.application_info()
    self.owner = app_info.owner
    logger.info("Owner is %s (%s)", self.owner.display_name, self.owner.id)

    # Load emojis
    await self._load_emojis()

    # Load eggs
    eggs_path = (self.config.resources / "emotes" / "eggs").resolve()
    self.egg_emotes = RandomItem(
        [self.app_emojis[path.stem] for path in eggs_path.glob("**/*")]
    )

    # Log all available guilds
    async for guild in self.fetch_guilds():
        logger.info("Guild %s (%s)", guild, guild.id)

    # Log user
    logger.info(
        "Logged in as %s (%s)",
        self.user,
        getattr(self.user, "id", "unknown"),
    )

    # Set init event as finished
    self.init_finished.set()

resolve_channel async

resolve_channel(
    channel_id: int,
) -> Optional[discord.TextChannel]

Get a text channel by its ID.

Parameters:
  • channel_id (int) –

    ID of the channel to fetch.

Returns:
  • Optional[TextChannel]

    The corresponding text channel, or None if unavailable.

Source code in easterobot/bot.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
async def resolve_channel(
    self,
    channel_id: int,
) -> Optional[discord.TextChannel]:
    """Get a text channel by its ID.

    Args:
        channel_id: ID of the channel to fetch.

    Returns:
        The corresponding text channel, or None if unavailable.
    """
    channel = self.get_channel(channel_id)
    if channel is None:
        try:
            channel = await self.fetch_channel(channel_id)
        except (discord.NotFound, discord.Forbidden):
            return None
    if not isinstance(channel, discord.TextChannel):
        return None
    return channel

setup_hook async

setup_hook() -> None

Load bot extensions (commands, games, hunts).

Source code in easterobot/bot.py
193
194
195
196
197
198
199
200
201
202
203
async def setup_hook(self) -> None:
    """Load bot extensions (commands, games, hunts)."""
    await self.load_extension(
        "easterobot.commands", package="easterobot.commands.__init__"
    )
    await self.load_extension(
        "easterobot.games", package="easterobot.games.__init__"
    )
    await self.load_extension(
        "easterobot.hunts", package="easterobot.hunts.__init__"
    )

start async

start(token: str, *, reconnect: bool = True) -> None

Start the bot and initialize the ready event.

Parameters:
  • token (str) –

    Bot authentication token.

  • reconnect (bool, default: True ) –

    Whether to automatically reconnect on disconnect.

Source code in easterobot/bot.py
209
210
211
212
213
214
215
216
217
async def start(self, token: str, *, reconnect: bool = True) -> None:
    """Start the bot and initialize the ready event.

    Args:
        token: Bot authentication token.
        reconnect: Whether to automatically reconnect on disconnect.
    """
    self.init_finished = asyncio.Event()
    await super().start(token=token, reconnect=reconnect)

entrypoint

entrypoint(argv: Optional[Sequence[str]] = None) -> None

Entrypoint for command line interface.

Source code in easterobot/cli.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def entrypoint(argv: Optional[Sequence[str]] = None) -> None:
    """Entrypoint for command line interface."""
    args = list(sys.argv[1:] if argv is None else argv)
    try:
        parser = get_parser()
        namespace = parser.parse_args(args)
        if namespace.action == "run":
            setup_logging(verbose=namespace.verbose)
            bot = Easterobot.from_config(
                namespace.config,
                token=namespace.token,
                env=namespace.env,
            )
            bot.auto_run()
        elif namespace.action == "generate":
            setup_logging(verbose=namespace.verbose)
            Easterobot.generate(
                destination=namespace.destination,
                token=namespace.token,
                env=namespace.env,
                interactive=namespace.interactive,
            )
        elif namespace.action == "alembic":
            if not hasattr(namespace, "cmd"):
                # see http://bugs.python.org/issue9253, argparse
                # behavior changed incompatibly in py3.3
                parser.error("too few arguments")
            else:
                config = load_config_from_path(namespace.config)
                cfg = config.alembic_config(namespace)
                cmd_alembic.run_cmd(cfg, namespace)
        else:
            parser.error("No command specified")  # pragma: no cover
    except Exception as err:  # NoQA: BLE001  # pragma: no cover
        setup_logging(verbose=True)
        logger.critical(
            "Unexpected error (%s, version %s)",
            __project__,
            __version__,
            exc_info=err,
        )
        logger.critical("Please, report this error to %s.", __issues__)
        sys.exit(1)