Skip to content

Commit b8fe93e

Browse files
committed
feat(db): add user bio and phone_number fields with context integration
- Add bio and phone_number to users schema and db migrations - Extract bio and phone_number using Pyrogram and store in DB - Inject 'You bio' and 'Them bio' into the LLM chat history context - Exclude phone_number from LLM context to preserve privacy - Auto-sync owner full profile (first_name, username, bio) on all bot command interactions - Skip fetching bio for group/channel chats to prevent leaking group descriptions
1 parent dc2a2bc commit b8fe93e

File tree

11 files changed

+186
-23
lines changed

11 files changed

+186
-23
lines changed

clients/pyrogram_client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ async def read_chat_history(user_id: int, chat_id: int, limit: int = MAX_CONTEXT
254254
"name": sender.first_name if sender else None,
255255
"last_name": sender.last_name if sender else None,
256256
"username": sender.username if sender else None,
257+
"phone_number": sender.phone_number if sender else None,
257258
})
258259

259260
if msg.voice and text is None:
@@ -360,6 +361,31 @@ async def get_dialog_info(user_id: int, limit: int) -> list[dict]:
360361
return dialogs
361362

362363

364+
async def get_chat_bio(user_id: int, chat_id: int) -> str | None:
365+
"""Возвращает bio (описание профиля) собеседника через get_chat().
366+
Для групп и каналов (chat_id < 0) всегда возвращает None.
367+
368+
Args:
369+
user_id: Telegram user ID (владелец сессии)
370+
chat_id: ID чата (собеседника)
371+
372+
Returns:
373+
Строка bio или None если недоступно / ошибка.
374+
"""
375+
if chat_id < 0:
376+
return None
377+
378+
client = _active_clients.get(user_id)
379+
if not client:
380+
return None
381+
382+
try:
383+
chat = await client.get_chat(chat_id)
384+
return getattr(chat, "bio", None) or None
385+
except Exception:
386+
return None
387+
388+
363389
def get_active_user_ids() -> list[int]:
364390
"""Возвращает ID пользователей с активными Pyrogram-клиентами."""
365391
return list(_active_clients.keys())

database/users.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ async def upsert_user(
3030
is_bot: bool = False,
3131
is_premium: bool = False,
3232
language_code: Optional[str] = None,
33+
phone_number: Optional[str] = None,
34+
bio: Optional[str] = None,
3335
) -> bool:
3436
"""Создаёт или обновляет пользователя в БД.
3537
@@ -45,6 +47,10 @@ async def upsert_user(
4547
data["last_name"] = last_name
4648
if language_code is not None:
4749
data["language_code"] = language_code
50+
if phone_number is not None:
51+
data["phone_number"] = phone_number
52+
if bio is not None:
53+
data["bio"] = bio
4854

4955
try:
5056
await run_supabase(
@@ -220,6 +226,8 @@ async def ensure_user_exists(
220226
is_bot: bool = False,
221227
is_premium: bool = False,
222228
language_code: Optional[str] = None,
229+
phone_number: Optional[str] = None,
230+
bio: Optional[str] = None,
223231
) -> dict:
224232
"""Возвращает пользователя, создавая запись при отсутствии.
225233
@@ -238,6 +246,8 @@ async def ensure_user_exists(
238246
is_bot=is_bot,
239247
is_premium=is_premium,
240248
language_code=language_code,
249+
phone_number=phone_number,
250+
bio=bio,
241251
)
242252
if not created:
243253
raise UserStorageError(f"Failed to create user {user_id}")
@@ -250,6 +260,8 @@ async def ensure_user_exists(
250260
"is_bot": is_bot,
251261
"is_premium": is_premium,
252262
"language_code": language_code,
263+
"phone_number": phone_number,
264+
"bio": bio,
253265
"settings": {},
254266
}
255267

handlers/pyrogram_handlers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ async def on_pyrogram_message(user_id: int, pyrogram_client_instance, message) -
347347
"username": opponent.username,
348348
"language_code": opponent.language_code,
349349
"is_premium": opponent.is_premium,
350+
"bio": await pyrogram_client.get_chat_bio(user_id, chat_id),
351+
"phone_number": opponent.phone_number,
350352
} if opponent else None
351353

