matplobbot-shared 0.1.330__tar.gz → 0.1.332__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/PKG-INFO +1 -1
  2. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/README.md +6 -1
  3. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/PKG-INFO +1 -1
  4. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/SOURCES.txt +4 -0
  5. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/setup.py +1 -1
  6. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/database.py +54 -8
  7. matplobbot_shared-0.1.332/shared_lib/html_tools.py +121 -0
  8. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/locales/en.json +1 -0
  9. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/locales/ru.json +1 -0
  10. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/mail_bridge.py +3 -20
  11. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/models.py +45 -4
  12. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/redis_client.py +5 -1
  13. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/university_api.py +13 -5
  14. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/tasks.py +111 -12
  15. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_authorization_guards.py +17 -2
  16. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_keyboards.py +2 -0
  17. matplobbot_shared-0.1.332/tests/test_latex_task_security.py +86 -0
  18. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_mail_bridge.py +1 -0
  19. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_schedule_search_api.py +58 -0
  20. matplobbot_shared-0.1.332/tests/test_scheduler_html_splitter.py +59 -0
  21. matplobbot_shared-0.1.332/tests/test_stats_avatar_security.py +141 -0
  22. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_studio_router_api.py +10 -1
  23. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/LICENSE +0 -0
  24. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/dependency_links.txt +0 -0
  25. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/requires.txt +0 -0
  26. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/top_level.txt +0 -0
  27. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/pyproject.toml +0 -0
  28. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/setup.cfg +0 -0
  29. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/__init__.py +0 -0
  30. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/celery_app.py +0 -0
  31. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/constants.py +0 -0
  32. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/egress.py +0 -0
  33. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/i18n.py +0 -0
  34. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/request_context.py +0 -0
  35. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/schemas.py +0 -0
  36. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/__init__.py +0 -0
  37. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/broadcast_service.py +0 -0
  38. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/calendar_sync_state.py +0 -0
  39. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/schedule_service.py +0 -0
  40. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/semantic_search.py +0 -0
  41. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telegram_bot_session.py +0 -0
  42. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telegram_http.py +0 -0
  43. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telegram_polling.py +0 -0
  44. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telemetry.py +0 -0
  45. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_auth_flow.py +0 -0
  46. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_broadcast_service.py +0 -0
  47. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_build_audit_requirements.py +0 -0
  48. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_calendar_api.py +0 -0
  49. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_correlation_middleware.py +0 -0
  50. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_dashboard_text_encoding.py +0 -0
  51. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_egress.py +0 -0
  52. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_fastapi_config.py +0 -0
  53. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_localization_completeness.py +0 -0
  54. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_myschedule_filters_settings.py +0 -0
  55. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_openapi_docs.py +0 -0
  56. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_proxy_cleaner.py +0 -0
  57. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_proxy_config.py +0 -0
  58. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_rate_limit.py +0 -0
  59. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_schedule_service_calendar.py +0 -0
  60. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_scheduler_service.py +0 -0
  61. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_search_center_helpers.py +0 -0
  62. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_action_users_api.py +0 -0
  63. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_export_api.py +0 -0
  64. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_leaderboard_api.py +0 -0
  65. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_profile_api.py +0 -0
  66. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_proxy_diagnostics_api.py +0 -0
  67. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_send_message_api.py +0 -0
  68. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_telegram_bot_session.py +0 -0
  69. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_telegram_polling.py +0 -0
  70. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_telemetry.py +0 -0
  71. {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_version_bumper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matplobbot-shared
3
- Version: 0.1.330
3
+ Version: 0.1.332
4
4
  Summary: Shared library for the Matplobbot ecosystem.
5
5
  Author: Ackrome
6
6
  Author-email: ivansergeyevich@gmail.com
@@ -59,7 +59,8 @@ The project currently includes:
59
59
  - View live usage stats over WebSockets.
60
60
  - See popular commands, text activity, action-type breakdowns, and activity trends.
61
61
  - Inspect user-specific pages with paginated history, filtering, and CSV export.
62
- - Stream the bot log into the dashboard for real-time monitoring.
62
+ - View bot logs with `docker compose logs -f mpb-telegram-bot`; the legacy log WebSocket is intentionally disabled.
63
+ - Configure private mailbox forwarding with `/mail` (requires `MAIL_CREDENTIAL_KEY`).
63
64
 
64
65
  ## Screenshots
65
66
 
@@ -102,6 +103,10 @@ Create a `.env` file in the project root. This is a minimal example for local st
102
103
  BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
103
104
  ADMIN_USER_IDS=123456789,987654321
104
105
  GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
106
+ JWT_SECRET_KEY=change_this_to_a_secure_random_jwt_secret_in_production
107
+ STATS_USER=admin
108
+ STATS_PASS=change_this_admin_password_in_production
109
+ ENVIRONMENT=development
105
110
 
106
111
  POSTGRES_USER=user
107
112
  POSTGRES_PASSWORD=password
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matplobbot-shared
3
- Version: 0.1.330
3
+ Version: 0.1.332
4
4
  Summary: Shared library for the Matplobbot ecosystem.
5
5
  Author: Ackrome
6
6
  Author-email: ivansergeyevich@gmail.com
@@ -12,6 +12,7 @@ shared_lib/celery_app.py
12
12
  shared_lib/constants.py
13
13
  shared_lib/database.py
14
14
  shared_lib/egress.py
15
+ shared_lib/html_tools.py
15
16
  shared_lib/i18n.py
16
17
  shared_lib/mail_bridge.py
17
18
  shared_lib/models.py
@@ -41,6 +42,7 @@ tests/test_dashboard_text_encoding.py
41
42
  tests/test_egress.py
42
43
  tests/test_fastapi_config.py
43
44
  tests/test_keyboards.py
45
+ tests/test_latex_task_security.py
44
46
  tests/test_localization_completeness.py
45
47
  tests/test_mail_bridge.py
46
48
  tests/test_myschedule_filters_settings.py
@@ -50,9 +52,11 @@ tests/test_proxy_config.py
50
52
  tests/test_rate_limit.py
51
53
  tests/test_schedule_search_api.py
52
54
  tests/test_schedule_service_calendar.py
55
+ tests/test_scheduler_html_splitter.py
53
56
  tests/test_scheduler_service.py
54
57
  tests/test_search_center_helpers.py
55
58
  tests/test_stats_action_users_api.py
59
+ tests/test_stats_avatar_security.py
56
60
  tests/test_stats_export_api.py
57
61
  tests/test_stats_leaderboard_api.py
58
62
  tests/test_stats_profile_api.py
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="matplobbot-shared",
5
- version="0.1.330", # Bump version
5
+ version="0.1.332", # Bump version
6
6
  packages=find_packages(include=["shared_lib", "shared_lib.*"]),
7
7
  description="Shared library for the Matplobbot ecosystem.",
8
8
  author="Ackrome",
@@ -815,14 +815,17 @@ async def delete_old_inactive_subscriptions(days_inactive: int = 30):
815
815
 
816
816
 
817
817
  async def get_subscriptions_for_notification(notification_time: str) -> list:
818
+ try:
819
+ target_time = datetime.time.fromisoformat(notification_time)
820
+ except (TypeError, ValueError):
821
+ logger.warning("Ignoring invalid notification time: %r", notification_time)
822
+ return []
823
+
818
824
  async with get_session() as session:
819
- # notification_time comes as "HH:MM"
820
- # We use PostgreSQL TO_CHAR on the Time column
821
825
  stmt = select(UserScheduleSubscription).where(
822
826
  and_(
823
827
  UserScheduleSubscription.is_active.is_(True),
824
- func.to_char(UserScheduleSubscription.notification_time, "HH24:MI")
825
- == notification_time,
828
+ UserScheduleSubscription.notification_time == target_time,
826
829
  )
827
830
  )
828
831
  result = await session.execute(stmt)
@@ -1004,20 +1007,46 @@ async def batch_update_subscription_hashes(entity_type: str, entity_id: str, new
1004
1007
 
1005
1008
 
1006
1009
  # --- Cached Schedules ---
1010
+ def _cached_entity_name(entity_type: str, data: list | dict, fallback: str) -> str:
1011
+ """Extract a stable display name without querying/expanding JSON later."""
1012
+ if not isinstance(data, list):
1013
+ return fallback
1014
+ field_by_type = {
1015
+ "group": "group",
1016
+ "person": "lecturer_title",
1017
+ "auditorium": "auditorium",
1018
+ }
1019
+ field = field_by_type.get(entity_type)
1020
+ if not field:
1021
+ return fallback
1022
+ for lesson in data:
1023
+ if isinstance(lesson, dict):
1024
+ value = str(lesson.get(field) or "").strip()
1025
+ if value:
1026
+ return value[:255]
1027
+ return fallback
1028
+
1029
+
1007
1030
  async def upsert_cached_schedule(entity_type: str, entity_id: str, data: list | dict):
1008
1031
  json_data = json.loads(json.dumps(data, default=str)) # Ensure serializable
1032
+ entity_name = _cached_entity_name(entity_type, json_data, str(entity_id))
1009
1033
  async with get_session() as session:
1010
1034
  stmt = (
1011
1035
  pg_insert(CachedSchedule)
1012
1036
  .values(
1013
1037
  entity_type=entity_type,
1014
1038
  entity_id=str(entity_id),
1039
+ entity_name=entity_name,
1015
1040
  schedule_data=json_data,
1016
1041
  updated_at=datetime.datetime.now(),
1017
1042
  )
1018
1043
  .on_conflict_do_update(
1019
1044
  constraint="uq_cached_schedule_entity",
1020
- set_=dict(schedule_data=json_data, updated_at=datetime.datetime.now()),
1045
+ set_=dict(
1046
+ entity_name=entity_name,
1047
+ schedule_data=json_data,
1048
+ updated_at=datetime.datetime.now(),
1049
+ ),
1021
1050
  )
1022
1051
  )
1023
1052
  await session.execute(stmt)
@@ -1324,7 +1353,14 @@ async def get_leaderboard_data_from_db(session: AsyncSession):
1324
1353
  ORDER BY actions_count DESC LIMIT 100;
1325
1354
  """)
1326
1355
  result = await session.execute(stmt)
1327
- return [dict(row._mapping) for row in result]
1356
+ rows = []
1357
+ for row in result:
1358
+ item = dict(row._mapping)
1359
+ avatar = item.get("avatar_pic_url")
1360
+ if avatar and "api.telegram.org/file/bot" in avatar:
1361
+ item["avatar_pic_url"] = f"/api/stats/users/{item['user_id']}/avatar"
1362
+ rows.append(item)
1363
+ return rows
1328
1364
 
1329
1365
 
1330
1366
  async def get_popular_commands_data_from_db(session: AsyncSession):
@@ -1462,11 +1498,15 @@ async def get_user_profile_data_from_db(
1462
1498
  for r in rows
1463
1499
  ]
1464
1500
 
1501
+ avatar_url = user.avatar_pic_url
1502
+ if avatar_url and "api.telegram.org/file/bot" in avatar_url:
1503
+ avatar_url = f"/api/stats/users/{user.user_id}/avatar"
1504
+
1465
1505
  user_details = {
1466
1506
  "user_id": user.user_id,
1467
1507
  "full_name": user.full_name,
1468
1508
  "username": user.username or "Нет username",
1469
- "avatar_pic_url": user.avatar_pic_url,
1509
+ "avatar_pic_url": avatar_url,
1470
1510
  "total_actions": total_actions,
1471
1511
  }
1472
1512
 
@@ -1587,6 +1627,7 @@ async def merge_cached_schedule(
1587
1627
  # merged_data.sort(key=lambda x: (x.get('date', ''), x.get('beginLesson', '')))
1588
1628
 
1589
1629
  json_data = json.loads(json.dumps(merged_data, default=str))
1630
+ entity_name = _cached_entity_name(entity_type, json_data, str(entity_id))
1590
1631
 
1591
1632
  # 4. Upsert (вставка или обновление)
1592
1633
  stmt_insert = (
@@ -1594,12 +1635,17 @@ async def merge_cached_schedule(
1594
1635
  .values(
1595
1636
  entity_type=entity_type,
1596
1637
  entity_id=str(entity_id),
1638
+ entity_name=entity_name,
1597
1639
  schedule_data=json_data,
1598
1640
  updated_at=datetime.datetime.now(),
1599
1641
  )
1600
1642
  .on_conflict_do_update(
1601
1643
  constraint="uq_cached_schedule_entity",
1602
- set_=dict(schedule_data=json_data, updated_at=datetime.datetime.now()),
1644
+ set_=dict(
1645
+ entity_name=entity_name,
1646
+ schedule_data=json_data,
1647
+ updated_at=datetime.datetime.now(),
1648
+ ),
1603
1649
  )
1604
1650
  )
1605
1651
 
@@ -0,0 +1,121 @@
1
+ import re
2
+
3
+ HTML_TAG_RE = re.compile(r"(</?([a-zA-Z0-9_-]+)(?:\s+[^>]*)?>)")
4
+
5
+
6
+ def split_telegram_html_message(text: str, max_chars: int = 3800) -> list[str]:
7
+ """
8
+ Safely split an HTML-formatted message into valid HTML chunks under max_chars.
9
+ Guarantees that:
10
+ 1. HTML tags are never split in half.
11
+ 2. Tags left open at the end of a chunk are safely closed.
12
+ 3. The same open tags are reopened at the beginning of the next chunk.
13
+ """
14
+ if not isinstance(max_chars, int) or max_chars < 1:
15
+ raise ValueError("max_chars must be a positive integer")
16
+
17
+ text = text.strip()
18
+ if not text:
19
+ return []
20
+ if len(text) <= max_chars:
21
+ return [text]
22
+
23
+ tokens: list[tuple[str, str, bool, bool, str]] = []
24
+ last_end = 0
25
+ for match in HTML_TAG_RE.finditer(text):
26
+ start, end = match.span()
27
+ if start > last_end:
28
+ tokens.append(("text", text[last_end:start], False, False, ""))
29
+ full_tag = match.group(1)
30
+ tag_name = match.group(2).lower()
31
+ is_closing = full_tag.startswith("</")
32
+ is_self_closing = full_tag.endswith("/>")
33
+ tokens.append(("tag", full_tag, is_closing, is_self_closing, tag_name))
34
+ last_end = end
35
+ if last_end < len(text):
36
+ tokens.append(("text", text[last_end:], False, False, ""))
37
+
38
+ chunks: list[str] = []
39
+ current_tokens: list[str] = []
40
+ current_len = 0
41
+ open_tags: list[tuple[str, str]] = []
42
+
43
+ def get_closing_tags() -> str:
44
+ return "".join(f"</{tag_name}>" for tag_name, _ in reversed(open_tags))
45
+
46
+ def get_opening_tags() -> str:
47
+ return "".join(full_tag for _, full_tag in open_tags)
48
+
49
+ def flush_chunk():
50
+ nonlocal current_tokens, current_len
51
+ if not current_tokens:
52
+ return
53
+ closing = get_closing_tags()
54
+ if closing:
55
+ current_tokens.append(closing)
56
+ chunk_str = "".join(current_tokens).strip()
57
+ if chunk_str:
58
+ chunks.append(chunk_str)
59
+ reopened = get_opening_tags()
60
+ current_tokens = [reopened] if reopened else []
61
+ current_len = len(reopened)
62
+
63
+ for kind, content, is_closing, is_self_closing, tag_name in tokens:
64
+ if kind == "tag":
65
+ overhead = len(get_closing_tags())
66
+ if current_len + len(content) + overhead > max_chars and current_tokens:
67
+ flush_chunk()
68
+
69
+ current_tokens.append(content)
70
+ current_len += len(content)
71
+
72
+ if not is_self_closing:
73
+ if is_closing:
74
+ for idx in range(len(open_tags) - 1, -1, -1):
75
+ if open_tags[idx][0] == tag_name:
76
+ open_tags.pop(idx)
77
+ break
78
+ else:
79
+ open_tags.append((tag_name, content))
80
+ else:
81
+ remaining = content
82
+ while remaining:
83
+ overhead = len(get_closing_tags())
84
+ available = max_chars - current_len - overhead
85
+ if len(remaining) <= available:
86
+ current_tokens.append(remaining)
87
+ current_len += len(remaining)
88
+ break
89
+ else:
90
+ if available <= 0:
91
+ if len(get_opening_tags()) + len(get_closing_tags()) >= max_chars:
92
+ raise ValueError(
93
+ "max_chars is too small to preserve the open HTML tags"
94
+ )
95
+ flush_chunk()
96
+ continue
97
+
98
+ slice_candidate = remaining[:available]
99
+ split_idx = -1
100
+ for delim in ["\n\n", "\n", " "]:
101
+ pos = slice_candidate.rfind(delim)
102
+ if pos > 0:
103
+ split_idx = pos + len(delim)
104
+ break
105
+ if split_idx <= 0:
106
+ split_idx = available
107
+
108
+ piece = remaining[:split_idx]
109
+ remaining = remaining[split_idx:]
110
+ current_tokens.append(piece)
111
+ flush_chunk()
112
+
113
+ if current_tokens:
114
+ closing = get_closing_tags()
115
+ if closing:
116
+ current_tokens.append(closing)
117
+ chunk_str = "".join(current_tokens).strip()
118
+ if chunk_str:
119
+ chunks.append(chunk_str)
120
+
121
+ return chunks
@@ -157,6 +157,7 @@
157
157
  "help_btn_lec_search": "📚 /lec_search - Search notes",
158
158
  "help_btn_lec_all": "📂 /lec_all - Browse notes",
159
159
  "help_btn_favorites": "⭐ /favorites - Favorites",
160
+ "help_btn_mail": "✉️ /mail - Mailboxes",
160
161
  "help_btn_settings": "⚙️ /settings - Settings",
161
162
  "help_btn_latex": "🧮 /latex - Render LaTeX",
162
163
  "help_btn_mermaid": "🎨 /mermaid - Render Mermaid",
@@ -136,6 +136,7 @@
136
136
  "help_btn_lec_search": "📚 /lec_search - Поиск по конспектам",
137
137
  "help_btn_lec_all": "📂 /lec_all - Просмотр конспектов",
138
138
  "help_btn_favorites": "⭐ /favorites - Избранное",
139
+ "help_btn_mail": "✉️ /mail - Почтовые ящики",
139
140
  "help_btn_settings": "⚙️ /settings - Настройки",
140
141
  "help_btn_latex": "🧮 /latex - Рендер LaTeX",
141
142
  "help_btn_mermaid": "🎨 /mermaid - Рендер Mermaid",
@@ -13,9 +13,10 @@ from html import escape
13
13
 
14
14
  from bs4 import BeautifulSoup
15
15
  from cryptography.fernet import Fernet
16
- from sqlalchemy import BigInteger, Boolean, Column, Integer, LargeBinary, String, UniqueConstraint
17
16
 
18
- from shared_lib.models import Base
17
+ from shared_lib.models import MailAccount
18
+
19
+ __all__ = ["MailAccount", "cipher", "seal", "unseal", "validate_host", "poll_mail", "parse_mail"]
19
20
 
20
21
  MAX_MAIL_BYTES = 35 * 1024 * 1024
21
22
  PASSWORD_PROMPT = "Пароль приложения для почты"
@@ -41,24 +42,6 @@ def sensitive_mail_update(event, data):
41
42
  )
42
43
 
43
44
 
44
- class MailAccount(Base):
45
- __tablename__ = "mail_accounts"
46
- __table_args__ = (
47
- UniqueConstraint("user_id", "address", "host", name="uq_mail_owner_address_host"),
48
- )
49
- id = Column(Integer, primary_key=True)
50
- user_id = Column(BigInteger, nullable=False, index=True)
51
- address = Column(String(320), nullable=False)
52
- host = Column(String(253), nullable=False)
53
- protocol = Column(String(8), nullable=False)
54
- port = Column(Integer, nullable=False, default=993)
55
- credential = Column(LargeBinary, nullable=False)
56
- enabled = Column(Boolean, nullable=False, default=True)
57
- checkpoint = Column(LargeBinary, nullable=False)
58
- pending = Column(LargeBinary, nullable=True)
59
- status = Column(String(80), nullable=False, default="ready")
60
-
61
-
62
45
  def cipher():
63
46
  return Fernet(os.environ["MAIL_CREDENTIAL_KEY"].encode("ascii"))
64
47
 
@@ -6,6 +6,7 @@ from sqlalchemy import (
6
6
  Column,
7
7
  DateTime,
8
8
  ForeignKey,
9
+ Index,
9
10
  Integer,
10
11
  LargeBinary,
11
12
  String,
@@ -35,10 +36,14 @@ class UserAction(Base):
35
36
  __tablename__ = "user_actions"
36
37
 
37
38
  id = Column(Integer, primary_key=True, autoincrement=True)
38
- user_id = Column(BigInteger, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
39
- action_type = Column(String, nullable=False)
39
+ user_id = Column(
40
+ BigInteger, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True
41
+ )
42
+ action_type = Column(String, nullable=False, index=True)
40
43
  action_details = Column(String, nullable=True)
41
- timestamp = Column(DateTime(timezone=True), server_default=func.now())
44
+ timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True)
45
+
46
+ __table_args__ = (Index("ix_user_actions_user_id_timestamp", "user_id", "timestamp"),)
42
47
 
43
48
 
44
49
  class UserFavorite(Base):
@@ -75,7 +80,12 @@ class UserScheduleSubscription(Base):
75
80
  __tablename__ = "user_schedule_subscriptions"
76
81
 
77
82
  id = Column(Integer, primary_key=True, autoincrement=True)
78
- user_id = Column(BigInteger, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
83
+ user_id = Column(
84
+ BigInteger,
85
+ ForeignKey("users.user_id", ondelete="CASCADE"),
86
+ nullable=False,
87
+ index=True,
88
+ )
79
89
  chat_id = Column(BigInteger, nullable=False)
80
90
  entity_type = Column(String, nullable=False)
81
91
  entity_id = Column(String, nullable=False)
@@ -92,6 +102,11 @@ class UserScheduleSubscription(Base):
92
102
  UniqueConstraint(
93
103
  "chat_id", "entity_type", "entity_id", "notification_time", name="uq_schedule_subs"
94
104
  ),
105
+ Index(
106
+ "ix_user_subscriptions_notification_time_active",
107
+ "notification_time",
108
+ "is_active",
109
+ ),
95
110
  )
96
111
 
97
112
 
@@ -129,6 +144,7 @@ class CachedSchedule(Base):
129
144
  id = Column(Integer, primary_key=True, autoincrement=True)
130
145
  entity_type = Column(String, nullable=False)
131
146
  entity_id = Column(String, nullable=False)
147
+ entity_name = Column(String(255), nullable=True)
132
148
  schedule_data = Column(JSONB, nullable=False)
133
149
  updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
134
150
 
@@ -137,6 +153,31 @@ class CachedSchedule(Base):
137
153
  )
138
154
 
139
155
 
156
+ class MailAccount(Base):
157
+ """Encrypted mailbox connection owned by a Telegram user."""
158
+
159
+ __tablename__ = "mail_accounts"
160
+ __table_args__ = (
161
+ UniqueConstraint("user_id", "address", "host", name="uq_mail_owner_address_host"),
162
+ )
163
+ id = Column(Integer, primary_key=True)
164
+ user_id = Column(
165
+ BigInteger,
166
+ ForeignKey("users.user_id", ondelete="CASCADE"),
167
+ nullable=False,
168
+ index=True,
169
+ )
170
+ address = Column(String(320), nullable=False)
171
+ host = Column(String(253), nullable=False)
172
+ protocol = Column(String(8), nullable=False)
173
+ port = Column(Integer, nullable=False, default=993)
174
+ credential = Column(LargeBinary, nullable=False)
175
+ enabled = Column(Boolean, nullable=False, default=True)
176
+ checkpoint = Column(LargeBinary, nullable=False)
177
+ pending = Column(LargeBinary, nullable=True)
178
+ status = Column(String(80), nullable=False, default="ready")
179
+
180
+
140
181
  class SearchDocument(Base):
141
182
  __tablename__ = "search_documents"
142
183
 
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ import os
3
4
 
4
5
  import redis.asyncio as redis
5
6
 
@@ -67,4 +68,7 @@ class RedisClient:
67
68
 
68
69
 
69
70
  # Создаем единственный экземпляр клиента
70
- redis_client = RedisClient(host="redis")
71
+ redis_client = RedisClient(
72
+ host=os.getenv("REDIS_HOST", "redis"),
73
+ port=int(os.getenv("REDIS_PORT", "6379")),
74
+ )
@@ -31,8 +31,10 @@ class RuzAPIClient:
31
31
  self.initial_delay = initial_delay
32
32
  self.backoff_factor = backoff_factor
33
33
 
34
- async def _request(self, sub_url: str) -> dict[str, Any] | list[dict[str, Any]]:
35
- full_url = self.HOST + sub_url
34
+ async def _request(
35
+ self, path: str, params: dict[str, str] | None = None
36
+ ) -> dict[str, Any] | list[dict[str, Any]]:
37
+ full_url = self.HOST + path
36
38
  ssl_context = ssl.create_default_context(cafile=certifi.where())
37
39
  last_exception = None
38
40
 
@@ -40,7 +42,10 @@ class RuzAPIClient:
40
42
  try:
41
43
  # Добавляем явный таймаут на запрос, чтобы не висеть бесконечно
42
44
  async with self.session.get(
43
- full_url, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=15)
45
+ full_url,
46
+ params=params,
47
+ ssl=ssl_context,
48
+ timeout=aiohttp.ClientTimeout(total=15),
44
49
  ) as response:
45
50
  if response.status == 200:
46
51
  try:
@@ -73,14 +78,17 @@ class RuzAPIClient:
73
78
 
74
79
  async def search(self, term: str, search_type: str) -> list[dict[str, Any]]:
75
80
  """Generic search function."""
76
- return await self._request(f"/api/search?term={term}&type={search_type}")
81
+ return await self._request(
82
+ "/api/search", params={"term": term, "type": search_type}
83
+ )
77
84
 
78
85
  async def get_schedule(
79
86
  self, entity_type: str, entity_id: str, start: str, finish: str
80
87
  ) -> list[dict[str, Any]]:
81
88
  """Generic function to get a schedule."""
82
89
  return await self._request(
83
- f"/api/schedule/{entity_type}/{entity_id}?start={start}&finish={finish}&lng=1"
90
+ f"/api/schedule/{entity_type}/{entity_id}",
91
+ params={"start": start, "finish": finish, "lng": "1"},
84
92
  )
85
93
 
86
94