Skip to content

Commit 5a96c9b

Browse files
committed
Updated dependencies, added CLAUDE.md, added more ruff rulesets, fixed beanie statements using 'and' instead of proper joins
1 parent 5b8a993 commit 5a96c9b

File tree

27 files changed

+393
-217
lines changed

27 files changed

+393
-217
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ repos:
2727
- id: trailing-whitespace
2828

2929
- repo: https://github.com/pycqa/isort
30-
rev: 7.0.0
30+
rev: 8.0.1
3131
hooks:
3232
- id: isort
3333

3434
- repo: https://github.com/astral-sh/ruff-pre-commit
35-
rev: v0.14.10
35+
rev: v0.15.6
3636
hooks:
3737
- id: ruff-check
3838
args: [ --fix ]

CLAUDE.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Modmail v5 is a complete rewrite of the Modmail Discord bot (alpha/WIP). It manages support tickets between users and Discord server staff via DM-based modmail. Requires Python 3.14+, uses `uv` as the package manager.
8+
9+
## Commands
10+
11+
### Setup
12+
```bash
13+
# Install with SQLite backend
14+
uv sync --locked --compile-bytecode --no-default-groups --extra speed --extra sqlite
15+
16+
# Install with MongoDB backend
17+
uv sync --locked --compile-bytecode --no-default-groups --extra speed --extra mongodb
18+
```
19+
20+
### Run
21+
```bash
22+
uv run python start.py
23+
# or
24+
python -m modmail
25+
```
26+
27+
### Lint & Format
28+
```bash
29+
uv run pre-commit run --all-files # Run all pre-commit hooks
30+
uv run ruff check --fix modmail tests
31+
uv run ruff format modmail tests
32+
uv run isort modmail tests
33+
```
34+
35+
### Type Check
36+
```bash
37+
uv run pyright
38+
```
39+
40+
### Tests (Not Yet Implemented)
41+
```bash
42+
uv run pytest
43+
uv run pytest tests/test_utils.py # Run a single test file
44+
uv run pytest -k "test_name" # Run a specific test
45+
uv run pytest --cov=modmail --cov-report html
46+
uv run tox # Run all tox environments (lint, type, py3.14)
47+
```
48+
49+
## Architecture
50+
51+
### Module Structure
52+
```
53+
modmail/
54+
├── backends/ # Database abstraction layer
55+
│ ├── common/ # Abstract base class & shared models
56+
│ ├── mongodb/ # Beanie ODM implementation
57+
│ └── sql/ # SQLAlchemy + aiosqlite implementation
58+
├── config/ # Pydantic config models + YAML loader
59+
├── core/ # Bot class, translator, permissions
60+
│ └── internals/ # Cog base, command types, UI views, embeds
61+
├── cogs/
62+
│ ├── modmail/ # Core ticket logic (commands + listeners)
63+
│ └── utility/ # about, status, profile commands
64+
└── locales/ # Fluent FTL translation files (en, de)
65+
```
66+
67+
### Key Design Patterns
68+
69+
**Database Abstraction**: `DBClientBase` in `backends/common/client_base.py` defines the interface. Both SQL and MongoDB clients implement this. The active backend is selected via `config.yaml`.
70+
71+
**Ticket Flow**:
72+
1. User DMs bot → `cogs/modmail/listeners/dm_receive.py` intercepts
73+
2. `core/internals/staff_guild.py` creates a channel or forum thread in the staff server
74+
3. Database stores ticket state via the active backend client
75+
4. Staff reply via commands in `cogs/modmail/commands/`
76+
77+
**Localization**: Fluent FTL format (`locales/en/main.ftl`). All user-facing strings go through `core/translator.py`. Commands use `LazyHybridCommand` (`core/internals/command.py`) supporting both prefix and slash commands.
78+
79+
**Cog Base**: Custom `Cog` class in `core/internals/cog.py` with enhanced send/reply helpers and access to the translator.
80+
81+
### Config
82+
Copy `config.yaml.example``config.yaml`. Required fields: `bot.token`, `bot.staff_server_id`, `log_url`. Database backend is configured under the `database` key.
83+
84+
### Testing Notes
85+
Tests are currently undergoing a rewrite. Pyright runs in strict mode but excludes the `tests/` directory. Pre-commit hooks enforce isort + ruff formatting on every commit.