352354
# Генерируем ответ
@@ -420,6 +422,8 @@ async def _clear_probe_draft() -> None:
420422
"first_name": msg.get("name"),
421423
"last_name": msg.get("last_name"),
422424
"username": msg.get("username"),
425+
"bio": await pyrogram_client.get_chat_bio(user_id, chat_id),
426+
"phone_number": msg.get("phone_number"),
423427
}
424428
break
425429

@@ -509,6 +513,8 @@ async def _regenerate_reply(user_id: int, chat_id: int) -> None:
509513
"first_name": msg.get("name"),
510514
"last_name": msg.get("last_name"),
511515
"username": msg.get("username"),
516+
"bio": await pyrogram_client.get_chat_bio(user_id, chat_id),
517+
"phone_number": msg.get("phone_number"),
512518
}
513519
break
514520

@@ -778,6 +784,8 @@ async def on_pyrogram_draft(user_id: int, chat_id: int, draft_text: str) -> None
778784
"first_name": msg.get("name"),
779785
"last_name": msg.get("last_name"),
780786
"username": msg.get("username"),
787+
"bio": await pyrogram_client.get_chat_bio(user_id, chat_id),
788+
"phone_number": msg.get("phone_number"),
781789
}
782790
break
783791

schema.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ create table if not exists public.users (
1212
first_seen timestamptz not null default now(), -- Время первого контакта
1313
last_msg_at timestamptz, -- Время последнего сообщения
1414
language_code text default 'en', -- Язык пользователя (ISO 639-1)
15+
phone_number text, -- Номер телефона пользователя
16+
bio text, -- Биография пользователя (из getChat)
1517
tg_rating integer, -- Рейтинг Telegram Stars (из getChat)
1618
session_string text, -- Зашифрованный Pyrogram session string (Client API)
1719
settings jsonb default '{}' -- Настройки пользователя (drafts_enabled, pro_model)

tests/test_database_users.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ async def test_creates_missing_user(self):
354354
is_bot=False,
355355
is_premium=False,
356356
language_code="ru",
357+
phone_number=None,
358+
bio=None,
357359
)
358360
assert result["user_id"] == 123
359361
assert result["username"] == "alice"

tests/test_handlers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,7 @@ async def test_voice_message_transcribes_and_generates_draft(self):
553553
mock_pc.read_chat_history = AsyncMock(return_value=voice_history)
554554
mock_pc.set_draft = AsyncMock(return_value=True)
555555
mock_pc.get_draft = AsyncMock(return_value=None)
556+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
556557
mock_gen.return_value = "Всё отлично!"
557558

558559
await on_pyrogram_message(123, MagicMock(), message)
@@ -614,6 +615,7 @@ async def test_generates_and_sets_draft(self):
614615
])
615616
mock_pc.set_draft = AsyncMock(return_value=True)
616617
mock_pc.get_draft = AsyncMock(return_value=None)
618+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
617619
mock_gen.return_value = "Hi there!"
618620

619621
await on_pyrogram_message(123, MagicMock(), message)
@@ -647,6 +649,7 @@ async def test_invalid_auto_reply_is_treated_as_off(self):
647649
])
648650
mock_pc.set_draft = AsyncMock(return_value=True)
649651
mock_pc.get_draft = AsyncMock(return_value=None)
652+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
650653
mock_gen.return_value = "Hi there!"
651654

652655
await on_pyrogram_message(123, MagicMock(), message)
@@ -675,6 +678,7 @@ async def test_second_message_during_lock_is_queued(self):
675678
patch("handlers.pyrogram_handlers.get_user", new_callable=AsyncMock, return_value={"language_code": "en", "settings": {}}):
676679
mock_pc.set_draft = AsyncMock()
677680
mock_pc.get_draft = AsyncMock(return_value=None)
681+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
678682

679683
await on_pyrogram_message(123, MagicMock(), message)
680684

@@ -711,6 +715,7 @@ async def test_pending_message_triggers_regeneration_after_lock_release(self):
711715
])
712716
mock_pc.set_draft = AsyncMock(return_value=True)
713717
mock_pc.get_draft = AsyncMock(return_value=None)
718+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
714719
mock_gen.return_value = "Hi there!"
715720

