Skip to content

Commit d2e53ab

Browse files
authored
Merge pull request #44 from pythonitalia/feature/user-tracking-and-welcome-improvements
feat: user tracking and welcome improvements
2 parents dbe905a + 44a37eb commit d2e53ab

14 files changed

Lines changed: 695 additions & 34 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,17 @@ env/
1515
.idea/
1616
.vscode/
1717
.cursor/
18+
.zed/
1819
*.swp
1920
*.swo
2021

22+
# AI / agent tooling
23+
.codex/
24+
.gemini/
25+
.mcp.json
26+
opencode.json
27+
electus/
28+
2129
# OS
2230
.DS_Store
2331
Thumbs.db

schema.sql

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,26 @@ CREATE TABLE IF NOT EXISTS global_bans (
6464
reason TEXT,
6565
created_at TIMESTAMPTZ DEFAULT NOW()
6666
);
67+
68+
CREATE TABLE IF NOT EXISTS known_users (
69+
user_id BIGINT PRIMARY KEY,
70+
username TEXT,
71+
first_name TEXT,
72+
last_name TEXT,
73+
updated_at TIMESTAMPTZ DEFAULT NOW()
74+
);
75+
76+
CREATE INDEX IF NOT EXISTS idx_known_users_username
77+
ON known_users (LOWER(username))
78+
WHERE username IS NOT NULL;
79+
80+
CREATE TABLE IF NOT EXISTS welcomed_users (
81+
user_id BIGINT NOT NULL,
82+
chat_id BIGINT NOT NULL,
83+
welcomed_at TIMESTAMPTZ DEFAULT NOW(),
84+
PRIMARY KEY (user_id, chat_id)
85+
);
86+
87+
-- Add welcome_delay_minutes column to group_settings (idempotent).
88+
ALTER TABLE group_settings
89+
ADD COLUMN IF NOT EXISTS welcome_delay_minutes INTEGER;

src/python_italy_bot/db/base.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from abc import ABC, abstractmethod
44

5+
from .models import KnownUser
6+
57

68
class Repository(ABC):
79
"""Abstract interface for data persistence (sync)."""
@@ -136,6 +138,53 @@ def is_globally_banned(self, user_id: int) -> bool:
136138
"""Check if user is globally banned."""
137139
...
138140

141+
# -- Known users (user tracking) --
142+
143+
@abstractmethod
144+
def upsert_known_user(
145+
self,
146+
user_id: int,
147+
username: str | None,
148+
first_name: str | None,
149+
last_name: str | None,
150+
) -> None:
151+
"""Insert or update a known user's info."""
152+
...
153+
154+
@abstractmethod
155+
def get_known_user(self, user_id: int) -> KnownUser | None:
156+
"""Get a known user by ID."""
157+
...
158+
159+
@abstractmethod
160+
def get_known_user_by_username(self, username: str) -> KnownUser | None:
161+
"""Get a known user by username (case-insensitive)."""
162+
...
163+
164+
# -- Welcomed users (welcome-once-per-group) --
165+
166+
@abstractmethod
167+
def has_been_welcomed(self, user_id: int, chat_id: int) -> bool:
168+
"""Check if user has already been welcomed in this chat."""
169+
...
170+
171+
@abstractmethod
172+
def mark_welcomed(self, user_id: int, chat_id: int) -> None:
173+
"""Mark user as having been welcomed in this chat."""
174+
...
175+
176+
# -- Welcome delay --
177+
178+
@abstractmethod
179+
def get_welcome_delay(self, chat_id: int) -> int | None:
180+
"""Get welcome message auto-delete delay in minutes for a chat."""
181+
...
182+
183+
@abstractmethod
184+
def set_welcome_delay(self, chat_id: int, minutes: int | None) -> None:
185+
"""Set welcome message auto-delete delay. None to reset to default."""
186+
...
187+
139188

140189
class AsyncRepository(ABC):
141190
"""Abstract interface for data persistence (async)."""
@@ -270,6 +319,53 @@ async def is_globally_banned(self, user_id: int) -> bool:
270319
"""Check if user is globally banned."""
271320
...
272321

