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/CLAUDE.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ktalk-cli — API Контур.Толк
|
|
2
|
+
|
|
3
|
+
Справочник вынесен из корневого `CLAUDE.md`: грузится только при работе под `src/`.
|
|
4
|
+
|
|
5
|
+
## Контракт API
|
|
6
|
+
- OpenAPI спецификация (справочник, **есть расхождения с реальностью**): `talk.public.api-api-2.json`
|
|
7
|
+
- Base URL: https://your-domain.ktalk.ru
|
|
8
|
+
- **Два режима авторизации** (решение — [ADR-003](content/00-project/adr/ADR-003-auth-modes.md)):
|
|
9
|
+
`KTALK_PERSONAL_API_KEY` → заголовок `X-Auth-Token`; иначе `KTALK_SESSION_TOKEN` →
|
|
10
|
+
query `sessionToken=`. Ключ побеждает; при обоих заданных session-токен не читается.
|
|
11
|
+
- **Третий источник сессии — файл** `~/.config/ktalk-mcp/token` (`token_file.py`, DEV-018):
|
|
12
|
+
читается валидатором `Settings`, только когда пусты обе переменные. Права шире `0600` —
|
|
13
|
+
файла как будто нет. Запись — `ktalk token set -`, формат значения проверяется до записи.
|
|
14
|
+
- **Набор путей зависит от режима** — интеграторский контур (`/api/Domain/*`, `/api/Recordings/*`,
|
|
15
|
+
`/api/ConferenceReports/*`) отдаёт 401/403 по сессии, поэтому пути живут в таблице профилей
|
|
16
|
+
`auth.py`, а не хардкодом в методах.
|
|
17
|
+
- Session-контур: `GET /api/recordings`, `/api/recordings/{id}`, `/api/conferencesHistory/{key}`
|
|
18
|
+
- Общее для обоих: `/api/recordings/{key}/transcript`, `/api/recordings/v2/{key}/summary`,
|
|
19
|
+
`/api/recordings/{key}/summary/{type}`
|
|
20
|
+
|
|
21
|
+
### Поведение API, проверенное эмпирически (спеке здесь верить нельзя)
|
|
22
|
+
- `top` максимум **100**, не 1000: `400 «The field Top must be between 1 and 100»`.
|
|
23
|
+
- `nextPageToken` во внутреннем контуре **не существует** — пагинация только через `skip`.
|
|
24
|
+
- `startFrom`/`startTo` **игнорируются**: окно дат обеспечивает клиент (`clip_to_window`),
|
|
25
|
+
обход прекращается на первой странице за порогом. Выдача отсортирована от новых к старым.
|
|
26
|
+
- `maxParticipantCount` в списке имеет максимум 10 и дефолт 6 — полный состав участников
|
|
27
|
+
берётся дообогащением по каждой записи, а не из списка.
|
|
28
|
+
- Чат требует необъявленный в спеке параметр `channel` (рабочее значение `general`).
|
|
29
|
+
- **401 ≠ 403**: 401 — ключ/токен невалиден, 403 — валиден, но не хватает scope. Тело 403
|
|
30
|
+
обычно пустое, диагностика строится на коде ответа и требуемом scope операции.
|
ktalk_cli/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import metadata as _installed_metadata
|
|
5
|
+
from importlib.metadata import version as _installed_version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
# Единственный источник истины — метаданные установленного дистрибутива.
|
|
9
|
+
# Литерал здесь разъехался с pyproject.toml в 0.8.0: дистрибутив был 0.8.0,
|
|
10
|
+
# а `ktalk --version` печатал 0.7.0, и гейт совместимости плагина
|
|
11
|
+
# (`installed_version()` в ktalk-onboard.sh читает именно его) браковал
|
|
12
|
+
# заведомо совместимую установку.
|
|
13
|
+
#
|
|
14
|
+
# ADR-022 §7 (SA-001): та же логика — теперь и для имени дистрибутива, не
|
|
15
|
+
# только версии. `__package__` — имя реального Python-пакета ("ktalk_cli"),
|
|
16
|
+
# не набранная заново строка; `importlib.metadata` нормализует "-"/"_"/регистр
|
|
17
|
+
# ключа поиска, так что он резолвится в тот же установленный дистрибутив,
|
|
18
|
+
# что и дефисное имя "ktalk-cli" из pyproject.toml.
|
|
19
|
+
__version__ = _installed_version(__package__)
|
|
20
|
+
__dist_name__ = _installed_metadata(__package__)["Name"]
|
|
21
|
+
except PackageNotFoundError: # исходное дерево без установки
|
|
22
|
+
__version__ = "0.0.0+source"
|
|
23
|
+
__dist_name__ = "ktalk-cli" # заглушка: метаданных читать неоткуда
|
ktalk_cli/auth.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Режим авторизации, профиль эндпоинтов, нормализованная форма страницы (ADR-003).
|
|
2
|
+
|
|
3
|
+
Вынесено из `client.py` для гейта C13 (объём кода): таблицы/DTO здесь — данные,
|
|
4
|
+
не поведение сети, `KTalkClient` остаётся единственным потребителем. Публичные
|
|
5
|
+
имена, ожидаемые тестами через `ktalk_cli.client` (`AuthStatus`,
|
|
6
|
+
`normalize_list_session`, `normalize_list_apikey`), реэкспортируются оттуда.
|
|
7
|
+
|
|
8
|
+
`OPERATION_PROFILES`/`OPERATION_LABELS`/`EndpointProfile`/`quote_path_param` —
|
|
9
|
+
в `endpoints.py` (тот же гейт: таблица профилей переросла порог top-level
|
|
10
|
+
декларации при добавлении ADR-010/ADR-011), реэкспортированы ниже — тесты и
|
|
11
|
+
остальной код по-прежнему видят их как `ktalk_cli.auth.*`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from ktalk_cli.config import AuthMode, KTalkConfigError
|
|
21
|
+
from ktalk_cli.endpoints import ( # noqa: F401 - реэкспорт публичного контракта модуля
|
|
22
|
+
OPERATION_LABELS,
|
|
23
|
+
OPERATION_PROFILES,
|
|
24
|
+
EndpointProfile,
|
|
25
|
+
quote_path_param,
|
|
26
|
+
)
|
|
27
|
+
from ktalk_cli.pagination import paginate_pages, skip_pages
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from ktalk_cli.client import KTalkClient
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
SCOPE_LABELS = {
|
|
35
|
+
"application.recording.read": "Записи (только чтение)",
|
|
36
|
+
"application.reporting.read": "Отчётность (только чтение)",
|
|
37
|
+
"application.applications.read": "Информация по API-ключам (только чтение)",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KTalkError(Exception):
|
|
42
|
+
"""Base error for KTalk API."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KTalkAuthError(KTalkError):
|
|
46
|
+
"""Session token or personal API key expired or invalid."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class KTalkScopeError(KTalkAuthError):
|
|
50
|
+
"""API key valid, but lacks the required scope for the operation."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KTalkWriteAuthMismatchError(KTalkAuthError):
|
|
54
|
+
"""401/403 на операции, credential которой в ту же секунду подтверждён рабочим
|
|
55
|
+
независимой проверкой (ADR-008) — обновление токена не помогает."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class KTalkNotFoundError(KTalkError):
|
|
59
|
+
"""Recording not found."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OperationNotAvailableError(KTalkError):
|
|
63
|
+
"""Operation has no endpoint profile for the currently active auth mode."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _auth_error(message: str, status_code: int, cls: type[KTalkAuthError] = KTalkAuthError):
|
|
67
|
+
"""ADR-008 §2: код ответа — отдельный атрибут исключения (`status_code`), читает
|
|
68
|
+
его `_status_hint` в `contour_diagnostics.py` без парсинга текста сообщения."""
|
|
69
|
+
exc = cls(message)
|
|
70
|
+
exc.status_code = status_code
|
|
71
|
+
return exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def classify_response(
|
|
75
|
+
mode: AuthMode, response: object, required_scope: str | None
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Вынесено из `KTalkClient._classify` (гейт C13) — не зависит от `self`, только
|
|
78
|
+
от режима, статус-кода и требуемого scope. ADR-003: коды 401/403 разводятся по
|
|
79
|
+
смыслу (истёк vs. запрещён), ADR-008: код ответа — атрибут `status_code` на
|
|
80
|
+
исключении, читает `contour_diagnostics._status_hint`."""
|
|
81
|
+
status = response.status_code # type: ignore[attr-defined]
|
|
82
|
+
if status == 401:
|
|
83
|
+
if mode is AuthMode.API_KEY:
|
|
84
|
+
raise _auth_error(
|
|
85
|
+
"Ключ авторизации истёк или невалиден. "
|
|
86
|
+
"Обновите KTALK_PERSONAL_API_KEY (см. README).",
|
|
87
|
+
401,
|
|
88
|
+
)
|
|
89
|
+
raise _auth_error(
|
|
90
|
+
"Токен сессии истёк или невалиден. Обновите его: `ktalk token set -` (или переменную KTALK_SESSION_TOKEN, если она задана) — см. README.", 401
|
|
91
|
+
)
|
|
92
|
+
if status == 403:
|
|
93
|
+
if mode is AuthMode.API_KEY:
|
|
94
|
+
if required_scope:
|
|
95
|
+
label = SCOPE_LABELS.get(required_scope, required_scope)
|
|
96
|
+
raise _auth_error(
|
|
97
|
+
f"Ключу не хватает разрешения «{label}» ({required_scope}). "
|
|
98
|
+
"Добавьте его ключу в настройках Толка (администратор домена).",
|
|
99
|
+
403,
|
|
100
|
+
KTalkScopeError,
|
|
101
|
+
)
|
|
102
|
+
raise _auth_error("Доступ запрещён. Обратитесь к администратору Толка.", 403)
|
|
103
|
+
# Session-режим: у токена нет понятия scope, но 403 — это не 401. Раньше оба
|
|
104
|
+
# кода давали одно сообщение «токен истёк» — ADR-003 разводит их по смыслу.
|
|
105
|
+
raise _auth_error(
|
|
106
|
+
"Доступ запрещён: у текущей сессии нет прав на эту операцию. "
|
|
107
|
+
"Токен при этом рабочий — обновлять его не нужно.",
|
|
108
|
+
403,
|
|
109
|
+
)
|
|
110
|
+
if status == 404:
|
|
111
|
+
raise KTalkNotFoundError("Ресурс не найден.")
|
|
112
|
+
if status >= 400:
|
|
113
|
+
raise KTalkError(f"Ошибка API Контур.Толк: HTTP {status}.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class AuthContext:
|
|
118
|
+
"""Неизменяемая пара (режим, credential) — вычисляется один раз."""
|
|
119
|
+
|
|
120
|
+
mode: AuthMode
|
|
121
|
+
credential: str
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str: # NFR-5: значение секрета никогда не в repr
|
|
124
|
+
return f"AuthContext(mode={self.mode!r}, credential='***')"
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def resolve(*, session_token: str | None, personal_api_key: str | None) -> AuthContext:
|
|
128
|
+
if personal_api_key:
|
|
129
|
+
if session_token:
|
|
130
|
+
logger.warning(
|
|
131
|
+
"Заданы обе переменные — используется KTALK_PERSONAL_API_KEY, "
|
|
132
|
+
"KTALK_SESSION_TOKEN игнорируется."
|
|
133
|
+
)
|
|
134
|
+
return AuthContext(AuthMode.API_KEY, personal_api_key)
|
|
135
|
+
if session_token:
|
|
136
|
+
return AuthContext(AuthMode.SESSION, session_token)
|
|
137
|
+
raise KTalkConfigError(
|
|
138
|
+
"Не задана ни KTALK_PERSONAL_API_KEY, ни KTALK_SESSION_TOKEN. "
|
|
139
|
+
"Укажите одну из переменных (см. README)."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class SkipCursor:
|
|
145
|
+
skip: int
|
|
146
|
+
top: int
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class TokenCursor:
|
|
151
|
+
token: str
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class NormalizedPage:
|
|
156
|
+
items: list[dict]
|
|
157
|
+
cursor: SkipCursor | TokenCursor | None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def normalize_list_session(raw: dict, *, skip: int, top: int) -> NormalizedPage:
|
|
161
|
+
"""`{"recordings": [...]}` (без токена страницы) -> единая форма.
|
|
162
|
+
|
|
163
|
+
Конец страницы — короткая/пустая страница (не полагается на отсутствующее в этой
|
|
164
|
+
форме поле пагинации, зонд Ф-3): курсор есть только когда страница ровно полная.
|
|
165
|
+
"""
|
|
166
|
+
items = raw.get("recordings") or []
|
|
167
|
+
cursor = SkipCursor(skip + len(items), top) if items and len(items) == top else None
|
|
168
|
+
return NormalizedPage(items=items, cursor=cursor)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def normalize_list_apikey(raw: dict) -> NormalizedPage:
|
|
172
|
+
"""`{"entities": [...], "nextPageToken": ...}` -> единая форма.
|
|
173
|
+
|
|
174
|
+
`nextPageToken: null` явно в JSON и отсутствующее поле — эквивалентны.
|
|
175
|
+
"""
|
|
176
|
+
items = raw.get("entities") or []
|
|
177
|
+
token = raw.get("nextPageToken")
|
|
178
|
+
cursor = TokenCursor(token) if token else None
|
|
179
|
+
return NormalizedPage(items=items, cursor=cursor)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class AuthStatus:
|
|
184
|
+
"""Результат диагностики активного механизма авторизации (FR-11)."""
|
|
185
|
+
|
|
186
|
+
alive: bool
|
|
187
|
+
scopes: list[dict] | None
|
|
188
|
+
expired_at: str | None
|
|
189
|
+
note: str | None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _display_name(info: dict) -> str:
|
|
193
|
+
surname = info.get("surname")
|
|
194
|
+
firstname = info.get("firstname")
|
|
195
|
+
if surname and firstname:
|
|
196
|
+
return f"{surname} {firstname}"
|
|
197
|
+
return surname or firstname or info.get("login") or "Неизвестный"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def normalize_participant(raw: dict) -> dict:
|
|
201
|
+
"""`TalkUserBaseInfoRef` -> `{"ktalk_id"|"anonymous_id", "name"}` (FR-8).
|
|
202
|
+
|
|
203
|
+
Отдельная схема от `enrichment.map_participants` (там оба случая используют
|
|
204
|
+
ключ `ktalk_id` ради совместимости с `registry.py`) — здесь ключи различны,
|
|
205
|
+
чтобы `get_full_participants` мог дедуплицировать по составному признаку.
|
|
206
|
+
"""
|
|
207
|
+
info = raw.get("userInfo")
|
|
208
|
+
if info:
|
|
209
|
+
return {"ktalk_id": info.get("key") or info.get("login"), "name": _display_name(info)}
|
|
210
|
+
return {
|
|
211
|
+
"anonymous_id": raw.get("anonymousId"),
|
|
212
|
+
"name": raw.get("anonymousName") or "Аноним",
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _dedup_key(raw: dict) -> tuple[str, str]:
|
|
217
|
+
info = raw.get("userInfo")
|
|
218
|
+
if info:
|
|
219
|
+
return "user", str(info.get("key") or info.get("login") or "")
|
|
220
|
+
return "anon", str(raw.get("anonymousId") or "")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def merge_participants(*groups: list[dict]) -> list[dict]:
|
|
224
|
+
"""Объединяет несколько источников участников без дублей (FR-8 dual-source):
|
|
225
|
+
дедуп по ключу участника (`userInfo.key`/`login` либо `anonymousId`), не по
|
|
226
|
+
позиции в массиве — источники частично пересекаются, не подмножества друг друга.
|
|
227
|
+
"""
|
|
228
|
+
seen: set[tuple[str, str]] = set()
|
|
229
|
+
merged: list[dict] = []
|
|
230
|
+
for group in groups:
|
|
231
|
+
for raw in group:
|
|
232
|
+
key = _dedup_key(raw)
|
|
233
|
+
if not key[1] or key in seen:
|
|
234
|
+
continue
|
|
235
|
+
seen.add(key)
|
|
236
|
+
merged.append(normalize_participant(raw))
|
|
237
|
+
return merged
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def full_participants_apikey(client: KTalkClient, recording_key: str) -> dict:
|
|
241
|
+
"""Api-key-ветка `KTalkClient.get_full_participants` — вынесена сюда ради гейта
|
|
242
|
+
C13 (объём client.py); использует "приватные" коллаборирующие атрибуты клиента
|
|
243
|
+
осознанно, оба модуля — одна логическая единица (ADR-003)."""
|
|
244
|
+
profile = client._profile_for("get_participants_full") # noqa: SLF001
|
|
245
|
+
|
|
246
|
+
async def raw_fetch(skip: int, top: int) -> dict:
|
|
247
|
+
path = profile.path_template.format(key=quote_path_param(recording_key))
|
|
248
|
+
response = await client._client.get(path, params={"skip": skip, "top": top}) # noqa: SLF001
|
|
249
|
+
client._classify(response, profile.required_scope) # noqa: SLF001
|
|
250
|
+
return response.json()
|
|
251
|
+
|
|
252
|
+
fetch_page = skip_pages(raw_fetch, page_size=100, items_key="entities")
|
|
253
|
+
out: list[dict] = []
|
|
254
|
+
async for page in paginate_pages(fetch_page):
|
|
255
|
+
out.extend(normalize_participant(p) for p in page)
|
|
256
|
+
return {"participants": out, "incomplete": False}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def resolve_chat_channel(client: KTalkClient, conference_key: str) -> str:
|
|
260
|
+
"""Определяет канал по умолчанию из деталей встречи (FR-10 AC-2), а не падает
|
|
261
|
+
с сырым 400 "The channel field is required" (зонд Ф-6)."""
|
|
262
|
+
conference = await client.get_conference(conference_key)
|
|
263
|
+
channels = (conference.get("artifacts") or {}).get("chatChannelHasMessages") or {}
|
|
264
|
+
for name, has_messages in channels.items():
|
|
265
|
+
if has_messages:
|
|
266
|
+
return name
|
|
267
|
+
return "general"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""FR-18: чтение календаря — сегментация 7-дневного окна, маппер 20 полей, дедуп на
|
|
2
|
+
стыках (rooms-calendar-spec §5). Свободные функции вне `client.py` (гейт C13)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, timedelta
|
|
8
|
+
|
|
9
|
+
from ktalk_cli.client import KTalkClient, KTalkError
|
|
10
|
+
from ktalk_cli.contour_diagnostics import (
|
|
11
|
+
TRANSIENT_ERRORS,
|
|
12
|
+
diagnose_undocumented_failure,
|
|
13
|
+
require_contract_field,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
CALENDAR_ITEM_FIELDS = (
|
|
17
|
+
"busyType",
|
|
18
|
+
"calendarSource",
|
|
19
|
+
"description",
|
|
20
|
+
"end",
|
|
21
|
+
"id",
|
|
22
|
+
"isRecurring",
|
|
23
|
+
"location",
|
|
24
|
+
"locationAttendee",
|
|
25
|
+
"meetId",
|
|
26
|
+
"onlineMeetingUrl",
|
|
27
|
+
"onlineUsers",
|
|
28
|
+
"optionalAttendees",
|
|
29
|
+
"organizer",
|
|
30
|
+
"requiredAttendees",
|
|
31
|
+
"room",
|
|
32
|
+
"roomName",
|
|
33
|
+
"start",
|
|
34
|
+
"stream",
|
|
35
|
+
"subject",
|
|
36
|
+
"urlParams",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Ф-26, дословный каталог известных 400 — обычная валидационная ошибка вызывающего
|
|
40
|
+
# или сегментации, не сигнал дрейфа контура (не тратит лишний сетевой вызов на
|
|
41
|
+
# корреляцию).
|
|
42
|
+
KNOWN_400_TEXTS = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"Дата начала является обязательной для заполнения",
|
|
45
|
+
"Должна быть задана или дата окончания, или количество запрашиваемых элементов; "
|
|
46
|
+
"Должна быть задана или дата окончания, или количество запрашиваемых элементов",
|
|
47
|
+
"Период запроса не должен превышать 7 дней",
|
|
48
|
+
"Дата окончания должна быть больше даты начала",
|
|
49
|
+
"The field Take must be between 1 and 1000.",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_PAGE_SIZE = 100
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def split_window(start: date, end: date, *, max_days: int = 7) -> list[tuple[date, date]]:
|
|
57
|
+
"""Непересекающиеся сегменты <=`max_days` дней без пропусков и без перекрытия:
|
|
58
|
+
следующий сегмент начинается на день позже конца предыдущего."""
|
|
59
|
+
if start > end:
|
|
60
|
+
# ADR-017 п.6: отказ до сети — единственная общая точка входа CLI/MCP.
|
|
61
|
+
raise KTalkError("Дата начала окна не может быть позже даты конца.")
|
|
62
|
+
segments: list[tuple[date, date]] = []
|
|
63
|
+
seg_start = start
|
|
64
|
+
while seg_start <= end:
|
|
65
|
+
seg_end = min(seg_start + timedelta(days=max_days - 1), end)
|
|
66
|
+
segments.append((seg_start, seg_end))
|
|
67
|
+
seg_start = seg_end + timedelta(days=1)
|
|
68
|
+
return segments
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def map_calendar_item(raw: dict) -> dict:
|
|
72
|
+
require_contract_field(raw, "roomName", "get_calendar")
|
|
73
|
+
require_contract_field(raw, "start", "get_calendar")
|
|
74
|
+
return {f: raw.get(f) for f in CALENDAR_ITEM_FIELDS}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_known_400(text: str) -> bool:
|
|
78
|
+
if text in KNOWN_400_TEXTS:
|
|
79
|
+
return True
|
|
80
|
+
return text.startswith("The value '") and text.endswith("is not valid for Start.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class CalendarReadResult:
|
|
85
|
+
items: list[dict] = field(default_factory=list)
|
|
86
|
+
incomplete_segments: list[tuple[date, date]] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _fetch_segment(
|
|
90
|
+
client: KTalkClient, seg_start: date, seg_end: date, room_name: str | None
|
|
91
|
+
) -> tuple[list[dict], bool]:
|
|
92
|
+
profile = client._profile_for("get_calendar") # noqa: SLF001 - fail-closed (api-key)
|
|
93
|
+
# ADR-017 п.1-2: сервер держит `end` полуоткрытой ([start 00:00, end 00:00),
|
|
94
|
+
# Ф-60 RES-004) — сегментация (`split_window`) оперирует включительными датами,
|
|
95
|
+
# компенсация исключающей границы сервера — обязанность этого тонкого слоя.
|
|
96
|
+
# Голая дата +1 день, не явное время конца суток (не зависит от часового базиса,
|
|
97
|
+
# Ф-62 — см. ADR-017 п.2/7).
|
|
98
|
+
params: dict = {
|
|
99
|
+
"start": seg_start.isoformat(),
|
|
100
|
+
"end": (seg_end + timedelta(days=1)).isoformat(),
|
|
101
|
+
"take": _PAGE_SIZE,
|
|
102
|
+
}
|
|
103
|
+
if room_name is not None:
|
|
104
|
+
params["roomName"] = room_name
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = await client._client.get(profile.path_template, params=params) # noqa: SLF001
|
|
108
|
+
except TRANSIENT_ERRORS as exc:
|
|
109
|
+
await diagnose_undocumented_failure(client, "get_calendar", exc)
|
|
110
|
+
raise # недостижимо
|
|
111
|
+
|
|
112
|
+
if response.status_code == 400:
|
|
113
|
+
text = response.text
|
|
114
|
+
if _is_known_400(text):
|
|
115
|
+
raise KTalkError(text) # валидационная ошибка вызывающего/сегментации
|
|
116
|
+
await diagnose_undocumented_failure(client, "get_calendar", KTalkError(text))
|
|
117
|
+
raise AssertionError("недостижимо") # diagnose_undocumented_failure всегда поднимает
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
client._classify(response, profile.required_scope) # noqa: SLF001
|
|
121
|
+
except TRANSIENT_ERRORS as exc:
|
|
122
|
+
await diagnose_undocumented_failure(client, "get_calendar", exc)
|
|
123
|
+
raise # недостижимо
|
|
124
|
+
|
|
125
|
+
payload = response.json()
|
|
126
|
+
if "items" not in payload:
|
|
127
|
+
from ktalk_cli.contour_diagnostics import ContourDriftError
|
|
128
|
+
|
|
129
|
+
raise ContourDriftError("get_calendar", "поле «items» отсутствует в ответе 200.")
|
|
130
|
+
items = payload["items"]
|
|
131
|
+
return items, len(items) >= _PAGE_SIZE
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def get_calendar_window(
|
|
135
|
+
client: KTalkClient, start: date, end: date, *, room_name: str | None = None
|
|
136
|
+
) -> CalendarReadResult:
|
|
137
|
+
result = CalendarReadResult()
|
|
138
|
+
seen: set[str | tuple] = set()
|
|
139
|
+
for seg_start, seg_end in split_window(start, end):
|
|
140
|
+
items, incomplete = await _fetch_segment(client, seg_start, seg_end, room_name)
|
|
141
|
+
if incomplete:
|
|
142
|
+
result.incomplete_segments.append((seg_start, seg_end))
|
|
143
|
+
for raw in items:
|
|
144
|
+
key = raw.get("id") or (raw.get("meetId"), raw.get("start"))
|
|
145
|
+
if key in seen:
|
|
146
|
+
continue
|
|
147
|
+
seen.add(key)
|
|
148
|
+
result.items.append(map_calendar_item(raw))
|
|
149
|
+
return result
|