modmail/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,10 @@ def run_bot() -> NoReturn:
8686
modmail_text_lines += [""]
8787
modmail_text_lines += [f"Starting at {current_time_text}"]
8888
modmail_text_lines += [
89-
f"Version: {__version__} | Python: {python_version} | "
90-
f"Language{'s' if len(CONFIG.allowed_locales) != 1 else ''}: {enabled_locales}"
89+
(
90+
f"Version: {__version__} | Python: {python_version} | "
91+
f"Language{'s' if len(CONFIG.allowed_locales) != 1 else ''}: {enabled_locales}"
92+
)
9193
]
9294
modmail_text_lines += [""]
9395
modmail_text_width = len(max(modmail_text_lines, key=len)) + 10

modmail/backends/common/models/activity_model.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55

66
from __future__ import annotations
77

8-
from discord import app_commands
8+
from typing import TYPE_CHECKING
9+
910
from pydantic import BaseModel, ConfigDict
1011

1112
from modmail.enum import ActivityType
1213

14+
if TYPE_CHECKING:
15+
from discord import app_commands
16+
1317
__all__ = ["ActivityModel"]
1418

1519

@@ -38,7 +42,7 @@ def __str__(self) -> str:
3842
case ActivityType.playing:
3943
return f"Playing {self.name}"
4044
case ActivityType.streaming:
41-
return f"Streaming {self.name} ({self.url if self.url else 'No URL'})"
45+
return f"Streaming {self.name} ({self.url or 'No URL'})"
4246
case ActivityType.listening:
4347
return f"Listening to {self.name}"
4448
case ActivityType.watching:

modmail/backends/common/models/ticket_user_model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77

88
from __future__ import annotations
99

10-
import discord
10+
from typing import TYPE_CHECKING
11+
1112
from pydantic import BaseModel, ConfigDict
1213

14+
if TYPE_CHECKING:
15+
import discord
16+
1317
__all__ = ["TicketUserModel"]
1418

1519

