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/endpoints.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Таблица «операция × режим авторизации -> путь + scope» (FR-6) + примитивы
|
|
2
|
+
подстановки в путь. Вынесено из `auth.py` (гейт C13 — самая длинная top-level
|
|
3
|
+
декларация модуля превысила порог при росте таблицы ADR-010/ADR-011).
|
|
4
|
+
|
|
5
|
+
`auth.py` реэкспортирует эти имена — расположение импорта не влияет на
|
|
6
|
+
видимость атрибута модуля, тот же приём, что `client.py` уже применяет к
|
|
7
|
+
`auth.py`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from ktalk_cli.config import AuthMode
|
|
16
|
+
|
|
17
|
+
OPERATION_LABELS = {
|
|
18
|
+
"list_archive": "архив",
|
|
19
|
+
"get_participants_full": "полный состав участников",
|
|
20
|
+
"get_participants_report": "отчёт по участникам встречи",
|
|
21
|
+
"get_room": "чтение комнаты",
|
|
22
|
+
"get_calendar": "чтение календаря",
|
|
23
|
+
"create_meeting": "создание встречи",
|
|
24
|
+
"search_contacts": "поиск контактов",
|
|
25
|
+
"cancel_meeting": "отмена встречи",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def quote_path_param(value: object) -> str:
|
|
30
|
+
"""SEC-001: квотирует значение перед подстановкой в `path_template.format(...)` —
|
|
31
|
+
без этого "../" в recording_key/conference_key меняет фактический путь запроса
|
|
32
|
+
(например, "../admin" схлопывает /api/recordings/../admin в /api/admin)."""
|
|
33
|
+
return quote(str(value), safe="")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class EndpointProfile:
|
|
38
|
+
"""Путь + требуемый scope одной операции для одного режима авторизации."""
|
|
39
|
+
|
|
40
|
+
path_template: str
|
|
41
|
+
required_scope: str | None
|
|
42
|
+
mutating: bool = False # ADR-008: session-режим шлёт доп. Authorization-заголовок
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Таблица «операция × режим -> путь + scope» (FR-6). Отсутствие записи для режима
|
|
46
|
+
# (значение None) — управляемый отказ до сети, не голый 401/403.
|
|
47
|
+
OPERATION_PROFILES: dict[str, dict[AuthMode, EndpointProfile | None]] = {
|
|
48
|
+
"list_recordings": {
|
|
49
|
+
AuthMode.SESSION: EndpointProfile("/api/recordings", None),
|
|
50
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
51
|
+
"/api/Domain/recordings/v2", "application.recording.read"
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
"get_recording": {
|
|
55
|
+
AuthMode.SESSION: EndpointProfile("/api/recordings/{key}", None),
|
|
56
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
57
|
+
"/api/Domain/recordings/{key}", "application.recording.read"
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
"get_transcript": {
|
|
61
|
+
AuthMode.SESSION: EndpointProfile("/api/recordings/{key}/transcript", None),
|
|
62
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
63
|
+
"/api/recordings/{key}/transcript", "application.recording.read"
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
"get_summary": {
|
|
67
|
+
AuthMode.SESSION: EndpointProfile("/api/recordings/v2/{key}/summary", None),
|
|
68
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
69
|
+
"/api/recordings/v2/{key}/summary", "application.recording.read"
|
|
70
|
+
),
|
|
71
|
+
},
|
|
72
|
+
"get_summary_by_type": {
|
|
73
|
+
AuthMode.SESSION: EndpointProfile("/api/recordings/{key}/summary/{summary_type}", None),
|
|
74
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
75
|
+
"/api/recordings/{key}/summary/{summary_type}", "application.recording.read"
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
"list_archive": {
|
|
79
|
+
AuthMode.SESSION: None,
|
|
80
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
81
|
+
"/api/domain/conferencesHistory", "application.reporting.read"
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
"get_conference": {
|
|
85
|
+
AuthMode.SESSION: EndpointProfile("/api/conferencesHistory/{key}", None),
|
|
86
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
87
|
+
"/api/ConferencesHistory/v2/{key}", "application.reporting.read"
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
"get_participants_full": {
|
|
91
|
+
# Session-режим дообогащает дуальным источником (get_recording + get_conference),
|
|
92
|
+
# не через выделенный путь — см. KTalkClient.get_full_participants.
|
|
93
|
+
AuthMode.SESSION: None,
|
|
94
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
95
|
+
"/api/Domain/recordings/{key}/participants", "application.recording.read"
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
"get_participants_report": {
|
|
99
|
+
AuthMode.SESSION: None,
|
|
100
|
+
AuthMode.API_KEY: EndpointProfile(
|
|
101
|
+
"/api/ConferenceReports/{key}/participants", "application.reporting.read"
|
|
102
|
+
),
|
|
103
|
+
},
|
|
104
|
+
"get_room": {
|
|
105
|
+
# FR-17: внутренний путь, вне спеки, регистронезависим (постановка §5, живой GET).
|
|
106
|
+
AuthMode.SESSION: EndpointProfile("/api/rooms/{room_name}", None),
|
|
107
|
+
# ADR-004, таблица «Подтверждённость»: api-key не проверен вовсе — fail-closed (FR-17 AC3).
|
|
108
|
+
AuthMode.API_KEY: None,
|
|
109
|
+
},
|
|
110
|
+
"get_calendar": {
|
|
111
|
+
# FR-18: внутренний путь, вне спеки, подтверждён исчерпывающе под session
|
|
112
|
+
# (Ф-17–Ф-31 RES-003).
|
|
113
|
+
AuthMode.SESSION: EndpointProfile("/api/calendar", None),
|
|
114
|
+
# ADR-004 п.2: 200 наблюдался под api-key (Ф-34), но необъяснимо (расходится с Ф-33/Ф-35
|
|
115
|
+
# тем же ключом) — не "рабочая" запись несмотря на живой позитивный результат.
|
|
116
|
+
# Ревизуемо отдельной задачей при появлении объяснения, не тихой правкой этой строки.
|
|
117
|
+
AuthMode.API_KEY: None,
|
|
118
|
+
},
|
|
119
|
+
"create_meeting": {
|
|
120
|
+
# ГИПОТЕЗА (mainpart-ktalk-mcp.md:192-193, RES-003 Ф-38, не проверено живым POST):
|
|
121
|
+
# путь с префиксом /api — mainpart документирует base_url = f"{space_url}/api" и
|
|
122
|
+
# использует /calendar относительно него; предыдущая запись без /api была ошибкой
|
|
123
|
+
# прочтения этого источника (ADR-007), не проверенным решением. Следующий боевой
|
|
124
|
+
# POST под новой санкцией владельца — единственная проверка.
|
|
125
|
+
# ГИПОТЕЗА (ADR-008, DEV-005 §5, не проверено живым POST): запись в session-режиме
|
|
126
|
+
# может требовать доп. заголовок Authorization: Session <token> — mutating=True
|
|
127
|
+
# добавляет его поверх query, не вместо. Следующий боевой POST — единственная проверка.
|
|
128
|
+
AuthMode.SESSION: EndpointProfile("/api/calendar", None, mutating=True),
|
|
129
|
+
# ФАКТ (ADR-004 п.2): api-key не проверен вовсе -> fail-closed.
|
|
130
|
+
AuthMode.API_KEY: None,
|
|
131
|
+
},
|
|
132
|
+
"search_contacts": {
|
|
133
|
+
# ADR-010 §1: путь наблюдён живым HAR (RES-003 §5, Ф-53), read-операция.
|
|
134
|
+
AuthMode.SESSION: EndpointProfile("/api/contacts", None),
|
|
135
|
+
# ADR-010: api-key-режим не проверен вовсе (нет выданных scope, Р-4) -> fail-closed.
|
|
136
|
+
AuthMode.API_KEY: None,
|
|
137
|
+
},
|
|
138
|
+
"cancel_meeting": {
|
|
139
|
+
# ADR-011 §1: симметрично create_meeting — Ф-50, mutating=True, тот же транспорт,
|
|
140
|
+
# что ADR-009 подтвердил живым POST создания.
|
|
141
|
+
AuthMode.SESSION: EndpointProfile("/api/calendar/{id}/cancel", None, mutating=True),
|
|
142
|
+
# ADR-011: api-key-режим для мутаций календаря не проверен ни разу -> fail-closed.
|
|
143
|
+
AuthMode.API_KEY: None,
|
|
144
|
+
},
|
|
145
|
+
}
|
ktalk_cli/enrichment.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Дообогащение состава участников (FR-8).
|
|
2
|
+
|
|
3
|
+
Срабатывает ТОЛЬКО когда `len(participants) < participantsCount` (строгое `<`,
|
|
4
|
+
не `<=`) — иначе полный `ktalk sync` (~1316 записей) слал бы лишний запрос на
|
|
5
|
+
каждую запись домена вместо ~23% (зонд, Ф-4). Конкурентность ограничена
|
|
6
|
+
`asyncio.Semaphore` — предела rate limiting API нигде не задокументировано
|
|
7
|
+
(RES-001), поэтому дефолт консервативен, а не выведен из замера пропускной
|
|
8
|
+
способности. Отказ по одной записи не должен прерывать fan-out целиком
|
|
9
|
+
(`asyncio.gather(..., return_exceptions=True)`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _display_name(info: dict) -> str:
|
|
21
|
+
surname = info.get("surname")
|
|
22
|
+
firstname = info.get("firstname")
|
|
23
|
+
if surname and firstname:
|
|
24
|
+
return f"{surname} {firstname}"
|
|
25
|
+
return surname or firstname or info.get("login") or "Неизвестный"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def map_participants(raw: list[dict]) -> list[dict]:
|
|
29
|
+
"""Замена замороженного `registry.participants_from_api`: не роняет анонимов.
|
|
30
|
+
|
|
31
|
+
Аноним -> `{"ktalk_id": anonymousId, "name": anonymousName or "Аноним"}`.
|
|
32
|
+
Именованный -> та же схема ключей `ktalk_id`/`name` — формат строки
|
|
33
|
+
`participants` в SQLite (`registry.py`) не меняется.
|
|
34
|
+
"""
|
|
35
|
+
out: list[dict] = []
|
|
36
|
+
for p in raw:
|
|
37
|
+
info = p.get("userInfo")
|
|
38
|
+
if info:
|
|
39
|
+
ktalk_id = info.get("key") or info.get("login")
|
|
40
|
+
if not ktalk_id:
|
|
41
|
+
continue
|
|
42
|
+
out.append({"ktalk_id": str(ktalk_id), "name": _display_name(info)})
|
|
43
|
+
continue
|
|
44
|
+
anon_id = p.get("anonymousId")
|
|
45
|
+
if not anon_id:
|
|
46
|
+
continue
|
|
47
|
+
out.append({"ktalk_id": str(anon_id), "name": p.get("anonymousName") or "Аноним"})
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def enrich_batch(
|
|
52
|
+
records: list[dict],
|
|
53
|
+
client,
|
|
54
|
+
*,
|
|
55
|
+
concurrency: int = 5,
|
|
56
|
+
) -> dict[str, list[dict]]:
|
|
57
|
+
"""Возвращает `{recording_id: participants[]}` только для записей, где
|
|
58
|
+
`len(participants) < participantsCount`. Остальные не трогает.
|
|
59
|
+
|
|
60
|
+
Отказ по одной записи (сеть/500/таймаут) не прерывает остальные — лог
|
|
61
|
+
предупреждения, запись просто отсутствует в результате этого прохода
|
|
62
|
+
(согласуется с общей веротерпимостью sync к частичным данным).
|
|
63
|
+
"""
|
|
64
|
+
targets = [
|
|
65
|
+
r for r in records if len(r.get("participants") or []) < r.get("participantsCount", 0)
|
|
66
|
+
]
|
|
67
|
+
if not targets:
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
semaphore = asyncio.Semaphore(max(1, concurrency))
|
|
71
|
+
|
|
72
|
+
async def _enrich_one(record: dict) -> tuple[str, list[dict] | None]:
|
|
73
|
+
rid = str(record.get("id") or record.get("key") or "")
|
|
74
|
+
async with semaphore:
|
|
75
|
+
try:
|
|
76
|
+
detail = await client.get_recording(rid)
|
|
77
|
+
except Exception: # noqa: BLE001 - одна запись не должна ронять весь sync
|
|
78
|
+
logger.warning("Не удалось дообогатить участников записи %s", rid)
|
|
79
|
+
return rid, None
|
|
80
|
+
return rid, map_participants(detail.get("participants") or [])
|
|
81
|
+
|
|
82
|
+
results = await asyncio.gather(*(_enrich_one(r) for r in targets))
|
|
83
|
+
return {rid: parts for rid, parts in results if parts is not None}
|