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/formatters.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Converters from KTalk API JSON responses to markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
_STATUS_MESSAGES = {
|
|
9
|
+
"inProgress": "В обработке... Попробуйте позже.",
|
|
10
|
+
"failed": "Ошибка обработки.",
|
|
11
|
+
"notFound": "Не найдено.",
|
|
12
|
+
"notAvailable": "Недоступно.",
|
|
13
|
+
"serviceError": "Ошибка сервиса.",
|
|
14
|
+
"recreateInProgress": "Пересоздаётся... Попробуйте позже.",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_SUMMARY_TYPE_TITLES = {
|
|
18
|
+
"shortSummary": "Краткое резюме",
|
|
19
|
+
"protocol": "Протокол",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_raw(data: dict) -> str:
|
|
24
|
+
"""Return raw JSON string."""
|
|
25
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_tool_output(data: dict, fmt: str, formatter, **kwargs) -> str:
|
|
29
|
+
"""Диспетчер raw/markdown, общий для MCP-инструментов и CLI (было
|
|
30
|
+
`server.py::_format_output`)."""
|
|
31
|
+
if fmt == "raw":
|
|
32
|
+
return format_raw(data)
|
|
33
|
+
return formatter(data, **kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _format_duration(seconds: int) -> str:
|
|
37
|
+
"""Format duration in seconds to human-readable Russian string."""
|
|
38
|
+
if seconds < 3600:
|
|
39
|
+
minutes = max(seconds // 60, 0 if seconds == 0 else 1)
|
|
40
|
+
return f"{minutes} мин"
|
|
41
|
+
hours = seconds // 3600
|
|
42
|
+
minutes = (seconds % 3600) // 60
|
|
43
|
+
return f"{hours} ч {minutes} мин"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_timestamp(millis: int) -> str:
|
|
47
|
+
"""Format milliseconds offset to HH:MM:SS."""
|
|
48
|
+
total_seconds = millis // 1000
|
|
49
|
+
hours = total_seconds // 3600
|
|
50
|
+
minutes = (total_seconds % 3600) // 60
|
|
51
|
+
seconds = total_seconds % 60
|
|
52
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_user_name(user_ref: dict | None, *, with_id: bool = False) -> str:
|
|
56
|
+
"""Extract display name from TalkUserRef or TalkUserBaseInfoRef."""
|
|
57
|
+
if not user_ref:
|
|
58
|
+
return "Неизвестный"
|
|
59
|
+
|
|
60
|
+
if user_ref.get("isAnonymous"):
|
|
61
|
+
return user_ref.get("anonymousName") or "Неизвестный"
|
|
62
|
+
|
|
63
|
+
user_info = user_ref.get("userInfo")
|
|
64
|
+
if not user_info:
|
|
65
|
+
return "Неизвестный"
|
|
66
|
+
|
|
67
|
+
name = _format_user_name_from_user(user_info)
|
|
68
|
+
if with_id:
|
|
69
|
+
uid = user_info.get("key") or user_info.get("login")
|
|
70
|
+
if uid:
|
|
71
|
+
return f"{name} ({uid})"
|
|
72
|
+
return name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_user_name_from_user(user: dict | None) -> str:
|
|
76
|
+
"""Extract display name from TalkUser object."""
|
|
77
|
+
if not user:
|
|
78
|
+
return "Неизвестный"
|
|
79
|
+
|
|
80
|
+
surname = user.get("surname")
|
|
81
|
+
firstname = user.get("firstname")
|
|
82
|
+
|
|
83
|
+
if surname and firstname:
|
|
84
|
+
return f"{surname} {firstname}"
|
|
85
|
+
if surname:
|
|
86
|
+
return surname
|
|
87
|
+
if firstname:
|
|
88
|
+
return firstname
|
|
89
|
+
return user.get("login") or "Неизвестный"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_user_name_short(user: dict | None) -> str:
|
|
93
|
+
"""Format user name as 'Фамилия И.' for table display."""
|
|
94
|
+
if not user:
|
|
95
|
+
return "Неизвестный"
|
|
96
|
+
|
|
97
|
+
surname = user.get("surname")
|
|
98
|
+
firstname = user.get("firstname")
|
|
99
|
+
|
|
100
|
+
if surname and firstname:
|
|
101
|
+
return f"{surname} {firstname[0]}."
|
|
102
|
+
if surname:
|
|
103
|
+
return surname
|
|
104
|
+
if firstname:
|
|
105
|
+
return firstname
|
|
106
|
+
return user.get("login") or "Неизвестный"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _format_datetime(dt_string: str | None) -> str:
|
|
110
|
+
"""Format ISO 8601 datetime to 'YYYY-MM-DD HH:MM'."""
|
|
111
|
+
if not dt_string:
|
|
112
|
+
return ""
|
|
113
|
+
try:
|
|
114
|
+
dt_string = dt_string.replace("Z", "+00:00")
|
|
115
|
+
dt = datetime.fromisoformat(dt_string)
|
|
116
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
return dt_string
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def format_recording(data: dict) -> str:
|
|
122
|
+
"""Format single recording details to markdown."""
|
|
123
|
+
title = data.get("title", "Без названия")
|
|
124
|
+
lines = [f"# {title}", ""]
|
|
125
|
+
|
|
126
|
+
lines.append(f"- **Ключ:** {data.get('id', data.get('key', 'N/A'))}")
|
|
127
|
+
lines.append(f"- **Дата:** {_format_datetime(data.get('createdDate'))}")
|
|
128
|
+
|
|
129
|
+
created_by = data.get("createdBy")
|
|
130
|
+
if created_by:
|
|
131
|
+
lines.append(f"- **Автор:** {_format_user_name_from_user(created_by)}")
|
|
132
|
+
|
|
133
|
+
room = data.get("roomName")
|
|
134
|
+
if room:
|
|
135
|
+
lines.append(f"- **Комната:** {room}")
|
|
136
|
+
|
|
137
|
+
lines.append(f"- **Длительность:** {_format_duration(data.get('duration', 0))}")
|
|
138
|
+
|
|
139
|
+
participants = data.get("participants", [])
|
|
140
|
+
count = data.get("participantsCount", len(participants))
|
|
141
|
+
if participants:
|
|
142
|
+
names = [_format_user_name(p, with_id=True) for p in participants]
|
|
143
|
+
lines.append(f"- **Участники ({count}):** {', '.join(names)}")
|
|
144
|
+
elif count:
|
|
145
|
+
lines.append(f"- **Участники:** {count}")
|
|
146
|
+
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def format_recordings_list(data: dict) -> str:
|
|
151
|
+
"""Format recordings list response to markdown table."""
|
|
152
|
+
entities = data.get("recordings") or data.get("entities") or []
|
|
153
|
+
|
|
154
|
+
if not entities:
|
|
155
|
+
return "# Записи KTalk\n\nЗаписей не найдено."
|
|
156
|
+
|
|
157
|
+
lines = [
|
|
158
|
+
"# Записи KTalk",
|
|
159
|
+
"",
|
|
160
|
+
"| ID | Название | Дата | Автор | Длительность | Участники |",
|
|
161
|
+
"|----|----------|------|-------|-------------|-----------|",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
for rec in entities:
|
|
165
|
+
rec_id = rec.get("id", rec.get("key", "N/A"))
|
|
166
|
+
title = rec.get("title", "Без названия")
|
|
167
|
+
date = _format_datetime(rec.get("createdDate"))
|
|
168
|
+
created_by = rec.get("createdBy") or {}
|
|
169
|
+
author_name = _format_user_name_from_user(created_by)
|
|
170
|
+
author_id = created_by.get("key") or created_by.get("login") or ""
|
|
171
|
+
author = f"{author_name} ({author_id})" if author_id else author_name
|
|
172
|
+
duration = _format_duration(rec.get("duration", 0))
|
|
173
|
+
participants_list = rec.get("participants") or []
|
|
174
|
+
if participants_list:
|
|
175
|
+
names = [_format_user_name(p, with_id=True) for p in participants_list]
|
|
176
|
+
participants = ", ".join(names)
|
|
177
|
+
else:
|
|
178
|
+
count = rec.get("participantsCount", 0)
|
|
179
|
+
participants = str(count) if count else "—"
|
|
180
|
+
lines.append(f"| {rec_id} | {title} | {date} | {author} | {duration} | {participants} |")
|
|
181
|
+
|
|
182
|
+
next_token = data.get("nextPageToken")
|
|
183
|
+
if next_token:
|
|
184
|
+
lines.append("")
|
|
185
|
+
lines.append(f'> Следующая страница: используйте `page_token: "{next_token}"`')
|
|
186
|
+
|
|
187
|
+
return "\n".join(lines)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def format_transcript(data: dict) -> str:
|
|
191
|
+
"""Format transcript response to markdown dialogue."""
|
|
192
|
+
status = data.get("status", "")
|
|
193
|
+
|
|
194
|
+
if status == "inProgress":
|
|
195
|
+
return "# Транскрипт\n\nВ обработке... Попробуйте позже."
|
|
196
|
+
|
|
197
|
+
if status in ("error", "failed"):
|
|
198
|
+
msg = data.get("statusMessage", "неизвестная ошибка")
|
|
199
|
+
return f"# Транскрипт\n\nОшибка транскрипции: {msg}"
|
|
200
|
+
|
|
201
|
+
tracks = data.get("tracks") or []
|
|
202
|
+
if not tracks:
|
|
203
|
+
return "# Транскрипт\n\nТранскрипт пуст."
|
|
204
|
+
|
|
205
|
+
# Collect all chunks with speaker info, sorted by time
|
|
206
|
+
entries: list[tuple[int, str, str]] = []
|
|
207
|
+
for track in tracks:
|
|
208
|
+
speaker_name = _format_user_name(track.get("speaker"))
|
|
209
|
+
for chunk in track.get("chunks") or []:
|
|
210
|
+
time_ms = chunk.get("startTimeOffsetInMillis", 0)
|
|
211
|
+
text = chunk.get("text", "")
|
|
212
|
+
entries.append((time_ms, speaker_name, text))
|
|
213
|
+
|
|
214
|
+
entries.sort(key=lambda e: e[0])
|
|
215
|
+
|
|
216
|
+
lines = ["# Транскрипт", ""]
|
|
217
|
+
for time_ms, speaker, text in entries:
|
|
218
|
+
timestamp = _format_timestamp(time_ms)
|
|
219
|
+
lines.append(f"**{speaker}** [{timestamp}]: {text}")
|
|
220
|
+
lines.append("")
|
|
221
|
+
|
|
222
|
+
return "\n".join(lines).rstrip()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def chunk_transcript_markdown(text: str, chunk_size: int) -> list[str]:
|
|
226
|
+
"""Split formatted markdown transcript into chunks at utterance boundaries.
|
|
227
|
+
|
|
228
|
+
Returns a list of chunk strings. Each chunk includes the header.
|
|
229
|
+
A single utterance longer than chunk_size is kept intact (never split mid-utterance).
|
|
230
|
+
"""
|
|
231
|
+
# Find header boundary (everything before first utterance)
|
|
232
|
+
header = ""
|
|
233
|
+
body = text
|
|
234
|
+
# Header is "# Транскрипт\n\n" — find first utterance marker
|
|
235
|
+
first_utterance = text.find("\n\n**")
|
|
236
|
+
if first_utterance != -1:
|
|
237
|
+
header = text[: first_utterance + 2] # include the \n\n
|
|
238
|
+
body = text[first_utterance + 2 :] # utterances start here
|
|
239
|
+
else:
|
|
240
|
+
# No utterances (empty/error/in-progress) — return as-is
|
|
241
|
+
return [text]
|
|
242
|
+
|
|
243
|
+
# Split body into individual utterances by \n\n
|
|
244
|
+
utterances = body.split("\n\n")
|
|
245
|
+
# Filter empty strings from trailing newlines
|
|
246
|
+
utterances = [u for u in utterances if u.strip()]
|
|
247
|
+
|
|
248
|
+
if not utterances:
|
|
249
|
+
return [text]
|
|
250
|
+
|
|
251
|
+
chunks: list[str] = []
|
|
252
|
+
current_parts: list[str] = []
|
|
253
|
+
current_len = len(header)
|
|
254
|
+
|
|
255
|
+
for utterance in utterances:
|
|
256
|
+
utterance_len = len(utterance)
|
|
257
|
+
# +2 for the \n\n separator between utterances
|
|
258
|
+
added_len = utterance_len + (2 if current_parts else 0)
|
|
259
|
+
|
|
260
|
+
if current_parts and current_len + added_len > chunk_size:
|
|
261
|
+
# Finalize current chunk
|
|
262
|
+
chunks.append(header + "\n\n".join(current_parts))
|
|
263
|
+
current_parts = [utterance]
|
|
264
|
+
current_len = len(header) + utterance_len
|
|
265
|
+
else:
|
|
266
|
+
current_parts.append(utterance)
|
|
267
|
+
current_len += added_len
|
|
268
|
+
|
|
269
|
+
# Don't forget the last chunk
|
|
270
|
+
if current_parts:
|
|
271
|
+
chunks.append(header + "\n\n".join(current_parts))
|
|
272
|
+
|
|
273
|
+
return chunks
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def chunk_transcript_raw(data: dict, chunk_size: int) -> list[str]:
|
|
277
|
+
"""Split transcript API data into chunks of JSON-serialized entry arrays.
|
|
278
|
+
|
|
279
|
+
Extracts entries from tracks, sorts by time, groups into chunks
|
|
280
|
+
where each chunk's serialized JSON length <= chunk_size.
|
|
281
|
+
Returns a list of JSON strings (each is a JSON array of entry objects).
|
|
282
|
+
"""
|
|
283
|
+
tracks = data.get("tracks") or []
|
|
284
|
+
entries: list[dict] = []
|
|
285
|
+
for track in tracks:
|
|
286
|
+
speaker_name = _format_user_name(track.get("speaker"))
|
|
287
|
+
for chunk in track.get("chunks") or []:
|
|
288
|
+
entries.append({
|
|
289
|
+
"speaker": speaker_name,
|
|
290
|
+
"timestamp_ms": chunk.get("startTimeOffsetInMillis", 0),
|
|
291
|
+
"text": chunk.get("text", ""),
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
entries.sort(key=lambda e: e["timestamp_ms"])
|
|
295
|
+
|
|
296
|
+
if not entries:
|
|
297
|
+
return [json.dumps([], ensure_ascii=False, indent=2)]
|
|
298
|
+
|
|
299
|
+
chunks: list[str] = []
|
|
300
|
+
current_entries: list[dict] = []
|
|
301
|
+
current_len = 2 # "[]" base length
|
|
302
|
+
|
|
303
|
+
for entry in entries:
|
|
304
|
+
entry_json = json.dumps(entry, ensure_ascii=False)
|
|
305
|
+
# +2 for ",\n" separator, +4 for indentation in pretty-print
|
|
306
|
+
entry_len = len(entry_json) + 6
|
|
307
|
+
if current_entries and current_len + entry_len > chunk_size:
|
|
308
|
+
chunks.append(json.dumps(current_entries, ensure_ascii=False, indent=2))
|
|
309
|
+
current_entries = [entry]
|
|
310
|
+
current_len = 2 + len(entry_json) + 4
|
|
311
|
+
else:
|
|
312
|
+
current_entries.append(entry)
|
|
313
|
+
current_len += entry_len
|
|
314
|
+
|
|
315
|
+
if current_entries:
|
|
316
|
+
chunks.append(json.dumps(current_entries, ensure_ascii=False, indent=2))
|
|
317
|
+
|
|
318
|
+
return chunks
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def render_transcript_output(data: dict, fmt: str, chunk: int, chunk_size: int) -> str:
|
|
322
|
+
"""Общий слой чтения транскрипта с чанкингом — единственная точка правды для
|
|
323
|
+
`ktalk_get_transcript` (MCP) и `ktalk get-transcript` (CLI, DEV-002 волны 3):
|
|
324
|
+
поведение auto/paged-чанкинга не дублируется по вызывающим обёрткам."""
|
|
325
|
+
if fmt == "raw":
|
|
326
|
+
full_text = format_raw(data)
|
|
327
|
+
else:
|
|
328
|
+
full_text = format_transcript(data)
|
|
329
|
+
|
|
330
|
+
total_characters = len(full_text)
|
|
331
|
+
|
|
332
|
+
if chunk == 0 and total_characters <= chunk_size:
|
|
333
|
+
return full_text
|
|
334
|
+
|
|
335
|
+
if fmt == "raw":
|
|
336
|
+
chunks = chunk_transcript_raw(data, chunk_size)
|
|
337
|
+
else:
|
|
338
|
+
chunks = chunk_transcript_markdown(full_text, chunk_size)
|
|
339
|
+
|
|
340
|
+
total_chunks = len(chunks)
|
|
341
|
+
chunk_index = 0 if chunk == 0 else chunk - 1
|
|
342
|
+
|
|
343
|
+
if chunk_index < 0 or chunk_index >= total_chunks:
|
|
344
|
+
return f"Чанк {chunk} не существует. Всего чанков: {total_chunks}"
|
|
345
|
+
|
|
346
|
+
return json.dumps(
|
|
347
|
+
{
|
|
348
|
+
"result": chunks[chunk_index],
|
|
349
|
+
"chunk": chunk_index + 1,
|
|
350
|
+
"total_chunks": total_chunks,
|
|
351
|
+
"has_more": chunk_index + 1 < total_chunks,
|
|
352
|
+
"total_characters": total_characters,
|
|
353
|
+
},
|
|
354
|
+
ensure_ascii=False,
|
|
355
|
+
indent=2,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _format_summary_chunks(chunks: list[dict] | None) -> str:
|
|
360
|
+
"""Render summary chunks to markdown text."""
|
|
361
|
+
if not chunks:
|
|
362
|
+
return ""
|
|
363
|
+
|
|
364
|
+
lines: list[str] = []
|
|
365
|
+
for chunk in chunks:
|
|
366
|
+
chunk_type = chunk.get("type", "text")
|
|
367
|
+
text = chunk.get("text", "")
|
|
368
|
+
if chunk_type == "heading":
|
|
369
|
+
lines.append(f"### {text}")
|
|
370
|
+
else:
|
|
371
|
+
lines.append(text)
|
|
372
|
+
lines.append("")
|
|
373
|
+
|
|
374
|
+
return "\n".join(lines).rstrip()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def format_summary(data: dict) -> str:
|
|
378
|
+
"""Format composite summary response (v2) to markdown."""
|
|
379
|
+
lines = ["# Саммари", ""]
|
|
380
|
+
|
|
381
|
+
short = data.get("shortSummaryV2", {})
|
|
382
|
+
protocol = data.get("protocolV2", {})
|
|
383
|
+
|
|
384
|
+
# Short summary section
|
|
385
|
+
lines.append("## Краткое резюме")
|
|
386
|
+
lines.append("")
|
|
387
|
+
short_status = short.get("status", "notFound")
|
|
388
|
+
if short_status == "success":
|
|
389
|
+
lines.append(_format_summary_chunks(short.get("chunks")))
|
|
390
|
+
else:
|
|
391
|
+
lines.append(_STATUS_MESSAGES.get(short_status, f"Статус: {short_status}"))
|
|
392
|
+
lines.append("")
|
|
393
|
+
|
|
394
|
+
# Protocol section
|
|
395
|
+
lines.append("## Протокол")
|
|
396
|
+
lines.append("")
|
|
397
|
+
protocol_status = protocol.get("status", "notFound")
|
|
398
|
+
if protocol_status == "success":
|
|
399
|
+
lines.append(_format_summary_chunks(protocol.get("chunks")))
|
|
400
|
+
else:
|
|
401
|
+
lines.append(_STATUS_MESSAGES.get(protocol_status, f"Статус: {protocol_status}"))
|
|
402
|
+
|
|
403
|
+
return "\n".join(lines).rstrip()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def format_summary_by_type(data: dict, summary_type: str) -> str:
|
|
407
|
+
"""Format single summary type response to markdown."""
|
|
408
|
+
title = _SUMMARY_TYPE_TITLES.get(summary_type, summary_type)
|
|
409
|
+
status = data.get("status", "notFound")
|
|
410
|
+
|
|
411
|
+
if status != "success":
|
|
412
|
+
msg = _STATUS_MESSAGES.get(status, f"Статус: {status}")
|
|
413
|
+
return f"# {title}\n\n{msg}"
|
|
414
|
+
|
|
415
|
+
chunks_text = _format_summary_chunks(data.get("chunks"))
|
|
416
|
+
return f"# {title}\n\n{chunks_text}"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def format_participants(data: dict) -> str:
|
|
420
|
+
"""Format `ktalk_get_participants` result (FR-8) to markdown."""
|
|
421
|
+
participants = data.get("participants") or []
|
|
422
|
+
lines = [f"# Участники ({len(participants)})", ""]
|
|
423
|
+
if data.get("incomplete"):
|
|
424
|
+
lines.append("> Внимание: состав может быть неполным (`incomplete: true`).")
|
|
425
|
+
lines.append("")
|
|
426
|
+
for p in participants:
|
|
427
|
+
name = p.get("name") or "Неизвестный"
|
|
428
|
+
pid = p.get("ktalk_id") or p.get("anonymous_id") or "—"
|
|
429
|
+
lines.append(f"- {name} (id: {pid})")
|
|
430
|
+
return "\n".join(lines)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def format_download_result(data: dict) -> str:
|
|
434
|
+
"""Format `ktalk_download_recording` result (FR-7) to markdown."""
|
|
435
|
+
return (
|
|
436
|
+
"# Скачивание завершено\n\n"
|
|
437
|
+
f"- **Файл:** {data.get('path')}\n"
|
|
438
|
+
f"- **Качество:** {data.get('quality')}\n"
|
|
439
|
+
f"- **Размер:** {data.get('bytes', 0)} байт\n"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def format_archive_list(data: dict | list) -> str:
|
|
444
|
+
"""Format `ktalk_list_archive` result (FR-9) to markdown."""
|
|
445
|
+
meetings = data if isinstance(data, list) else data.get("conferences") or []
|
|
446
|
+
if not meetings:
|
|
447
|
+
return "# Архив встреч\n\nВстреч не найдено."
|
|
448
|
+
lines = ["# Архив встреч", "", "| Ключ | Название | Комната | Начало |", "|---|---|---|---|"]
|
|
449
|
+
for m in meetings:
|
|
450
|
+
lines.append(
|
|
451
|
+
f"| {m.get('key', 'N/A')} | {m.get('title', 'Без названия')} | "
|
|
452
|
+
f"{m.get('roomName', '')} | {_format_datetime(m.get('startTime'))} |"
|
|
453
|
+
)
|
|
454
|
+
return "\n".join(lines)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def format_chat_messages(data: dict | list) -> str:
|
|
458
|
+
"""Format `ktalk_get_chat_messages` result (FR-10) to markdown."""
|
|
459
|
+
messages = data if isinstance(data, list) else data.get("messages") or []
|
|
460
|
+
if not messages:
|
|
461
|
+
return "# Сообщения чата\n\nСообщений не найдено."
|
|
462
|
+
lines = ["# Сообщения чата", ""]
|
|
463
|
+
for m in messages:
|
|
464
|
+
lines.append(f"- {m.get('text', '')}")
|
|
465
|
+
return "\n".join(lines)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def format_room(data: dict) -> str:
|
|
469
|
+
"""Format `ktalk_get_room` result (FR-17) to markdown — generic по `ROOM_FIELDS`."""
|
|
470
|
+
from ktalk_cli.rooms import ROOM_FIELDS
|
|
471
|
+
|
|
472
|
+
lines = [f"# Комната: {data.get('roomName', 'N/A')}", ""]
|
|
473
|
+
for field in ROOM_FIELDS:
|
|
474
|
+
if field == "roomName":
|
|
475
|
+
continue
|
|
476
|
+
value = data.get(field)
|
|
477
|
+
if isinstance(value, (dict, list)):
|
|
478
|
+
value = json.dumps(value, ensure_ascii=False)
|
|
479
|
+
lines.append(f"- **{field}:** {value}")
|
|
480
|
+
return "\n".join(lines)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def format_calendar(data: dict) -> str:
|
|
484
|
+
"""Format `ktalk_list_calendar` result (FR-18) to markdown. Заголовок нейтрален
|
|
485
|
+
(AC-7): "видимые активной авторизации", не «ваш календарь»."""
|
|
486
|
+
items = data.get("items") or []
|
|
487
|
+
incomplete = data.get("incomplete_segments") or []
|
|
488
|
+
lines = [f"# Запланированные встречи, видимые активной авторизации ({len(items)})", ""]
|
|
489
|
+
if incomplete:
|
|
490
|
+
lines.append(
|
|
491
|
+
f"> Внимание: {len(incomplete)} сегмент(ов) окна вернули потолок в 100 "
|
|
492
|
+
"элементов — выдача может быть неполной."
|
|
493
|
+
)
|
|
494
|
+
lines.append("")
|
|
495
|
+
lines.append("| Тема | Комната | Начало | Конец |")
|
|
496
|
+
lines.append("|---|---|---|---|")
|
|
497
|
+
for item in items:
|
|
498
|
+
lines.append(
|
|
499
|
+
f"| {item.get('subject', '')} | {item.get('roomName', '')} | "
|
|
500
|
+
f"{_format_datetime(item.get('start'))} | {_format_datetime(item.get('end'))} |"
|
|
501
|
+
)
|
|
502
|
+
return "\n".join(lines)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
_MEETING_PREVIEW_FIELDS = (
|
|
506
|
+
("start", "Начало"),
|
|
507
|
+
("end", "Конец"),
|
|
508
|
+
("timezone", "Часовой пояс"),
|
|
509
|
+
("roomName", "Комната"),
|
|
510
|
+
("requiredAttendees", "Обязательные участники"),
|
|
511
|
+
("description", "Описание"),
|
|
512
|
+
("enableAutoRecording", "Автозапись"),
|
|
513
|
+
("pinCode", "PIN-код"),
|
|
514
|
+
("allowAnonymous", "Анонимный доступ"),
|
|
515
|
+
("anonymousAccessExpirationDate", "Истечение анонимного доступа"),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def format_meeting_preview(data: dict) -> str:
|
|
520
|
+
"""Format `ktalk_preview_meeting`/`create-meeting-preview`/`create-meeting-confirm`
|
|
521
|
+
result (FR-13) to markdown. `confirmation_id` явно помечен справочным (ADR-005
|
|
522
|
+
§6.3) — нет машинной связки между MCP-предпросмотром и CLI-подтверждением.
|
|
523
|
+
|
|
524
|
+
Намеренно компактно: `create-meeting-confirm` печатает это же в реальный TTY
|
|
525
|
+
без чтения с другой стороны в тестах (ADR-005-spec «не мокируется как чистая
|
|
526
|
+
функция») — обильный текст рискует упереться в размер буфера терминала.
|
|
527
|
+
"""
|
|
528
|
+
body = data.get("body") or {}
|
|
529
|
+
lines = [f"# Встреча: {body.get('subject', '')} (ещё не создана)", ""]
|
|
530
|
+
for field, label in _MEETING_PREVIEW_FIELDS:
|
|
531
|
+
lines.append(f"- {label}: {body.get(field)}")
|
|
532
|
+
lines.append(f"- confirmation_id (справочно, не межпроцессный): {data.get('confirmation_id')}")
|
|
533
|
+
return "\n".join(lines)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def format_cancel_preview(data: dict) -> str:
|
|
537
|
+
"""Format `ktalk_preview_cancel_meeting`/`cancel-meeting-preview`/
|
|
538
|
+
`cancel-meeting-confirm` result (ADR-011-spec §6) to markdown."""
|
|
539
|
+
payload = data.get("payload") or {}
|
|
540
|
+
lines = [
|
|
541
|
+
f"# Отмена встречи {payload.get('id')} (ещё не выполнена)",
|
|
542
|
+
"",
|
|
543
|
+
f"- Причина: {payload.get('reason') or '(пусто)'}",
|
|
544
|
+
f"- confirmation_id (справочно, не межпроцессный): {data.get('confirmation_id')}",
|
|
545
|
+
]
|
|
546
|
+
return "\n".join(lines)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def format_search_contacts(candidates: list[dict], *, query: str) -> str:
|
|
550
|
+
"""Format `search_contacts` result (ADR-010-spec §4) to markdown. Три
|
|
551
|
+
различимые ветки по числу совпадений (0/1/>1) — без автовыбора (NFR-9)."""
|
|
552
|
+
if not candidates:
|
|
553
|
+
return f'# Поиск контактов «{query}»\n\nНичего не найдено по запросу «{query}».'
|
|
554
|
+
if len(candidates) == 1:
|
|
555
|
+
c = candidates[0]
|
|
556
|
+
return (
|
|
557
|
+
f'# Поиск контактов «{query}»\n\n'
|
|
558
|
+
f"Найден один кандидат:\n\n"
|
|
559
|
+
f"- **key:** {c.get('key')}\n"
|
|
560
|
+
f"- **ФИО:** {c.get('name')}\n"
|
|
561
|
+
f"- **Должность:** {c.get('post') or '—'}\n"
|
|
562
|
+
)
|
|
563
|
+
lines = [
|
|
564
|
+
f'# Поиск контактов «{query}» ({len(candidates)} совпадений)',
|
|
565
|
+
"",
|
|
566
|
+
"Без ранжирования, порядок — как в ответе сервера; выбор — за вами.",
|
|
567
|
+
"",
|
|
568
|
+
"| key | ФИО | Должность |",
|
|
569
|
+
"|---|---|---|",
|
|
570
|
+
]
|
|
571
|
+
for c in candidates:
|
|
572
|
+
lines.append(f"| {c.get('key')} | {c.get('name')} | {c.get('post') or '—'} |")
|
|
573
|
+
return "\n".join(lines)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def format_auth_status(data: dict) -> str:
|
|
577
|
+
"""Format `ktalk_auth_status`/`ktalk auth-status` result (FR-11) to markdown."""
|
|
578
|
+
lines = [
|
|
579
|
+
"# Диагностика авторизации",
|
|
580
|
+
"",
|
|
581
|
+
f"- **Активна:** {'да' if data.get('alive') else 'нет'}",
|
|
582
|
+
]
|
|
583
|
+
if data.get("scopes") is not None:
|
|
584
|
+
lines.append(f"- **Разрешения:** {data['scopes']}")
|
|
585
|
+
if data.get("expired_at") is not None:
|
|
586
|
+
lines.append(f"- **Истекает:** {data['expired_at']}")
|
|
587
|
+
if data.get("note"):
|
|
588
|
+
lines.append(f"- **Примечание:** {data['note']}")
|
|
589
|
+
return "\n".join(lines)
|