716721
await on_pyrogram_message(123, MagicMock(), message)
@@ -746,6 +751,7 @@ async def test_no_regeneration_without_pending(self):
746751
])
747752
mock_pc.set_draft = AsyncMock(return_value=True)
748753
mock_pc.get_draft = AsyncMock(return_value=None)
754+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
749755
mock_gen.return_value = "Hi there!"
750756

751757
await on_pyrogram_message(123, MagicMock(), message)
@@ -781,6 +787,7 @@ async def test_duplicate_message_is_skipped(self):
781787
])
782788
mock_pc.set_draft = AsyncMock(return_value=True)
783789
mock_pc.get_draft = AsyncMock(return_value=None)
790+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
784791
mock_gen.return_value = "Hi there!"
785792
await on_pyrogram_message(123, MagicMock(), message)
786793
assert mock_gen.call_count == 1
@@ -1768,6 +1775,7 @@ async def test_on_pyrogram_message_uses_pro_model_by_default(self):
17681775
])
17691776
mock_pc.set_draft = AsyncMock(return_value=True)
17701777
mock_pc.get_draft = AsyncMock(return_value=None)
1778+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
17711779
mock_gen.return_value = "Hi there!"
17721780

17731781
await on_pyrogram_message(123, MagicMock(), message)
@@ -1789,6 +1797,7 @@ async def test_regenerate_reply_uses_pro_model_by_default(self):
17891797
])
17901798
mock_pc.set_draft = AsyncMock(return_value=True)
17911799
mock_pc.get_draft = AsyncMock(return_value=None)
1800+
mock_pc.get_chat_bio = AsyncMock(return_value=None)
17921801
mock_gen.return_value = "Hi there!"
17931802

17941803
await _regenerate_reply(123, 456)

tests/test_pyrogram_client.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,3 +573,63 @@ async def test_returns_false_on_error(self):
573573
assert result is False
574574

575575
pyrogram_client._active_clients.pop(123, None)
576+
577+
578+
class TestGetChatBio:
579+
"""Тесты для get_chat_bio()."""
580+
581+
@pytest.mark.asyncio
582+
async def test_returns_bio_string(self):
583+
"""Возвращает bio при успешном get_chat."""
584+
mock_client = AsyncMock()
585+
mock_chat = MagicMock()
586+
mock_chat.bio = "Дизайнер из Москвы"
587+
mock_client.get_chat = AsyncMock(return_value=mock_chat)
588+
589+
pyrogram_client._active_clients[123] = mock_client
590+
591+
result = await pyrogram_client.get_chat_bio(123, 456)
592+
593+
assert result == "Дизайнер из Москвы"
594+
mock_client.get_chat.assert_called_once_with(456)
595+
596+
pyrogram_client._active_clients.pop(123, None)
597+
598+
@pytest.mark.asyncio
599+
async def test_returns_none_when_no_client(self):
600+
"""Без активного клиента → None."""
601+
pyrogram_client._active_clients.pop(123, None)
602+
603+
result = await pyrogram_client.get_chat_bio(123, 456)
604+
605+
assert result is None
606+
607+
@pytest.mark.asyncio
608+
async def test_returns_none_on_exception(self):
609+
"""При ошибке get_chat → None (graceful)."""
610+
mock_client = AsyncMock()
611+
mock_client.get_chat = AsyncMock(side_effect=Exception("API error"))
612+
613+
pyrogram_client._active_clients[123] = mock_client
614+
615+
result = await pyrogram_client.get_chat_bio(123, 456)
616+
617+
assert result is None
618+
619+
pyrogram_client._active_clients.pop(123, None)
620+
621+
@pytest.mark.asyncio
622+
async def test_returns_none_when_bio_is_empty(self):
623+
"""Пустая строка bio → None."""
624+
mock_client = AsyncMock()
625+
mock_chat = MagicMock()
626+
mock_chat.bio = ""
627+
mock_client.get_chat = AsyncMock(return_value=mock_chat)
628+
629+
pyrogram_client._active_clients[123] = mock_client
630+
631+
result = await pyrogram_client.get_chat_bio(123, 456)
632+
633+
assert result is None
634+
635+
pyrogram_client._active_clients.pop(123, None)

