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/cli_store.py ADDED
@@ -0,0 +1,59 @@
1
+ """CLI-обвязка миграции в централизованное хранилище (NFR-12, MAJ-04 security
2
+ review SEC-003).
3
+
4
+ Отдельная явная команда — `migrate_to_central_store` (`store_migration.py`) не
5
+ запускается неявно ни из установки, ни из первого открытия реестра
6
+ (ADR-013-central-transcript-store-spec.md, «Контракт команды миграции»).
7
+ Не путать с `ktalk migrate <vault>` (`registry.py::migrate_from_vault`, импорт
8
+ markdown-архивов ADR-002) — разные команды, разные модули.
9
+
10
+ Команда не входит в `_REGISTRY_FREE_COMMANDS` (cli.py) в смысле "не открывает
11
+ БД вообще" — она открывает и путь-источник, и путь-цель сама, вручную, минуя
12
+ `Registry(resolve_db_path(...))`, поэтому регистрируется отдельным хендлером,
13
+ принимающим `reg=None` не глядя.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from ktalk_cli.config import redact_secrets
23
+ from ktalk_cli.store import resolve_store_root
24
+ from ktalk_cli.store_migration import (
25
+ MigrationTargetExistsError,
26
+ MigrationVerificationError,
27
+ migrate_to_central_store,
28
+ )
29
+
30
+
31
+ def register_subparsers(sub) -> None:
32
+ p = sub.add_parser(
33
+ "migrate-to-central-store",
34
+ help="Перенести SQLite-реестр в централизованное хранилище (NFR-12)",
35
+ )
36
+ p.add_argument("source", help="Путь к существующему registry.db (vault)")
37
+ p.add_argument(
38
+ "--target",
39
+ default=None,
40
+ help="Путь назначения (по умолчанию — машинный дефолт store.resolve_store_root())",
41
+ )
42
+ p.add_argument("--json", action="store_true")
43
+
44
+
45
+ def cmd_migrate_to_central_store(reg, args) -> int: # noqa: ARG001 - reg не используется (см. модуль)
46
+ source = Path(args.source)
47
+ target = Path(args.target) if args.target else resolve_store_root() / "registry.db"
48
+
49
+ try:
50
+ migrate_to_central_store(source, target)
51
+ except (MigrationTargetExistsError, MigrationVerificationError) as exc:
52
+ print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
53
+ return 1
54
+
55
+ if args.json:
56
+ print(json.dumps({"source": str(source), "target": str(target)}, ensure_ascii=False))
57
+ else:
58
+ print(f"Перенесено: {source} -> {target}")
59
+ return 0
ktalk_cli/cli_sync.py ADDED
@@ -0,0 +1,169 @@
1
+ """`ktalk sync` / `ktalk auth-status` — команды, требующие сетевого клиента.
2
+
3
+ Вынесено из `cli.py` ради гейта C13: эти две команды одни тянут за собой
4
+ пагинацию (FR-14), дообогащение участников (FR-8) и сверку id (FR-15) — вместе
5
+ с остальными подкомандами реестра `cli.py` не проходил бы порог объёма.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import sys
13
+ from datetime import date, timedelta
14
+
15
+ from ktalk_cli.client import AuthStatus, KTalkClient, KTalkError
16
+ from ktalk_cli.config import AuthMode, KTalkConfigError, Settings, redact_secrets
17
+ from ktalk_cli.enrichment import enrich_batch, map_participants
18
+ from ktalk_cli.pagination import clip_to_window, paginate_pages, skip_pages, token_pages
19
+ from ktalk_cli.reconciliation import dry_run_report, recording_ids
20
+ from ktalk_cli.registry import Registry, recording_fields_from_api
21
+
22
+ _AUTH_ERRORS = (KTalkError, KTalkConfigError)
23
+
24
+ _STATUSES = ("new", "processing", "done", "skipped", "partial")
25
+
26
+
27
+ def _print_json(data) -> None:
28
+ print(json.dumps(data, ensure_ascii=False, indent=2))
29
+
30
+
31
+ def _window_start(days: int) -> str:
32
+ """Нижняя граница окна `--days` в формате YYYY-MM-DD."""
33
+ return (date.today() - timedelta(days=days)).isoformat()
34
+
35
+
36
+ async def _fetch_recordings(
37
+ days: int, *, enrich: bool = False
38
+ ) -> tuple[list[dict], dict[str, list[dict]]]:
39
+ """Обходит окно дат через `pagination.py` (FR-14: skip/token по режиму, клэмп
40
+ 1-100). `enrich=True` дополнительно дообогащает участников (FR-8) — пропущено
41
+ для `--dry-run`."""
42
+ settings = Settings()
43
+ start_from = _window_start(days)
44
+ out: list[dict] = []
45
+ async with KTalkClient.from_settings(settings) as client:
46
+ if settings.auth_mode is AuthMode.API_KEY:
47
+
48
+ async def raw_fetch_token(token: str | None) -> dict:
49
+ return await client.list_recordings(
50
+ start_from=start_from, top=100, page_token=token
51
+ )
52
+
53
+ fetch_page = token_pages(raw_fetch_token)
54
+ else:
55
+
56
+ async def raw_fetch_skip(skip: int, top: int) -> dict:
57
+ return await client.list_recordings(start_from=start_from, top=top, skip=skip)
58
+
59
+ fetch_page = skip_pages(raw_fetch_skip, page_size=100)
60
+
61
+ # Окно дат держит клиент: API игнорирует startFrom (Ф-15). Обход
62
+ # прекращается на первой странице, вышедшей за порог — выдача
63
+ # отсортирована от новых к старым (Ф-16).
64
+ async for page in paginate_pages(fetch_page):
65
+ kept, exhausted = clip_to_window(page, start_from)
66
+ out.extend(kept)
67
+ if exhausted:
68
+ break
69
+
70
+ enriched = await enrich_batch(out, client) if enrich else {}
71
+ return out, enriched
72
+
73
+
74
+ async def _fetch_auth_status() -> AuthStatus:
75
+ settings = Settings()
76
+ async with KTalkClient.from_settings(settings) as client:
77
+ return await client.get_auth_status()
78
+
79
+
80
+ def _sync_dry_run(reg: Registry, recordings: list[dict], args) -> int:
81
+ """FR-15: сверка id без записи в реестр — гейт перед первым боевым api-key sync.
82
+
83
+ Сравнивается одно и то же окно с обеих сторон. Реестр хранит всю историю
84
+ (в боевом домене ~1316 строк), а из API приходит только окно `--days`;
85
+ сравнение окна со всей историей давало бы сотни ложных «пропавших» записей
86
+ и предохранитель, который срабатывает всегда, то есть не работает.
87
+ """
88
+ start_from = _window_start(args.days)
89
+ existing_ids = [
90
+ r["recording_id"] for r in reg.list_recordings() if (r["date"] or "") >= start_from
91
+ ]
92
+ report = dry_run_report(recording_ids(recordings), existing_ids)
93
+ if args.json:
94
+ _print_json(report)
95
+ else:
96
+ print(report["message"])
97
+ return 0 if report["ok"] else 1
98
+
99
+
100
+ def cmd_sync(reg: Registry, args) -> int:
101
+ try:
102
+ recordings, enriched = asyncio.run(_fetch_recordings(args.days, enrich=not args.dry_run))
103
+ except _AUTH_ERRORS as exc:
104
+ print(redact_secrets(str(exc)), file=sys.stderr)
105
+ return 1
106
+
107
+ if args.dry_run:
108
+ return _sync_dry_run(reg, recordings, args)
109
+
110
+ inserted = updated = 0
111
+ for rec in recordings:
112
+ fields = recording_fields_from_api(rec)
113
+ if not fields["recording_id"]:
114
+ continue
115
+ parts = enriched.get(fields["recording_id"]) or map_participants(
116
+ rec.get("participants") or []
117
+ )
118
+ result = reg.upsert_recording(fields, participants=parts)
119
+ if result == "inserted":
120
+ inserted += 1
121
+ else:
122
+ updated += 1
123
+ expired = reg.expire_new(days=args.days)
124
+ count = int(reg.get_meta("sync_count") or "0") + 1
125
+ reg.set_meta("sync_count", str(count))
126
+ reg.set_meta("last_synced", date.today().isoformat())
127
+
128
+ if args.json:
129
+ recs = reg.list_recordings()
130
+ stats = {s: sum(1 for r in recs if r["status"] == s) for s in _STATUSES}
131
+ _print_json(
132
+ {
133
+ "synced": len(recordings),
134
+ "inserted": inserted,
135
+ "updated": updated,
136
+ "expired": expired,
137
+ "stats": stats,
138
+ }
139
+ )
140
+ return 0
141
+ from ktalk_cli.cli import _cmd_dashboard
142
+
143
+ return _cmd_dashboard(reg, args)
144
+
145
+
146
+ def cmd_auth_status(reg: Registry, args) -> int:
147
+ del reg # реестр для этой команды больше не открывается вовсе, FR-19 — reg всегда None
148
+ try:
149
+ status = asyncio.run(_fetch_auth_status())
150
+ except _AUTH_ERRORS as exc:
151
+ print(redact_secrets(str(exc)), file=sys.stderr)
152
+ return 1
153
+ data = {
154
+ "alive": status.alive,
155
+ "scopes": status.scopes,
156
+ "expired_at": status.expired_at,
157
+ "note": status.note,
158
+ }
159
+ if args.json:
160
+ _print_json(data)
161
+ return 0
162
+ print(f"alive: {status.alive}")
163
+ if status.scopes is not None:
164
+ print(f"scopes: {status.scopes}")
165
+ if status.expired_at is not None:
166
+ print(f"expired_at: {status.expired_at}")
167
+ if status.note:
168
+ print(f"note: {status.note}")
169
+ return 0
ktalk_cli/cli_token.py ADDED
@@ -0,0 +1,88 @@
1
+ """`ktalk token set|status` — запись и диагностика файла session-токена.
2
+
3
+ Команда не печатает значение токена ни в одном режиме: маска показывает длину и
4
+ последние символы, чтобы отличить «положил не тот токен» от «положил не туда»,
5
+ и этого достаточно.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+
14
+ from ktalk_cli import token_file
15
+
16
+ EXIT_OK = 0
17
+ EXIT_ERROR = 1
18
+ EXIT_BAD_ARGS = 2
19
+
20
+
21
+ def register_subparsers(sub) -> None:
22
+ parser = sub.add_parser("token", help="Файл session-токена (см. README)")
23
+ actions = parser.add_subparsers(dest="token_action")
24
+
25
+ p_set = actions.add_parser("set", help="Записать токен (значение или `-` для stdin)")
26
+ p_set.add_argument("value", help="Токен, либо `-` — прочитать из stdin")
27
+
28
+ p_status = actions.add_parser("status", help="Есть ли файл, права, маска значения")
29
+ p_status.add_argument("--json", action="store_true")
30
+
31
+
32
+ def _mask(token: str) -> str:
33
+ tail = token[-4:] if len(token) >= 8 else ""
34
+ return f"{'*' * max(len(token) - len(tail), 0)}{tail} ({len(token)} символов)"
35
+
36
+
37
+ def _cmd_set(args: argparse.Namespace) -> int:
38
+ raw = sys.stdin.read() if args.value == "-" else args.value
39
+ try:
40
+ path = token_file.write_token(raw)
41
+ except (OSError, ValueError) as exc:
42
+ print(f"Ошибка: {exc}", file=sys.stderr)
43
+ return EXIT_ERROR
44
+
45
+ token = token_file.read_token() or ""
46
+ print(f"Токен записан: {path} (права {token_file.file_mode()}), {_mask(token)}")
47
+ print("Проверка: ktalk auth-status")
48
+ return EXIT_OK
49
+
50
+
51
+ def _cmd_status(args: argparse.Namespace) -> int:
52
+ path = token_file.token_path()
53
+ mode = token_file.file_mode()
54
+ token = token_file.read_token()
55
+ present = mode is not None
56
+ payload = {
57
+ "path": str(path),
58
+ "present": present,
59
+ "mode": mode,
60
+ "usable": token is not None,
61
+ }
62
+
63
+ if args.json:
64
+ print(json.dumps(payload, ensure_ascii=False))
65
+ return EXIT_OK
66
+
67
+ print(f"Файл: {path}")
68
+ if not present:
69
+ print("present: False — токен не записан (ktalk token set -)")
70
+ return EXIT_OK
71
+ print(f"present: True, права {mode}")
72
+ if token is None:
73
+ print(
74
+ "usable: False — файл пуст или его права шире 0600; "
75
+ f"почините: chmod 600 {path}"
76
+ )
77
+ return EXIT_OK
78
+ print(f"usable: True, {_mask(token)}")
79
+ return EXIT_OK
80
+
81
+
82
+ def cmd_token(_reg, args: argparse.Namespace) -> int:
83
+ action = getattr(args, "token_action", None)
84
+ handler = {"set": _cmd_set, "status": _cmd_status}.get(action)
85
+ if handler is None:
86
+ print("Укажите действие: token set | token status", file=sys.stderr)
87
+ return EXIT_BAD_ARGS
88
+ return handler(args)
ktalk_cli/client.py ADDED
@@ -0,0 +1,348 @@
1
+ """Async HTTP client for KTalk API — режимы авторизации, профиль эндпоинтов,
2
+ диагностика 401/403, диагностика ключа/токена (ADR-003).
3
+
4
+ Таблица профиля/DTO/нормализаторы вынесены в `ktalk_cli.auth` (гейт C13); публичные
5
+ имена, которые тесты и остальной код ожидают именно из `ktalk_cli.client`
6
+ (`AuthStatus`, `normalize_list_session`, `normalize_list_apikey`, ...), реэкспортированы
7
+ ниже — расположение импорта не влияет на видимость атрибута модуля.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import httpx
13
+
14
+ from ktalk_cli.auth import ( # noqa: F401 - реэкспорт публичного контракта модуля
15
+ OPERATION_LABELS,
16
+ OPERATION_PROFILES,
17
+ SCOPE_LABELS,
18
+ AuthContext,
19
+ AuthStatus,
20
+ EndpointProfile,
21
+ KTalkAuthError,
22
+ KTalkError,
23
+ KTalkNotFoundError,
24
+ KTalkScopeError,
25
+ KTalkWriteAuthMismatchError,
26
+ OperationNotAvailableError,
27
+ classify_response,
28
+ full_participants_apikey,
29
+ merge_participants,
30
+ normalize_list_apikey,
31
+ normalize_list_session,
32
+ quote_path_param,
33
+ resolve_chat_channel,
34
+ )
35
+ from ktalk_cli.config import AuthMode, Settings
36
+ from ktalk_cli.pagination import paginate_pages, skip_pages
37
+
38
+
39
+ class KTalkClient:
40
+ """Async HTTP client for KTalk API.
41
+
42
+ Usage::
43
+
44
+ async with KTalkClient(base_url, session_token=..., personal_api_key=...) as client:
45
+ recordings = await client.list_recordings()
46
+
47
+ Ровно один из `session_token`/`personal_api_key` определяет режим (ADR-003):
48
+ ключ побеждает, если задано и то, и другое; ни один из двух не отправляется
49
+ вместе с механизмом другого режима — заголовок либо query-параметр настраиваются
50
+ один раз при конструировании, не проверяются на каждый запрос.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ base_url: str,
56
+ session_token: str | None = None,
57
+ personal_api_key: str | None = None,
58
+ ) -> None:
59
+ self._auth = AuthContext.resolve(
60
+ session_token=session_token, personal_api_key=personal_api_key
61
+ )
62
+ # Авторизация в этом клиенте — только заголовок/query (ADR-003), cookie-jar
63
+ # нигде не читается. Без явной зачистки httpx.AsyncClient по умолчанию копит
64
+ # Set-Cookie между запросами одного инстанса (httpx не даёт конструктору
65
+ # флага "без cookie" — `cookies=` принимает jar/dict, не bool) — контрольный
66
+ # вызов ADR-004/diagnose_undocumented_failure после проваленного мутирующего
67
+ # POST унаследовал бы cookie именно этого провала (DEV-007), теряя
68
+ # независимость диагностики от отказа, который она проверяет.
69
+ if self._auth.mode is AuthMode.API_KEY:
70
+ self._client = httpx.AsyncClient(
71
+ base_url=base_url,
72
+ headers={"X-Auth-Token": self._auth.credential},
73
+ timeout=30.0,
74
+ )
75
+ else:
76
+ self._client = httpx.AsyncClient(
77
+ base_url=base_url,
78
+ params={"sessionToken": self._auth.credential},
79
+ timeout=30.0,
80
+ )
81
+ # Хук ставится после конструирования — сам себе замыкание на `self._client`,
82
+ # httpx извлекает Set-Cookie до вызова response-хуков (`Cookies.extract_cookies`
83
+ # в `_send_single_request` отрабатывает раньше), поэтому зачистка здесь не
84
+ # мешает уже полученному ответу — снимает cookie только со следующего запроса.
85
+ self._client.event_hooks["response"] = [self._forget_response_cookies]
86
+
87
+ async def _forget_response_cookies(self, _response: httpx.Response) -> None:
88
+ self._client.cookies.clear()
89
+
90
+ @classmethod
91
+ def from_settings(cls, settings: Settings) -> KTalkClient:
92
+ return cls(
93
+ base_url=settings.ktalk_base_url,
94
+ session_token=settings.ktalk_session_token,
95
+ personal_api_key=settings.ktalk_personal_api_key,
96
+ )
97
+
98
+ async def __aenter__(self) -> KTalkClient:
99
+ return self
100
+
101
+ async def __aexit__(self, *args: object) -> None:
102
+ await self._client.aclose()
103
+
104
+ @property
105
+ def auth_mode(self) -> AuthMode:
106
+ return self._auth.mode
107
+
108
+ def stream(self, method: str, url: str):
109
+ """Потоковый запрос без буферизации целиком (FR-7 AC4) — для `download.py`."""
110
+ return self._client.stream(method, url)
111
+
112
+ def check_response(self, response: httpx.Response, required_scope: str | None = None) -> None:
113
+ """Публичная обёртка над `_classify` — для потоковых вызовов вне `_call`."""
114
+ self._classify(response, required_scope)
115
+
116
+ def _profile_for(self, operation: str) -> EndpointProfile:
117
+ """Code review (epic-capability-pairing, Р1/Р2): текст отказа обязан называть
118
+ режим, реально подтверждённый для операции (`OPERATION_PROFILES`), не
119
+ зашитый заранее «режим ключа» — верно ровно для `list_archive`, для
120
+ `get_room`/`get_calendar`/`create_meeting`/`cancel_meeting`/`search_contacts`
121
+ (все пять подтверждены только под session, `AuthMode.API_KEY: None`)
122
+ пользователю с ключом раньше советовали включить ключ, которым он уже
123
+ пользуется."""
124
+ profiles = OPERATION_PROFILES.get(operation, {})
125
+ profile = profiles.get(self._auth.mode)
126
+ if profile is None:
127
+ label = OPERATION_LABELS.get(operation, operation)
128
+ required_mode = next(
129
+ (mode for mode, candidate in profiles.items() if candidate is not None),
130
+ None,
131
+ )
132
+ if required_mode is AuthMode.SESSION:
133
+ raise OperationNotAvailableError(
134
+ f"Операция «{label}» доступна только в режиме сессии "
135
+ "(переменная KTALK_SESSION_TOKEN)."
136
+ )
137
+ if required_mode is AuthMode.API_KEY:
138
+ raise OperationNotAvailableError(
139
+ f"Операция «{label}» доступна только в режиме персонального ключа "
140
+ "(переменная KTALK_PERSONAL_API_KEY)."
141
+ )
142
+ # Операция вне OPERATION_PROFILES вовсе (например, update_meeting,
143
+ # ADR-011 п.5) — ни один режим её не подтверждает, называть конкретный
144
+ # режим было бы неверно.
145
+ raise OperationNotAvailableError(
146
+ f"Операция «{label}» недоступна ни в одном режиме авторизации."
147
+ )
148
+ return profile
149
+
150
+ def _classify(self, response: httpx.Response, required_scope: str | None) -> None:
151
+ """Логика вынесена в `auth.classify_response` (гейт C13) — не зависит от
152
+ `self`, только от режима/статус-кода/scope."""
153
+ classify_response(self._auth.mode, response, required_scope)
154
+
155
+ async def _call(self, operation: str, params: dict) -> dict:
156
+ profile = self._profile_for(operation)
157
+ path = profile.path_template.format(**{k: quote_path_param(v) for k, v in params.items()})
158
+ response = await self._client.get(path)
159
+ self._classify(response, profile.required_scope)
160
+ return response.json()
161
+
162
+ async def list_recordings(
163
+ self,
164
+ *,
165
+ query: str | None = None,
166
+ start_from: str | None = None,
167
+ start_to: str | None = None,
168
+ top: int = 30,
169
+ skip: int = 0,
170
+ order_mode: str = "byTimeNewFirst",
171
+ page_token: str | None = None,
172
+ title: str | None = None,
173
+ ) -> dict:
174
+ profile = self._profile_for("list_recordings")
175
+ params: dict = {"top": top, "orderMode": order_mode}
176
+ if query is not None:
177
+ params["query"] = query
178
+ if start_from is not None:
179
+ params["startFrom"] = start_from
180
+ if start_to is not None:
181
+ params["startTo"] = start_to
182
+ if title is not None:
183
+ params["title"] = title
184
+ if self._auth.mode is AuthMode.API_KEY:
185
+ if page_token is not None:
186
+ params["pageTokenString"] = page_token
187
+ elif skip:
188
+ params["skip"] = skip
189
+ response = await self._client.get(profile.path_template, params=params)
190
+ self._classify(response, profile.required_scope)
191
+ return response.json()
192
+
193
+ async def get_recording(self, recording_key: str) -> dict:
194
+ return await self._call("get_recording", {"key": recording_key})
195
+
196
+ async def get_transcript(self, recording_key: str) -> dict:
197
+ return await self._call("get_transcript", {"key": recording_key})
198
+
199
+ async def get_summary(self, recording_key: str) -> dict:
200
+ return await self._call("get_summary", {"key": recording_key})
201
+
202
+ async def get_summary_by_type(self, recording_key: str, summary_type: str) -> dict:
203
+ return await self._call(
204
+ "get_summary_by_type", {"key": recording_key, "summary_type": summary_type}
205
+ )
206
+
207
+ async def list_archive(
208
+ self,
209
+ *,
210
+ from_date: str,
211
+ to_date: str,
212
+ room_names: list[str] | None = None,
213
+ page_size: int = 100,
214
+ ) -> list[dict]:
215
+ profile = self._profile_for("list_archive")
216
+
217
+ async def raw_fetch(skip: int, top: int) -> dict:
218
+ params: dict = {"fromDate": from_date, "toDate": to_date, "skip": skip, "take": top}
219
+ if room_names:
220
+ params["roomName"] = room_names
221
+ response = await self._client.get(profile.path_template, params=params)
222
+ self._classify(response, profile.required_scope)
223
+ return response.json()
224
+
225
+ fetch_page = skip_pages(raw_fetch, page_size=page_size, items_key="conferences")
226
+ out: list[dict] = []
227
+ async for page in paginate_pages(fetch_page):
228
+ out.extend(page)
229
+ return out
230
+
231
+ async def get_conference(self, conference_key: str) -> dict:
232
+ return await self._call("get_conference", {"key": conference_key})
233
+
234
+ async def get_full_participants(self, recording_key: str) -> dict:
235
+ """Полный состав участников (FR-8): api-key — паджинированный путь профиля,
236
+ session — объединение get_recording + get_conference (ADR-003-spec, п.2
237
+ «Закрытые открытые вопросы BA»), с явным `incomplete`, если всё равно короче
238
+ `participantsCount`."""
239
+ if self._auth.mode is AuthMode.API_KEY:
240
+ return await full_participants_apikey(self, recording_key)
241
+ detail = await self.get_recording(recording_key)
242
+ conference_key = detail.get("conferenceKey") or recording_key
243
+ conference = await self.get_conference(conference_key)
244
+ conf_participants = (conference.get("artifacts") or {}).get("participants") or []
245
+ merged = merge_participants(detail.get("participants") or [], conf_participants)
246
+ count = detail.get("participantsCount", len(merged))
247
+ return {"participants": merged, "incomplete": len(merged) < count}
248
+
249
+ async def get_chat_messages(
250
+ self,
251
+ recording_key: str | None = None,
252
+ conference_key: str | None = None,
253
+ channel: str | None = None,
254
+ ) -> list[dict]:
255
+ """Оркестрация FR-10: recording_key -> conferenceKey -> канал -> сообщения."""
256
+ if conference_key is None:
257
+ if recording_key is None:
258
+ raise ValueError("Нужен recording_key или conference_key")
259
+ detail = await self.get_recording(recording_key)
260
+ conference_key = detail.get("conferenceKey") or recording_key
261
+ if channel is None:
262
+ channel = await resolve_chat_channel(self, conference_key)
263
+ return await self._fetch_chat_messages(conference_key, channel)
264
+
265
+ async def _fetch_chat_messages(self, conference_key: str, channel: str) -> list[dict]:
266
+ key = quote_path_param(conference_key)
267
+ if self._auth.mode is AuthMode.API_KEY:
268
+ path = f"/api/ConferenceReports/{key}/messages"
269
+ scope = "application.reporting.read"
270
+ else:
271
+ path = f"/api/conferencesHistory/{key}/chat/messages"
272
+ scope = None
273
+ response = await self._client.get(path, params={"channel": channel})
274
+ if response.status_code == 403:
275
+ raise KTalkError(
276
+ f"Нет прав на получение сообщений чат-канала «{channel}» (см. README)."
277
+ )
278
+ self._classify(response, scope)
279
+ return response.json().get("messages", [])
280
+
281
+ async def get_participants_report(self, conference_key: str) -> dict:
282
+ return await self._call("get_participants_report", {"key": conference_key})
283
+
284
+ async def get_auth_status(self) -> AuthStatus:
285
+ if self._auth.mode is AuthMode.API_KEY:
286
+ return await self._auth_status_apikey()
287
+ return await self._auth_status_session()
288
+
289
+ async def _auth_status_apikey(self) -> AuthStatus:
290
+ response = await self._client.get("/api/domain/applications/access-info")
291
+ if response.status_code == 401:
292
+ return AuthStatus(
293
+ alive=False,
294
+ scopes=None,
295
+ expired_at=None,
296
+ note="Ключ авторизации истёк или невалиден.",
297
+ )
298
+ if response.status_code == 403:
299
+ # Зонд Ф-12: 403 на access-info значит «ключ жив», не «ключ мёртв» — сам
300
+ # запрос диагностики требует application.applications.read.
301
+ return AuthStatus(
302
+ alive=True,
303
+ scopes=None,
304
+ expired_at=None,
305
+ note=(
306
+ "Ключ валиден, но не хватает разрешения application.applications.read "
307
+ "для просмотра списка scope'ов."
308
+ ),
309
+ )
310
+ if response.status_code != 200:
311
+ raise KTalkError(
312
+ f"Ошибка API Контур.Толк при диагностике ключа: HTTP {response.status_code}."
313
+ )
314
+ data = response.json()
315
+ return AuthStatus(
316
+ alive=True, scopes=data.get("scopes"), expired_at=data.get("expiredAt"), note=None
317
+ )
318
+
319
+ async def _auth_status_session(self) -> AuthStatus:
320
+ try:
321
+ await self.list_recordings(top=1)
322
+ except KTalkAuthError:
323
+ return AuthStatus(
324
+ alive=False,
325
+ scopes=None,
326
+ expired_at=None,
327
+ note="Токен сессии не прошёл проверку (пробный запрос списка записей).",
328
+ )
329
+ return AuthStatus(
330
+ alive=True,
331
+ scopes=None,
332
+ expired_at=None,
333
+ note=(
334
+ "У сессионного токена нет понятия scope/срока действия — выполнена "
335
+ "пробная проверка list_recordings(top=1), а не имитация без сети."
336
+ ),
337
+ )
338
+
339
+
340
+ _shared_client: KTalkClient | None = None
341
+
342
+
343
+ def get_shared_client() -> KTalkClient:
344
+ """Singleton клиента для MCP-инструментов (было `server.py::_get_client`)."""
345
+ global _shared_client
346
+ if _shared_client is None:
347
+ _shared_client = KTalkClient.from_settings(Settings())
348
+ return _shared_client