Skip to content

Commit b4cc355

Browse files
committed
feat(rewrite-v2): add python_italy_bot application
- Entry point, config, handlers (welcome, moderation, spam) - Services: captcha, spam detector, moderation - DB: in-memory and Postgres repositories
1 parent fd0c0a6 commit b4cc355

17 files changed

Lines changed: 1447 additions & 0 deletions

File tree

src/python_italy_bot/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Python Italy Telegram Bot - Official bot for Italian Python community groups."""
2+
3+
__version__ = "0.1.0"

src/python_italy_bot/assets/.gitkeep

Whitespace-only changes.

src/python_italy_bot/config.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Configuration loaded from environment variables."""
2+
3+
import os
4+
5+
from dotenv import load_dotenv
6+
7+
load_dotenv()
8+
9+
10+
def _get_env(key: str, default: str | None = None) -> str:
11+
value = os.environ.get(key, default)
12+
if value is None:
13+
raise ValueError(f"Missing required environment variable: {key}")
14+
return value
15+
16+
17+
def _get_optional_env(key: str, default: str | None = None) -> str | None:
18+
return os.environ.get(key, default)
19+
20+
21+
def _get_optional_int(key: str) -> int | None:
22+
val = os.environ.get(key)
23+
if val is None or val.strip() == "":
24+
return None
25+
try:
26+
return int(val.strip())
27+
except ValueError:
28+
return None
29+
30+
31+
def _get_int_list(key: str) -> list[int]:
32+
val = os.environ.get(key)
33+
if val is None or val.strip() == "":
34+
return []
35+
return [int(x.strip()) for x in val.split(",") if x.strip()]
36+
37+
38+
class Settings:
39+
"""Bot configuration from environment."""
40+
41+
def __init__(self) -> None:
42+
self.telegram_bot_token: str = _get_env("TELEGRAM_BOT_TOKEN")
43+
self.database_url: str | None = _get_optional_env("DATABASE_URL")
44+
self.captcha_secret_command: str = _get_optional_env(
45+
"CAPTCHA_SECRET_COMMAND", "python-italy"
46+
)
47+
self.captcha_file_path: str = _get_optional_env(
48+
"CAPTCHA_FILE_PATH", "assets/regolamento.md"
49+
)
50+
self.main_group_id: int | None = _get_optional_int("MAIN_GROUP_ID")
51+
self.local_group_ids: list[int] = _get_int_list("LOCAL_GROUP_IDS")
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Database abstraction layer."""
2+
3+
from .base import AsyncRepository, Repository
4+
from .in_memory import InMemoryRepository
5+
from .models import Ban, Mute, Report
6+
from .postgres import PostgresRepository
7+
8+
__all__ = [
9+
"Repository",
10+
"AsyncRepository",
11+
"InMemoryRepository",
12+
"PostgresRepository",
13+
"Ban",
14+
"Mute",
15+
"Report",
16+
"create_repository",
17+
]
18+
19+
20+
async def create_repository(database_url: str | None) -> AsyncRepository:
21+
"""Create a repository based on the database URL.
22+
23+
Returns PostgresRepository if database_url is a PostgreSQL URL,
24+
otherwise returns InMemoryRepository.
25+
"""
26+
if database_url and database_url.startswith("postgresql"):
27+
from psycopg_pool import AsyncConnectionPool
28+
29+
pool = AsyncConnectionPool(database_url, open=False)
30+
await pool.open()
31+
return PostgresRepository(pool)
32+
return InMemoryRepository()

src/python_italy_bot/db/base.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Abstract repository interface for persistence."""
2+
3+
from abc import ABC, abstractmethod
4+
5+
6+
class Repository(ABC):
7+
"""Abstract interface for data persistence (sync)."""
8+
9+
@abstractmethod
10+
def add_pending_verification(self, user_id: int, chat_id: int) -> None:
11+
"""Record that user joined chat and needs to complete captcha."""
12+
...
13+
14+
@abstractmethod
15+
def get_pending_chats(self, user_id: int) -> list[int]:
16+
"""Return chat IDs where user is pending verification."""
17+
...
18+
19+
@abstractmethod
20+
def remove_pending(self, user_id: int, chat_id: int) -> bool:
21+
"""Remove pending verification. Returns True if existed."""
22+
...
23+
24+
@abstractmethod
25+
def is_user_verified(self, user_id: int, chat_id: int) -> bool:
26+
"""Check if user has completed captcha for the given chat."""
27+
...
28+
29+
@abstractmethod
30+
def mark_user_verified(self, user_id: int, chat_id: int) -> None:
31+
"""Mark user as verified for the given chat."""
32+
...
33+
34+
@abstractmethod
35+
def get_banned_users(self, chat_id: int) -> list[int]:
36+
"""Return user IDs banned in the given chat."""
37+
...
38+
39+
@abstractmethod
40+
def add_ban(
41+
self,
42+
user_id: int,
43+
chat_id: int,
44+
admin_id: int,
45+
reason: str | None = None,
46+
) -> None:
47+
"""Record a ban."""
48+
...
49+
50+
@abstractmethod
51+
def remove_ban(self, user_id: int, chat_id: int) -> bool:
52+
"""Remove a ban. Returns True if ban existed."""
53+
...
54+
55+
@abstractmethod
56+
def get_muted_users(self, chat_id: int) -> list[int]:
57+
"""Return user IDs currently muted in the given chat."""
58+
...
59+
60+
@abstractmethod
61+
def add_mute(
62+
self,
63+
user_id: int,
64+
chat_id: int,
65+
admin_id: int,
66+
reason: str | None = None,
67+
until: int | None = None,
68+
) -> None:
69+
"""Record a mute. until is Unix timestamp or None for indefinite."""
70+
...
71+
72+
@abstractmethod
73+
def remove_mute(self, user_id: int, chat_id: int) -> bool:
74+
"""Remove a mute. Returns True if mute existed."""
75+
...
76+
77+
@abstractmethod
78+
def add_report(
79+
self,
80+
reporter_id: int,
81+
reported_user_id: int,
82+
chat_id: int,
83+
message_id: int | None = None,
84+
reason: str | None = None,
85+
) -> None:
86+
"""Record a report."""
87+
...
88+
89+
90+
class AsyncRepository(ABC):
91+
"""Abstract interface for data persistence (async)."""
92+
93+
@abstractmethod
94+
async def add_pending_verification(self, user_id: int, chat_id: int) -> None:
95+
"""Record that user joined chat and needs to complete captcha."""
96+
...
97+
98+
@abstractmethod
99+
async def get_pending_chats(self, user_id: int) -> list[int]:
100+
"""Return chat IDs where user is pending verification."""
101+
...
102+
103+
@abstractmethod
104+
async def remove_pending(self, user_id: int, chat_id: int) -> bool:
105+
"""Remove pending verification. Returns True if existed."""
106+
...
107+
108+
@abstractmethod
109+
async def is_user_verified(self, user_id: int, chat_id: int) -> bool:
110+
"""Check if user has completed captcha for the given chat."""
111+
...
112+
113+
@abstractmethod
114+
async def mark_user_verified(self, user_id: int, chat_id: int) -> None:
115+
"""Mark user as verified for the given chat."""
116+
...
117+
118+
@abstractmethod
119+
async def get_banned_users(self, chat_id: int) -> list[int]:
120+
"""Return user IDs banned in the given chat."""
121+
...
122+
123+
@abstractmethod
124+
async def add_ban(
125+
self,
126+
user_id: int,
127+
chat_id: int,
128+
admin_id: int,
129+
reason: str | None = None,
130+
) -> None:
131+
"""Record a ban."""
132+
...
133+
134+
@abstractmethod
135+
async def remove_ban(self, user_id: int, chat_id: int) -> bool:
136+
"""Remove a ban. Returns True if ban existed."""
137+
...
138+
139+
@abstractmethod
140+
async def get_muted_users(self, chat_id: int) -> list[int]:
141+
"""Return user IDs currently muted in the given chat."""
142+
...
143+
144+
@abstractmethod
145+
async def add_mute(
146+
self,
147+
user_id: int,
148+
chat_id: int,
149+
admin_id: int,
150+
reason: str | None = None,
151+
until: int | None = None,
152+
) -> None:
153+
"""Record a mute. until is Unix timestamp or None for indefinite."""
154+
...
155+
156+
@abstractmethod
157+
async def remove_mute(self, user_id: int, chat_id: int) -> bool:
158+
"""Remove a mute. Returns True if mute existed."""
159+
...
160+
161+
@abstractmethod
162+
async def add_report(
163+
self,
164+
reporter_id: int,
165+
reported_user_id: int,
166+
chat_id: int,
167+
message_id: int | None = None,
168+
reason: str | None = None,
169+
) -> None:
170+
"""Record a report."""
171+
...
172+
173+
async def close(self) -> None:
174+
"""Close any resources (override if needed)."""
175+
pass
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""In-memory implementation of the repository (no persistent DB)."""
2+
3+
from datetime import datetime, timezone
4+
5+
from .base import AsyncRepository
6+
from .models import Ban, Mute, Report
7+
8+
9+
class InMemoryRepository(AsyncRepository):
10+
"""In-memory repository for development and testing."""
11+
12+
def __init__(self) -> None:
13+
self._verified: set[tuple[int, int]] = set()
14+
self._pending: set[tuple[int, int]] = set()
15+
self._bans: list[Ban] = []
16+
self._mutes: list[Mute] = []
17+
self._reports: list[Report] = []
18+
19+
async def add_pending_verification(self, user_id: int, chat_id: int) -> None:
20+
self._pending.add((user_id, chat_id))
21+
22+
async def get_pending_chats(self, user_id: int) -> list[int]:
23+
return [c for u, c in self._pending if u == user_id]
24+
25+
async def remove_pending(self, user_id: int, chat_id: int) -> bool:
26+
key = (user_id, chat_id)
27+
if key in self._pending:
28+
self._pending.discard(key)
29+
return True
30+
return False
31+
32+
async def is_user_verified(self, user_id: int, chat_id: int) -> bool:
33+
return (user_id, chat_id) in self._verified
34+
35+
async def mark_user_verified(self, user_id: int, chat_id: int) -> None:
36+
self._verified.add((user_id, chat_id))
37+
38+
async def get_banned_users(self, chat_id: int) -> list[int]:
39+
return [b.user_id for b in self._bans if b.chat_id == chat_id]
40+
41+
async def add_ban(
42+
self,
43+
user_id: int,
44+
chat_id: int,
45+
admin_id: int,
46+
reason: str | None = None,
47+
) -> None:
48+
self._bans.append(
49+
Ban(
50+
user_id=user_id,
51+
chat_id=chat_id,
52+
admin_id=admin_id,
53+
reason=reason,
54+
created_at=datetime.now(timezone.utc),
55+
)
56+
)
57+
58+
async def remove_ban(self, user_id: int, chat_id: int) -> bool:
59+
before = len(self._bans)
60+
self._bans = [
61+
b for b in self._bans if not (b.user_id == user_id and b.chat_id == chat_id)
62+
]
63+
return len(self._bans) < before
64+
65+
async def get_muted_users(self, chat_id: int) -> list[int]:
66+
now = datetime.now(timezone.utc)
67+
return [
68+
m.user_id
69+
for m in self._mutes
70+
if m.chat_id == chat_id and (m.until is None or m.until > now)
71+
]
72+
73+
async def add_mute(
74+
self,
75+
user_id: int,
76+
chat_id: int,
77+
admin_id: int,
78+
reason: str | None = None,
79+
until: int | None = None,
80+
) -> None:
81+
until_dt = datetime.fromtimestamp(until, tz=timezone.utc) if until else None
82+
self._mutes.append(
83+
Mute(
84+
user_id=user_id,
85+
chat_id=chat_id,
86+
admin_id=admin_id,
87+
reason=reason,
88+
until=until_dt,
89+
created_at=datetime.now(timezone.utc),
90+
)
91+
)
92+
93+
async def remove_mute(self, user_id: int, chat_id: int) -> bool:
94+
before = len(self._mutes)
95+
self._mutes = [
96+
m
97+
for m in self._mutes
98+
if not (m.user_id == user_id and m.chat_id == chat_id)
99+
]
100+
return len(self._mutes) < before
101+
102+
async def add_report(
103+
self,
104+
reporter_id: int,
105+
reported_user_id: int,
106+
chat_id: int,
107+
message_id: int | None = None,
108+
reason: str | None = None,
109+
) -> None:
110+
self._reports.append(
111+
Report(
112+
reporter_id=reporter_id,
113+
reported_user_id=reported_user_id,
114+
chat_id=chat_id,
115+
message_id=message_id,
116+
reason=reason,
117+
created_at=datetime.now(timezone.utc),
118+
)
119+
)

0 commit comments

Comments
 (0)