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/host_config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Discovery, парсинг и валидация `.ktalk.toml` — конфигурации проекта-хозяина.
|
|
2
|
+
|
|
3
|
+
SA-003 (`content/40-architecture/ktalk-plugin-spec.md`), FR-20. Отдельный модуль
|
|
4
|
+
от `registry.py` (грандфазер заморожен на 562 строках). Не пишет файлы и не
|
|
5
|
+
создаёт каталоги — только читает и валидирует (границы спеки).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_KNOWN_TOP_LEVEL_KEYS = {"registry", "directories", "routing", "integrations"}
|
|
16
|
+
_CONFIG_FILE_NAME = ".ktalk.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HostConfigError(Exception):
|
|
20
|
+
"""`.ktalk.toml` не парсится TOML-синтаксисом или проваливает валидацию схемы.
|
|
21
|
+
|
|
22
|
+
Именует конкретный файл и причину — процесс останавливается на этом шаге, не
|
|
23
|
+
продолжает работу на дефолте молча (ktalk-plugin-spec.md, «Поведение при
|
|
24
|
+
малформенном файле»).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class HostConfig:
|
|
30
|
+
"""Распарсенный и провалидированный `.ktalk.toml`.
|
|
31
|
+
|
|
32
|
+
Каждая секция — `dict`-подобная с `.get(...)`; отсутствующий ключ означает
|
|
33
|
+
«не объявлен», не «объявлен пустым» (FR-20 AC-2/AC-3, FR-24 деградация).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
registry: dict = field(default_factory=dict)
|
|
37
|
+
directories: dict = field(default_factory=dict)
|
|
38
|
+
routing: dict = field(default_factory=dict)
|
|
39
|
+
integrations: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_string_map(section_name: str, section: dict, path: Path) -> None:
|
|
43
|
+
for key, value in section.items():
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
raise HostConfigError(
|
|
46
|
+
f"{path}: {section_name}.{key} должен быть строкой, получено {type(value).__name__}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_host_config(path: str | Path) -> HostConfig:
|
|
51
|
+
"""Парсит и валидирует `.ktalk.toml` по указанному пути.
|
|
52
|
+
|
|
53
|
+
Пустой/отсутствующий-по-ключам файл — валиден (FR-20 AC-2, boundary): каждая
|
|
54
|
+
секция трактуется как отсутствующая по отдельности, не как ошибка.
|
|
55
|
+
"""
|
|
56
|
+
path = Path(path)
|
|
57
|
+
try:
|
|
58
|
+
raw = path.read_text(encoding="utf-8")
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
raise HostConfigError(f"{path}: не удалось прочитать файл: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
data = tomllib.loads(raw)
|
|
64
|
+
except tomllib.TOMLDecodeError as exc:
|
|
65
|
+
raise HostConfigError(f"{path}: некорректный TOML-синтаксис: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
unknown = set(data.keys()) - _KNOWN_TOP_LEVEL_KEYS
|
|
68
|
+
if unknown:
|
|
69
|
+
raise HostConfigError(
|
|
70
|
+
f"{path}: неизвестные секции {sorted(unknown)} "
|
|
71
|
+
f"(ожидаются только {sorted(_KNOWN_TOP_LEVEL_KEYS)})"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
registry = data.get("registry", {})
|
|
75
|
+
directories = data.get("directories", {})
|
|
76
|
+
routing = data.get("routing", {})
|
|
77
|
+
integrations = data.get("integrations", {})
|
|
78
|
+
|
|
79
|
+
for name, section in (
|
|
80
|
+
("registry", registry),
|
|
81
|
+
("directories", directories),
|
|
82
|
+
("routing", routing),
|
|
83
|
+
("integrations", integrations),
|
|
84
|
+
):
|
|
85
|
+
if not isinstance(section, dict):
|
|
86
|
+
raise HostConfigError(f"{path}: секция [{name}] должна быть таблицей")
|
|
87
|
+
|
|
88
|
+
if "db_path" in registry and not isinstance(registry["db_path"], str):
|
|
89
|
+
raise HostConfigError(f"{path}: registry.db_path должен быть строкой")
|
|
90
|
+
_validate_string_map("directories", directories, path)
|
|
91
|
+
_validate_string_map("routing", routing, path)
|
|
92
|
+
if "qmd" in integrations and not isinstance(integrations["qmd"], bool):
|
|
93
|
+
raise HostConfigError(f"{path}: integrations.qmd должен быть булевым")
|
|
94
|
+
|
|
95
|
+
return HostConfig(
|
|
96
|
+
registry=registry,
|
|
97
|
+
directories=directories,
|
|
98
|
+
routing=routing,
|
|
99
|
+
integrations=integrations,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def discover_host_config(project_dir: str | Path | None = None) -> HostConfig | None:
|
|
104
|
+
"""Ищет и загружает `.ktalk.toml` (ktalk-plugin-spec.md, «Discovery»).
|
|
105
|
+
|
|
106
|
+
1. `project_dir` (явный оверрайд, тот же контракт, что и `${CLAUDE_PROJECT_DIR}`)
|
|
107
|
+
или `${CLAUDE_PROJECT_DIR}`, если задан, — конфиг ищется РОВНО по этому
|
|
108
|
+
корню, обхода вверх нет.
|
|
109
|
+
2. Иначе (голый CLI) — обход вверх от `cwd` до первого найденного
|
|
110
|
+
`.ktalk.toml`, либо до первого каталога с `.git` (граница проекта), либо
|
|
111
|
+
до корня файловой системы.
|
|
112
|
+
3. Ничего не найдено -> `None`, не ошибка (FR-20 AC-2). Малформенный
|
|
113
|
+
найденный файл -> `HostConfigError` доходит до вызывающей стороны, не
|
|
114
|
+
гасится тихим откатом (FR-20 AC-3).
|
|
115
|
+
"""
|
|
116
|
+
if project_dir is not None:
|
|
117
|
+
root = Path(project_dir)
|
|
118
|
+
candidate = root / _CONFIG_FILE_NAME
|
|
119
|
+
return load_host_config(candidate) if candidate.is_file() else None
|
|
120
|
+
|
|
121
|
+
claude_project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
122
|
+
if claude_project_dir:
|
|
123
|
+
candidate = Path(claude_project_dir) / _CONFIG_FILE_NAME
|
|
124
|
+
return load_host_config(candidate) if candidate.is_file() else None
|
|
125
|
+
|
|
126
|
+
current = Path.cwd()
|
|
127
|
+
while True:
|
|
128
|
+
candidate = current / _CONFIG_FILE_NAME
|
|
129
|
+
if candidate.is_file():
|
|
130
|
+
return load_host_config(candidate)
|
|
131
|
+
if (current / ".git").exists():
|
|
132
|
+
return None
|
|
133
|
+
parent = current.parent
|
|
134
|
+
if parent == current:
|
|
135
|
+
return None
|
|
136
|
+
current = parent
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""FR-13/NFR-9: allow-list компоновщик тела встречи + канонический хеш (ADR-005
|
|
2
|
+
«Решение», ADR-009 §1-4 — состав тела приведён к живому снимку DevTools).
|
|
3
|
+
|
|
4
|
+
Единая функция принимает только согласованный постановкой набор полей — без
|
|
5
|
+
`**kwargs` и без прохода через произвольный словарь вызывающего. `isRecurring`/
|
|
6
|
+
`recurrence` и поля вне этого состава физически не имеют параметра, через который
|
|
7
|
+
могли бы попасть в тело — структурная невозможность, не рантайм-фильтр. ADR-009
|
|
8
|
+
расширяет тот же приём на `autoRunDeepFakeDetection`/`maskingSettings` и убирает
|
|
9
|
+
`enableSip` тем же способом (параметра больше нет).
|
|
10
|
+
|
|
11
|
+
ГИПОТЕЗА (ADR-009): состав тела и конвертация времени в UTC не проверены
|
|
12
|
+
собственным боевым POST — построены по одному живому снимку DevTools, ревизуются
|
|
13
|
+
следующей санкционированной попыткой.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
|
|
23
|
+
from ktalk_cli.client import KTalkError
|
|
24
|
+
|
|
25
|
+
_TIMEZONE_RE = re.compile(r"^GMT[+-](?:[0-9]|1[0-4])$")
|
|
26
|
+
|
|
27
|
+
# ADR-009 §1-2: 12 полей от вызывающего/архитектуры + `description` с тихим
|
|
28
|
+
# дефолтом = 13 ключей тела. `pinCode`/`anonymousAccessExpirationDate` не входят
|
|
29
|
+
# в этот общий None-цикл — у них своя развилка (условная обязательность), не
|
|
30
|
+
# плоское «передано/не передано».
|
|
31
|
+
_REQUIRED = (
|
|
32
|
+
"subject",
|
|
33
|
+
"start",
|
|
34
|
+
"end",
|
|
35
|
+
"timezone",
|
|
36
|
+
"roomName",
|
|
37
|
+
"requiredAttendees",
|
|
38
|
+
"allowAnonymous",
|
|
39
|
+
"enableAutoRecording",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ADR-009 §2: архитектурные константы — не параметры `build_meeting_body`, не
|
|
43
|
+
# решения вызывающего, поэтому вне периметра NFR-9 (запрет тихих дефолтов
|
|
44
|
+
# относится к полям, которые вызывающий мог бы решить, но не решил).
|
|
45
|
+
_FIXED = {
|
|
46
|
+
"isRecurring": False,
|
|
47
|
+
"autoRunDeepFakeDetection": None,
|
|
48
|
+
"maskingSettings": {
|
|
49
|
+
"nameMaskingMode": "none",
|
|
50
|
+
"postMaskingMode": "none",
|
|
51
|
+
"showAdditionalInfo": True,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MissingFieldError(KTalkError):
|
|
57
|
+
"""Поле не передано явно (`None`) — отказ до сетевого вызова (NFR-9)."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, field: str) -> None:
|
|
60
|
+
super().__init__(
|
|
61
|
+
f'Поле «{field}» не передано явно вызывающим — запрос на создание встречи '
|
|
62
|
+
"отклонён до сетевого вызова (NFR-9)."
|
|
63
|
+
)
|
|
64
|
+
self.field = field
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TimezoneFormatError(KTalkError):
|
|
68
|
+
"""Форма `timezone` не распознана — отказ до сетевого вызова (FR-40).
|
|
69
|
+
|
|
70
|
+
ГИПОТЕЗА: диапазон `GMT-14..GMT+14` не измерен целиком, подтверждено
|
|
71
|
+
замером только `GMT+3` (rooms-calendar-scheduling.md:357-368,
|
|
72
|
+
ADR-020 §3). Границы взяты по общемировому диапазону смещений UTC, не по
|
|
73
|
+
факту API — ревизуются следующим боевым замером с иным значением.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, value: str) -> None:
|
|
77
|
+
super().__init__(
|
|
78
|
+
f'Часовой пояс «{value}» не распознан. Требуемый формат: `GMT±N` '
|
|
79
|
+
"(пример: `GMT+3`). Другие нотации (IANA, Windows ID, ISO-смещение, "
|
|
80
|
+
"аббревиатуры) сервер не принимает — см. FR-40."
|
|
81
|
+
)
|
|
82
|
+
self.value = value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_required_attendees(keys: list[str]) -> list[dict]:
|
|
86
|
+
"""ADR-009 §4: `requiredUserKeys` (список логинов) заменён на список объектов
|
|
87
|
+
`{"type": "user", "key": str}` — `key` числовой id строкой (снимок сериализует
|
|
88
|
+
id как `"668"`, не `668`). Формат `key` не валидируется (`.isdigit()`) —
|
|
89
|
+
компоновщик не знает формат id других типов участников за пределами снимка."""
|
|
90
|
+
return [{"type": "user", "key": key} for key in keys]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _to_utc_z(value: str) -> str:
|
|
94
|
+
"""ADR-009 §1: `start`/`end` — UTC с суффиксом `Z` и миллисекундами, не
|
|
95
|
+
локальное смещение. Вызывающий передаёт локальное ISO с оффсетом,
|
|
96
|
+
конвертация — деталь этого модуля (единственная точка правки, не
|
|
97
|
+
компоновщика вызывающего кода)."""
|
|
98
|
+
dt = datetime.fromisoformat(value)
|
|
99
|
+
dt_utc = dt.astimezone(UTC)
|
|
100
|
+
millis = dt_utc.microsecond // 1000
|
|
101
|
+
return f"{dt_utc.strftime('%Y-%m-%dT%H:%M:%S')}.{millis:03d}Z"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_meeting_body(
|
|
105
|
+
*,
|
|
106
|
+
subject: str | None = None,
|
|
107
|
+
start: str | None = None,
|
|
108
|
+
end: str | None = None,
|
|
109
|
+
timezone: str | None = None,
|
|
110
|
+
room_name: str | None = None,
|
|
111
|
+
required_attendee_keys: list[str] | None = None,
|
|
112
|
+
description: str | None = None,
|
|
113
|
+
enable_auto_recording: bool | None = None,
|
|
114
|
+
pin_code: str | None = None,
|
|
115
|
+
pin_code_explicit_none: bool = False,
|
|
116
|
+
allow_anonymous: bool | None = None,
|
|
117
|
+
anonymous_access_expiration: str | None = None,
|
|
118
|
+
) -> dict:
|
|
119
|
+
"""Каждое поле `_REQUIRED` проверяется через `is None` (не truthiness) — явный
|
|
120
|
+
пустой список участников/`False` — валидные явные решения, отсутствие
|
|
121
|
+
значения — нет. `description` — единственное поле с разрешённым тихим
|
|
122
|
+
дефолтом (пустая строка, NFR-9). `pinCode`/`anonymousAccessExpirationDate`
|
|
123
|
+
решаются отдельными условными правилами (ADR-009 §2-3), не общим циклом.
|
|
124
|
+
|
|
125
|
+
`timezone` принимает единственную форму `GMT±N` (пример `GMT+3`, диапазон
|
|
126
|
+
`GMT-14..GMT+14`, ГИПОТЕЗА — измерен только `GMT+3`). Другая нотация
|
|
127
|
+
отклоняется `TimezoneFormatError` до сборки тела.
|
|
128
|
+
"""
|
|
129
|
+
body = {
|
|
130
|
+
"subject": subject,
|
|
131
|
+
"start": _to_utc_z(start) if start is not None else None,
|
|
132
|
+
"end": _to_utc_z(end) if end is not None else None,
|
|
133
|
+
"timezone": timezone,
|
|
134
|
+
"roomName": room_name,
|
|
135
|
+
"requiredAttendees": (
|
|
136
|
+
build_required_attendees(required_attendee_keys)
|
|
137
|
+
if required_attendee_keys is not None
|
|
138
|
+
else None
|
|
139
|
+
),
|
|
140
|
+
"description": description if description is not None else "",
|
|
141
|
+
"enableAutoRecording": enable_auto_recording,
|
|
142
|
+
"allowAnonymous": allow_anonymous,
|
|
143
|
+
}
|
|
144
|
+
for field in _REQUIRED:
|
|
145
|
+
if body[field] is None:
|
|
146
|
+
raise MissingFieldError(field)
|
|
147
|
+
if not _TIMEZONE_RE.match(body["timezone"]):
|
|
148
|
+
raise TimezoneFormatError(body["timezone"])
|
|
149
|
+
|
|
150
|
+
# ADR-009 §2: `pin_code_explicit_none=True` побеждает при одновременной
|
|
151
|
+
# передаче обоих сигналов — «решено: нет PIN» перекрывает конкретное
|
|
152
|
+
# значение `pin_code` (порядок не специфицирован спекой, зафиксирован здесь).
|
|
153
|
+
if pin_code_explicit_none:
|
|
154
|
+
body["pinCode"] = None
|
|
155
|
+
elif pin_code is not None:
|
|
156
|
+
body["pinCode"] = pin_code
|
|
157
|
+
else:
|
|
158
|
+
raise MissingFieldError("pinCode")
|
|
159
|
+
|
|
160
|
+
# ADR-009 §3: `anonymousAccessExpirationDate` условно обязателен — вне
|
|
161
|
+
# общего `_REQUIRED`-цикла. `allow_anonymous=False` + значение передано —
|
|
162
|
+
# значение отбрасывается (поле неприменимо, доступ выключен), не ошибка:
|
|
163
|
+
# решение Dev по edge case, не специфицированному спекой дословно.
|
|
164
|
+
if allow_anonymous is True and anonymous_access_expiration is None:
|
|
165
|
+
raise MissingFieldError("anonymousAccessExpirationDate")
|
|
166
|
+
body["anonymousAccessExpirationDate"] = (
|
|
167
|
+
anonymous_access_expiration if allow_anonymous else None
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
body.update(_FIXED)
|
|
171
|
+
return body
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def canonical_body_hash(body: dict) -> str:
|
|
175
|
+
"""sha256 канонической (стабильной по порядку ключей) сериализации тела —
|
|
176
|
+
основа привязки подтверждения к содержимому (ADR-005)."""
|
|
177
|
+
canonical = json.dumps(body, sort_keys=True, ensure_ascii=False)
|
|
178
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""ADR-011 §2: компоновка данных отмены встречи (без сети).
|
|
2
|
+
|
|
3
|
+
Параллель `meeting_scheduling.PreviewService` — чистые функции, без `KTalkClient`,
|
|
4
|
+
физическая невозможность сетевого вызова из этого модуля.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ktalk_cli.confirmation import ConfirmationStore
|
|
10
|
+
from ktalk_cli.meeting_body import canonical_body_hash
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_cancel_confirmation_payload(*, id: str, reason: str = "") -> dict:
|
|
14
|
+
"""Не тело запроса (то — `{"reason": reason}`), а предмет хеширования
|
|
15
|
+
подтверждения (ADR-011 п.2): `id` — часть пути, не тела, но обязан входить
|
|
16
|
+
в хеш, иначе подтверждение для одной встречи матчится для любой другой.
|
|
17
|
+
`operation` — дискриминатор против путаницы с будущим подтверждением
|
|
18
|
+
`update_meeting` на том же `id`."""
|
|
19
|
+
return {"operation": "cancel_meeting", "id": id, "reason": reason}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CancelPreviewService:
|
|
23
|
+
"""Параллель `PreviewService` (`meeting_scheduling.py`) — тот же
|
|
24
|
+
`ConfirmationStore`, та же `canonical_body_hash`."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, store: ConfirmationStore) -> None:
|
|
27
|
+
self._store = store
|
|
28
|
+
|
|
29
|
+
def preview(self, *, id: str, reason: str = "") -> tuple[dict, str]:
|
|
30
|
+
payload = build_cancel_confirmation_payload(id=id, reason=reason)
|
|
31
|
+
payload_hash = canonical_body_hash(payload)
|
|
32
|
+
confirmation_id = self._store.issue(payload_hash)
|
|
33
|
+
return payload, confirmation_id
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""FR-13 §6.6: предпросмотр (без сети) + создание (ровно одна сетевая попытка).
|
|
2
|
+
|
|
3
|
+
`create_meeting` — свободная функция вне `client.py` (гейт C13), тем же приёмом,
|
|
4
|
+
что `rooms.get_room`/`calendar_reader._fetch_segment`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from ktalk_cli.auth import quote_path_param
|
|
12
|
+
from ktalk_cli.client import KTalkClient
|
|
13
|
+
from ktalk_cli.confirmation import ConfirmationStore
|
|
14
|
+
from ktalk_cli.contour_diagnostics import TRANSIENT_ERRORS, diagnose_undocumented_failure
|
|
15
|
+
from ktalk_cli.meeting_body import build_meeting_body, canonical_body_hash
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PreviewService:
|
|
21
|
+
def __init__(self, store: ConfirmationStore) -> None:
|
|
22
|
+
self._store = store
|
|
23
|
+
|
|
24
|
+
def preview(self, **fields: object) -> tuple[dict, str]:
|
|
25
|
+
"""`build_meeting_body` -> `canonical_body_hash` -> `store.issue`. Без сети
|
|
26
|
+
физически — не получает `KTalkClient` (структурная невозможность, не
|
|
27
|
+
поведенческая)."""
|
|
28
|
+
body = build_meeting_body(**fields)
|
|
29
|
+
body_hash = canonical_body_hash(body)
|
|
30
|
+
confirmation_id = self._store.issue(body_hash)
|
|
31
|
+
return body, confirmation_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def create_meeting(client: KTalkClient, body: dict) -> dict:
|
|
35
|
+
"""Ровно одна сетевая попытка `POST /api/calendar`, без retry. ADR-007 п.3:
|
|
36
|
+
оборачивается корреляционной диагностикой ADR-004, тем же приёмом, что
|
|
37
|
+
`calendar_reader._fetch_segment` — сбой недокументированного пути неотличим
|
|
38
|
+
от обычного auth/сетевого сбоя без контрольного вызова.
|
|
39
|
+
|
|
40
|
+
ADR-009 §6 (пересматривает ADR-008 §1): на `profile.mutating` (сегодня только
|
|
41
|
+
session-режим этой операции) заголовки `Authorization: Session <token>` +
|
|
42
|
+
`X-Platform: web` отправляются ВМЕСТО query-параметра `sessionToken`, не
|
|
43
|
+
поверх него — единственная известная рабочая конфигурация (снимок DevTools).
|
|
44
|
+
Query вырезается точечно из уже построенного `httpx.Request`
|
|
45
|
+
(`copy_remove_param`) — конструктор клиента (`client._client.params`,
|
|
46
|
+
ADR-003) не трогается, следующий read-путь того же клиента снова несёт
|
|
47
|
+
`sessionToken` в query как обычно. ГИПОТЕЗА до следующего боевого POST. Тело
|
|
48
|
+
ответа 4xx/5xx читается до классификации и прикрепляется к перехваченному
|
|
49
|
+
исключению атрибутом `response_body` (обрезано до 500 символов) — переживает
|
|
50
|
+
прогон независимо от конфигурации `logging`, которой в проекте нет."""
|
|
51
|
+
profile = client._profile_for("create_meeting") # noqa: SLF001
|
|
52
|
+
headers = (
|
|
53
|
+
{"Authorization": f"Session {client._auth.credential}", "X-Platform": "web"} # noqa: SLF001
|
|
54
|
+
if profile.mutating
|
|
55
|
+
else None
|
|
56
|
+
)
|
|
57
|
+
request = client._client.build_request( # noqa: SLF001
|
|
58
|
+
"POST", profile.path_template, json=body, headers=headers
|
|
59
|
+
)
|
|
60
|
+
if profile.mutating:
|
|
61
|
+
request.url = request.url.copy_remove_param("sessionToken")
|
|
62
|
+
try:
|
|
63
|
+
response = await client._client.send(request) # noqa: SLF001
|
|
64
|
+
except TRANSIENT_ERRORS as exc:
|
|
65
|
+
await diagnose_undocumented_failure(client, "create_meeting", exc)
|
|
66
|
+
raise # недостижимо
|
|
67
|
+
|
|
68
|
+
body_text = response.text[:500] if response.status_code >= 400 else None
|
|
69
|
+
if body_text:
|
|
70
|
+
logger.warning("create_meeting: HTTP %s, тело: %s", response.status_code, body_text)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
client._classify(response, profile.required_scope) # noqa: SLF001
|
|
74
|
+
except TRANSIENT_ERRORS as exc:
|
|
75
|
+
# DEV-008: тело пустое ("") — это факт контура, отличный от "тело не
|
|
76
|
+
# прикреплено вовсе" (той ветки, где ответа не было — сетевой сбой выше).
|
|
77
|
+
# `if body_text:` терял этот факт при пустой строке.
|
|
78
|
+
if body_text is not None:
|
|
79
|
+
exc.response_body = body_text # атрибут читает CLI/MCP на границе вывода
|
|
80
|
+
await diagnose_undocumented_failure(client, "create_meeting", exc)
|
|
81
|
+
raise # недостижимо
|
|
82
|
+
return response.json()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def cancel_meeting(client: KTalkClient, *, id: str, reason: str = "") -> dict:
|
|
86
|
+
"""Ровно одна сетевая попытка `POST /api/calendar/{id}/cancel`, без retry —
|
|
87
|
+
тот же контракт, что `create_meeting` (ADR-005 п.2). Транспорт (заголовки,
|
|
88
|
+
отсутствие query) — тот же код, что `create_meeting`: `profile.mutating`
|
|
89
|
+
общий признак, не специфичный для операции. `{id}` — обязательный
|
|
90
|
+
`quote_path_param` (Ф-56): `+`/`/`/`=` меняют путь без квотирования."""
|
|
91
|
+
profile = client._profile_for("cancel_meeting") # noqa: SLF001
|
|
92
|
+
path = profile.path_template.format(id=quote_path_param(id))
|
|
93
|
+
headers = (
|
|
94
|
+
{"Authorization": f"Session {client._auth.credential}", "X-Platform": "web"} # noqa: SLF001
|
|
95
|
+
if profile.mutating
|
|
96
|
+
else None
|
|
97
|
+
)
|
|
98
|
+
request = client._client.build_request( # noqa: SLF001
|
|
99
|
+
"POST", path, json={"reason": reason}, headers=headers
|
|
100
|
+
)
|
|
101
|
+
if profile.mutating:
|
|
102
|
+
request.url = request.url.copy_remove_param("sessionToken")
|
|
103
|
+
try:
|
|
104
|
+
response = await client._client.send(request) # noqa: SLF001
|
|
105
|
+
except TRANSIENT_ERRORS as exc:
|
|
106
|
+
await diagnose_undocumented_failure(client, "cancel_meeting", exc)
|
|
107
|
+
raise # недостижимо
|
|
108
|
+
|
|
109
|
+
body_text = response.text[:500] if response.status_code >= 400 else None
|
|
110
|
+
try:
|
|
111
|
+
client._classify(response, profile.required_scope) # noqa: SLF001
|
|
112
|
+
except TRANSIENT_ERRORS as exc:
|
|
113
|
+
if body_text is not None:
|
|
114
|
+
exc.response_body = body_text
|
|
115
|
+
await diagnose_undocumented_failure(client, "cancel_meeting", exc)
|
|
116
|
+
raise # недостижимо
|
|
117
|
+
# Ф-57: успешная отмена отвечает 200 с ПУСТЫМ телом — `response.json()` на нём
|
|
118
|
+
# падает, и необратимая выполненная операция докладывалась как «исход неизвестен».
|
|
119
|
+
# Отличие от `create_meeting`, чей ответ несёт объект встречи.
|
|
120
|
+
return response.json() if response.content else {}
|
ktalk_cli/pagination.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Единый асинхронный итератор постраничного чтения (FR-9, FR-14).
|
|
2
|
+
|
|
3
|
+
`paginate_pages` — форма-независимый движок термination-логики: продолжает, пока
|
|
4
|
+
очередная страница непустая И следующий курсор истинный; останавливается на первом
|
|
5
|
+
же из двух отрицательных сигналов. Не полагается на конкретное имя поля пагинации
|
|
6
|
+
в ответе API — это и закрывает Ф-3 (`/api/recordings` не отдаёт `nextPageToken`).
|
|
7
|
+
|
|
8
|
+
`skip_pages` — offset-адаптер (session-список записей, архив, FR-9/FR-14): жёстко
|
|
9
|
+
клэмпит размер страницы в [1, 100] (закрывает регрессию `top=1000`, зонд Ф-2) и
|
|
10
|
+
продолжает по `skip`, пока очередной запрос не вернёт пустую страницу — намеренно
|
|
11
|
+
НЕ останавливается на «короткой», но непустой странице: домен ни разу не подтвердил,
|
|
12
|
+
что API отдаёт заведомо неполные непоследние страницы, а недоверие отсутствующему
|
|
13
|
+
полю (Ф-3) распространяется и на предположение «короткая = последняя».
|
|
14
|
+
|
|
15
|
+
`token_pages` — курсорный адаптер (api-key список записей): `nextPageToken`.
|
|
16
|
+
|
|
17
|
+
`clip_to_window` — клиентское окно дат (Ф-15). API игнорирует `startFrom`/`startTo`:
|
|
18
|
+
зонд показал побитово одинаковую выдачу с фильтром и без него, а описания этих
|
|
19
|
+
параметров у v1 и v2 в спеке вдобавок зеркальны. Поэтому окно `--days` обеспечивает
|
|
20
|
+
клиент, а не сервер. Ранняя остановка опирается на `orderMode=byTimeNewFirst`
|
|
21
|
+
и проверена на пяти страницах подряд (Ф-16): порядок строго убывающий внутри
|
|
22
|
+
страницы и монотонный между страницами.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
Cursor = Any
|
|
31
|
+
FetchPage = Callable[[Cursor | None], Awaitable[tuple[list[dict], Cursor | None]]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clip_to_window(
|
|
35
|
+
items: list[dict],
|
|
36
|
+
start_from: str | None,
|
|
37
|
+
*,
|
|
38
|
+
date_key: str = "createdDate",
|
|
39
|
+
) -> tuple[list[dict], bool]:
|
|
40
|
+
"""Оставляет записи не старше `start_from`, сообщая, исчерпано ли окно.
|
|
41
|
+
|
|
42
|
+
Возвращает `(kept, exhausted)`. `exhausted=True` означает, что страница
|
|
43
|
+
содержала запись старше порога — при сортировке от новых к старым все
|
|
44
|
+
последующие страницы заведомо вне окна и запрашивать их не нужно.
|
|
45
|
+
|
|
46
|
+
Записи без разбираемой даты не отбрасываются: пустой `createdDate` — это
|
|
47
|
+
неизвестность, а не «старая запись», и терять её молча нельзя.
|
|
48
|
+
"""
|
|
49
|
+
if not start_from:
|
|
50
|
+
return items, False
|
|
51
|
+
kept: list[dict] = []
|
|
52
|
+
exhausted = False
|
|
53
|
+
for item in items:
|
|
54
|
+
raw = (item.get(date_key) or "")[:10]
|
|
55
|
+
if not raw:
|
|
56
|
+
kept.append(item)
|
|
57
|
+
continue
|
|
58
|
+
if raw < start_from[:10]:
|
|
59
|
+
exhausted = True
|
|
60
|
+
continue
|
|
61
|
+
kept.append(item)
|
|
62
|
+
return kept, exhausted
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def paginate_pages(fetch_page: FetchPage) -> AsyncIterator[list[dict]]:
|
|
66
|
+
"""Обходит страницы через `fetch_page(cursor) -> (items, next_cursor)`."""
|
|
67
|
+
cursor: Cursor | None = None
|
|
68
|
+
while True:
|
|
69
|
+
items, next_cursor = await fetch_page(cursor)
|
|
70
|
+
if not items:
|
|
71
|
+
return
|
|
72
|
+
yield items
|
|
73
|
+
if not next_cursor:
|
|
74
|
+
return
|
|
75
|
+
cursor = next_cursor
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def skip_pages(
|
|
79
|
+
fetch: Callable[[int, int], Awaitable[dict]],
|
|
80
|
+
page_size: int = 100,
|
|
81
|
+
items_key: str = "recordings",
|
|
82
|
+
) -> FetchPage:
|
|
83
|
+
"""Строит `fetch_page` для offset-пагинации поверх `fetch(skip, top) -> raw_dict`."""
|
|
84
|
+
page_size = max(1, min(int(page_size), 100))
|
|
85
|
+
|
|
86
|
+
async def fetch_page(cursor: int | None) -> tuple[list[dict], int | None]:
|
|
87
|
+
skip = cursor or 0
|
|
88
|
+
raw = await fetch(skip, page_size)
|
|
89
|
+
items = raw.get(items_key) or []
|
|
90
|
+
next_cursor = skip + len(items) if items else None
|
|
91
|
+
return items, next_cursor
|
|
92
|
+
|
|
93
|
+
return fetch_page
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def token_pages(
|
|
97
|
+
fetch: Callable[[str | None], Awaitable[dict]],
|
|
98
|
+
items_key: str = "entities",
|
|
99
|
+
token_key: str = "nextPageToken",
|
|
100
|
+
) -> FetchPage:
|
|
101
|
+
"""Строит `fetch_page` для курсорной пагинации поверх `fetch(token) -> raw_dict`."""
|
|
102
|
+
|
|
103
|
+
async def fetch_page(cursor: str | None) -> tuple[list[dict], str | None]:
|
|
104
|
+
raw = await fetch(cursor)
|
|
105
|
+
items = raw.get(items_key) or []
|
|
106
|
+
return items, raw.get(token_key) or None
|
|
107
|
+
|
|
108
|
+
return fetch_page
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Сверка идентификаторов перед первым api-key sync (FR-15).
|
|
2
|
+
|
|
3
|
+
Внутренний контур отдаёт только `id`, интеграторский — `id` и `key`; совпадают ли
|
|
4
|
+
значения между контурами не проверено эмпирически (ключ выдан без нужных scope,
|
|
5
|
+
зонд Ф-11/Ф-14). Если разойдутся, первый `ktalk sync` под ключом задвоит реестр
|
|
6
|
+
(~1316 записей) — обязателен `ktalk sync --dry-run` до первого боевого прогона.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def recording_ids(recordings: list[dict]) -> list[str]:
|
|
13
|
+
"""Извлекает id (fallback на key, как `registry.recording_fields_from_api`)."""
|
|
14
|
+
return [rid for r in recordings if (rid := r.get("id") or r.get("key"))]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compare_id_sets(fetched: list[str], existing: list[str]) -> dict:
|
|
18
|
+
"""Сравнивает множество id из API-ответа с уже существующими строками реестра."""
|
|
19
|
+
fetched_set = set(fetched)
|
|
20
|
+
existing_set = set(existing)
|
|
21
|
+
return {
|
|
22
|
+
"only_in_fetched": fetched_set - existing_set,
|
|
23
|
+
"only_in_existing": existing_set - fetched_set,
|
|
24
|
+
"common": fetched_set & existing_set,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dry_run_report(fetched_ids: list[str], existing_ids: list[str]) -> dict:
|
|
29
|
+
"""Отчёт для `ktalk sync --dry-run` (FR-15 AC-2/AC-3).
|
|
30
|
+
|
|
31
|
+
`ok=True` — можно продолжать обычную синхронизацию (полное совпадение либо
|
|
32
|
+
реестр ещё пуст, сверивать не с чем). `ok=False` — расхождение идентификаторов,
|
|
33
|
+
боевой sync заблокирован до осознанного решения оператора (используется как
|
|
34
|
+
exit-код CLI, по аналогии с уже принятым в проекте `migrate --dry-run`).
|
|
35
|
+
"""
|
|
36
|
+
if not existing_ids:
|
|
37
|
+
return {
|
|
38
|
+
"ok": True,
|
|
39
|
+
"message": (
|
|
40
|
+
"Реестр пуст — нет данных для сравнения. Сверка id будет возможна "
|
|
41
|
+
"после первого сохранённого sync."
|
|
42
|
+
),
|
|
43
|
+
"only_in_fetched": sorted(set(fetched_ids)),
|
|
44
|
+
"only_in_existing": [],
|
|
45
|
+
}
|
|
46
|
+
comparison = compare_id_sets(fetched_ids, existing_ids)
|
|
47
|
+
mismatch = bool(comparison["only_in_fetched"] or comparison["only_in_existing"])
|
|
48
|
+
message = (
|
|
49
|
+
"Обнаружено расхождение идентификаторов — боевой sync заблокирован до "
|
|
50
|
+
"осознанного решения оператора."
|
|
51
|
+
if mismatch
|
|
52
|
+
else "Идентификаторы полностью совпадают — можно продолжать обычную синхронизацию."
|
|
53
|
+
)
|
|
54
|
+
return {
|
|
55
|
+
"ok": not mismatch,
|
|
56
|
+
"message": message,
|
|
57
|
+
"only_in_fetched": sorted(comparison["only_in_fetched"]),
|
|
58
|
+
"only_in_existing": sorted(comparison["only_in_existing"]),
|
|
59
|
+
}
|