Skip to content

Commit c7838d9

Browse files
committed
Flatten modmail/core/internals/ into modmail/core/, add PermissionCommandIndex with locale-aware command resolution
1 parent 502dd9c commit c7838d9

21 files changed

Lines changed: 1227 additions & 1230 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Modmail is a Discord DM-based support ticket bot. It routes user DMs into staff
44

55
## Structure
66

7-
- Core runtime and Discord integration live in `modmail/core/` (bot, permissions, translator, internal helpers).
7+
- Core runtime and Discord integration live in `modmail/core/` — flat layout, one concern per file: bot, cog base/lazy-command machinery, context helpers, argument converters, embed builder, access-level decorators and command permission index, staff guild lifecycle, FTL translator, and a private ticket view.
88
- Persistence is abstracted behind `modmail/backends/common/` (`DBBackend`, `DBClient`, shared models) with concrete implementations in `modmail/backends/mongodb/` and `modmail/backends/sql/`.
99
- Configuration models and loading logic live in `modmail/config/`.
1010
- Discord-facing behavior is in `modmail/cogs/modmail/` (modmail flows) and `modmail/cogs/utility/` (utility commands).

config.yaml.example

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,8 @@ permission:
139139
# * manager
140140
# * admin
141141
# * owner
142-
# Command names should be as appears in the code.
143-
# For example, the `/status clear` command is called "status_clear_command" in the code (cogs/utility/commands/status.py).
144-
# So you would use "status clear" as the command name for the override.
145-
# For most cases, the command name is the same as the actual command.
146-
# You can set wildcard (+) to override a command group. For example, "status+" will override all related commands (i.e. `/status clear`).
142+
# The command name is the English name of the command as it appears in Discord (e.g. "status clear" for `/status clear`),
143+
# You can append "+" to a group name to override all of its subcommands (e.g. "status+" covers `/status clear`).
147144
overrides:
148145
some command name: staff
149146
some group+: everyone

modmail/__init__.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,29 @@
1212
import sys
1313
from importlib.metadata import version
1414
from textwrap import dedent
15-
from typing import NoReturn
15+
from typing import TYPE_CHECKING, NoReturn
1616

17-
from .config import Config, load_config
17+
if TYPE_CHECKING:
18+
from .config import Config
1819

19-
__all__ = ["__version__", "init", "run_bot"]
20+
CONFIG: Config
21+
22+
__all__ = ["CONFIG", "__version__", "init", "run_bot"]
2023

2124
__version__ = version("modmail.py")
2225

2326
logger = _logging.getLogger(__name__)
2427

28+
_state: dict[str, Config] = {}
29+
2530

26-
# Global variable to store the loaded configuration.
27-
CONFIG: Config
31+
def __getattr__(name: str) -> object:
32+
if name == "CONFIG":
33+
try:
34+
return _state["config"]
35+
except KeyError:
36+
raise RuntimeError("modmail.CONFIG is not available — call modmail.init() first") from None
37+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
2838

2939

3040
def init(config_file_path: str = "config.yaml", *, configure_logging: bool = True) -> None:
@@ -34,20 +44,20 @@ def init(config_file_path: str = "config.yaml", *, configure_logging: bool = Tru
3444
config_file_path: Path to the configuration file. Defaults to "config.yaml".
3545
configure_logging: Whether to configure logging when logging is enabled in the configs.
3646
"""
37-
global CONFIG # noqa: PLW0603
38-
# noinspection PyPep8Naming
39-
CONFIG_ = load_config(config_file_path) # noqa: N806
40-
if CONFIG_ is None:
47+
from .config import load_config
48+
49+
config = load_config(config_file_path)
50+
if config is None:
4151
logger.critical("Failed to load config. Exiting.")
4252
sys.exit(1)
43-
CONFIG = CONFIG_ # pyright: ignore [reportConstantRedefinition]
53+
_state["config"] = config
4454

45-
if configure_logging and CONFIG.logging.enabled:
55+
if configure_logging and config.logging.enabled:
4656
from .logging import setup_logging
4757

4858
setup_logging()
4959

50-
logger.debug("Loaded config: %s", CONFIG.model_dump_json())
60+
logger.debug("Loaded config: %s", config.model_dump_json(indent=2))
5161

5262

5363
def run_bot() -> NoReturn:
@@ -59,7 +69,7 @@ def run_bot() -> NoReturn:
5969
6070
This function does not return as it runs the bot until termination.
6171
"""
62-
if "CONFIG" not in globals():
72+
if "config" not in _state:
6373
logger.warning("init() was not called. Calling init() with the default args.")
6474
init()
6575

@@ -75,9 +85,10 @@ def run_bot() -> NoReturn:
7585
)
7686
current_time_text = datetime.datetime.now(tz=datetime.UTC).astimezone().strftime("%B %d, %Y %H:%M:%S %Z")
7787
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
78-
allowed_locale = CONFIG.allowed_locales
88+
config = _state["config"]
89+
allowed_locale = config.allowed_locales
7990
enabled_locales = ", ".join(
80-
[CONFIG.default_locale] + [locale for locale in allowed_locale if locale != CONFIG.default_locale]
91+
[config.default_locale] + [locale for locale in allowed_locale if locale != config.default_locale]
8192
)
8293

