ktalk-cli 1.0.0__py3-none-any.whl

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.
ktalk_cli/endpoints.py ADDED
@@ -0,0 +1,145 @@
1
+ """Таблица «операция × режим авторизации -> путь + scope» (FR-6) + примитивы
2
+ подстановки в путь. Вынесено из `auth.py` (гейт C13 — самая длинная top-level
3
+ декларация модуля превысила порог при росте таблицы ADR-010/ADR-011).
4
+
5
+ `auth.py` реэкспортирует эти имена — расположение импорта не влияет на
6
+ видимость атрибута модуля, тот же приём, что `client.py` уже применяет к
7
+ `auth.py`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from urllib.parse import quote
14
+
15
+ from ktalk_cli.config import AuthMode
16
+
17
+ OPERATION_LABELS = {
18
+ "list_archive": "архив",
19
+ "get_participants_full": "полный состав участников",
20
+ "get_participants_report": "отчёт по участникам встречи",
21
+ "get_room": "чтение комнаты",
22
+ "get_calendar": "чтение календаря",
23
+ "create_meeting": "создание встречи",
24
+ "search_contacts": "поиск контактов",
25
+ "cancel_meeting": "отмена встречи",
26
+ }
27
+
28
+
29
+ def quote_path_param(value: object) -> str:
30
+ """SEC-001: квотирует значение перед подстановкой в `path_template.format(...)` —
31
+ без этого "../" в recording_key/conference_key меняет фактический путь запроса
32
+ (например, "../admin" схлопывает /api/recordings/../admin в /api/admin)."""
33
+ return quote(str(value), safe="")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class EndpointProfile:
38
+ """Путь + требуемый scope одной операции для одного режима авторизации."""
39
+
40
+ path_template: str
41
+ required_scope: str | None
42
+ mutating: bool = False # ADR-008: session-режим шлёт доп. Authorization-заголовок
43
+
44
+
45
+ # Таблица «операция × режим -> путь + scope» (FR-6). Отсутствие записи для режима
46
+ # (значение None) — управляемый отказ до сети, не голый 401/403.
47
+ OPERATION_PROFILES: dict[str, dict[AuthMode, EndpointProfile | None]] = {
48
+ "list_recordings": {
49
+ AuthMode.SESSION: EndpointProfile("/api/recordings", None),
50
+ AuthMode.API_KEY: EndpointProfile(
51
+ "/api/Domain/recordings/v2", "application.recording.read"
52
+ ),
53
+ },
54
+ "get_recording": {
55
+ AuthMode.SESSION: EndpointProfile("/api/recordings/{key}", None),
56
+ AuthMode.API_KEY: EndpointProfile(
57
+ "/api/Domain/recordings/{key}", "application.recording.read"
58
+ ),
59
+ },
60
+ "get_transcript": {
61
+ AuthMode.SESSION: EndpointProfile("/api/recordings/{key}/transcript", None),
62
+ AuthMode.API_KEY: EndpointProfile(
63
+ "/api/recordings/{key}/transcript", "application.recording.read"
64
+ ),
65
+ },
66
+ "get_summary": {
67
+ AuthMode.SESSION: EndpointProfile("/api/recordings/v2/{key}/summary", None),
68
+ AuthMode.API_KEY: EndpointProfile(
69
+ "/api/recordings/v2/{key}/summary", "application.recording.read"
70
+ ),
71
+ },
72
+ "get_summary_by_type": {
73
+ AuthMode.SESSION: EndpointProfile("/api/recordings/{key}/summary/{summary_type}", None),
74
+ AuthMode.API_KEY: EndpointProfile(
75
+ "/api/recordings/{key}/summary/{summary_type}", "application.recording.read"
76
+ ),
77
+ },
78
+ "list_archive": {
79
+ AuthMode.SESSION: None,
80
+ AuthMode.API_KEY: EndpointProfile(
81
+ "/api/domain/conferencesHistory", "application.reporting.read"
82
+ ),
83
+ },
84
+ "get_conference": {
85
+ AuthMode.SESSION: EndpointProfile("/api/conferencesHistory/{key}", None),
86
+ AuthMode.API_KEY: EndpointProfile(
87
+ "/api/ConferencesHistory/v2/{key}", "application.reporting.read"
88
+ ),
89
+ },
90
+ "get_participants_full": {
91
+ # Session-режим дообогащает дуальным источником (get_recording + get_conference),
92
+ # не через выделенный путь — см. KTalkClient.get_full_participants.
93
+ AuthMode.SESSION: None,
94
+ AuthMode.API_KEY: EndpointProfile(
95
+ "/api/Domain/recordings/{key}/participants", "application.recording.read"
96
+ ),
97
+ },
98
+ "get_participants_report": {
99
+ AuthMode.SESSION: None,
100
+ AuthMode.API_KEY: EndpointProfile(
101
+ "/api/ConferenceReports/{key}/participants", "application.reporting.read"
102
+ ),
103
+ },
104
+ "get_room": {
105
+ # FR-17: внутренний путь, вне спеки, регистронезависим (постановка §5, живой GET).
106
+ AuthMode.SESSION: EndpointProfile("/api/rooms/{room_name}", None),
107
+ # ADR-004, таблица «Подтверждённость»: api-key не проверен вовсе — fail-closed (FR-17 AC3).
108
+ AuthMode.API_KEY: None,
109
+ },
110
+ "get_calendar": {
111
+ # FR-18: внутренний путь, вне спеки, подтверждён исчерпывающе под session
112
+ # (Ф-17–Ф-31 RES-003).
113
+ AuthMode.SESSION: EndpointProfile("/api/calendar", None),
114
+ # ADR-004 п.2: 200 наблюдался под api-key (Ф-34), но необъяснимо (расходится с Ф-33/Ф-35
115
+ # тем же ключом) — не "рабочая" запись несмотря на живой позитивный результат.
116
+ # Ревизуемо отдельной задачей при появлении объяснения, не тихой правкой этой строки.
117
+ AuthMode.API_KEY: None,
118
+ },
119
+ "create_meeting": {
120
+ # ГИПОТЕЗА (mainpart-ktalk-mcp.md:192-193, RES-003 Ф-38, не проверено живым POST):
121
+ # путь с префиксом /api — mainpart документирует base_url = f"{space_url}/api" и
122
+ # использует /calendar относительно него; предыдущая запись без /api была ошибкой
123
+ # прочтения этого источника (ADR-007), не проверенным решением. Следующий боевой
124
+ # POST под новой санкцией владельца — единственная проверка.
125
+ # ГИПОТЕЗА (ADR-008, DEV-005 §5, не проверено живым POST): запись в session-режиме
126
+ # может требовать доп. заголовок Authorization: Session <token> — mutating=True
127
+ # добавляет его поверх query, не вместо. Следующий боевой POST — единственная проверка.
128
+ AuthMode.SESSION: EndpointProfile("/api/calendar", None, mutating=True),
129
+ # ФАКТ (ADR-004 п.2): api-key не проверен вовсе -> fail-closed.
130
+ AuthMode.API_KEY: None,
131
+ },
132
+ "search_contacts": {
133
+ # ADR-010 §1: путь наблюдён живым HAR (RES-003 §5, Ф-53), read-операция.
134
+ AuthMode.SESSION: EndpointProfile("/api/contacts", None),
135
+ # ADR-010: api-key-режим не проверен вовсе (нет выданных scope, Р-4) -> fail-closed.
136
+ AuthMode.API_KEY: None,
137
+ },
138
+ "cancel_meeting": {
139
+ # ADR-011 §1: симметрично create_meeting — Ф-50, mutating=True, тот же транспорт,
140
+ # что ADR-009 подтвердил живым POST создания.
141
+ AuthMode.SESSION: EndpointProfile("/api/calendar/{id}/cancel", None, mutating=True),
142
+ # ADR-011: api-key-режим для мутаций календаря не проверен ни разу -> fail-closed.
143
+ AuthMode.API_KEY: None,
144
+ },
145
+ }
@@ -0,0 +1,83 @@
1
+ """Дообогащение состава участников (FR-8).
2
+
3
+ Срабатывает ТОЛЬКО когда `len(participants) < participantsCount` (строгое `<`,
4
+ не `<=`) — иначе полный `ktalk sync` (~1316 записей) слал бы лишний запрос на
5
+ каждую запись домена вместо ~23% (зонд, Ф-4). Конкурентность ограничена
6
+ `asyncio.Semaphore` — предела rate limiting API нигде не задокументировано
7
+ (RES-001), поэтому дефолт консервативен, а не выведен из замера пропускной
8
+ способности. Отказ по одной записи не должен прерывать fan-out целиком
9
+ (`asyncio.gather(..., return_exceptions=True)`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _display_name(info: dict) -> str:
21
+ surname = info.get("surname")
22
+ firstname = info.get("firstname")
23
+ if surname and firstname:
24
+ return f"{surname} {firstname}"
25
+ return surname or firstname or info.get("login") or "Неизвестный"
26
+
27
+
28
+ def map_participants(raw: list[dict]) -> list[dict]:
29
+ """Замена замороженного `registry.participants_from_api`: не роняет анонимов.
30
+
31
+ Аноним -> `{"ktalk_id": anonymousId, "name": anonymousName or "Аноним"}`.
32
+ Именованный -> та же схема ключей `ktalk_id`/`name` — формат строки
33
+ `participants` в SQLite (`registry.py`) не меняется.
34
+ """
35
+ out: list[dict] = []
36
+ for p in raw:
37
+ info = p.get("userInfo")
38
+ if info:
39
+ ktalk_id = info.get("key") or info.get("login")
40
+ if not ktalk_id:
41
+ continue
42
+ out.append({"ktalk_id": str(ktalk_id), "name": _display_name(info)})
43
+ continue
44
+ anon_id = p.get("anonymousId")
45
+ if not anon_id:
46
+ continue
47
+ out.append({"ktalk_id": str(anon_id), "name": p.get("anonymousName") or "Аноним"})
48
+ return out
49
+
50
+
51
+ async def enrich_batch(
52
+ records: list[dict],
53
+ client,
54
+ *,
55
+ concurrency: int = 5,
56
+ ) -> dict[str, list[dict]]:
57
+ """Возвращает `{recording_id: participants[]}` только для записей, где
58
+ `len(participants) < participantsCount`. Остальные не трогает.
59
+
60
+ Отказ по одной записи (сеть/500/таймаут) не прерывает остальные — лог
61
+ предупреждения, запись просто отсутствует в результате этого прохода
62
+ (согласуется с общей веротерпимостью sync к частичным данным).
63
+ """
64
+ targets = [
65
+ r for r in records if len(r.get("participants") or []) < r.get("participantsCount", 0)
66
+ ]
67
+ if not targets:
68
+ return {}
69
+
70
+ semaphore = asyncio.Semaphore(max(1, concurrency))
71
+
72
+ async def _enrich_one(record: dict) -> tuple[str, list[dict] | None]:
73
+ rid = str(record.get("id") or record.get("key") or "")
74
+ async with semaphore:
75
+ try:
76
+ detail = await client.get_recording(rid)
77
+ except Exception: # noqa: BLE001 - одна запись не должна ронять весь sync
78
+ logger.warning("Не удалось дообогатить участников записи %s", rid)
79
+ return rid, None
80
+ return rid, map_participants(detail.get("participants") or [])
81
+
82
+ results = await asyncio.gather(*(_enrich_one(r) for r in targets))
83
+ return {rid: parts for rid, parts in results if parts is not None}