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_meeting.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""FR-13 CLI: `create-meeting-preview`/`cancel-meeting-preview` — предпросмотр без сети.
|
|
2
|
+
|
|
3
|
+
Подтверждающие команды переехали в `cli_meeting_confirm.py` (ADR-016 §6): обе ветки
|
|
4
|
+
подтверждения в одном файле с аргументами перевалили бы порог гейта C13 (350 строк).
|
|
5
|
+
Общие аргументы и печать ошибок — `cli_meeting_args.py`.
|
|
6
|
+
|
|
7
|
+
ADR-016 §2: `confirmation_id` снова выводится в `--json`. Решение ADR-015 его не
|
|
8
|
+
выводить следовало из хранилища в памяти («не переживает границу процессов») и
|
|
9
|
+
отменено вместе с причиной — хранилище персистентно, и именно id связывает
|
|
10
|
+
предъявленное оператору тело с фактической записью.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
|
|
17
|
+
from ktalk_cli.cli_meeting_args import (
|
|
18
|
+
add_cancel_args,
|
|
19
|
+
add_meeting_args,
|
|
20
|
+
meeting_kwargs,
|
|
21
|
+
print_error,
|
|
22
|
+
)
|
|
23
|
+
from ktalk_cli.confirmation import ConfirmationStore
|
|
24
|
+
from ktalk_cli.formatters import (
|
|
25
|
+
format_cancel_preview,
|
|
26
|
+
format_meeting_preview,
|
|
27
|
+
format_raw,
|
|
28
|
+
render_tool_output,
|
|
29
|
+
)
|
|
30
|
+
from ktalk_cli.meeting_body import MissingFieldError, TimezoneFormatError
|
|
31
|
+
from ktalk_cli.meeting_cancel import CancelPreviewService
|
|
32
|
+
from ktalk_cli.meeting_scheduling import PreviewService
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_subparsers(sub) -> None:
|
|
36
|
+
p_create_preview = sub.add_parser("create-meeting-preview", help="Предпросмотр встречи (FR-13)")
|
|
37
|
+
add_meeting_args(p_create_preview)
|
|
38
|
+
p_create_preview.add_argument("--json", action="store_true")
|
|
39
|
+
|
|
40
|
+
p_cancel_preview = sub.add_parser(
|
|
41
|
+
"cancel-meeting-preview", help="Предпросмотр отмены (ADR-011)"
|
|
42
|
+
)
|
|
43
|
+
add_cancel_args(p_cancel_preview)
|
|
44
|
+
p_cancel_preview.add_argument("--json", action="store_true")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_create_meeting_preview(_reg, args: argparse.Namespace) -> int:
|
|
48
|
+
"""Без сети — только валидация и предпросмотр."""
|
|
49
|
+
service = PreviewService(ConfirmationStore())
|
|
50
|
+
try:
|
|
51
|
+
body, confirmation_id = service.preview(**meeting_kwargs(args))
|
|
52
|
+
except (MissingFieldError, TimezoneFormatError) as exc:
|
|
53
|
+
print_error(str(exc))
|
|
54
|
+
return 1
|
|
55
|
+
data = {"body": body, "confirmation_id": confirmation_id}
|
|
56
|
+
print(format_raw(data) if args.json else render_tool_output(data, "markdown",
|
|
57
|
+
format_meeting_preview))
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_cancel_meeting_preview(_reg, args: argparse.Namespace) -> int:
|
|
62
|
+
"""Без сети — только предпросмотр (ADR-011-spec §4)."""
|
|
63
|
+
service = CancelPreviewService(ConfirmationStore())
|
|
64
|
+
payload, confirmation_id = service.preview(id=args.id, reason=args.reason)
|
|
65
|
+
data = {"payload": payload, "confirmation_id": confirmation_id}
|
|
66
|
+
print(format_raw(data) if args.json else render_tool_output(data, "markdown",
|
|
67
|
+
format_cancel_preview))
|
|
68
|
+
return 0
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Общие аргументы и вывод ошибок пишущих команд встреч (ADR-016 §6).
|
|
2
|
+
|
|
3
|
+
Выделено из `cli_meeting.py` при расщеплении на предпросмотр и подтверждение:
|
|
4
|
+
общий у команд именно набор аргументов, а не операция, поэтому расщепление прошло
|
|
5
|
+
по каналу («предпросмотр / подтверждение»), а не по операции.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from ktalk_cli.config import redact_secrets
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def tri_bool(value: str) -> bool:
|
|
17
|
+
"""NFR-9: явный `true|false`, не `store_true` — `store_true` дал бы молчаливый
|
|
18
|
+
`False` при отсутствии флага, ровно тихий дефолт, который NFR-9 запрещает."""
|
|
19
|
+
lowered = value.lower()
|
|
20
|
+
if lowered == "true":
|
|
21
|
+
return True
|
|
22
|
+
if lowered == "false":
|
|
23
|
+
return False
|
|
24
|
+
raise argparse.ArgumentTypeError(f'Ожидается "true" или "false", получено: {value!r}')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add_meeting_args(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument("--subject", default=None)
|
|
29
|
+
parser.add_argument("--start", default=None)
|
|
30
|
+
parser.add_argument("--end", default=None)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--timezone",
|
|
33
|
+
default=None,
|
|
34
|
+
help="Формат GMT±N, пример: GMT+3 (единственная подтверждённая форма, FR-40)",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--room-name", default=None)
|
|
37
|
+
# ADR-009 §5: `--required-user-key` (логин) переименован в `--required-attendee-key`
|
|
38
|
+
# (числовой id) — резолюция логина в id не реализуется этим ADR, оператор
|
|
39
|
+
# передаёт значение явно (см. Бриф для Dev spec §5).
|
|
40
|
+
parser.add_argument("--required-attendee-key", action="append", default=None)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--no-required-attendees",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="Явное «без обязательных участников» (не то же самое, что отсутствие флага)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--description", default=None)
|
|
47
|
+
parser.add_argument("--enable-auto-recording", type=tri_bool, default=None)
|
|
48
|
+
parser.add_argument("--pin-code", default=None)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--no-pin-code",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help='Явное «без PIN» (JSON null) — не то же самое, что отсутствие флага (ADR-009 §2)',
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--allow-anonymous", type=tri_bool, default=None)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--anonymous-access-expiration",
|
|
57
|
+
default=None,
|
|
58
|
+
help=(
|
|
59
|
+
"Обязателен, если --allow-anonymous true (ADR-009 §3 — нет вычисляемого "
|
|
60
|
+
"дефолта, значение указывается явно)"
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def add_cancel_args(parser: argparse.ArgumentParser) -> None:
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--id",
|
|
68
|
+
required=True,
|
|
69
|
+
help="Base64 id встречи (Ф-56, из ответа create-meeting-confirm или чтения "
|
|
70
|
+
"календаря); не хранится проектом",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--reason",
|
|
74
|
+
default="",
|
|
75
|
+
help='Причина отмены (опционально, дефолт "" — единственный подтверждённый '
|
|
76
|
+
"рабочий образец, Ф-50)",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def required_attendee_keys(args: argparse.Namespace) -> list[str] | None:
|
|
81
|
+
"""`--no-required-attendees` побеждает при одновременной передаче обоих
|
|
82
|
+
флагов — порядок не специфицирован спекой, зафиксирован здесь и тестом."""
|
|
83
|
+
if args.no_required_attendees:
|
|
84
|
+
return []
|
|
85
|
+
return args.required_attendee_key
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def meeting_kwargs(args: argparse.Namespace) -> dict:
|
|
89
|
+
return {
|
|
90
|
+
"subject": args.subject,
|
|
91
|
+
"start": args.start,
|
|
92
|
+
"end": args.end,
|
|
93
|
+
"timezone": args.timezone,
|
|
94
|
+
"room_name": args.room_name,
|
|
95
|
+
"required_attendee_keys": required_attendee_keys(args),
|
|
96
|
+
"description": args.description,
|
|
97
|
+
"enable_auto_recording": args.enable_auto_recording,
|
|
98
|
+
"pin_code": args.pin_code,
|
|
99
|
+
"pin_code_explicit_none": args.no_pin_code,
|
|
100
|
+
"allow_anonymous": args.allow_anonymous,
|
|
101
|
+
"anonymous_access_expiration": args.anonymous_access_expiration,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# DEV-008: сентинел, а не `None` по умолчанию — различает "response_body не
|
|
106
|
+
# прикреплён вовсе" (нет ответа сервера, печатать нечего) и "прикреплён пустой"
|
|
107
|
+
# (тело ответа сервера фактически пустое — это наблюдение о контуре, не потеря
|
|
108
|
+
# кода). Оба раньше давали одно и то же "ничего не печатать".
|
|
109
|
+
NO_RESPONSE_BODY = object()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def print_error(message: str, response_body: object = NO_RESPONSE_BODY) -> None:
|
|
113
|
+
"""ADR-008 §3: тело ответа сервера (если было прикреплено к исключению) печатается
|
|
114
|
+
вместе с основным текстом, тем же проходом `redact_secrets` — новой точки
|
|
115
|
+
маскирования не вводится. Пустое тело печатается явно как факт (DEV-008), не
|
|
116
|
+
опускается."""
|
|
117
|
+
if response_body is NO_RESPONSE_BODY:
|
|
118
|
+
text = message
|
|
119
|
+
elif response_body:
|
|
120
|
+
text = f"{message}\nТело ответа сервера: {response_body}"
|
|
121
|
+
else:
|
|
122
|
+
text = f"{message}\nТело ответа сервера: (пусто)"
|
|
123
|
+
print(f"Ошибка: {redact_secrets(text)}", file=sys.stderr)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""ADR-016 §1: `create-meeting-confirm`/`cancel-meeting-confirm` — два канала.
|
|
2
|
+
|
|
3
|
+
| Канал | Признак | Требуется | Не требуется |
|
|
4
|
+
|---|---|---|---|
|
|
5
|
+
| `tty` | `isatty()` на stdin и stdout | слово подтверждения | санкция, `--confirmation-id` |
|
|
6
|
+
| `sanctioned` | терминала нет | санкция + валидный `--confirmation-id` | слово подтверждения |
|
|
7
|
+
|
|
8
|
+
Канал выбирается по факту наличия терминала, не флагом: флаг был бы параметром,
|
|
9
|
+
который вызывающий выставляет сам, — ровно та ошибка, которую ADR-005 отклонил для
|
|
10
|
+
подтверждения-параметра.
|
|
11
|
+
|
|
12
|
+
Порядок проверок в санкционном канале: санкция -> подтверждение -> журнал -> сеть.
|
|
13
|
+
Санкция раньше подтверждения намеренно: иначе перебор `--confirmation-id` различал
|
|
14
|
+
бы состояния санкции по коду возврата.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from ktalk_cli import write_sanction
|
|
25
|
+
from ktalk_cli.cli_meeting_args import (
|
|
26
|
+
NO_RESPONSE_BODY,
|
|
27
|
+
add_cancel_args,
|
|
28
|
+
add_meeting_args,
|
|
29
|
+
meeting_kwargs,
|
|
30
|
+
print_error,
|
|
31
|
+
)
|
|
32
|
+
from ktalk_cli.client import KTalkClient
|
|
33
|
+
from ktalk_cli.config import Settings
|
|
34
|
+
from ktalk_cli.confirmation import ConfirmationStore
|
|
35
|
+
from ktalk_cli.formatters import (
|
|
36
|
+
format_cancel_preview,
|
|
37
|
+
format_meeting_preview,
|
|
38
|
+
render_tool_output,
|
|
39
|
+
)
|
|
40
|
+
from ktalk_cli.meeting_body import MissingFieldError, TimezoneFormatError, canonical_body_hash
|
|
41
|
+
from ktalk_cli.meeting_cancel import CancelPreviewService, build_cancel_confirmation_payload
|
|
42
|
+
from ktalk_cli.meeting_scheduling import PreviewService, cancel_meeting, create_meeting
|
|
43
|
+
from ktalk_cli.write_journal import JournalUnavailableError, append_attempt, append_outcome
|
|
44
|
+
|
|
45
|
+
# Слово подтверждения не зафиксировано ни спекой, ни ADR — рабочая гипотеза
|
|
46
|
+
# QA-author (at-design.md «Допущения»), подтверждена здесь как решение Dev.
|
|
47
|
+
_CONFIRM_WORD = "да"
|
|
48
|
+
|
|
49
|
+
EXIT_OK = 0
|
|
50
|
+
EXIT_ERROR = 1
|
|
51
|
+
EXIT_NO_SANCTION = 40
|
|
52
|
+
EXIT_SANCTION_EXPIRED = 41
|
|
53
|
+
EXIT_SANCTION_EXHAUSTED = 42
|
|
54
|
+
EXIT_BAD_CONFIRMATION = 44
|
|
55
|
+
|
|
56
|
+
_SANCTION_EXIT = {
|
|
57
|
+
"absent": EXIT_NO_SANCTION,
|
|
58
|
+
"expired": EXIT_SANCTION_EXPIRED,
|
|
59
|
+
"exhausted": EXIT_SANCTION_EXHAUSTED,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register_subparsers(sub) -> None:
|
|
64
|
+
# DEV-009 оставлял `*-confirm` без `--json` («их не вызывают программно»).
|
|
65
|
+
# ADR-016 это отменяет: теперь вызывают, и разбирать надо машиночитаемо.
|
|
66
|
+
p_create = sub.add_parser("create-meeting-confirm", help="Создать встречу (ADR-016)")
|
|
67
|
+
add_meeting_args(p_create)
|
|
68
|
+
_add_confirm_args(p_create)
|
|
69
|
+
|
|
70
|
+
p_cancel = sub.add_parser("cancel-meeting-confirm", help="Отменить встречу (ADR-016)")
|
|
71
|
+
add_cancel_args(p_cancel)
|
|
72
|
+
_add_confirm_args(p_cancel)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _add_confirm_args(parser: argparse.ArgumentParser) -> None:
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--confirmation-id",
|
|
78
|
+
default=None,
|
|
79
|
+
help="id из соответствующего *-preview; обязателен вне интерактивного терминала",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument("--json", action="store_true")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _interactive() -> bool:
|
|
85
|
+
return bool(sys.stdin.isatty() and sys.stdout.isatty())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _ask_confirmation(prompt: str) -> bool:
|
|
89
|
+
print(f'\nВведите "{_CONFIRM_WORD}" для подтверждения {prompt}: ', end="", flush=True)
|
|
90
|
+
return sys.stdin.readline().strip().lower() == _CONFIRM_WORD
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _authorize(operation: str, args: argparse.Namespace, store, body_hash: str):
|
|
94
|
+
"""Возвращает `(channel, confirmation_id, remaining, exit_code)`; `exit_code`
|
|
95
|
+
не `None` — отказ, сетевого вызова не будет."""
|
|
96
|
+
if _interactive():
|
|
97
|
+
return "tty", None, None, None
|
|
98
|
+
|
|
99
|
+
state = write_sanction.read_state(operation)
|
|
100
|
+
if not state.active:
|
|
101
|
+
print_error(
|
|
102
|
+
f"Нет действующей санкции на операцию «{operation}» (состояние: {state.status}). "
|
|
103
|
+
f"Выдать её может только человек в своём терминале: "
|
|
104
|
+
f"ktalk sanction grant {operation.replace('_', '-')}",
|
|
105
|
+
)
|
|
106
|
+
return "sanctioned", None, None, _SANCTION_EXIT[state.status]
|
|
107
|
+
|
|
108
|
+
confirmation_id = args.confirmation_id
|
|
109
|
+
if not confirmation_id or not store.match(confirmation_id, body_hash):
|
|
110
|
+
print_error(
|
|
111
|
+
"Подтверждение недействительно: нужен --confirmation-id из свежего "
|
|
112
|
+
"*-preview с теми же параметрами (ADR-016 §2)."
|
|
113
|
+
)
|
|
114
|
+
return "sanctioned", None, None, EXIT_BAD_CONFIRMATION
|
|
115
|
+
|
|
116
|
+
store.consume(confirmation_id) # до сетевой попытки — повтор требует нового предпросмотра
|
|
117
|
+
remaining = write_sanction.consume(operation)
|
|
118
|
+
return "sanctioned", confirmation_id, remaining, None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _report(args: argparse.Namespace, payload: dict, text: str) -> None:
|
|
122
|
+
if getattr(args, "json", False):
|
|
123
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
124
|
+
else:
|
|
125
|
+
print(text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_confirm(operation, args, *, preview, formatter, prompt, network, describe) -> int:
|
|
129
|
+
store = ConfirmationStore()
|
|
130
|
+
try:
|
|
131
|
+
body, own_confirmation_id = preview(store)
|
|
132
|
+
except (MissingFieldError, TimezoneFormatError) as exc:
|
|
133
|
+
print_error(str(exc))
|
|
134
|
+
return EXIT_ERROR
|
|
135
|
+
|
|
136
|
+
body_hash = canonical_body_hash(body)
|
|
137
|
+
channel, confirmation_id, remaining, refusal = _authorize(operation, args, store, body_hash)
|
|
138
|
+
if refusal is not None:
|
|
139
|
+
return refusal
|
|
140
|
+
|
|
141
|
+
# Предпросмотр печатается в обоих каналах: в `tty` — перед вопросом человеку,
|
|
142
|
+
# в `sanctioned` — как след в выводе команды рядом с журнальной записью.
|
|
143
|
+
print(render_tool_output({**describe(body), "confirmation_id": own_confirmation_id},
|
|
144
|
+
"markdown", formatter))
|
|
145
|
+
if channel == "tty":
|
|
146
|
+
if not _ask_confirmation(prompt):
|
|
147
|
+
print("Отменено — подтверждение не получено.", file=sys.stderr)
|
|
148
|
+
return EXIT_ERROR
|
|
149
|
+
if not store.match(own_confirmation_id, body_hash):
|
|
150
|
+
print_error("Подтверждение истекло или недействительно — начните заново.")
|
|
151
|
+
return EXIT_BAD_CONFIRMATION
|
|
152
|
+
store.consume(own_confirmation_id)
|
|
153
|
+
confirmation_id = own_confirmation_id
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
append_attempt(
|
|
157
|
+
operation=operation,
|
|
158
|
+
channel=channel,
|
|
159
|
+
confirmation_id=confirmation_id or "",
|
|
160
|
+
body_sha256=body_hash,
|
|
161
|
+
body=body,
|
|
162
|
+
sanction_remaining=remaining,
|
|
163
|
+
)
|
|
164
|
+
except JournalUnavailableError as exc:
|
|
165
|
+
print_error(f"{exc} — запись в контур не выполнена (ADR-016 §5).")
|
|
166
|
+
return EXIT_ERROR
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
result = asyncio.run(network(body))
|
|
170
|
+
except Exception as exc: # noqa: BLE001 - любая ошибка = "исход неизвестен", без retry
|
|
171
|
+
status_code = getattr(exc, "status_code", None)
|
|
172
|
+
_outcome(
|
|
173
|
+
operation, channel, confirmation_id, body_hash,
|
|
174
|
+
"failed" if status_code is not None else "unknown", status_code,
|
|
175
|
+
)
|
|
176
|
+
_print_failure(args, exc, status_code, operation)
|
|
177
|
+
return EXIT_ERROR
|
|
178
|
+
|
|
179
|
+
_outcome(operation, channel, confirmation_id, body_hash, "ok", 200)
|
|
180
|
+
_report(args, {"status": "ok", "operation": operation, "result": result},
|
|
181
|
+
f"{'Встреча создана' if operation == 'create_meeting' else 'Встреча отменена'}: "
|
|
182
|
+
f"{result.get('id', result) if isinstance(result, dict) else result}")
|
|
183
|
+
return EXIT_OK
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _outcome(operation, channel, confirmation_id, body_hash, result, status_code) -> None:
|
|
187
|
+
try:
|
|
188
|
+
append_outcome(
|
|
189
|
+
operation=operation,
|
|
190
|
+
channel=channel,
|
|
191
|
+
confirmation_id=confirmation_id or "",
|
|
192
|
+
body_sha256=body_hash,
|
|
193
|
+
result=result,
|
|
194
|
+
status_code=status_code,
|
|
195
|
+
)
|
|
196
|
+
except JournalUnavailableError as exc:
|
|
197
|
+
# Откатывать нечем — операция уже выполнена; молчать нельзя.
|
|
198
|
+
print(f"Внимание: исход не записан в журнал ({exc}).", file=sys.stderr)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _print_failure(args, exc, status_code, operation) -> None:
|
|
202
|
+
base_message = str(exc)
|
|
203
|
+
if status_code is not None and f"HTTP {status_code}" not in base_message:
|
|
204
|
+
base_message = f"{base_message} (HTTP {status_code})"
|
|
205
|
+
command = f"{operation.replace('_meeting', '-meeting').replace('_', '-')}-confirm"
|
|
206
|
+
message = (
|
|
207
|
+
f"{base_message} — исход неизвестен, проверьте `ktalk list-calendar` перед "
|
|
208
|
+
f"повторной попыткой (повторный запуск {command} не выполняет автоматический "
|
|
209
|
+
"retry, NFR-22; подтверждение и единица бюджета санкции уже потрачены)."
|
|
210
|
+
)
|
|
211
|
+
control_probe = getattr(exc, "control_probe", None)
|
|
212
|
+
if control_probe:
|
|
213
|
+
message = f"{message}\n{control_probe}"
|
|
214
|
+
if getattr(args, "json", False):
|
|
215
|
+
print(json.dumps({"status": "unknown", "operation": operation, "message": message},
|
|
216
|
+
ensure_ascii=False))
|
|
217
|
+
print_error(message, getattr(exc, "response_body", NO_RESPONSE_BODY))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _create_over_network(body: dict) -> dict:
|
|
221
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
222
|
+
return await create_meeting(client, body)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_create_meeting_confirm(_reg, args: argparse.Namespace) -> int:
|
|
226
|
+
service_kwargs = meeting_kwargs(args)
|
|
227
|
+
return _run_confirm(
|
|
228
|
+
"create_meeting",
|
|
229
|
+
args,
|
|
230
|
+
preview=lambda store: PreviewService(store).preview(**service_kwargs),
|
|
231
|
+
formatter=format_meeting_preview,
|
|
232
|
+
prompt="создания встречи",
|
|
233
|
+
network=_create_over_network,
|
|
234
|
+
describe=lambda body: {"body": body},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def _cancel_over_network(id: str, reason: str) -> dict:
|
|
239
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
240
|
+
return await cancel_meeting(client, id=id, reason=reason)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_cancel_meeting_confirm(_reg, args: argparse.Namespace) -> int:
|
|
244
|
+
return _run_confirm(
|
|
245
|
+
"cancel_meeting",
|
|
246
|
+
args,
|
|
247
|
+
preview=lambda store: CancelPreviewService(store).preview(id=args.id, reason=args.reason),
|
|
248
|
+
formatter=format_cancel_preview,
|
|
249
|
+
prompt="отмены встречи",
|
|
250
|
+
network=lambda _body: _cancel_over_network(args.id, args.reason),
|
|
251
|
+
describe=lambda payload: {"payload": payload},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_cancel_payload(args: argparse.Namespace) -> dict:
|
|
256
|
+
return build_cancel_confirmation_payload(id=args.id, reason=args.reason)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""CLI-эквиваленты оставшихся читающих MCP-инструментов (DEV-002 волны 3, ADR-012
|
|
2
|
+
§2а): `list-archive` (FR-9), `get-chat-messages` (FR-10), `list-calendar` (FR-18),
|
|
3
|
+
`get-room` (FR-17) — те же операции, что `tools_meetings.py`/`tools_rooms.py`,
|
|
4
|
+
вторая обёртка над тем же клиентом/форматтерами.
|
|
5
|
+
|
|
6
|
+
Все команды — `_REGISTRY_FREE_COMMANDS` (не открывают SQLite, читают только сеть).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import date
|
|
15
|
+
|
|
16
|
+
from ktalk_cli.calendar_reader import get_calendar_window
|
|
17
|
+
from ktalk_cli.client import KTalkClient
|
|
18
|
+
from ktalk_cli.config import Settings, redact_secrets
|
|
19
|
+
from ktalk_cli.formatters import (
|
|
20
|
+
format_archive_list,
|
|
21
|
+
format_calendar,
|
|
22
|
+
format_chat_messages,
|
|
23
|
+
format_room,
|
|
24
|
+
render_tool_output,
|
|
25
|
+
)
|
|
26
|
+
from ktalk_cli.rooms import get_room
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_subparsers(sub) -> None:
|
|
30
|
+
p_arch = sub.add_parser("list-archive", help="Архив встреч за окно дат (сеть, FR-9)")
|
|
31
|
+
p_arch.add_argument("--from", dest="from_date", required=True)
|
|
32
|
+
p_arch.add_argument("--to", dest="to_date", required=True)
|
|
33
|
+
p_arch.add_argument("--room-name", action="append", default=None)
|
|
34
|
+
p_arch.add_argument("--json", action="store_true")
|
|
35
|
+
|
|
36
|
+
p_chat = sub.add_parser("get-chat-messages", help="Сообщения чата встречи (сеть, FR-10)")
|
|
37
|
+
p_chat.add_argument("--recording-key", default=None)
|
|
38
|
+
p_chat.add_argument("--conference-key", default=None)
|
|
39
|
+
p_chat.add_argument("--channel", default=None)
|
|
40
|
+
p_chat.add_argument("--json", action="store_true")
|
|
41
|
+
|
|
42
|
+
p_cal = sub.add_parser("list-calendar", help="Календарь встреч (сеть, FR-18)")
|
|
43
|
+
p_cal.add_argument("--start", required=True, help="Начало окна (ISO 8601), включительно")
|
|
44
|
+
p_cal.add_argument("--end", required=True, help="Конец окна (ISO 8601), включительно")
|
|
45
|
+
p_cal.add_argument("--room-name", default=None)
|
|
46
|
+
p_cal.add_argument("--json", action="store_true")
|
|
47
|
+
|
|
48
|
+
p_room = sub.add_parser(
|
|
49
|
+
"get-room",
|
|
50
|
+
help="Детали комнаты (сеть, FR-17 — ПОБОЧНЫЙ ЭФФЕКТ: создаёт комнату, если имя новое)",
|
|
51
|
+
)
|
|
52
|
+
p_room.add_argument("room_name")
|
|
53
|
+
p_room.add_argument("--json", action="store_true")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run(coro, formatter, *, json_flag: bool, **formatter_kwargs) -> int:
|
|
57
|
+
try:
|
|
58
|
+
data = asyncio.run(coro)
|
|
59
|
+
except Exception as exc: # noqa: BLE001 - surface as CLI error, NFR-5 маскирует
|
|
60
|
+
print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
fmt = "raw" if json_flag else "markdown"
|
|
63
|
+
print(render_tool_output(data, fmt, formatter, **formatter_kwargs))
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _list_archive(args: argparse.Namespace) -> list[dict]:
|
|
68
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
69
|
+
return await client.list_archive(
|
|
70
|
+
from_date=args.from_date, to_date=args.to_date, room_names=args.room_name
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_list_archive(_reg, args: argparse.Namespace) -> int:
|
|
75
|
+
return _run(_list_archive(args), format_archive_list, json_flag=args.json)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _get_chat_messages(args: argparse.Namespace) -> list[dict]:
|
|
79
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
80
|
+
return await client.get_chat_messages(
|
|
81
|
+
recording_key=args.recording_key,
|
|
82
|
+
conference_key=args.conference_key,
|
|
83
|
+
channel=args.channel,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_get_chat_messages(_reg, args: argparse.Namespace) -> int:
|
|
88
|
+
return _run(_get_chat_messages(args), format_chat_messages, json_flag=args.json)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _list_calendar(args: argparse.Namespace) -> dict:
|
|
92
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
93
|
+
result = await get_calendar_window(
|
|
94
|
+
client, date.fromisoformat(args.start), date.fromisoformat(args.end),
|
|
95
|
+
room_name=args.room_name,
|
|
96
|
+
)
|
|
97
|
+
return {
|
|
98
|
+
"items": result.items,
|
|
99
|
+
"incomplete_segments": [
|
|
100
|
+
[s.isoformat(), e.isoformat()] for s, e in result.incomplete_segments
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_list_calendar(_reg, args: argparse.Namespace) -> int:
|
|
106
|
+
return _run(_list_calendar(args), format_calendar, json_flag=args.json)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _get_room(args: argparse.Namespace) -> dict:
|
|
110
|
+
async with KTalkClient.from_settings(Settings()) as client:
|
|
111
|
+
return await get_room(client, args.room_name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_get_room(_reg, args: argparse.Namespace) -> int:
|
|
115
|
+
return _run(_get_room(args), format_room, json_flag=args.json)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""ADR-016 §3: `ktalk sanction status|grant|revoke` — выдача и отзыв мандата на запись.
|
|
2
|
+
|
|
3
|
+
TTY-проверка живёт здесь, а не в `write_sanction.py`: модуль остаётся тестируемым,
|
|
4
|
+
барьер — непроходимым из кода. Флага `--force`/`--yes` и переменной окружения,
|
|
5
|
+
обходящей проверку, нет намеренно: обходной путь «для тестов» является обходным
|
|
6
|
+
путём и в бою (ADR-014 §8).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from ktalk_cli import write_sanction
|
|
16
|
+
from ktalk_cli.write_sanction import DEFAULT_HOURS, DEFAULT_OPERATIONS, OPERATIONS
|
|
17
|
+
|
|
18
|
+
EXIT_OK = 0
|
|
19
|
+
EXIT_BAD_ARGS = 2
|
|
20
|
+
EXIT_INTERNAL = 20
|
|
21
|
+
EXIT_NO_TTY = 43
|
|
22
|
+
|
|
23
|
+
_CLI_TO_OPERATION = {operation.replace("_", "-"): operation for operation in OPERATIONS}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_subparsers(sub) -> None:
|
|
27
|
+
parser = sub.add_parser("sanction", help="Санкция на запись в контур (ADR-016)")
|
|
28
|
+
actions = parser.add_subparsers(dest="sanction_action")
|
|
29
|
+
|
|
30
|
+
p_status = actions.add_parser("status", help="Состояние санкций")
|
|
31
|
+
p_status.add_argument("--json", action="store_true")
|
|
32
|
+
|
|
33
|
+
p_grant = actions.add_parser("grant", help="Выдать санкцию (только в терминале)")
|
|
34
|
+
p_grant.add_argument("operation", choices=sorted(_CLI_TO_OPERATION))
|
|
35
|
+
p_grant.add_argument("--hours", type=int, default=DEFAULT_HOURS)
|
|
36
|
+
p_grant.add_argument("--operations", type=int, default=DEFAULT_OPERATIONS)
|
|
37
|
+
|
|
38
|
+
p_revoke = actions.add_parser("revoke", help="Отозвать санкцию")
|
|
39
|
+
p_revoke.add_argument("operation", choices=[*sorted(_CLI_TO_OPERATION), "all"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _state_json(state) -> dict:
|
|
43
|
+
return {
|
|
44
|
+
"status": state.status,
|
|
45
|
+
"active": state.active,
|
|
46
|
+
"expires_at": state.expires_at,
|
|
47
|
+
"remaining": state.remaining,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
52
|
+
states = {operation: write_sanction.read_state(operation) for operation in OPERATIONS}
|
|
53
|
+
if args.json:
|
|
54
|
+
print(json.dumps({key: _state_json(state) for key, state in states.items()}))
|
|
55
|
+
else:
|
|
56
|
+
for operation, state in states.items():
|
|
57
|
+
suffix = (
|
|
58
|
+
f", до {state.expires_at}, осталось операций: {state.remaining}"
|
|
59
|
+
if state.expires_at
|
|
60
|
+
else ""
|
|
61
|
+
)
|
|
62
|
+
print(f"{operation}: {state.status}{suffix}")
|
|
63
|
+
print(f"Файл: {write_sanction.sanction_path()}")
|
|
64
|
+
return EXIT_OK
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cmd_grant(args: argparse.Namespace) -> int:
|
|
68
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
69
|
+
print(
|
|
70
|
+
"Санкция на запись выдаётся только в интерактивном терминале. "
|
|
71
|
+
f"Запустите вручную: ktalk sanction grant {args.operation}",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
return EXIT_NO_TTY
|
|
75
|
+
|
|
76
|
+
operation = _CLI_TO_OPERATION[args.operation]
|
|
77
|
+
try:
|
|
78
|
+
state = write_sanction.grant(
|
|
79
|
+
operation, hours=args.hours, operations=args.operations
|
|
80
|
+
)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
print(f"Ошибка: {exc}", file=sys.stderr)
|
|
83
|
+
return EXIT_INTERNAL
|
|
84
|
+
|
|
85
|
+
print(
|
|
86
|
+
f"Санкция «{args.operation}» выдана до {state.expires_at} "
|
|
87
|
+
f"на {state.remaining} операц(ий).\n"
|
|
88
|
+
f"Отзыв: ktalk sanction revoke {args.operation}\n"
|
|
89
|
+
f"Файл: {write_sanction.sanction_path()}"
|
|
90
|
+
)
|
|
91
|
+
return EXIT_OK
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _cmd_revoke(args: argparse.Namespace) -> int:
|
|
95
|
+
targets = OPERATIONS if args.operation == "all" else (_CLI_TO_OPERATION[args.operation],)
|
|
96
|
+
for operation in targets:
|
|
97
|
+
write_sanction.revoke(operation)
|
|
98
|
+
print(f"Санкция «{operation.replace('_', '-')}» отозвана.")
|
|
99
|
+
return EXIT_OK
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_sanction(_reg, args: argparse.Namespace) -> int:
|
|
103
|
+
action = getattr(args, "sanction_action", None)
|
|
104
|
+
handler = {"status": _cmd_status, "grant": _cmd_grant, "revoke": _cmd_revoke}.get(action)
|
|
105
|
+
if handler is None:
|
|
106
|
+
print("Укажите действие: sanction status | grant | revoke", file=sys.stderr)
|
|
107
|
+
return EXIT_BAD_ARGS
|
|
108
|
+
return handler(args)
|