8394
modmail_text_lines: list[str] = []
@@ -88,7 +99,7 @@ def run_bot() -> NoReturn:
8899
modmail_text_lines += [
89100
(
90101
f"Version: {__version__} | Python: {python_version} | "
91-
f"Language{'s' if len(CONFIG.allowed_locales) != 1 else ''}: {enabled_locales}"
102+
f"Language{'s' if len(config.allowed_locales) != 1 else ''}: {enabled_locales}"
92103
)
93104
]
94105
modmail_text_lines += [""]

modmail/cogs/modmail/commands/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ async def do_setup(ctx: Context) -> None:
565565
logger.debug("Bot does not have enough permissions to run setup in %s", ctx.guild)
566566
return
567567

568-
wizard = SetupWizardView(ctx=ctx, show_reconfigure_warning=ctx.bot.staff_guild.is_configured())
568+
wizard = SetupWizardView(ctx=ctx, show_reconfigure_warning=ctx.bot.staff_guild.is_setup())
569569
await wizard.build()
570570

571571
wizard.message = await ctx.reply(view=wizard)

modmail/cogs/modmail/listeners/dm_receive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async def dm_receive(cog: Modmail, message: discord.Message) -> None:
4747
success_emoji = "✅"
4848
error_emoji = "❌"
4949

50-
if not staff_guild.is_configured():
50+
if not staff_guild.is_setup():
5151
prefix = await cog.bot.get_prefix(message)
5252
if isinstance(prefix, str):
5353
prefix = [prefix]