322+
# -- Known users (user tracking) --
323+
324+
@abstractmethod
325+
async def upsert_known_user(
326+
self,
327+
user_id: int,
328+
username: str | None,
329+
first_name: str | None,
330+
last_name: str | None,
331+
) -> None:
332+
"""Insert or update a known user's info."""
333+
...
334+
335+
@abstractmethod
336+
async def get_known_user(self, user_id: int) -> KnownUser | None:
337+
"""Get a known user by ID."""
338+
...
339+
340+
@abstractmethod
341+
async def get_known_user_by_username(self, username: str) -> KnownUser | None:
342+
"""Get a known user by username (case-insensitive)."""
343+
...
344+
345+
# -- Welcomed users (welcome-once-per-group) --
346+
347+
@abstractmethod
348+
async def has_been_welcomed(self, user_id: int, chat_id: int) -> bool:
349+
"""Check if user has already been welcomed in this chat."""
350+
...
351+
352+
@abstractmethod
353+
async def mark_welcomed(self, user_id: int, chat_id: int) -> None:
354+
"""Mark user as having been welcomed in this chat."""
355+
...
356+
357+
# -- Welcome delay --
358+
359+
@abstractmethod
360+
async def get_welcome_delay(self, chat_id: int) -> int | None:
361+
"""Get welcome message auto-delete delay in minutes for a chat."""
362+
...
363+
364+
@abstractmethod
365+
async def set_welcome_delay(self, chat_id: int, minutes: int | None) -> None:
366+
"""Set welcome message auto-delete delay. None to reset to default."""
367+
...
368+
273369
async def close(self) -> None:
274370
"""Close any resources (override if needed)."""
275371
pass

src/python_italy_bot/db/in_memory.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime, timezone
44

55
from .base import AsyncRepository
6-
from .models import Ban, Mute, Report
6+
from .models import Ban, KnownUser, Mute, Report
77

88

99
class InMemoryRepository(AsyncRepository):
@@ -19,6 +19,10 @@ def __init__(self) -> None:
1919
self._globally_verified: set[int] = set()
2020
self._bot_chats: set[int] = set()
2121
self._global_bans: dict[int, tuple[int, str | None]] = {}
22+
self._known_users: dict[int, KnownUser] = {}
23+
self._username_to_user_id: dict[str, int] = {}
24+
self._welcomed: set[tuple[int, int]] = set()
25+
self._welcome_delays: dict[int, int] = {}
2226

2327
async def add_pending_verification(self, user_id: int, chat_id: int) -> None:
2428
self._pending.add((user_id, chat_id))
@@ -159,3 +163,54 @@ async def remove_global_ban(self, user_id: int) -> bool:
159163

160164
async def is_globally_banned(self, user_id: int) -> bool:
161165
return user_id in self._global_bans
166+
167+
# -- Known users --
168+
169+
async def upsert_known_user(
170+
self,
171+
user_id: int,
172+
username: str | None,
173+
first_name: str | None,
174+
last_name: str | None,
175+
) -> None:
176+
old = self._known_users.get(user_id)
177+
if old and old.username:
178+
self._username_to_user_id.pop(old.username.lower(), None)
179+
user = KnownUser(
180+
user_id=user_id,
181+
username=username,
182+
first_name=first_name,
183+
last_name=last_name,
184+
updated_at=datetime.now(timezone.utc),
185+
)
186+
self._known_users[user_id] = user
187+
if username:
188+
self._username_to_user_id[username.lower()] = user_id
189+
190+
async def get_known_user(self, user_id: int) -> KnownUser | None:
191+
return self._known_users.get(user_id)
192+
193+
async def get_known_user_by_username(self, username: str) -> KnownUser | None:
194+
uid = self._username_to_user_id.get(username.lower())
195+
if uid is not None:
196+
return self._known_users.get(uid)
197+
return None
198+
199+
# -- Welcomed users --
200+
201+
async def has_been_welcomed(self, user_id: int, chat_id: int) -> bool:
202+
return (user_id, chat_id) in self._welcomed
203+
204+
async def mark_welcomed(self, user_id: int, chat_id: int) -> None:
205+
self._welcomed.add((user_id, chat_id))
206+
207+
# -- Welcome delay --
208+
209+
async def get_welcome_delay(self, chat_id: int) -> int | None:
210+
return self._welcome_delays.get(chat_id)
211+
212+
async def set_welcome_delay(self, chat_id: int, minutes: int | None) -> None:
213+
if minutes is None:
214+
self._welcome_delays.pop(chat_id, None)
215+
else:
216+
self._welcome_delays[chat_id] = minutes

src/python_italy_bot/db/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
from datetime import datetime
55

66

7+
@dataclass
8+
class KnownUser:
9+
"""A user the bot has interacted with."""
10+
11+
user_id: int
12+
username: str | None
13+
first_name: str | None
14+
last_name: str | None
15+
updated_at: datetime
16+
17+
718
@dataclass
819
class Ban:
920
"""A user ban in a chat."""

src/python_italy_bot/db/postgres.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from psycopg_pool import AsyncConnectionPool
44

