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/CLAUDE.md +30 -0
- ktalk_cli/__init__.py +23 -0
- ktalk_cli/auth.py +267 -0
- ktalk_cli/calendar_reader.py +149 -0
- ktalk_cli/cli.py +445 -0
- ktalk_cli/cli_contacts.py +55 -0
- ktalk_cli/cli_content.py +167 -0
- ktalk_cli/cli_meeting.py +68 -0
- ktalk_cli/cli_meeting_args.py +123 -0
- ktalk_cli/cli_meeting_confirm.py +256 -0
- ktalk_cli/cli_meetings_read.py +115 -0
- ktalk_cli/cli_sanction.py +108 -0
- ktalk_cli/cli_store.py +59 -0
- ktalk_cli/cli_sync.py +169 -0
- ktalk_cli/cli_token.py +88 -0
- ktalk_cli/client.py +348 -0
- ktalk_cli/config.py +152 -0
- ktalk_cli/confirmation.py +129 -0
- ktalk_cli/contacts.py +45 -0
- ktalk_cli/contour_diagnostics.py +107 -0
- ktalk_cli/download.py +109 -0
- ktalk_cli/endpoints.py +145 -0
- ktalk_cli/enrichment.py +83 -0
- ktalk_cli/formatters.py +589 -0
- ktalk_cli/host_config.py +136 -0
- ktalk_cli/meeting_body.py +178 -0
- ktalk_cli/meeting_cancel.py +33 -0
- ktalk_cli/meeting_scheduling.py +120 -0
- ktalk_cli/pagination.py +108 -0
- ktalk_cli/reconciliation.py +59 -0
- ktalk_cli/registry.py +562 -0
- ktalk_cli/rooms.py +62 -0
- ktalk_cli/store.py +88 -0
- ktalk_cli/store_migration.py +89 -0
- ktalk_cli/token_file.py +101 -0
- ktalk_cli/write_journal.py +94 -0
- ktalk_cli/write_sanction.py +187 -0
- ktalk_cli-1.0.0.dist-info/METADATA +391 -0
- ktalk_cli-1.0.0.dist-info/RECORD +42 -0
- ktalk_cli-1.0.0.dist-info/WHEEL +4 -0
- ktalk_cli-1.0.0.dist-info/entry_points.txt +2 -0
- ktalk_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
ktalk_cli/config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, model_validator
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ktalk_cli.host_config import HostConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthMode(str, Enum):
|
|
14
|
+
"""Активный механизм авторизации клиента (ADR-003)."""
|
|
15
|
+
|
|
16
|
+
SESSION = "session"
|
|
17
|
+
API_KEY = "api_key"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KTalkConfigError(Exception):
|
|
21
|
+
"""Ни KTALK_PERSONAL_API_KEY, ни KTALK_SESSION_TOKEN не заданы."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_db_path(
|
|
25
|
+
cli_db: str | None = None, host_config: "HostConfig | None" = None
|
|
26
|
+
) -> Path:
|
|
27
|
+
"""Resolve the registry DB path (ADR-013 §3, расширено волной 3):
|
|
28
|
+
|
|
29
|
+
`--db` > `KTALK_REGISTRY_DB` > `host_config.registry.db_path` (SA-003,
|
|
30
|
+
discovery выполняется вызывающей стороной — `resolve_db_path` только
|
|
31
|
+
использует уже готовый `HostConfig`) > машинный дефолт централизованного
|
|
32
|
+
хранилища (`store.resolve_store_root`, FR-22).
|
|
33
|
+
|
|
34
|
+
Старый относительный дефолт `95_TRANSCRIPTS/.registry.db` (ADR-002) заменён
|
|
35
|
+
машинным дефолтом вне cwd (ADR-013) — единственный оставшийся источник
|
|
36
|
+
относительного пути в этой функции теперь `host_config`, если проект-хозяин
|
|
37
|
+
явно объявляет относительный `registry.db_path`.
|
|
38
|
+
|
|
39
|
+
Code review (epic-capability-pairing, Р4): `warn_if_sync_dir` (NFR-14 AC-2)
|
|
40
|
+
обязана применяться к итоговому пути независимо от источника (ADR-013-spec
|
|
41
|
+
§«Поток данных» п.4) — до этой правки вызывалась только в ветке машинного
|
|
42
|
+
дефолта; `--db`/`KTALK_REGISTRY_DB`/конфиг хозяина возвращали путь раньше.
|
|
43
|
+
"""
|
|
44
|
+
from ktalk_cli.store import resolve_store_root, warn_if_sync_dir
|
|
45
|
+
|
|
46
|
+
if cli_db:
|
|
47
|
+
path = Path(cli_db)
|
|
48
|
+
warn_if_sync_dir(path)
|
|
49
|
+
return path
|
|
50
|
+
env = os.environ.get("KTALK_REGISTRY_DB")
|
|
51
|
+
if env:
|
|
52
|
+
path = Path(env)
|
|
53
|
+
warn_if_sync_dir(path)
|
|
54
|
+
return path
|
|
55
|
+
if host_config is not None:
|
|
56
|
+
configured = host_config.registry.get("db_path")
|
|
57
|
+
if configured:
|
|
58
|
+
path = Path(configured)
|
|
59
|
+
warn_if_sync_dir(path)
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
path = resolve_store_root() / "registry.db"
|
|
63
|
+
warn_if_sync_dir(path)
|
|
64
|
+
return path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Settings(BaseSettings):
|
|
68
|
+
"""KTalk MCP server configuration.
|
|
69
|
+
|
|
70
|
+
Environment variables:
|
|
71
|
+
KTALK_BASE_URL: KTalk instance URL (default: https://your-domain.ktalk.ru)
|
|
72
|
+
KTALK_SESSION_TOKEN: Session token from browser cookies
|
|
73
|
+
KTALK_PERSONAL_API_KEY: Персональный API-ключ (ADR-003) — приоритетнее сессии
|
|
74
|
+
|
|
75
|
+
Оба секретных поля опциональны на уровне модели (ADR-003): конструирование
|
|
76
|
+
`Settings()` никогда не падает само по себе. Приоритет режима (ключ -> сессия ->
|
|
77
|
+
явная ошибка) вычисляется лениво в `.auth_mode` — единственной точке, где
|
|
78
|
+
отсутствие ОБЕИХ переменных становится `KTalkConfigError`.
|
|
79
|
+
|
|
80
|
+
NFR-5 / security review (SEC-001): `ktalk_session_token`/`ktalk_personal_api_key`
|
|
81
|
+
объявлены `Field(repr=False)` — pydantic по умолчанию печатает значения ВСЕХ полей
|
|
82
|
+
в `repr(settings)`/`str(settings)` (в отличие от `AuthContext`, который уже маскирует
|
|
83
|
+
себя явно), а это ровно тот текст, что мог бы случайно попасть в отладочный
|
|
84
|
+
`print`/`logger.debug(settings)` или в текст будущего `ValidationError`.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
ktalk_base_url: str = "https://your-domain.ktalk.ru"
|
|
88
|
+
ktalk_session_token: str | None = Field(default=None, repr=False)
|
|
89
|
+
ktalk_personal_api_key: str | None = Field(default=None, repr=False)
|
|
90
|
+
|
|
91
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
@model_validator(mode="after")
|
|
94
|
+
def _fall_back_to_token_file(self) -> "Settings":
|
|
95
|
+
"""Третий источник сессии — файл `~/.config/ktalk-mcp/token` (token_file.py).
|
|
96
|
+
|
|
97
|
+
Подставляется здесь, а не в `.auth_mode`, чтобы у файла и у переменной
|
|
98
|
+
окружения была ровно одна точка входа в модель: всё остальное (приоритет
|
|
99
|
+
ключа, `auth_credential`, барьер маскирования `redact_secrets`) продолжает
|
|
100
|
+
читать одно поле и о существовании файла не знает.
|
|
101
|
+
|
|
102
|
+
Порядок источников: `KTALK_PERSONAL_API_KEY` > `KTALK_SESSION_TOKEN` > файл.
|
|
103
|
+
Файл читается только когда пусты ОБЕ переменные — заданное окружение
|
|
104
|
+
сильнее лежащего на диске, иначе протухший файл молча перебивал бы токен,
|
|
105
|
+
который оператор передал явно.
|
|
106
|
+
"""
|
|
107
|
+
if self.ktalk_personal_api_key or self.ktalk_session_token:
|
|
108
|
+
return self
|
|
109
|
+
from ktalk_cli.token_file import read_token
|
|
110
|
+
|
|
111
|
+
token = read_token()
|
|
112
|
+
if token:
|
|
113
|
+
object.__setattr__(self, "ktalk_session_token", token)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def auth_mode(self) -> AuthMode:
|
|
118
|
+
if self.ktalk_personal_api_key:
|
|
119
|
+
return AuthMode.API_KEY
|
|
120
|
+
if self.ktalk_session_token:
|
|
121
|
+
return AuthMode.SESSION
|
|
122
|
+
raise KTalkConfigError(
|
|
123
|
+
"Не задана ни KTALK_PERSONAL_API_KEY, ни KTALK_SESSION_TOKEN, "
|
|
124
|
+
"и файла токена нет. Задайте переменную или выполните "
|
|
125
|
+
"`ktalk token set -` (см. README)."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def auth_credential(self) -> str:
|
|
130
|
+
# .auth_mode уже проверил, что хотя бы одно поле непустое.
|
|
131
|
+
return self.ktalk_personal_api_key or self.ktalk_session_token or ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def redact_secrets(text: str) -> str:
|
|
135
|
+
"""Барьер маскирования (NFR-5, ADR-003 «SecretRedactor», ранее спроектирован, но не
|
|
136
|
+
вызывался ниоткуда — security review SEC-001).
|
|
137
|
+
|
|
138
|
+
Ни `KTalkError`, ни `httpx`-обёртки этого проекта сегодня не строят текст ошибки из
|
|
139
|
+
`request.url`/`request.headers`/`repr(request)` (проверено ревью) — секрет не должен
|
|
140
|
+
появиться в тексте исключения. Это тем не менее последний рубеж на границе CLI: если
|
|
141
|
+
секрет всё же попадёт в текст произвольного, не-`KTalkError`-исключения (например, из
|
|
142
|
+
сторонней зависимости, которая не следует той же дисциплине), значение маскируется
|
|
143
|
+
здесь перед печатью, а не полагается только на дисциплину каждого источника ошибки.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
settings = Settings()
|
|
147
|
+
except Exception: # noqa: BLE001 - барьер не должен сам стать новым источником отказа
|
|
148
|
+
return text
|
|
149
|
+
for value in (settings.ktalk_personal_api_key, settings.ktalk_session_token):
|
|
150
|
+
if value:
|
|
151
|
+
text = text.replace(value, "***REDACTED***")
|
|
152
|
+
return text
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""FR-13 §6.2: хранилище подтверждений — TTL, single-use, привязка к хешу тела
|
|
2
|
+
(ADR-005-spec «Форма подтверждения»/«Сценарии отказа»).
|
|
3
|
+
|
|
4
|
+
ADR-016 §2: хранилище переехало из памяти в файл `$XDG_STATE_HOME/ktalk`. Вывод
|
|
5
|
+
ADR-015 «`confirmation_id` не переживает границу процессов» был следствием
|
|
6
|
+
хранилища в памяти, а не свойством задачи, и снят вместе с причиной: агент теперь
|
|
7
|
+
выполняет и предпросмотр, и подтверждение, и именно id связывает предъявленное
|
|
8
|
+
оператору тело с фактической записью.
|
|
9
|
+
|
|
10
|
+
TTL, одноразовость и неразличимость причин отказа (`match` возвращает один `False`)
|
|
11
|
+
не пересматриваются. Битый или нечитаемый файл читается как пустое хранилище —
|
|
12
|
+
fail-closed: все подтверждения недействительны, а не «проверку пропустить».
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime, timedelta, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Дизайн-выбор, не измеренная величина (тот же класс решения, что concurrency=5 в
|
|
26
|
+
# enrichment.py) — обоснование в rooms-calendar-spec §6.2.
|
|
27
|
+
CONFIRMATION_TTL = timedelta(minutes=10)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ConfirmationRecord:
|
|
32
|
+
body_hash: str
|
|
33
|
+
expires_at: datetime # UTC-aware
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def confirmation_store_path() -> Path:
|
|
37
|
+
"""Состояние, а не конфигурация -> `$XDG_STATE_HOME`, не `$XDG_CONFIG_HOME`;
|
|
38
|
+
и не рядом с транскриптами в `$XDG_DATA_HOME` (ADR-013 — данные пользователя,
|
|
39
|
+
переживающие переустановку; подтверждение живёт десять минут)."""
|
|
40
|
+
xdg_state_home = os.environ.get("XDG_STATE_HOME") or None
|
|
41
|
+
root = Path(xdg_state_home) if xdg_state_home else Path.home() / ".local" / "state"
|
|
42
|
+
return root / "ktalk" / "pending-confirmations.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfirmationStore:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
|
50
|
+
path: Path | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._clock = clock
|
|
53
|
+
self._path = path
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def path(self) -> Path:
|
|
57
|
+
return self._path if self._path is not None else confirmation_store_path()
|
|
58
|
+
|
|
59
|
+
def _load(self) -> dict[str, ConfirmationRecord]:
|
|
60
|
+
try:
|
|
61
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
return {}
|
|
64
|
+
records: dict[str, ConfirmationRecord] = {}
|
|
65
|
+
if not isinstance(raw, dict):
|
|
66
|
+
return {}
|
|
67
|
+
for confirmation_id, record in raw.items():
|
|
68
|
+
try:
|
|
69
|
+
expires_at = datetime.fromisoformat(record["expires_at"])
|
|
70
|
+
records[confirmation_id] = ConfirmationRecord(
|
|
71
|
+
body_hash=record["body_hash"], expires_at=expires_at
|
|
72
|
+
)
|
|
73
|
+
except (TypeError, KeyError, ValueError):
|
|
74
|
+
continue
|
|
75
|
+
return records
|
|
76
|
+
|
|
77
|
+
def _save(self, records: dict[str, ConfirmationRecord]) -> None:
|
|
78
|
+
path = self.path
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
os.chmod(path.parent, 0o700)
|
|
81
|
+
payload = {
|
|
82
|
+
confirmation_id: {
|
|
83
|
+
"body_hash": record.body_hash,
|
|
84
|
+
"expires_at": record.expires_at.isoformat(),
|
|
85
|
+
}
|
|
86
|
+
for confirmation_id, record in records.items()
|
|
87
|
+
}
|
|
88
|
+
tmp = path.with_name(f".{path.name}.tmp")
|
|
89
|
+
tmp.write_text(json.dumps(payload), encoding="utf-8")
|
|
90
|
+
os.chmod(tmp, 0o600)
|
|
91
|
+
tmp.replace(path)
|
|
92
|
+
|
|
93
|
+
def issue(self, body_hash: str) -> str:
|
|
94
|
+
"""Непредсказуемый одноразовый идентификатор, не производный от `body_hash`
|
|
95
|
+
— иначе агент вычислил бы его локально, минуя факт вызова предпросмотра.
|
|
96
|
+
Попутно вычищает истёкшие записи: файл не растёт бесконечно, и уборка не
|
|
97
|
+
требует отдельной команды."""
|
|
98
|
+
# Префикс `c` — не украшение: `token_urlsafe` может начаться с `-`, и такой
|
|
99
|
+
# id argparse принимает за флаг (`--confirmation-id -x8Q…` -> «expected one
|
|
100
|
+
# argument»). В памяти это было безразлично, на границе командной строки —
|
|
101
|
+
# плавающий отказ примерно в каждом двадцатом вызове (DEV-012).
|
|
102
|
+
confirmation_id = f"c{secrets.token_urlsafe(24)}"
|
|
103
|
+
now = self._clock()
|
|
104
|
+
records = {
|
|
105
|
+
key: record for key, record in self._load().items() if now < record.expires_at
|
|
106
|
+
}
|
|
107
|
+
records[confirmation_id] = ConfirmationRecord(
|
|
108
|
+
body_hash=body_hash, expires_at=now + CONFIRMATION_TTL
|
|
109
|
+
)
|
|
110
|
+
self._save(records)
|
|
111
|
+
return confirmation_id
|
|
112
|
+
|
|
113
|
+
def match(self, confirmation_id: str, body_hash: str) -> bool:
|
|
114
|
+
"""id неизвестен/потреблён/TTL истёк/хеш не совпал -> одно и то же `False`
|
|
115
|
+
(не различаем причины наружу — ничего из этого не должно ускользать в
|
|
116
|
+
подсказку "как обойти")."""
|
|
117
|
+
record = self._load().get(confirmation_id)
|
|
118
|
+
if record is None:
|
|
119
|
+
return False
|
|
120
|
+
if self._clock() >= record.expires_at:
|
|
121
|
+
return False
|
|
122
|
+
return record.body_hash == body_hash
|
|
123
|
+
|
|
124
|
+
def consume(self, confirmation_id: str) -> None:
|
|
125
|
+
"""Вызывается ДО сетевой попытки — повтор после сбоя требует нового
|
|
126
|
+
предпросмотра и нового подтверждения, не автоматического retry."""
|
|
127
|
+
records = self._load()
|
|
128
|
+
if records.pop(confirmation_id, None) is not None:
|
|
129
|
+
self._save(records)
|
ktalk_cli/contacts.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""ADR-010: резолюция участника встречи через справочник контактов
|
|
2
|
+
(`GET /api/contacts`) — маппер + сетевой вызов вне `client.py` (гейт C13).
|
|
3
|
+
|
|
4
|
+
Вынесено свободной функцией по тому же приёму, что `rooms.get_room`. Читающая
|
|
5
|
+
операция, не мутирует состояние — не оборачивается корреляционной диагностикой
|
|
6
|
+
ADR-004 (тот же класс, что `get_room`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ktalk_cli.auth import _display_name
|
|
12
|
+
from ktalk_cli.client import KTalkClient
|
|
13
|
+
|
|
14
|
+
_TOP = 25
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _map_candidate(raw: dict) -> dict:
|
|
18
|
+
return {
|
|
19
|
+
"key": raw.get("key"),
|
|
20
|
+
"name": _display_name(raw),
|
|
21
|
+
"post": raw.get("post"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def search_contacts(client: KTalkClient, query: str) -> list[dict]:
|
|
26
|
+
"""Один GET `/api/contacts` -> список кандидатов `{key, name, post}`.
|
|
27
|
+
|
|
28
|
+
`top=25`, `fillInMeetingStatus=false`, `includeKiosks=true` — фиксированные
|
|
29
|
+
константы компоновщика запроса (ADR-010-spec §«Интеграционные точки»), не
|
|
30
|
+
параметры вызывающего в этой волне. Автовыбора нет: 0/1/>1 совпадений —
|
|
31
|
+
решение оператора остаётся снаружи (см. `formatters.format_search_contacts`).
|
|
32
|
+
"""
|
|
33
|
+
profile = client._profile_for("search_contacts") # noqa: SLF001 - fail-closed до сети (api-key)
|
|
34
|
+
response = await client._client.get( # noqa: SLF001
|
|
35
|
+
profile.path_template,
|
|
36
|
+
params={
|
|
37
|
+
"query": query,
|
|
38
|
+
"top": _TOP,
|
|
39
|
+
"fillInMeetingStatus": "false",
|
|
40
|
+
"includeKiosks": "true",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
client._classify(response, profile.required_scope) # noqa: SLF001
|
|
44
|
+
raw = response.json()
|
|
45
|
+
return [_map_candidate(c) for c in raw.get("contacts") or []]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Корреляционная диагностика недокументированного контура (ADR-004).
|
|
2
|
+
|
|
3
|
+
Единственный переиспользуемый компонент FR-17/FR-18 (rooms.py, calendar_reader.py):
|
|
4
|
+
отказ на недокументированном пути сам по себе неотличим от обычного auth/сетевого
|
|
5
|
+
сбоя — модуль запускает контрольный вызов (`list_recordings(top=1)`, уже подтверждён
|
|
6
|
+
рабочим в обоих режимах) и решает, где локализована проблема.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ktalk_cli.client import (
|
|
14
|
+
KTalkAuthError,
|
|
15
|
+
KTalkClient,
|
|
16
|
+
KTalkError,
|
|
17
|
+
KTalkScopeError,
|
|
18
|
+
KTalkWriteAuthMismatchError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
TRANSIENT_ERRORS = (KTalkError, httpx.HTTPError)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContourDriftError(KTalkError):
|
|
25
|
+
"""Контрольная операция в порядке, но недокументированный путь отказал —
|
|
26
|
+
сбой локализован там, не в правах/сети (ADR-004 «Механизм детекции»)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, operation: str, detail: str) -> None:
|
|
29
|
+
self.operation = operation
|
|
30
|
+
self.detail = detail
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"Недокументированная операция «{operation}» ведёт себя не так, как "
|
|
33
|
+
f"ожидалось (контроль авторизации прошёл): {detail}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _status_hint(error: Exception) -> str:
|
|
38
|
+
"""Код ответа для текста сообщения (ADR-008 §2). `_with_status` (client.py)
|
|
39
|
+
прикрепляет `status_code` на исключении при классификации — используется,
|
|
40
|
+
если есть; иначе — общая формулировка без числа (не должно происходить для
|
|
41
|
+
401/403, оставлено как защита от рассинхронизации)."""
|
|
42
|
+
status = getattr(error, "status_code", None)
|
|
43
|
+
return f"HTTP {status}" if status is not None else "ошибку авторизации"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _describe_control_failure(control_error: Exception) -> str:
|
|
47
|
+
"""DEV-008: класс/HTTP-код/текст контрольного вызова — тот же формат, что
|
|
48
|
+
`_status_hint`, но не глотает исключение целиком. Нужен, когда контроль ТОЖЕ
|
|
49
|
+
падает: раньше его исход терялся в `raise error from None`, и человек не мог
|
|
50
|
+
отличить «контроль упал» от «диагностика не отработала»."""
|
|
51
|
+
status = getattr(control_error, "status_code", None)
|
|
52
|
+
status_part = f"HTTP {status}" if status is not None else "без HTTP-кода"
|
|
53
|
+
return f"{type(control_error).__name__} ({status_part}): {control_error}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def diagnose_undocumented_failure(
|
|
57
|
+
client: KTalkClient, operation: str, error: Exception
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Всегда завершается исключением: либо перевыбрасывает `error` (контроль тоже
|
|
60
|
+
провалился — не дрейф контура), либо поднимает `KTalkWriteAuthMismatchError`
|
|
61
|
+
(ADR-008: исходная ошибка — 401/403, не scope-специфичная, контроль в порядке —
|
|
62
|
+
credential подтверждён рабочим независимой проверкой), либо `ContourDriftError`
|
|
63
|
+
(прочие классы сбоя — контроль в порядке, сбой локализован в недокументированном
|
|
64
|
+
пути)."""
|
|
65
|
+
try:
|
|
66
|
+
await client.list_recordings(top=1)
|
|
67
|
+
except TRANSIENT_ERRORS as control_error:
|
|
68
|
+
# DEV-008: контроль тоже упал — это не "диагностика не отработала", а
|
|
69
|
+
# самостоятельный факт, который должен дойти до человека вместе с
|
|
70
|
+
# исходной ошибкой, не молча (`except TRANSIENT_ERRORS: raise error from
|
|
71
|
+
# None` раньше терял его целиком).
|
|
72
|
+
error.control_probe = (
|
|
73
|
+
"Контрольный вызов list_recordings(top=1) тоже упал: "
|
|
74
|
+
f"{_describe_control_failure(control_error)}."
|
|
75
|
+
)
|
|
76
|
+
raise error from None
|
|
77
|
+
if isinstance(error, KTalkAuthError) and not isinstance(error, KTalkScopeError):
|
|
78
|
+
new_exc = KTalkWriteAuthMismatchError(
|
|
79
|
+
f"Операция «{operation}» вернула {_status_hint(error)}, хотя тот же credential "
|
|
80
|
+
"подтверждён рабочим независимой проверкой (list_recordings) в ту же секунду. "
|
|
81
|
+
"Обновлять токен/ключ не нужно — причина в том, как именно эта операция "
|
|
82
|
+
"принимает credential (см. ADR-008), не в его валидности."
|
|
83
|
+
)
|
|
84
|
+
_carry(error, new_exc)
|
|
85
|
+
raise new_exc from error
|
|
86
|
+
new_drift = ContourDriftError(operation, str(error))
|
|
87
|
+
_carry(error, new_drift)
|
|
88
|
+
raise new_drift from error
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _carry(error: Exception, derived: Exception) -> None:
|
|
92
|
+
"""DEV-012 (ADR-016 §5): вместе с телом ответа переносится и `status_code`.
|
|
93
|
+
Без него журнал операций не отличал «сервер отказал» от «ответа не было» на той
|
|
94
|
+
ветке, где контроль прошёл, — а именно этот класс исходов и разбирают постфактум."""
|
|
95
|
+
derived.response_body = getattr(error, "response_body", None)
|
|
96
|
+
status_code = getattr(error, "status_code", None)
|
|
97
|
+
if status_code is not None:
|
|
98
|
+
derived.status_code = status_code
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def require_contract_field(payload: dict, field: str, operation: str) -> None:
|
|
102
|
+
"""ContourDriftError вместо тихого KeyError/None при отсутствии поля-якоря
|
|
103
|
+
контракта на коде 200 — без корреляции (доступ уже подтверждён кодом 200)."""
|
|
104
|
+
if field not in payload:
|
|
105
|
+
raise ContourDriftError(
|
|
106
|
+
operation, f"поле-якорь контракта «{field}» отсутствует в ответе 200."
|
|
107
|
+
)
|
ktalk_cli/download.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Потоковая запись видеофайла записи на диск (FR-7).
|
|
2
|
+
|
|
3
|
+
Session-режим — ссылка уже готова в `qualities[].fileUrl` деталей записи.
|
|
4
|
+
Api-key-режим — отдельный путь с именем качества в шаблоне (`RES-001` п.5), список
|
|
5
|
+
доступных качеств под ключом взять неоткуда (открытый вопрос SA) — запрошенное
|
|
6
|
+
качество используется как есть, без валидации по списку.
|
|
7
|
+
|
|
8
|
+
Зонд Ф-7: имя качества расходится между режимами (`900p` без пробела в session,
|
|
9
|
+
`900 p` с пробелом рекомендует спека api-key) — наивная сборка URL с пробелом
|
|
10
|
+
ломается (`InvalidURL`). `build_download_url` нормализует и квотирует.
|
|
11
|
+
|
|
12
|
+
Политика записи на диск — базовый безопасный минимум (SA сознательно оставил её
|
|
13
|
+
открытой, полное ревью — DevSecOps): пишем только по явно переданному пути, не
|
|
14
|
+
угадываем и не создаём файлы в неожиданных местах, отказываем при попытке
|
|
15
|
+
перезаписать существующий файл без явного `overwrite=True`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
from ktalk_cli.config import AuthMode
|
|
25
|
+
|
|
26
|
+
DEFAULT_QUALITY = "900p"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QualityNotFoundError(Exception):
|
|
30
|
+
"""Запрошенное качество недоступно для этой записи (FR-7 AC-3)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_quality(quality: str) -> str:
|
|
34
|
+
"""`900p` и `900 p` -> одна и та же каноническая форма без пробела."""
|
|
35
|
+
return quality.replace(" ", "").lower()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_download_url(recording_key: str, quality: str) -> str:
|
|
39
|
+
"""Api-key-путь скачивания, URL-квотированный (FR-7 AC-2, зонд Ф-7)."""
|
|
40
|
+
normalized = _normalize_quality(quality)
|
|
41
|
+
key = quote(str(recording_key), safe="")
|
|
42
|
+
q = quote(normalized, safe="")
|
|
43
|
+
return f"/api/Recordings/{key}/file/{q}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_session_quality(qualities: list[dict], quality: str | None) -> tuple[str, str]:
|
|
47
|
+
if not qualities:
|
|
48
|
+
raise QualityNotFoundError("У записи нет доступных качеств скачивания.")
|
|
49
|
+
by_normalized = {_normalize_quality(q.get("name", "")): q for q in qualities}
|
|
50
|
+
wanted = _normalize_quality(quality) if quality else next(iter(by_normalized))
|
|
51
|
+
match = by_normalized.get(wanted)
|
|
52
|
+
if match is None:
|
|
53
|
+
available = ", ".join(q.get("name", "?") for q in qualities)
|
|
54
|
+
raise QualityNotFoundError(
|
|
55
|
+
f"Качество «{quality}» недоступно для этой записи. Доступные: {available}."
|
|
56
|
+
)
|
|
57
|
+
return match.get("name", wanted), match["fileUrl"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def download_recording_file(
|
|
61
|
+
client,
|
|
62
|
+
recording_key: str,
|
|
63
|
+
target_path: str,
|
|
64
|
+
quality: str | None = None,
|
|
65
|
+
*,
|
|
66
|
+
overwrite: bool = False,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Скачивает файл записи потоково, без буферизации целиком в памяти (FR-7 AC-4)."""
|
|
69
|
+
if client.auth_mode is AuthMode.API_KEY:
|
|
70
|
+
resolved_quality = quality or DEFAULT_QUALITY
|
|
71
|
+
url = build_download_url(recording_key, resolved_quality)
|
|
72
|
+
scope = "application.recording.read"
|
|
73
|
+
else:
|
|
74
|
+
detail = await client.get_recording(recording_key)
|
|
75
|
+
resolved_quality, url = _resolve_session_quality(detail.get("qualities") or [], quality)
|
|
76
|
+
scope = None
|
|
77
|
+
|
|
78
|
+
target = Path(target_path)
|
|
79
|
+
if target.exists() and not overwrite:
|
|
80
|
+
# Быстрый отказ до сетевого вызова — сохраняет прежнее поведение/сообщение.
|
|
81
|
+
raise FileExistsError(
|
|
82
|
+
f"Файл уже существует: {target}. Укажите overwrite=True для перезаписи."
|
|
83
|
+
)
|
|
84
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
total = 0
|
|
87
|
+
async with client.stream("GET", url) as response:
|
|
88
|
+
client.check_response(response, scope)
|
|
89
|
+
# Security review SEC-001: `target.exists()` выше следует за симлинками и
|
|
90
|
+
# возвращает False для «оборванного» симлинка (указывающего на
|
|
91
|
+
# несуществующий путь) — наивный `target.open("wb")` в этом случае писал бы
|
|
92
|
+
# СКВОЗЬ симлинк в произвольное место, куда указывает ссылка. Между
|
|
93
|
+
# проверкой и записью есть и обычное TOCTOU-окно (сетевой вызов между ними).
|
|
94
|
+
# `os.O_EXCL` с `os.O_CREAT` атомарно отказывает и на гонке, и на висящем
|
|
95
|
+
# симлинке (POSIX). При `overwrite=True` поведение не меняется — перезапись
|
|
96
|
+
# была осознанно запрошена вызывающим.
|
|
97
|
+
flags = os.O_WRONLY | os.O_CREAT | (0 if overwrite else os.O_EXCL)
|
|
98
|
+
try:
|
|
99
|
+
fd = os.open(target, flags, 0o644)
|
|
100
|
+
except FileExistsError as exc:
|
|
101
|
+
raise FileExistsError(
|
|
102
|
+
f"Файл уже существует: {target}. Укажите overwrite=True для перезаписи."
|
|
103
|
+
) from exc
|
|
104
|
+
with os.fdopen(fd, "wb") as fh:
|
|
105
|
+
async for chunk in response.aiter_bytes():
|
|
106
|
+
fh.write(chunk)
|
|
107
|
+
total += len(chunk)
|
|
108
|
+
|
|
109
|
+
return {"path": str(target), "bytes": total, "quality": resolved_quality}
|