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/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
|