55
from .base import AsyncRepository
6+
from .models import KnownUser
67

78

89
class PostgresRepository(AsyncRepository):
@@ -261,5 +262,122 @@ async def is_globally_banned(self, user_id: int) -> bool:
261262
)
262263
return await cur.fetchone() is not None
263264

265+
# -- Known users --
266+
267+
async def upsert_known_user(
268+
self,
269+
user_id: int,
270+
username: str | None,
271+
first_name: str | None,
272+
last_name: str | None,
273+
) -> None:
274+
async with self._pool.connection() as conn:
275+
await conn.execute(
276+
"""
277+
INSERT INTO known_users (user_id, username, first_name, last_name, updated_at)
278+
VALUES (%s, %s, %s, %s, NOW())
279+
ON CONFLICT (user_id)
280+
DO UPDATE SET username = EXCLUDED.username,
281+
first_name = EXCLUDED.first_name,
282+
last_name = EXCLUDED.last_name,
283+
updated_at = NOW()
284+
""",
285+
(user_id, username, first_name, last_name),
286+
)
287+
288+
async def get_known_user(self, user_id: int) -> KnownUser | None:
289+
async with self._pool.connection() as conn:
290+
async with conn.cursor() as cur:
291+
await cur.execute(
292+
"SELECT user_id, username, first_name, last_name, updated_at "
293+
"FROM known_users WHERE user_id = %s",
294+
(user_id,),
295+
)
296+
row = await cur.fetchone()
297+
if row is None:
298+
return None
299+
return KnownUser(
300+
user_id=row[0],
301+
username=row[1],
302+
first_name=row[2],
303+
last_name=row[3],
304+
updated_at=row[4],
305+
)
306+
307+
async def get_known_user_by_username(self, username: str) -> KnownUser | None:
308+
async with self._pool.connection() as conn:
309+
async with conn.cursor() as cur:
310+
await cur.execute(
311+
"SELECT user_id, username, first_name, last_name, updated_at "
312+
"FROM known_users WHERE LOWER(username) = LOWER(%s)",
313+
(username,),
314+
)
315+
row = await cur.fetchone()
316+
if row is None:
317+
return None
318+
return KnownUser(
319+
user_id=row[0],
320+
username=row[1],
321+
first_name=row[2],
322+
last_name=row[3],
323+
updated_at=row[4],
324+
)
325+
326+
# -- Welcomed users --
327+
328+
async def has_been_welcomed(self, user_id: int, chat_id: int) -> bool:
329+
async with self._pool.connection() as conn:
330+
async with conn.cursor() as cur:
331+
await cur.execute(
332+
"SELECT 1 FROM welcomed_users WHERE user_id = %s AND chat_id = %s",
333+
(user_id, chat_id),
334+
)
335+
return await cur.fetchone() is not None
336+
337+
async def mark_welcomed(self, user_id: int, chat_id: int) -> None:
338+
async with self._pool.connection() as conn:
339+
await conn.execute(
340+
"""
341+
INSERT INTO welcomed_users (user_id, chat_id)
342+
VALUES (%s, %s)
343+
ON CONFLICT (user_id, chat_id) DO NOTHING
344+
""",
345+
(user_id, chat_id),
346+
)
347+
348+
# -- Welcome delay --
349+
350+
async def get_welcome_delay(self, chat_id: int) -> int | None:
351+
async with self._pool.connection() as conn:
352+
async with conn.cursor() as cur:
353+
await cur.execute(
354+
"SELECT welcome_delay_minutes FROM group_settings WHERE chat_id = %s",
355+
(chat_id,),
356+
)
357+
row = await cur.fetchone()
358+
return row[0] if row else None
359+
360+
async def set_welcome_delay(self, chat_id: int, minutes: int | None) -> None:
361+
async with self._pool.connection() as conn:
362+
if minutes is None:
363+
await conn.execute(
364+
"""
365+
UPDATE group_settings SET welcome_delay_minutes = NULL, updated_at = NOW()
366+
WHERE chat_id = %s
367+
""",
368+
(chat_id,),
369+
)
370+
else:
371+
await conn.execute(
372+
"""
373+
INSERT INTO group_settings (chat_id, welcome_delay_minutes, updated_at)
374+
VALUES (%s, %s, NOW())
375+
ON CONFLICT (chat_id)
376+
DO UPDATE SET welcome_delay_minutes = EXCLUDED.welcome_delay_minutes,
377+
updated_at = NOW()
378+
""",
379+
(chat_id, minutes),
380+
)
381+
264382
async def close(self) -> None:
265383
await self._pool.close()

0 commit comments

Comments
 (0)