modmail/cogs/utility/commands/profile.py

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -173,50 +173,67 @@ async def on_submit(self, interaction: discord.Interaction) -> None:
173173
Args:
174174
interaction: The submission interaction from Discord.
175175
"""
176-
command_name = utils.sanitize_user_command_name(self.command_name.value)
177-
command_name_no_wildcard = command_name.split("+")[0].strip()
176+
locale = str(interaction.locale) if interaction.locale else CONFIG.default_locale
177+
index = self._ctx.bot.permission_command_index(locale)
178+
179+
canonical = index.resolve(self.command_name.value)
180+
if canonical is None:
181+
await interaction.response.send_message(
182+
self._ctx.translate(
183+
_(
184+
"ftl-cmd-profile-override-command-not-found",
185+
command=self.command_name.value,
186+
)
187+
),
188+
ephemeral=True,
189+
)
190+
return
191+
192+
base = canonical.removesuffix("+")
178193

179194
for bot_command in self._ctx.bot.walk_commands():
180-
bot_command_name = utils.get_command_name(bot_command)
181-
if bot_command_name == command_name_no_wildcard:
182-
if "+" in command_name and not isinstance(bot_command, commands.Group):
183-
command_name = command_name_no_wildcard
195+
if self._ctx.bot.get_canonical_command_name(bot_command) == base:
196+
if canonical != base and not isinstance(bot_command, commands.Group):
197+
canonical = base # wildcards only apply to groups
198+
184199
if self._ctx.bot.get_command_access_level(bot_command) == RequiredAccessLevel.owner:
200+
# Only actual owner allowed to override owner-only commands
185201
if not await self._ctx.bot.is_owner(self._ctx.author):
186202
await interaction.response.send_message(
187-
self._ctx.translate(_("ftl-cmd-profile-override-owner-command", command=command_name)),
203+
self._ctx.translate(
204+
_(
205+
"ftl-cmd-profile-override-owner-command",
206+
command=self.command_name.value,
207+
)
208+
),
188209
ephemeral=True,
189210
)
190211
return
191212
break
192-
else:
193-
await interaction.response.send_message(
194-
self._ctx.translate(_("ftl-cmd-profile-override-command-not-found", command=command_name)),
195-
ephemeral=True,
196-
)
197-
return
213+
214+
display = index.label(canonical)
198215

199216
changed = self._editor_view.resync_profile()
200-
if self.profile.permission_overrides.get(command_name) == self._override_value:
217+
if self.profile.permission_overrides.get(canonical) == self._override_value:
201218
if changed:
202219
await self._editor_view.rebuild()
203220
await interaction.response.send_message(
204221
self._ctx.translate(
205-
_("ftl-modal-profile-add-override-already-allow", command=command_name)
222+
_("ftl-modal-profile-add-override-already-allow", command=display)
206223
if self._override_value == PermissionOverrideValue.allow
207-
else _("ftl-modal-profile-add-override-already-deny", command=command_name)
224+
else _("ftl-modal-profile-add-override-already-deny", command=display)
208225
),
209226
ephemeral=True,
210227
)
211228
return
212229

213230
overrides = self.profile.permission_overrides.copy()
214-
overrides[command_name] = self._override_value
231+
overrides[canonical] = self._override_value
215232
new_profile = self.profile.model_copy(update={"permission_overrides": overrides})
216233
try:
217234
await self._ctx.bot.database_client.update_profile(new_profile)
218235
except DatabaseOperationError as e:
219-
logger.error("Failed to set override %r on profile %d: %s", command_name, new_profile.profile_id, e)
236+
logger.error("Failed to set override %r on profile %d: %s", canonical, new_profile.profile_id, e)
220237
if changed:
221238
await self._editor_view.rebuild()
222239
await interaction.response.send_message(
@@ -227,17 +244,17 @@ async def on_submit(self, interaction: discord.Interaction) -> None:
227244
logger.debug(
228245
"Set %s override for command %r on profile %d.",
229246
self._override_value.value,
230-
command_name,
247+
canonical,
231248
new_profile.profile_id,
232249
)
233250
self._editor_view.profile = new_profile
234251
await self._editor_view.rebuild()
235252

236253
await interaction.response.send_message(
237254
self._ctx.translate(
238-
_("ftl-view-profile-editor-override-allow-success", command=command_name)
255+
_("ftl-view-profile-editor-override-allow-success", command=display)
239256
if self._override_value == PermissionOverrideValue.allow
240-
else _("ftl-view-profile-editor-override-deny-success", command=command_name)
257+
else _("ftl-view-profile-editor-override-deny-success", command=display)
241258
),
242259
ephemeral=True,
243260
)
@@ -280,26 +297,43 @@ async def on_submit(self, interaction: discord.Interaction) -> None:
280297
Args:
281298
interaction: The submission interaction from Discord.
282299
"""
283-
name = utils.sanitize_user_command_name(self.override_name.value)
300+
locale = str(interaction.locale) if interaction.locale else CONFIG.default_locale
301+
index = self._ctx.bot.permission_command_index(locale)
302+
sanitized = index.sanitize(self.override_name.value)
303+
304+
resolved = index.resolve(self.override_name.value, allow_raw_key=True)
284305

285306
changed = self._editor_view.resync_profile()
286-
if name not in self.profile.permission_overrides:
307+
308+
# Remove resolved if exists, otherwise remove sanitized input if it's an orphaned key
309+
key_to_remove = (
310+
resolved
311+
if resolved is not None and resolved in self.profile.permission_overrides
312+
else sanitized
313+
if sanitized in self.profile.permission_overrides
314+
else None
315+
)
316+
317+
if key_to_remove is None:
287318
if changed:
288319
await self._editor_view.rebuild()
289320
await interaction.response.send_message(
290-
self._ctx.translate(_("ftl-modal-profile-remove-override-not-found", command=name)),
321+
self._ctx.translate(
322+
_("ftl-modal-profile-remove-override-not-found", command=self.override_name.value)
323+
),
291324
ephemeral=True,
292325
)
293326
return
294327

295-
if not await self._editor_view.remove_override(name, interaction):
328+
if not await self._editor_view.remove_override(key_to_remove, interaction):
296329
if changed:
297330
await self._editor_view.rebuild()
298331
return
299332

300333
await self._editor_view.rebuild()
334+
display = index.label(key_to_remove)
301335
await interaction.response.send_message(
302-
self._ctx.translate(_("ftl-modal-profile-remove-override-success", command=name)),
336+
self._ctx.translate(_("ftl-modal-profile-remove-override-success", command=display)),
303337
ephemeral=True,
304338
)
305339