modmail/backends/mongodb/client.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,8 @@ async def delete_profile(self, profile_id: int) -> None:
384384
"""
385385
# Delete the profile from the database.
386386
await MongoDBProfileDocument.find(
387-
MongoDBProfileDocument.bot_id == self._config.bot.bot_id
388-
and MongoDBProfileDocument.profile_id == profile_id
387+
MongoDBProfileDocument.bot_id == self._config.bot.bot_id,
388+
MongoDBProfileDocument.profile_id == profile_id,
389389
).delete()
390390

391391
# Delete the profile from cache.
@@ -402,8 +402,8 @@ async def sync_open_tickets(self) -> None:
402402
open_tickets_cache = MultiKeyCollection[MongoDBTicketDocument]("key", "channel_id")
403403

404404
async for ticket_document in MongoDBTicketDocument.find(
405-
MongoDBTicketDocument.bot_id == self._config.bot.bot_id
406-
and MongoDBTicketDocument.status == TicketStatus.open,
405+
MongoDBTicketDocument.bot_id == self._config.bot.bot_id,
406+
MongoDBTicketDocument.status == TicketStatus.open,
407407
fetch_links=True,
408408
):
409409
open_tickets_cache.add(ticket_document, key=ticket_document.key, channel_id=ticket_document.channel_id)
@@ -448,7 +448,7 @@ async def create_ticket(self, ticket: TicketModel) -> None:
448448
if ticket_document.channel_id == ticket.channel_id:
449449
raise TicketCreationError("Ticket with this channel ID already exists.")
450450
if any(
451-
new_recipient.user_id == cast(MongoDBTicketUserDocument, old_recipient).id
451+
new_recipient.user_id == cast("MongoDBTicketUserDocument", old_recipient).id
452452
for old_recipient in ticket_document.recipients
453453
for new_recipient in ticket.recipients
454454
):
@@ -476,7 +476,8 @@ async def set_ticket_log_channel_message_id(self, ticket_key: str, message_id: i
476476
TicketNotFoundError: If the ticket is not found.
477477
"""
478478
ticket_document = await MongoDBTicketDocument.find_one(
479-
MongoDBTicketDocument.bot_id == self._config.bot.bot_id and MongoDBTicketDocument.key == ticket_key
479+
MongoDBTicketDocument.bot_id == self._config.bot.bot_id,
480+
MongoDBTicketDocument.key == ticket_key,
480481
)
481482
if ticket_document is None:
482483
raise TicketNotFoundError(f"Ticket with key {ticket_key} not found.")
@@ -509,8 +510,8 @@ async def get_ticket_by_channel(self, channel_id: int, *, only_open: bool = True
509510
return None
510511

511512
ticket_document = await MongoDBTicketDocument.find_one(
512-
MongoDBTicketDocument.bot_id == self._config.bot.bot_id
513-
and MongoDBTicketDocument.channel_id == channel_id,
513+
MongoDBTicketDocument.bot_id == self._config.bot.bot_id,
514+
MongoDBTicketDocument.channel_id == channel_id,
514515
fetch_links=True,
515516
)
516517
if ticket_document is not None:
@@ -535,7 +536,8 @@ async def get_ticket_by_key(self, key: str, *, only_open: bool = True) -> Ticket
535536
return None
536537

537538
ticket_document = await MongoDBTicketDocument.find_one(
538-
MongoDBTicketDocument.bot_id == self._config.bot.bot_id and MongoDBTicketDocument.key == key,
539+
MongoDBTicketDocument.bot_id == self._config.bot.bot_id,
540+
MongoDBTicketDocument.key == key,
539541
fetch_links=True,
540542
)
541543
if ticket_document is not None:
@@ -555,7 +557,7 @@ async def get_ticket_by_recipient(self, recipient_id: int) -> TicketModel | None
555557
await ticket_document.fetch_all_links()
556558
# Check if the recipient ID is in the ticket's recipients.
557559
if any(
558-
cast(MongoDBTicketUserDocument, recipient).id == recipient_id
560+
cast("MongoDBTicketUserDocument", recipient).id == recipient_id
559561
for recipient in ticket_document.recipients
560562
):
561563
return await ticket_document.get_model()
@@ -584,18 +586,18 @@ async def get_all_tickets_by_recipient(
584586
Returns:
585587
A list of ticket models associated with the recipient or the count of tickets if count is True.
586588
"""
587-
query = (
588-
MongoDBTicketDocument.bot_id == self._config.bot.bot_id
589-
and cast(MongoDBTicketUserDocument, MongoDBTicketDocument.recipients).id == recipient_id
590-
)
589+
conditions = [
590+
MongoDBTicketDocument.bot_id == self._config.bot.bot_id,
591+
cast("MongoDBTicketUserDocument", MongoDBTicketDocument.recipients).id == recipient_id,
592+
]
591593

592594
if only_closed:
593-
query = query and MongoDBTicketDocument.status != TicketStatus.open
595+
conditions.append(MongoDBTicketDocument.status != TicketStatus.open)
594596

595597
if count:
596-
return await MongoDBTicketDocument.find(query, fetch_links=True).count()
598+
return await MongoDBTicketDocument.find(*conditions, fetch_links=True).count()
597599

598-
ticket_documents = await MongoDBTicketDocument.find(query, fetch_links=True).to_list()
600+
ticket_documents = await MongoDBTicketDocument.find(*conditions, fetch_links=True).to_list()
599601
return await asyncio.gather(*[ticket_document.get_model() for ticket_document in ticket_documents])
600602

601603
async def save_message(self, ticket_message: TicketMessageModel) -> None:
@@ -645,7 +647,8 @@ async def close_ticket(
645647

646648
# Find the ticket document
647649
ticket_document = await MongoDBTicketDocument.find_one(
648-
MongoDBTicketDocument.bot_id == self._config.bot.bot_id and MongoDBTicketDocument.key == ticket_key,
650+
MongoDBTicketDocument.bot_id == self._config.bot.bot_id,
651+
MongoDBTicketDocument.key == ticket_key,
649652
fetch_links=True,
650653
)
651654

modmail/backends/mongodb/convert.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,22 @@
77

88
from __future__ import annotations
99

10-
from typing import cast
10+
from typing import TYPE_CHECKING, cast
1111

1212
from beanie import Link, UpdateResponse
13-
from beanie.odm.queries.update import UpdateOne
1413

15-
from ..common import TicketDMMessageModel, TicketMessageModel, TicketModel, TicketUserModel
1614
from .models import (
1715
MongoDBTicketDMMessageModel,
1816
MongoDBTicketDocument,
1917
MongoDBTicketMessageDocument,
2018
MongoDBTicketUserDocument,
2119
)
2220

21+
if TYPE_CHECKING:
22+
from beanie.odm.queries.update import UpdateOne
23+
24+
from ..common import TicketDMMessageModel, TicketMessageModel, TicketModel, TicketUserModel
25+
2326
__all__ = [
2427
"get_or_create_ticket_user",
2528
"ticket_dm_message_model_to_document",
@@ -54,9 +57,9 @@ async def get_or_create_ticket_user(ticket_user: TicketUserModel) -> MongoDBTick
5457
"""
5558
ticket_user_document = ticket_user_model_to_document(ticket_user)
5659
return cast(
57-
MongoDBTicketUserDocument,
60+
"MongoDBTicketUserDocument",
5861
await cast(
59-
UpdateOne,
62+
"UpdateOne",
6063
MongoDBTicketUserDocument.find_one(MongoDBTicketUserDocument.id == ticket_user.user_id).upsert(
6164
{"$set": ticket_user_document.model_dump(exclude={"id"})},
6265
on_insert=ticket_user_document,
@@ -86,19 +89,19 @@ async def ticket_model_to_document(ticket: TicketModel) -> MongoDBTicketDocument
8689
for recipient in ticket.recipients:
8790
if recipient.user_id not in ticket_users_cache:
8891
ticket_users_cache[recipient.user_id] = cast(
89-
Link[MongoDBTicketUserDocument], await get_or_create_ticket_user(recipient)
92+
"Link[MongoDBTicketUserDocument]", await get_or_create_ticket_user(recipient)
9093
)
9194
recipients.append(ticket_users_cache[recipient.user_id])
9295

9396
if ticket.created_by.user_id not in ticket_users_cache:
9497
ticket_users_cache[ticket.created_by.user_id] = cast(
95-
Link[MongoDBTicketUserDocument], await get_or_create_ticket_user(ticket.created_by)
98+
"Link[MongoDBTicketUserDocument]", await get_or_create_ticket_user(ticket.created_by)
9699
)
97100
created_by = ticket_users_cache[ticket.created_by.user_id]
98101

99102
if ticket.closed_by and ticket.closed_by.user_id not in ticket_users_cache:
100103
ticket_users_cache[ticket.closed_by.user_id] = cast(
101-
Link[MongoDBTicketUserDocument], await get_or_create_ticket_user(ticket.closed_by)
104+
"Link[MongoDBTicketUserDocument]", await get_or_create_ticket_user(ticket.closed_by)
102105
)
103106
closed_by = ticket_users_cache[ticket.closed_by.user_id] if ticket.closed_by else None
104107

@@ -140,7 +143,7 @@ async def ticket_dm_message_model_to_document(
140143
# Ensure the recipient user exists in DB
141144
if ticket_dm_message.recipient.user_id not in ticket_users_cache:
142145
ticket_users_cache[ticket_dm_message.recipient.user_id] = cast(
143-
Link[MongoDBTicketUserDocument], await get_or_create_ticket_user(ticket_dm_message.recipient)
146+
"Link[MongoDBTicketUserDocument]", await get_or_create_ticket_user(ticket_dm_message.recipient)
144147
)
145148

146149
return MongoDBTicketDMMessageModel(
@@ -162,27 +165,26 @@ async def ticket_message_model_to_document(ticket_message: TicketMessageModel) -
162165
"""
163166
ticket_users_cache: dict[int, Link[MongoDBTicketUserDocument]] = {}
164167

165-
dm_messages: list[MongoDBTicketDMMessageModel] = []
166-
for dm_message in ticket_message.dm_messages:
167-
dm_messages.append(
168-
await ticket_dm_message_model_to_document(dm_message, ticket_users_cache=ticket_users_cache),
169-
)
168+
dm_messages: list[MongoDBTicketDMMessageModel] = [
169+
await ticket_dm_message_model_to_document(dm_message, ticket_users_cache=ticket_users_cache)
170+
for dm_message in ticket_message.dm_messages
171+
]
170172

171173
if ticket_message.author.user_id not in ticket_users_cache:
172174
ticket_users_cache[ticket_message.author.user_id] = cast(
173-
Link[MongoDBTicketUserDocument], await get_or_create_ticket_user(ticket_message.author)
175+
"Link[MongoDBTicketUserDocument]", await get_or_create_ticket_user(ticket_message.author)
174176
)
175177
author = ticket_users_cache[ticket_message.author.user_id]
176178

177179
if ticket_message.edited_by and ticket_message.edited_by.user_id not in ticket_users_cache:
178180
ticket_users_cache[ticket_message.edited_by.user_id] = cast(
179-
Link[MongoDBTicketUserDocument], await get_or_create_ticket_user(ticket_message.edited_by)
181+
"Link[MongoDBTicketUserDocument]", await get_or_create_ticket_user(ticket_message.edited_by)
180182
)
181183
edited_by = ticket_message.edited_by and ticket_users_cache[ticket_message.edited_by.user_id]
182184

183185
if ticket_message.deleted_by and ticket_message.deleted_by.user_id not in ticket_users_cache:
184186
ticket_users_cache[ticket_message.deleted_by.user_id] = cast(
185-
Link[MongoDBTicketUserDocument], await get_or_create_ticket_user(ticket_message.deleted_by)
187+
"Link[MongoDBTicketUserDocument]", await get_or_create_ticket_user(ticket_message.deleted_by)
186188
)
187189
deleted_by = ticket_message.deleted_by and ticket_users_cache[ticket_message.deleted_by.user_id]
188190

modmail/backends/mongodb/models/ticket_model.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@ async def get_model(self) -> TicketModel:
8686
await self.fetch_all_links() # Make sure all links are fetched
8787

8888
recipients = await asyncio.gather(*[
89-
cast(MongoDBTicketUserDocument, recipient).get_model() for recipient in self.recipients
89+
cast("MongoDBTicketUserDocument", recipient).get_model() for recipient in self.recipients
9090
])
91-
created_by = await cast(MongoDBTicketUserDocument, self.created_by).get_model()
92-
closed_by = await cast(MongoDBTicketUserDocument, self.closed_by).get_model() if self.closed_by else None
91+
created_by = await cast("MongoDBTicketUserDocument", self.created_by).get_model()
92+
closed_by = await cast("MongoDBTicketUserDocument", self.closed_by).get_model() if self.closed_by else None
9393

9494
return TicketModel(
9595
bot_id=self.bot_id,

modmail/backends/sql/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import asyncio
1010
import datetime
1111
import logging
12-
from collections.abc import Awaitable
1312
from concurrent.futures import ProcessPoolExecutor
1413
from typing import TYPE_CHECKING, Any, Literal, overload
1514

@@ -41,6 +40,8 @@
4140
)
4241

4342
if TYPE_CHECKING:
43+
from collections.abc import Awaitable
44+
4445
from sqlalchemy.engine.interfaces import DBAPIConnection
4546
from sqlalchemy.pool import ConnectionPoolEntry
4647

0 commit comments

Comments
 (0)