Skip to content

Commit 311543b

Browse files
authored
Merge pull request #1 from rezhajulio/multi-groups-clean
✨ feat: add multi-group support
2 parents 5981766 + 747f818 commit 311543b

26 files changed

Lines changed: 1917 additions & 923 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ CAPTCHA_ENABLED=false
3636
# Default: 120 seconds (2 minutes)
3737
CAPTCHA_TIMEOUT_SECONDS=120
3838

39+
# Path to groups.json for multi-group support (optional)
40+
# If this file exists, per-group settings are loaded from it instead of the
41+
# GROUP_ID/WARNING_TOPIC_ID/etc. fields above. See groups.json.example.
42+
# GROUPS_CONFIG_PATH=groups.json
43+
3944
# Logfire Configuration (optional - for production logging)
4045
# Get your token from https://logfire.pydantic.dev
4146
LOGFIRE_TOKEN=your_logfire_token_here

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.env
22
.env.staging
3+
groups.json
34
.venv/
45
__pycache__/
56
*.pyc

groups.json.example

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{
3+
"group_id": -1001234567890,
4+
"warning_topic_id": 123,
5+
"restrict_failed_users": false,
6+
"warning_threshold": 3,
7+
"warning_time_threshold_minutes": 180,
8+
"captcha_enabled": false,
9+
"captcha_timeout_seconds": 120,
10+
"new_user_probation_hours": 72,
11+
"new_user_violation_threshold": 3,
12+
"rules_link": "https://t.me/pythonID/290029/321799"
13+
},
14+
{
15+
"group_id": -1009876543210,
16+
"warning_topic_id": 456,
17+
"restrict_failed_users": true,
18+
"warning_threshold": 5,
19+
"warning_time_threshold_minutes": 60,
20+
"captcha_enabled": true,
21+
"captcha_timeout_seconds": 180,
22+
"new_user_probation_hours": 168,
23+
"new_user_violation_threshold": 2,
24+
"rules_link": "https://t.me/mygroup/rules"
25+
}
26+
]

src/bot/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class Settings(BaseSettings):
7979
captcha_timeout_seconds: int = 120
8080
new_user_probation_hours: int = 72 # 3 days default
8181
new_user_violation_threshold: int = 3 # restrict after this many violations
82+
groups_config_path: str = "groups.json"
8283
logfire_token: str | None = None
8384
logfire_service_name: str = "pythonid-bot"
8485
logfire_environment: str = "production"
@@ -88,6 +89,7 @@ class Settings(BaseSettings):
8889
model_config = SettingsConfigDict(
8990
env_file=get_env_file(),
9091
env_file_encoding="utf-8",
92+
extra="ignore",
9193
)
9294

9395
def model_post_init(self, __context):

src/bot/database/service.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,32 @@ def get_warnings_past_time_threshold(
374374
)
375375
return [record for record in records]
376376