@@ -383,7 +417,7 @@ async def _on_confirm(self, interaction: discord.Interaction) -> None:
383417
access_sync_failed = False
384418
if self.profile.access_level is not None and self.profile.access_level != AccessLevel.everyone:
385419
try:
386-
await self._ctx.bot.staff_guild.revoke_access(self.profile.profile_id, self.profile.profile_type)
420+
await self._ctx.bot.staff_guild.revoke_access(self.profile.profile_id)
387421
except Exception as e:
388422
logger.error("Failed to revoke Discord access for profile %d: %s", self.profile.profile_id, e)
389423
access_sync_failed = True
@@ -732,9 +766,7 @@ async def on_level_select(interaction: discord.Interaction) -> None:
732766
access_sync_failed = False
733767
try:
734768
if previously_had_access and not now_has_access:
735-
await self._ctx.bot.staff_guild.revoke_access(
736-
self.profile.profile_id, self.profile.profile_type
737-
)
769+
await self._ctx.bot.staff_guild.revoke_access(self.profile.profile_id)
738770
elif not previously_had_access and now_has_access:
739771
await self._ctx.bot.staff_guild.grant_access(
740772
self.profile.profile_id, self.profile.profile_type
@@ -793,6 +825,14 @@ def _build_overrides_section(self) -> list[discord.ui.Item[ProfileEditorView]]:
793825
Returns:
794826
A list of Component v2 items for the overrides section of the editor card.
795827
"""
828+
locale = str(self._ctx.interaction.locale) if self._ctx.interaction else CONFIG.default_locale
829+
index = self._ctx.bot.permission_command_index(locale)
830+
831+
def _label(k: str) -> str:
832+
if k.removesuffix("+") not in index.keys:
833+
return "⚠️ " + k
834+
return index.label(k)
835+
796836
# ── Header: override count ──
797837

798838
overrides_header = self._ctx.translate(
@@ -830,9 +870,9 @@ async def on_add_deny(interaction: discord.Interaction) -> None:
830870
if self.profile.permission_overrides:
831871
lines = [
832872
self._ctx.translate(
833-
_("ftl-view-profile-editor-override-line-allow", command=k)
873+
_("ftl-view-profile-editor-override-line-allow", command=_label(k))
834874
if v == PermissionOverrideValue.allow
835-
else _("ftl-view-profile-editor-override-line-deny", command=k)
875+
else _("ftl-view-profile-editor-override-line-deny", command=_label(k))
836876
)
837877
for k, v in self.profile.permission_overrides.items()
838878
]
@@ -843,7 +883,7 @@ async def on_add_deny(interaction: discord.Interaction) -> None:
843883

844884
remove_options = [
845885
discord.SelectOption(
846-
label=k,
886+
label=_label(k)[:100],
847887
value=k,
848888
description=self._ctx.translate(
849889
_("ftl-view-profile-editor-override-value-allow")
@@ -1054,7 +1094,7 @@ async def profile_delete_command(
10541094
access_sync_failed = False
10551095
if profile.access_level is not None and profile.access_level != AccessLevel.everyone:
10561096
try:
1057-
await ctx.bot.staff_guild.revoke_access(profile.profile_id, profile.profile_type)
1097+
await ctx.bot.staff_guild.revoke_access(profile.profile_id)
10581098
except Exception as e:
10591099
logger.error("Failed to revoke Discord access for profile %d: %s", profile.profile_id, e)
10601100
access_sync_failed = True

modmail/config/models/permission_model.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from pydantic import BaseModel, Field, field_validator
1212

13-
from modmail import utils
1413
from modmail.enum import RequiredAccessLevel
1514

1615
__all__ = ["PermissionConfig"]
@@ -57,7 +56,7 @@ def sanitize_overrides_values_config(
5756

5857
new_v: dict[str, RequiredAccessLevel] = {}
5958
for key, value in v.items():
60-
key = utils.sanitize_user_command_name(key)
59+
key = key.casefold().strip()
6160
if isinstance(value, str):
6261
try:
6362
new_v[key] = RequiredAccessLevel[value.casefold()]

modmail/core/__init__.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@
99

1010
try: # Check if modmail is initialized
1111
from .. import CONFIG
12-
13-
del CONFIG
14-
except ImportError as e: # pragma: no cover
12+
except RuntimeError as e: # pragma: no cover
1513
raise RuntimeError("Did you forget to first run modmail.init()?") from e
14+
else:
15+
del CONFIG
1616

1717
from .bot import *
18-
from .internals import *
18+
from .cog import *
19+
from .context import *
20+
from .converters import *
21+
from .embed import *
1922
from .permission import *
23+
from .staff_guild import *
2024
from .translator import *

0 commit comments

Comments
 (0)