tests/test_user_helpers.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,14 @@ class TestEnsureEffectiveUser:
1111
"""Тесты для ensure_effective_user()."""
1212

1313
@pytest.mark.asyncio
14-
async def test_passes_effective_user_fields(self, mock_update):
14+
async def test_always_upserts_and_returns_user(self, mock_update):
1515
expected_user = {"user_id": mock_update.effective_user.id, "settings": {}}
1616

17-
with patch("utils.telegram_user.ensure_user_exists", new_callable=AsyncMock, return_value=expected_user) as mock_ensure:
17+
with patch("utils.telegram_user.get_user", new_callable=AsyncMock, return_value=expected_user), \
18+
patch("utils.telegram_user.upsert_effective_user", new_callable=AsyncMock) as mock_upsert:
1819
result = await ensure_effective_user(mock_update)
1920

20-
mock_ensure.assert_called_once_with(
21-
user_id=mock_update.effective_user.id,
22-
username=mock_update.effective_user.username,
23-
first_name=mock_update.effective_user.first_name,
24-
last_name=mock_update.effective_user.last_name,
25-
is_bot=mock_update.effective_user.is_bot,
26-
is_premium=bool(mock_update.effective_user.is_premium),
27-
language_code=mock_update.effective_user.language_code,
28-
)
21+
mock_upsert.assert_called_once_with(mock_update)
2922
assert result == expected_user
3023

3124

@@ -34,7 +27,8 @@ class TestUpsertEffectiveUser:
3427

3528
@pytest.mark.asyncio
3629
async def test_passes_effective_user_fields(self, mock_update):
37-
with patch("utils.telegram_user.upsert_user", new_callable=AsyncMock, return_value=True) as mock_upsert:
30+
with patch("utils.telegram_user.upsert_user", new_callable=AsyncMock, return_value=True) as mock_upsert, \
31+
patch("utils.telegram_user._fetch_bio", new_callable=AsyncMock, return_value=None):
3832
result = await upsert_effective_user(mock_update)
3933

4034
mock_upsert.assert_called_once_with(
@@ -45,5 +39,7 @@ async def test_passes_effective_user_fields(self, mock_update):
4539
is_bot=mock_update.effective_user.is_bot,
4640
is_premium=bool(mock_update.effective_user.is_premium),
4741
language_code=mock_update.effective_user.language_code,
42+
phone_number=None,
43+
bio=None,
4844
)
4945
assert result is True

tests/test_utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,34 @@ def test_tz_offset_zero_unchanged(self):
192192
result = format_chat_history(history, tz_offset=0)
193193
assert "[2026-03-14 14:30]" in result
194194

195+
def test_bio_shown_in_participants(self):
196+
"""Bio оппонента отображается в PARTICIPANTS блоке."""
197+
history = [{"role": "other", "text": "Привет", "name": "Марина"}]
198+
opponent_info = {"first_name": "Марина", "bio": "Дизайнер из Москвы"}
199+
200+
result = format_chat_history(history, None, opponent_info)
201+
assert "Them bio: Дизайнер из Москвы" in result
202+
203+
def test_bio_absent_when_none(self):
204+
"""Строка bio отсутствует когда bio = None."""
205+
history = [{"role": "other", "text": "Привет", "name": "Марина"}]
206+
opponent_info = {"first_name": "Марина", "bio": None}
207+
208+
result = format_chat_history(history, None, opponent_info)
209+
assert "bio" not in result
210+
211+
def test_bio_absent_when_missing(self):
212+
"""Строка bio отсутствует когда ключ bio отсутствует."""
213+
history = [{"role": "other", "text": "Привет", "name": "Марина"}]
214+
opponent_info = {"first_name": "Марина"}
215+
216+
result = format_chat_history(history, None, opponent_info)
217+
assert "bio" not in result
218+
219+
def test_you_bio_shown(self):
220+
"""Bio владельца отображается в PARTICIPANTS блоке."""
221+
history = [{"role": "other", "text": "Привет", "name": "Марина"}]
222+
user_info = {"first_name": "Алексей", "bio": "Программист из РФ"}
223+
224+
result = format_chat_history(history, user_info, None)
225+
assert "You bio: Программист из РФ" in result

0 commit comments

Comments
 (0)