377+
def get_warnings_past_time_threshold_for_group(
378+
self, group_id: int, threshold: timedelta
379+
) -> list[UserWarning]:
380+
"""
381+
Find active warnings for a specific group that exceeded the time threshold.
382+
383+
Args:
384+
group_id: Telegram group ID to filter by.
385+
threshold: Time duration since first warning to trigger restriction.
386+
387+
Returns:
388+
list[UserWarning]: Warning records that should be auto-restricted.
389+
"""
390+
with Session(self._engine) as session:
391+
cutoff_time = datetime.now(UTC) - threshold
392+
statement = select(UserWarning).where(
393+
UserWarning.group_id == group_id,
394+
~UserWarning.is_restricted,
395+
UserWarning.first_warned_at <= cutoff_time,
396+
)
397+
records = session.exec(statement).all()
398+
logger.info(
399+
f"Found {len(records)} warnings past {threshold} threshold for group {group_id}"
400+
)
401+
return [record for record in records]
402+
377403
def add_pending_captcha(
378404
self,
379405
user_id: int,

src/bot/group_config.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""
2+
Multi-group configuration for the PythonID bot.
3+
4+
This module provides per-group settings via GroupConfig and a GroupRegistry
5+
that allows a single bot instance to manage multiple Telegram groups.
6+
Groups can be configured via a groups.json file or fall back to the
7+
single-group .env configuration for backward compatibility.
8+
"""
9+
10+
import json
11+
import logging
12+
from datetime import timedelta
13+
from pathlib import Path
14+
15+
from pydantic import BaseModel, field_validator
16+
from telegram import Update
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class GroupConfig(BaseModel):
22+
"""
23+
Per-group configuration settings.
24+
25+
Each monitored group has its own set of feature flags and thresholds.
26+
"""
27+
28+
group_id: int
29+
warning_topic_id: int
30+
restrict_failed_users: bool = False
31+
warning_threshold: int = 3
32+
warning_time_threshold_minutes: int = 180
33+
captcha_enabled: bool = False
34+
captcha_timeout_seconds: int = 120
35+
new_user_probation_hours: int = 72
36+
new_user_violation_threshold: int = 3
37+
rules_link: str = "https://t.me/pythonID/290029/321799"
38+
39+
@field_validator("group_id")
40+
@classmethod
41+
def group_id_must_be_negative(cls, v: int) -> int:
42+
if v >= 0:
43+
raise ValueError("group_id must be negative (Telegram supergroup IDs are negative)")
44+
return v
45+
46+
@field_validator("warning_threshold")
47+
@classmethod
48+
def warning_threshold_must_be_positive(cls, v: int) -> int:
49+
if v <= 0:
50+
raise ValueError("warning_threshold must be greater than 0")
51+
return v
52+
53+
@field_validator("warning_time_threshold_minutes")
54+
@classmethod
55+
def warning_time_threshold_must_be_positive(cls, v: int) -> int:
56+
if v <= 0:
57+
raise ValueError("warning_time_threshold_minutes must be greater than 0")
58+
return v
59+
60+
@field_validator("captcha_timeout_seconds")
61+
@classmethod
62+
def captcha_timeout_must_be_in_range(cls, v: int) -> int:
63+
if not (10 <= v <= 600):
64+
raise ValueError("captcha_timeout_seconds must be between 10 and 600 seconds")
65+
return v
66+
67+
@field_validator("new_user_probation_hours")
68+
@classmethod
69+
def probation_hours_must_be_non_negative(cls, v: int) -> int:
70+
if v < 0:
71+
raise ValueError("new_user_probation_hours must be >= 0")
72+
return v
73+
74+
@property
75+
def probation_timedelta(self) -> timedelta:
76+
return timedelta(hours=self.new_user_probation_hours)
77+
78+
@property
79+
def warning_time_threshold_timedelta(self) -> timedelta:
80+
return timedelta(minutes=self.warning_time_threshold_minutes)
81+
82+
@property
83+
def captcha_timeout_timedelta(self) -> timedelta:
84+
return timedelta(seconds=self.captcha_timeout_seconds)
85+
86+
87+
class GroupRegistry:
88+
"""
89+
Registry of monitored groups.
90+
91+
Provides O(1) lookup by group_id and iteration over all groups.
92+
"""
93+
94+
def __init__(self) -> None:
95+
self._groups: dict[int, GroupConfig] = {}
96+
97+
def register(self, config: GroupConfig) -> None:
98+
if config.group_id in self._groups:
99+
raise ValueError(f"Duplicate group_id: {config.group_id}")
100+
self._groups[config.group_id] = config
101+
logger.info(f"Registered group {config.group_id} (warning_topic={config.warning_topic_id})")
102+
103+
def get(self, group_id: int) -> GroupConfig | None:
104+
return self._groups.get(group_id)
105+
106+
def all_groups(self) -> list[GroupConfig]:
107+
return list(self._groups.values())
108+
109+
def is_monitored(self, group_id: int) -> bool:
110+
return group_id in self._groups
111+
112+
113+
def load_groups_from_json(path: str) -> list[GroupConfig]:
114+
"""
115+
Parse a groups.json file into a list of GroupConfig objects.
116+
117+
Args:
118+
path: Path to the JSON file.
119+
120+
Returns:
121+
List of GroupConfig instances.
122+
123+
Raises:
124+
FileNotFoundError: If the file doesn't exist.
125+
json.JSONDecodeError: If the file is not valid JSON.
126+
ValueError: If the JSON structure is invalid.
127+
"""
128+
with open(path) as f:
129+
data = json.load(f)
130+
131+
if not isinstance(data, list):
132+
raise ValueError("groups.json must contain a JSON array of group objects")
133+
134+
if not data:
135+
raise ValueError("groups.json must contain at least one group")
136+
137+
configs = [GroupConfig(**item) for item in data]
138+
139+
# Check for duplicate group_ids
140+
seen_ids: set[int] = set()
141+
for config in configs:
142+
if config.group_id in seen_ids:
143+
raise ValueError(f"Duplicate group_id in groups.json: {config.group_id}")
144+
seen_ids.add(config.group_id)
145+
146+
return configs
147+
148+
149+
def build_group_registry(settings: object) -> GroupRegistry:
150+
"""
151+
Build a GroupRegistry from settings.
152+
153+
If groups.json exists at the configured path, loads from it.
154+
Otherwise creates a single GroupConfig from .env fields (backward compatible).
155+
156+
Args:
157+
settings: Application Settings instance.
158+
159+
Returns:
160+
Populated GroupRegistry.
161+
"""
162+
registry = GroupRegistry()
163+
groups_path = getattr(settings, "groups_config_path", "groups.json")
164+
165+
if Path(groups_path).exists():
166+
logger.info(f"Loading group configuration from {groups_path}")
167+
configs = load_groups_from_json(groups_path)
168+
for config in configs:
169+
registry.register(config)
170+
logger.info(f"Loaded {len(configs)} group(s) from {groups_path}")
171+
else:
172+
logger.info("No groups.json found, using single-group config from .env")
173+
config = GroupConfig(
174+
group_id=settings.group_id,
175+
warning_topic_id=settings.warning_topic_id,
176+
restrict_failed_users=settings.restrict_failed_users,
177+
warning_threshold=settings.warning_threshold,
178+
warning_time_threshold_minutes=settings.warning_time_threshold_minutes,
179+
captcha_enabled=settings.captcha_enabled,
180+
captcha_timeout_seconds=settings.captcha_timeout_seconds,
181+
new_user_probation_hours=settings.new_user_probation_hours,
182+
new_user_violation_threshold=settings.new_user_violation_threshold,
183+
rules_link=settings.rules_link,
184+
)
185+
registry.register(config)
186+
187+
return registry
188+
189+
190+
def get_group_config_for_update(update: Update) -> GroupConfig | None:
191+
"""
192+
Get the GroupConfig for the group in the given Update.
193+
194+
Returns None if the update's chat is not a monitored group.
195+
196+
Args:
197+
update: Telegram Update object.
198+
199+
Returns:
200+
GroupConfig if the chat is monitored, None otherwise.
201+
"""
202+
if not update.effective_chat:
203+
return None
204+
try:
205+
return get_group_registry().get(update.effective_chat.id)
206+
except RuntimeError:
207+
logger.error("Group registry not initialized; skipping update")
208+
return None
209+
210+
211+
# Module-level singleton
212+
_registry: GroupRegistry | None = None
213+
214+
215+
def init_group_registry(settings: object) -> GroupRegistry:
216+
"""
217+
Initialize the global group registry singleton.
218+
219+
Must be called once at application startup.
220+
221+
Args:
222+
settings: Application Settings instance.
223+
224+
Returns:
225+
Initialized GroupRegistry.
226+
"""
227+
global _registry
228+
_registry = build_group_registry(settings)
229+
return _registry
230+
231+
232+
def get_group_registry() -> GroupRegistry:
233+
"""
234+
Get the global group registry singleton.
235+
236+
Returns:
237+
GroupRegistry instance.
238+
239+
Raises:
240+
RuntimeError: If init_group_registry() hasn't been called.
241+
"""
242+
if _registry is None:
243+
raise RuntimeError("Group registry not initialized. Call init_group_registry() first.")
244+
return _registry
245+
246+
247+
def reset_group_registry() -> None:
248+
"""Reset the group registry singleton (for testing)."""
249+
global _registry
250+
_registry = None

0 commit comments

Comments
 (0)