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.
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/PKG-INFO +1 -1
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/README.md +6 -1
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/PKG-INFO +1 -1
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/SOURCES.txt +4 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/setup.py +1 -1
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/database.py +54 -8
- matplobbot_shared-0.1.332/shared_lib/html_tools.py +121 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/locales/en.json +1 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/locales/ru.json +1 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/mail_bridge.py +3 -20
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/models.py +45 -4
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/redis_client.py +5 -1
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/university_api.py +13 -5
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/tasks.py +111 -12
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_authorization_guards.py +17 -2
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_keyboards.py +2 -0
- matplobbot_shared-0.1.332/tests/test_latex_task_security.py +86 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_mail_bridge.py +1 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_schedule_search_api.py +58 -0
- matplobbot_shared-0.1.332/tests/test_scheduler_html_splitter.py +59 -0
- matplobbot_shared-0.1.332/tests/test_stats_avatar_security.py +141 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_studio_router_api.py +10 -1
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/LICENSE +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/dependency_links.txt +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/requires.txt +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/top_level.txt +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/pyproject.toml +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/setup.cfg +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/__init__.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/celery_app.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/constants.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/egress.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/i18n.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/request_context.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/schemas.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/__init__.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/broadcast_service.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/calendar_sync_state.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/schedule_service.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/semantic_search.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telegram_bot_session.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telegram_http.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telegram_polling.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/telemetry.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_auth_flow.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_broadcast_service.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_build_audit_requirements.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_calendar_api.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_correlation_middleware.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_dashboard_text_encoding.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_egress.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_fastapi_config.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_localization_completeness.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_myschedule_filters_settings.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_openapi_docs.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_proxy_cleaner.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_proxy_config.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_rate_limit.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_schedule_service_calendar.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_scheduler_service.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_search_center_helpers.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_action_users_api.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_export_api.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_leaderboard_api.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_profile_api.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_proxy_diagnostics_api.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_stats_send_message_api.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_telegram_bot_session.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_telegram_polling.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_telemetry.py +0 -0
- {matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/tests/test_version_bumper.py +0 -0
|
@@ -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
|
-
-
|
|
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
|
{matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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":
|
|
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(
|
|
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
|
|
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(
|
|
39
|
-
|
|
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(
|
|
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(
|
|
71
|
+
redis_client = RedisClient(
|
|
72
|
+
host=os.getenv("REDIS_HOST", "redis"),
|
|
73
|
+
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
74
|
+
)
|
{matplobbot_shared-0.1.330 → matplobbot_shared-0.1.332}/shared_lib/services/university_api.py
RENAMED
|
@@ -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(
|
|
35
|
-
|
|
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,
|
|
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(
|
|
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}
|
|
90
|
+
f"/api/schedule/{entity_type}/{entity_id}",
|
|
91
|
+
params={"start": start, "finish": finish, "lng": "1"},
|
|
84
92
|
)
|
|
85
93
|
|
|
86
94
|
|