@okostream/mcp 0.12.1 → 0.12.2
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.
- package/README.md +1 -1
- package/dist/firebase.js +12 -3
- package/dist/guides.js +1 -1
- package/dist/onboarding.js +5 -1
- package/dist/sessionRepair.js +10 -4
- package/dist/tools/chat.js +7 -0
- package/dist/tools/events.js +35 -2
- package/dist/tools/formatting.js +18 -0
- package/dist/tools/imagePipeline.js +37 -24
- package/dist/tools/images.js +35 -6
- package/dist/tools/lifecycle.js +8 -1
- package/dist/tools/live.js +39 -2
- package/dist/tools/notifications.js +6 -1
- package/dist/tools/schemas.js +45 -12
- package/dist/tools/sessions.js +14 -4
- package/dist/tools/telegram.js +21 -8
- package/dist/tools/workspaceAccess.js +9 -4
- package/dist/tools/workspaces.js +29 -8
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ analytics, and integrations around a video player supplied by the organizer.
|
|
|
7
7
|
## Before You Connect
|
|
8
8
|
|
|
9
9
|
Webinar data and actions require an active OKO organizer account. Organizer
|
|
10
|
-
|
|
10
|
+
an active organizer account is required before an API key can be created
|
|
11
11
|
signup.
|
|
12
12
|
|
|
13
13
|
- Already invited: [sign in or register](https://okostream.app/auth?redirect=/app/mcp).
|
package/dist/firebase.js
CHANGED
|
@@ -16,7 +16,16 @@ export const firebaseApp = getApps().length ? getApp() : initializeApp({
|
|
|
16
16
|
export const auth = getAuth(firebaseApp);
|
|
17
17
|
export const firestore = getFirestore(firebaseApp);
|
|
18
18
|
export const rtdb = getDatabase(firebaseApp);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// 🔴 Отсутствие адреса чат-базы — отказ, а не выбор варианта.
|
|
20
|
+
// Прежде пустое значение молча давало ОСНОВНУЮ базу, где путей чата нет по
|
|
21
|
+
// инварианту 16 архитектуры: чтения возвращали бы null (getChatRole тихо
|
|
22
|
+
// понижал бы каждого до "viewer"), записи отвергались бы запретом по
|
|
23
|
+
// умолчанию — и ничто не сообщило бы, что использована не та база.
|
|
24
|
+
// Канон: «отсутствие обязательной переменной обязано ронять, а не выбирать
|
|
25
|
+
// вариант» (ревью 02.09, А15).
|
|
26
|
+
if (!env.firebase.chatDatabaseURL) {
|
|
27
|
+
throw new Error("OKO MCP: chat database URL is not configured. Set OKO_FIREBASE_CHAT_DB_URL "
|
|
28
|
+
+ "(or keep the bundled default) — chat paths do not exist in the main database.");
|
|
29
|
+
}
|
|
30
|
+
export const chatRtdb = getDatabase(firebaseApp, env.firebase.chatDatabaseURL);
|
|
22
31
|
export const functions = getFunctions(firebaseApp, "us-central1");
|
package/dist/guides.js
CHANGED
|
@@ -33,7 +33,7 @@ const GUIDE_RESOURCES = [
|
|
|
33
33
|
OKO_PRODUCT_INFO.accountRequirement,
|
|
34
34
|
OKO_PRODUCT_INFO.accessModel,
|
|
35
35
|
"",
|
|
36
|
-
"1.
|
|
36
|
+
"1. Sign in or register at " + OKO_ONBOARDING_URLS.signIn + ".",
|
|
37
37
|
"2. Open the authenticated MCP install center at " + OKO_ONBOARDING_URLS.installCenter + ".",
|
|
38
38
|
"3. Create a scoped API key and set it as `OKO_API_KEY` in the MCP client configuration.",
|
|
39
39
|
"4. Restart or reconnect the MCP server, then call `oko_connection_status`.",
|
package/dist/onboarding.js
CHANGED
|
@@ -13,7 +13,11 @@ export const OKO_PRODUCT_INFO = {
|
|
|
13
13
|
summary: "OKO Stream is a webinar operations platform. It adds an event page, configurable audience blocks, chat, analytics, forms, and integrations around an organizer-supplied video player.",
|
|
14
14
|
videoBoundary: "OKO does not host or start the underlying video stream. Organizers provide a YouTube, iframe, or other player URL.",
|
|
15
15
|
accountRequirement: "An active OKO organizer account is required to access webinar data or perform actions.",
|
|
16
|
-
|
|
16
|
+
// Решение 24.07 предписывало заменить формулировку про приглашения на
|
|
17
|
+
// нейтральное требование активного аккаунта «в следующей версии»; строка
|
|
18
|
+
// дожила до 0.12.x и уходит в каждый `oko_connection_status` и в гайд
|
|
19
|
+
// (ревью 02.09, В8).
|
|
20
|
+
accessModel: "An active OKO organizer account is required before an API key can be created.",
|
|
17
21
|
};
|
|
18
22
|
export const OKO_CONNECTION_STATUSES = [
|
|
19
23
|
"connected",
|
package/dist/sessionRepair.js
CHANGED
|
@@ -23,15 +23,21 @@ export function summarizeSessionRepair(input) {
|
|
|
23
23
|
if (input.verificationError) {
|
|
24
24
|
warnings.push(`postcondition verification: ${input.verificationError}`);
|
|
25
25
|
}
|
|
26
|
+
// 🔴 Прибор обязан сказать, чего он не измерил. Когда контрольное чтение
|
|
27
|
+
// упало, указатели остаются в исходном `null` — и прежде отчёт выдавал
|
|
28
|
+
// `remainingPointers: {null, null}`, то есть НЕИЗМЕРЕННОЕ состояние выглядело
|
|
29
|
+
// как измеренное чистое. Модель читала «ничего не осталось» и
|
|
30
|
+
// останавливалась (ревью 02.09, Б7).
|
|
31
|
+
const verified = !input.verificationError;
|
|
26
32
|
return {
|
|
27
33
|
eventId: input.eventId,
|
|
28
34
|
sessionId: input.sessionId,
|
|
29
35
|
fixed,
|
|
30
36
|
cleared,
|
|
31
37
|
warnings,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
rtdb: input.rtdbPointer
|
|
35
|
-
|
|
38
|
+
verified,
|
|
39
|
+
remainingPointers: verified
|
|
40
|
+
? { firestore: input.firestorePointer, rtdb: input.rtdbPointer }
|
|
41
|
+
: null,
|
|
36
42
|
};
|
|
37
43
|
}
|
package/dist/tools/chat.js
CHANGED
|
@@ -215,6 +215,13 @@ export function registerChatTools(server) {
|
|
|
215
215
|
patch[`settings/${eventId}/slowModeMs`] = slowModeMs;
|
|
216
216
|
if (automodEnabled !== undefined)
|
|
217
217
|
patch[`settings/${eventId}/automodEnabled`] = automodEnabled;
|
|
218
|
+
// Пустой вызов делал холостую запись и рапортовал успех с `updated: []` —
|
|
219
|
+
// модель сообщала организатору, что настройки изменены. Соседи
|
|
220
|
+
// (`oko_update_profile`, `oko_mark_notifications_read`) в этом случае
|
|
221
|
+
// бросают (ревью 02.09, В9).
|
|
222
|
+
if (Object.keys(patch).length === 0) {
|
|
223
|
+
throw new Error("Provide slowModeMs and/or automodEnabled to change chat settings.");
|
|
224
|
+
}
|
|
218
225
|
await update(ref(chatRtdb), patch);
|
|
219
226
|
return textResult({ eventId, updated: Object.keys(patch) });
|
|
220
227
|
});
|
package/dist/tools/events.js
CHANGED
|
@@ -231,7 +231,21 @@ export function registerEventTools(server) {
|
|
|
231
231
|
if (requiredScopes.size === 0)
|
|
232
232
|
throw new Error("Provide at least one field to update.");
|
|
233
233
|
const { uid } = await authed([...requiredScopes]);
|
|
234
|
-
await readEvent(args.eventId, uid, "edit");
|
|
234
|
+
const current = await readEvent(args.eventId, uid, "edit");
|
|
235
|
+
// 🔴 Режим по расписанию без даты — состояние, которое молча не работает:
|
|
236
|
+
// CF51 отбирает события запросом `analyticsSessionMode == "schedule"` И
|
|
237
|
+
// `meta.eventDate` в окне, поэтому событие без даты не выбирается никогда,
|
|
238
|
+
// а инструмент рапортовал успех. Веб на том же состоянии показывает
|
|
239
|
+
// подсказку `sessionMode.needsDate` (ревью 02.09, Б8).
|
|
240
|
+
if (args.sessionMode === "schedule") {
|
|
241
|
+
const dateAfterPatch = args.eventDate !== undefined
|
|
242
|
+
? args.eventDate
|
|
243
|
+
: current.meta?.eventDate ?? null;
|
|
244
|
+
if (!dateAfterPatch) {
|
|
245
|
+
throw new Error("sessionMode 'schedule' requires meta.eventDate: without a date OKO can never start "
|
|
246
|
+
+ "the session automatically. Set eventDate in the same call, or keep sessionMode 'manual'.");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
235
249
|
const patch = {};
|
|
236
250
|
if (args.title !== undefined)
|
|
237
251
|
patch.title = args.title.trim();
|
|
@@ -285,7 +299,7 @@ export function registerEventTools(server) {
|
|
|
285
299
|
annotations: writeTool,
|
|
286
300
|
}, async (args) => {
|
|
287
301
|
const { uid } = await authed(["setup:write"]);
|
|
288
|
-
await readEvent(args.eventId, uid, "edit");
|
|
302
|
+
const current = await readEvent(args.eventId, uid, "edit");
|
|
289
303
|
const patch = {};
|
|
290
304
|
if (args.chat?.enabled !== undefined)
|
|
291
305
|
patch["blocks.chat.enabled"] = args.chat.enabled;
|
|
@@ -350,6 +364,25 @@ export function registerEventTools(server) {
|
|
|
350
364
|
patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
|
|
351
365
|
patch["telegram.channelIds"] = args.telegramChannelIds;
|
|
352
366
|
}
|
|
367
|
+
// 🔴 `formId` баннера и CTA проверяется на существование — ровно так же,
|
|
368
|
+
// как у карточек в этом же файле (`buildCardsItems` бросает «Form X not
|
|
369
|
+
// found»). Прежде запись проходила, инструмент рапортовал «привязано», а
|
|
370
|
+
// кнопка у зрителей не делала НИЧЕГО: FormHost формы не находит, а
|
|
371
|
+
// buttonUrl в этом сценарии пуст. Формы сверяются с состоянием ПОСЛЕ
|
|
372
|
+
// патча: замена `forms` в том же вызове перегенерирует id (02.09, Б6).
|
|
373
|
+
const formsAfterPatch = (patch.forms ?? current.forms);
|
|
374
|
+
const knownFormIds = new Set((Array.isArray(formsAfterPatch) ? formsAfterPatch : [])
|
|
375
|
+
.map((form) => (typeof form?.id === "string" ? form.id : null))
|
|
376
|
+
.filter((id) => Boolean(id)));
|
|
377
|
+
for (const [field, value] of [
|
|
378
|
+
["blocks.promoBanner.formId", patch["blocks.promoBanner.formId"]],
|
|
379
|
+
["blocks.primaryCta.formId", patch["blocks.primaryCta.formId"]],
|
|
380
|
+
]) {
|
|
381
|
+
if (typeof value === "string" && value && !knownFormIds.has(value)) {
|
|
382
|
+
throw new Error(`${field}: form "${value}" not found in event.forms. Create the form in the same call `
|
|
383
|
+
+ "(or beforehand) — a button bound to a missing form does nothing for viewers.");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
353
386
|
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
354
387
|
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
355
388
|
});
|
package/dist/tools/formatting.js
CHANGED
|
@@ -196,3 +196,21 @@ export function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
|
|
|
196
196
|
];
|
|
197
197
|
return lines.join("\n");
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Поля `telegram`, которые копируются в дубликат события.
|
|
201
|
+
*
|
|
202
|
+
* 🔴 Инвариант: копия не наследует server-owned маркеры. Их ставит сервер по
|
|
203
|
+
* факту уже случившегося действия, и для нового события они ложны. Сейчас
|
|
204
|
+
* такой маркер один — `reminderSent` (CF17 ставит после отправки напоминания
|
|
205
|
+
* и навсегда пропускает события с ним), но список именно белый: новое
|
|
206
|
+
* серверное поле не должно попасть в копию по умолчанию.
|
|
207
|
+
*/
|
|
208
|
+
export const TELEGRAM_COPYABLE_KEYS = ["enabled", "channelIds", "autoReminder"];
|
|
209
|
+
export function copyableTelegram(source) {
|
|
210
|
+
const out = {};
|
|
211
|
+
for (const key of TELEGRAM_COPYABLE_KEYS) {
|
|
212
|
+
if (source[key] !== undefined)
|
|
213
|
+
out[key] = source[key];
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
@@ -288,15 +288,22 @@ export function readCardsConfig(eventData) {
|
|
|
288
288
|
const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
289
289
|
const rawItems = Array.isArray(raw.items) ? raw.items : [];
|
|
290
290
|
const repairedIdPositions = [];
|
|
291
|
+
// 🔴 Позиция считается по ИСХОДНОМУ массиву, а не по отфильтрованному:
|
|
292
|
+
// иначе путь `blocks.cards.items[N]` в ошибке валидации указывает не на ту
|
|
293
|
+
// карточку. Фича существует ровно для битых массивов, то есть для случая,
|
|
294
|
+
// где отсев и создаёт сдвиг (ревью 02.09, Б14).
|
|
291
295
|
const items = rawItems
|
|
292
|
-
.
|
|
293
|
-
.
|
|
296
|
+
.map((item, position) => ({ item, position }))
|
|
297
|
+
.filter((entry) => Boolean(entry.item) && typeof entry.item === "object" && !Array.isArray(entry.item))
|
|
298
|
+
.map(({ item, position }) => {
|
|
294
299
|
const hasUsableId = typeof item.id === "string" && CARD_ID_PATTERN.test(item.id);
|
|
295
300
|
if (!hasUsableId)
|
|
296
301
|
repairedIdPositions.push(position);
|
|
297
|
-
const fallbackId = randomUUID();
|
|
298
302
|
return {
|
|
299
|
-
|
|
303
|
+
// 🔴 UUID генерируется только когда он нужен: прежде crypto-RNG
|
|
304
|
+
// дёргался для каждой карточки при каждом чтении, а значение
|
|
305
|
+
// отбрасывалось (ревью 02.09, В14)
|
|
306
|
+
id: hasUsableId ? item.id : randomUUID(),
|
|
300
307
|
title: safeString(item.title).trim() || null,
|
|
301
308
|
text: safeString(item.text).trim() || null,
|
|
302
309
|
buttonLabel: safeString(item.buttonLabel).trim() || null,
|
|
@@ -407,8 +414,19 @@ export async function deleteStoragePaths(eventId, paths) {
|
|
|
407
414
|
if (uniquePaths.length === 0)
|
|
408
415
|
return [];
|
|
409
416
|
const removeImages = httpsCallable(functions, "deleteMcpImages");
|
|
410
|
-
|
|
411
|
-
|
|
417
|
+
// 🔴 Уборка объектов — best-effort и НЕ имеет права уронить уже
|
|
418
|
+
// совершившуюся операцию. Отказ CF27 (например, путь чужого префикса у
|
|
419
|
+
// непроусыновлённого дубликата) прежде вылетал наружу: организатору
|
|
420
|
+
// сообщалось, что удаление не удалось, хотя ссылки уже сняты, — а после
|
|
421
|
+
// загрузки это провоцировало повторную закачку той же картинки. Соседний
|
|
422
|
+
// `oko_delete_image` прямо обещает обратное (ревью 02.09, В4).
|
|
423
|
+
try {
|
|
424
|
+
const result = await removeImages({ eventId, paths: uniquePaths });
|
|
425
|
+
return Array.isArray(result.data.warnings) ? result.data.warnings : [];
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
return [`storage cleanup failed: ${error instanceof Error ? error.message : String(error)}`];
|
|
429
|
+
}
|
|
412
430
|
}
|
|
413
431
|
export async function deleteStorageUrls(eventId, masterUrl, fallbackUrl) {
|
|
414
432
|
const paths = [];
|
|
@@ -510,18 +528,11 @@ export function sleep(ms) {
|
|
|
510
528
|
export async function waitForImageVariantTargets(uid, eventId, targets, timeoutSec) {
|
|
511
529
|
const startedAt = Date.now();
|
|
512
530
|
const deadline = startedAt + timeoutSec * 1000;
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
imageUrl: null,
|
|
519
|
-
imageMasterUrl: null,
|
|
520
|
-
imageVariants: null,
|
|
521
|
-
ready: false,
|
|
522
|
-
reason: "not_checked",
|
|
523
|
-
elapsedMs: 0,
|
|
524
|
-
}));
|
|
531
|
+
// Мёртвая заготовка снята: цикл ниже выполняется хотя бы раз и
|
|
532
|
+
// перезаписывает `latest` до любого чтения, так что наблюдать её было
|
|
533
|
+
// нечем — а `imageId: target.cardId` в ней ещё и вводил в заблуждение
|
|
534
|
+
// (ревью 02.09, В15).
|
|
535
|
+
let latest;
|
|
525
536
|
while (true) {
|
|
526
537
|
const elapsedMs = Date.now() - startedAt;
|
|
527
538
|
const event = await readEvent(eventId, uid, "read");
|
|
@@ -542,7 +553,6 @@ export async function uploadEventImageForUser(uid, eventId, kind, cardId, source
|
|
|
542
553
|
const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
|
|
543
554
|
const deleteWarnings = [];
|
|
544
555
|
let deletesAttempted = 0;
|
|
545
|
-
const expectedCardPath = kind === "card" ? imageStoragePath(eventId, uid, kind, cardId) : null;
|
|
546
556
|
// 🔴 Сначала загрузка, потом удаление старого. Прежде варианты карточки
|
|
547
557
|
// стирались ДО `uploadMasterImage`, и упавшая загрузка оставляла документ
|
|
548
558
|
// Firestore со ссылками на удалённые объекты — зритель получал 404, а
|
|
@@ -556,11 +566,14 @@ export async function uploadEventImageForUser(uid, eventId, kind, cardId, source
|
|
|
556
566
|
const uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
|
|
557
567
|
if (!uploaded)
|
|
558
568
|
throw new Error("Image upload did not return a result.");
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
569
|
+
// 🔴 Старые варианты по ТОМУ ЖЕ пути не удаляются — и не нужны.
|
|
570
|
+
//
|
|
571
|
+
// Новый master пишется по тому же адресу и запускает CF4, которая
|
|
572
|
+
// перезаписывает все три варианта. Удаление рядом с этим было гонкой: если
|
|
573
|
+
// CF4 успевала первой, мы сносили объекты, на которые Firestore уже
|
|
574
|
+
// ссылается, а повторно она не запустится — нового мастера не будет. Зритель
|
|
575
|
+
// получал 404 навсегда. Прежний комментарий рассуждал только о случае
|
|
576
|
+
// «CF4 упала» и не рассматривал «CF4 успела» (ревью 02.09, В5).
|
|
564
577
|
if (kind !== "card" || (previousMasterPath && previousMasterPath !== uploaded.storagePath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
565
578
|
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
566
579
|
deletesAttempted += cleanup.attempted;
|
package/dist/tools/images.js
CHANGED
|
@@ -39,7 +39,7 @@ export function registerImageTools(server) {
|
|
|
39
39
|
inputSchema: {
|
|
40
40
|
eventId: eventIdField,
|
|
41
41
|
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
42
|
-
cardId:
|
|
42
|
+
cardId: cardIdSchema.optional().describe("Required when kind=card."),
|
|
43
43
|
source: imageSourceSchema.describe("Image source: exactly one of path or url."),
|
|
44
44
|
confirm: z.boolean().optional().describe("Required true for image upload/replacement."),
|
|
45
45
|
},
|
|
@@ -84,19 +84,48 @@ export function registerImageTools(server) {
|
|
|
84
84
|
}, async ({ eventId, images, timeoutSec, confirm }) => {
|
|
85
85
|
const { uid } = await authed(["setup:write"]);
|
|
86
86
|
requireConfirm(confirm, "oko_upload_card_images_batch", { eventId, cardIds: images.map((image) => image.cardId).join(",") });
|
|
87
|
+
// 🔴 Один и тот же id дважды — это две записи по одному пути Storage:
|
|
88
|
+
// вторая молча затирает первую, а в отчёте карточка появляется дважды.
|
|
89
|
+
// `buildCardsItems` отвергает такое же для карточек (ревью 02.09, В12).
|
|
90
|
+
const duplicateIds = images
|
|
91
|
+
.map((image) => image.cardId)
|
|
92
|
+
.filter((id, index, all) => all.indexOf(id) !== index);
|
|
93
|
+
if (duplicateIds.length > 0) {
|
|
94
|
+
throw new Error(`Duplicate cardId in batch: ${[...new Set(duplicateIds)].join(", ")}.`);
|
|
95
|
+
}
|
|
96
|
+
// 🔴 Частичный отказ обязан быть виден. Прежде бросок на N-й картинке
|
|
97
|
+
// терял запись об успешных N−1: модель не знала, что часть уже загружена,
|
|
98
|
+
// и перезагружала всё заново, зря дёргая CF4 (ревью 02.09, В12).
|
|
87
99
|
const uploads = [];
|
|
100
|
+
const failures = [];
|
|
88
101
|
for (const image of images) {
|
|
89
|
-
|
|
102
|
+
try {
|
|
103
|
+
uploads.push(await uploadEventImageForUser(uid, eventId, "card", image.cardId, image.source));
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
failures.push({
|
|
107
|
+
cardId: image.cardId,
|
|
108
|
+
error: error instanceof Error ? error.message : String(error),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
90
111
|
}
|
|
91
|
-
const
|
|
112
|
+
const uploadedCardIds = new Set(images
|
|
113
|
+
.map((image) => image.cardId)
|
|
114
|
+
.filter((cardId) => !failures.some((failure) => failure.cardId === cardId)));
|
|
115
|
+
const variants = uploadedCardIds.size > 0
|
|
116
|
+
? await waitForImageVariantTargets(uid, eventId, [...uploadedCardIds].map((cardId) => ({ kind: "card", cardId })), timeoutSec)
|
|
117
|
+
: [];
|
|
92
118
|
const warnings = variants
|
|
93
119
|
.filter((item) => !item.ready)
|
|
94
120
|
.map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`);
|
|
121
|
+
warnings.push(...failures.map((failure) => `Card ${failure.cardId} upload failed: ${failure.error}`));
|
|
95
122
|
return textResult({
|
|
96
123
|
eventId,
|
|
97
124
|
uploads,
|
|
98
125
|
variants,
|
|
99
|
-
|
|
126
|
+
failed: failures,
|
|
127
|
+
// «готово» не может быть истиной при непогруженной картинке
|
|
128
|
+
ready: failures.length === 0 && variants.every((item) => item.ready),
|
|
100
129
|
warnings,
|
|
101
130
|
});
|
|
102
131
|
});
|
|
@@ -117,7 +146,7 @@ export function registerImageTools(server) {
|
|
|
117
146
|
inputSchema: {
|
|
118
147
|
eventId: eventIdField,
|
|
119
148
|
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
120
|
-
cardId:
|
|
149
|
+
cardId: cardIdSchema.optional().describe("Required when kind=card."),
|
|
121
150
|
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait."),
|
|
122
151
|
},
|
|
123
152
|
outputSchema: imageVariantsOutputSchema,
|
|
@@ -151,7 +180,7 @@ export function registerImageTools(server) {
|
|
|
151
180
|
inputSchema: {
|
|
152
181
|
eventId: eventIdField,
|
|
153
182
|
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
154
|
-
cardId:
|
|
183
|
+
cardId: cardIdSchema.optional().describe("Required when kind=card."),
|
|
155
184
|
confirm: z.boolean().optional().describe("Required true for image deletion."),
|
|
156
185
|
},
|
|
157
186
|
outputSchema: deleteImageOutputSchema,
|
package/dist/tools/lifecycle.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// разрушающих инструмента требуют явного confirm и показывают цель заранее.
|
|
5
5
|
// Тяжёлая работа (очистка обеих RTDB, Storage, уведомлений) живёт в CF31:
|
|
6
6
|
// клиент такую последовательность довести до конца не может.
|
|
7
|
+
import { copyableTelegram } from "./formatting.js";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { requireConfirm, safeString, textResult } from "../utils.js";
|
|
9
10
|
import { authed, eventLinks } from "./context.js";
|
|
@@ -131,7 +132,13 @@ export function registerLifecycleTools(server) {
|
|
|
131
132
|
meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
|
|
132
133
|
blocks: source.blocks ?? {},
|
|
133
134
|
...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
|
|
134
|
-
|
|
135
|
+
// 🔴 Копия НЕ наследует server-owned маркеры Telegram. `reminderSent`
|
|
136
|
+
// ставит CF17 после отправки напоминания, и она же навсегда пропускает
|
|
137
|
+
// события с этим флагом: копия события, чьё напоминание уже ушло, не
|
|
138
|
+
// получала бы своего никогда — молча (ревью 02.09, Б5).
|
|
139
|
+
...(source.telegram && typeof source.telegram === "object"
|
|
140
|
+
? { telegram: copyableTelegram(source.telegram) }
|
|
141
|
+
: {}),
|
|
135
142
|
});
|
|
136
143
|
return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
|
|
137
144
|
});
|
package/dist/tools/live.js
CHANGED
|
@@ -63,6 +63,31 @@ export function registerLiveTools(server) {
|
|
|
63
63
|
cards: "cardsVisible",
|
|
64
64
|
placeholder: "placeholderVisible",
|
|
65
65
|
};
|
|
66
|
+
// 🔴 У чата один источник истины — `chatMode`, и только он зеркалится в
|
|
67
|
+
// правила (CF5 → `settings/{eventId}/mode`). Legacy-флаг `chatVisible`
|
|
68
|
+
// перекрывает режим ТОЛЬКО на клиенте: после записи `chatVisible: true`
|
|
69
|
+
// поверх `chatMode: "off"` зритель получал включённое поле ввода, каждая
|
|
70
|
+
// отправка отвергалась правилом, а организатор видел чат работающим
|
|
71
|
+
// (ревью 02.09, З4).
|
|
72
|
+
if (block === "chat") {
|
|
73
|
+
const modeRef = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
|
|
74
|
+
const legacyRef = ref(rtdb, `events/${eventId}/ctrl/chatVisible`);
|
|
75
|
+
if (visible === null) {
|
|
76
|
+
await Promise.all([remove(modeRef), remove(legacyRef)]);
|
|
77
|
+
}
|
|
78
|
+
else if (visible === false) {
|
|
79
|
+
await set(modeRef, "off");
|
|
80
|
+
await remove(legacyRef);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const current = await get(modeRef);
|
|
84
|
+
// снимаем только паузу; выбранный режим (auth/owner) не трогаем
|
|
85
|
+
if (current.val() === "off")
|
|
86
|
+
await remove(modeRef);
|
|
87
|
+
await remove(legacyRef);
|
|
88
|
+
}
|
|
89
|
+
return textResult({ eventId, block, visible });
|
|
90
|
+
}
|
|
66
91
|
const target = ref(rtdb, `events/${eventId}/ctrl/${paths[block]}`);
|
|
67
92
|
if (visible === null)
|
|
68
93
|
await remove(target);
|
|
@@ -114,8 +139,20 @@ export function registerLiveTools(server) {
|
|
|
114
139
|
const { uid } = await authed(["live:control"]);
|
|
115
140
|
await readEvent(eventId, uid, "live");
|
|
116
141
|
requireConfirm(confirm, "oko_set_live_badge", { eventId, mode, durationMinutes: durationMinutes ?? null });
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
// 🔴 Непереданное окно сохраняется, а не сбрасывается в дефолт: прежде
|
|
143
|
+
// смена одного лишь режима молча роняла настроенные 300 минут до 120, и
|
|
144
|
+
// плашка у зрителей гасла раньше срока. `LiveBadgeControl` в вебе на том
|
|
145
|
+
// же действии сохраняет значение (ревью 02.09, Б4).
|
|
146
|
+
const badgeRef = ref(rtdb, `events/${eventId}/ctrl/liveBadge`);
|
|
147
|
+
let storedDuration;
|
|
148
|
+
if (durationMinutes === undefined) {
|
|
149
|
+
const snapshot = await get(badgeRef);
|
|
150
|
+
const stored = snapshot.val();
|
|
151
|
+
if (typeof stored?.durationMinutes === "number")
|
|
152
|
+
storedDuration = stored.durationMinutes;
|
|
153
|
+
}
|
|
154
|
+
const value = { mode, durationMinutes: durationMinutes ?? storedDuration ?? 120 };
|
|
155
|
+
await set(badgeRef, value);
|
|
119
156
|
return textResult({ eventId, ...value });
|
|
120
157
|
});
|
|
121
158
|
server.registerTool("oko_set_chat_mode", {
|
|
@@ -96,7 +96,12 @@ export function registerNotificationTools(server) {
|
|
|
96
96
|
// Такое уведомление висело бы вечно, а модель не могла бы его погасить.
|
|
97
97
|
// Поэтому фильтруем клиентом тем же предикатом, что и список, набирая
|
|
98
98
|
// с запасом — приём уже применён в `oko_list_notifications` выше.
|
|
99
|
-
|
|
99
|
+
// 🔴 `limit` БЕЗ сортировки даёт срез по идентификатору документа, а не
|
|
100
|
+
// «последние»: помечались произвольные документы, а не те, что показал
|
|
101
|
+
// список — колокольчик оставался зажжённым, а повторные вызовы гасили
|
|
102
|
+
// ту же случайную выборку. Ровно тот дефект, что разобран 01.09 в
|
|
103
|
+
// `LeadsAnalytics` и записан в `schema.md` (ревью 02.09, В3).
|
|
104
|
+
const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), orderBy("createdAt", "desc"), limit(Math.min(max * 5 + 1, 501))));
|
|
100
105
|
ids = snap.docs.filter((item) => item.data().read !== true).slice(0, max).map((item) => item.id);
|
|
101
106
|
}
|
|
102
107
|
await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
|
package/dist/tools/schemas.js
CHANGED
|
@@ -20,7 +20,10 @@ export const ANALYTICS_DEFAULT_LIMIT = 10;
|
|
|
20
20
|
export const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
|
|
21
21
|
export const MAX_WORKSPACE_NAME_LENGTH = 120;
|
|
22
22
|
export const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
|
|
23
|
-
|
|
23
|
+
// `pending` — состояние поста, отправляемого прямо сейчас (его пишут и веб, и
|
|
24
|
+
// MCP, и правила Firestore его знают). Без него фильтр по статусу отвергал
|
|
25
|
+
// схемой каждый пост, находящийся в полёте (ревью 02.09, В6).
|
|
26
|
+
export const TELEGRAM_POST_STATUSES = ["draft", "pending", "scheduled", "sent", "failed", "delivery_unknown"];
|
|
24
27
|
export const readOnlyTool = {
|
|
25
28
|
readOnlyHint: true,
|
|
26
29
|
destructiveHint: false,
|
|
@@ -244,7 +247,21 @@ export const profileDisplayNameSchema = z.string().max(40).nullable();
|
|
|
244
247
|
export const timezoneSchema = z.string()
|
|
245
248
|
.min(1)
|
|
246
249
|
.max(100)
|
|
247
|
-
.regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.")
|
|
250
|
+
.regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.")
|
|
251
|
+
// 🔴 Проверяем ТЕМ ЖЕ способом, каким значение потом используется. Regex
|
|
252
|
+
// пропускал «Europe/Mosow», «UTC+3», «Moscow», а кабинет рендерит зону через
|
|
253
|
+
// `Intl.DateTimeFormat`, который на таком значении бросает RangeError —
|
|
254
|
+
// и роняет всё дерево клиента на /app/events. Веб такое состояние создать
|
|
255
|
+
// не может: там выбор из `Intl.supportedValuesOf` (ревью 02.09, В1).
|
|
256
|
+
.refine((value) => {
|
|
257
|
+
try {
|
|
258
|
+
new Intl.DateTimeFormat("en-CA", { timeZone: value });
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}, "Unknown IANA timezone: use a value such as Europe/Moscow, America/New_York or UTC.");
|
|
248
265
|
export const cardsConfigOutputSchema = z.object({
|
|
249
266
|
eventId: z.string(),
|
|
250
267
|
updated: z.array(z.string()),
|
|
@@ -260,17 +277,33 @@ export const cardsConfigOutputSchema = z.object({
|
|
|
260
277
|
preservedImageCardIds: z.array(z.string()),
|
|
261
278
|
warnings: z.array(z.string()),
|
|
262
279
|
}).passthrough();
|
|
280
|
+
/**
|
|
281
|
+
* Права участника — ЦЕЛЬНАЯ карта девяти значений, а не набор правок.
|
|
282
|
+
*
|
|
283
|
+
* 🔴 Инвариант: после записи права равны присланным. Отсутствующее право
|
|
284
|
+
* означает «нет», а не «как было».
|
|
285
|
+
*
|
|
286
|
+
* Прежде все девять были `.optional()` при `.default({})`, а запись шла
|
|
287
|
+
* `setDoc(merge:true)`: отзыв права пропуском ключа МОЛЧА не срабатывал —
|
|
288
|
+
* вложенные карты сливаются, — и инструмент рапортовал успех. Вызов вообще
|
|
289
|
+
* без прав записывал пустую карту и тоже отчитывался «обновлено». Веб пишет
|
|
290
|
+
* карту целиком (`updateDoc` заменяет вложенный объект), то есть две
|
|
291
|
+
* поверхности расходились в том, кто что может (ревью 02.09, Б1).
|
|
292
|
+
*
|
|
293
|
+
* Обязательность всех девяти делает случайное стирание непредставимым:
|
|
294
|
+
* перечислить права придётся явно, а не получить их снос по умолчанию.
|
|
295
|
+
*/
|
|
263
296
|
export const workspacePermissionsSchema = z.object({
|
|
264
|
-
createEvents: z.boolean()
|
|
265
|
-
editSetup: z.boolean()
|
|
266
|
-
deleteEvents: z.boolean()
|
|
267
|
-
controlLive: z.boolean()
|
|
268
|
-
moderateChat: z.boolean()
|
|
269
|
-
startSession: z.boolean()
|
|
270
|
-
viewAnalytics: z.boolean()
|
|
271
|
-
downloadReports: z.boolean()
|
|
272
|
-
sendNotifications: z.boolean()
|
|
273
|
-
}).strict()
|
|
297
|
+
createEvents: z.boolean(),
|
|
298
|
+
editSetup: z.boolean(),
|
|
299
|
+
deleteEvents: z.boolean(),
|
|
300
|
+
controlLive: z.boolean(),
|
|
301
|
+
moderateChat: z.boolean(),
|
|
302
|
+
startSession: z.boolean(),
|
|
303
|
+
viewAnalytics: z.boolean(),
|
|
304
|
+
downloadReports: z.boolean(),
|
|
305
|
+
sendNotifications: z.boolean(),
|
|
306
|
+
}).strict();
|
|
274
307
|
export const WORKSPACE_PERMISSION_KEYS = [
|
|
275
308
|
"createEvents",
|
|
276
309
|
"editSetup",
|
package/dist/tools/sessions.js
CHANGED
|
@@ -49,18 +49,28 @@ export function registerSessionTools(server) {
|
|
|
49
49
|
examples: ["Use after the webinar analytics window is over and the user confirms the exact event."],
|
|
50
50
|
notes: ["Destructive for the active session pointer, but it does not stop the video stream."],
|
|
51
51
|
}),
|
|
52
|
-
inputSchema: {
|
|
52
|
+
inputSchema: {
|
|
53
|
+
eventId: eventIdField,
|
|
54
|
+
confirm: z.boolean().optional().describe("Required true for ending the active session."),
|
|
55
|
+
reportLocale: z.enum(["en", "ru", "es", "pt-BR", "fr", "de"]).optional()
|
|
56
|
+
.describe("Language of the session report PDF, pre-rendered right after the session ends. Server defaults to ru; the choice is permanent for this session."),
|
|
57
|
+
},
|
|
53
58
|
outputSchema: basicEventOutputSchema,
|
|
54
59
|
annotations: destructiveTool,
|
|
55
|
-
}, async ({ eventId, confirm }) => {
|
|
60
|
+
}, async ({ eventId, confirm, reportLocale }) => {
|
|
56
61
|
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
57
62
|
const event = await readEvent(eventId, uid, "session");
|
|
58
63
|
const sessionId = safeString(event.activeAnalyticsSessionId);
|
|
59
64
|
if (!sessionId)
|
|
60
65
|
throw new Error("No active analytics session.");
|
|
61
|
-
requireConfirm(confirm, "oko_end_session", { eventId, sessionId });
|
|
66
|
+
requireConfirm(confirm, "oko_end_session", { eventId, sessionId, reportLocale: reportLocale ?? null });
|
|
62
67
|
const end = httpsCallable(functions, "endAnalyticsSessionV3");
|
|
63
|
-
|
|
68
|
+
// 🔴 Язык отчёта задаётся ЗДЕСЬ и больше нигде: CF55 рендерит PDF сразу
|
|
69
|
+
// после эфира, организатора в этот момент нет, а в профиле языка не
|
|
70
|
+
// хранится. Локаль входит в путь готового файла, и сессия неизменяема —
|
|
71
|
+
// русский PDF у англоязычного организатора исправить уже нельзя. Веб
|
|
72
|
+
// передаёт `locale` именно поэтому (ревью 02.09, Б3).
|
|
73
|
+
const result = await end({ eventId, sessionId, ...(reportLocale ? { locale: reportLocale } : {}) });
|
|
64
74
|
// Тот же случай, что у запуска: сессия уже завершена и отчёт сохранён,
|
|
65
75
|
// а ответ без `eventId` не проходил собственную схему.
|
|
66
76
|
return textResult({ eventId, ...result.data });
|
package/dist/tools/telegram.js
CHANGED
|
@@ -170,16 +170,22 @@ export function registerTelegramTools(server) {
|
|
|
170
170
|
if (cursor)
|
|
171
171
|
constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
|
|
172
172
|
const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
|
|
173
|
-
// 🔴 Фильтр применяется
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
173
|
+
// 🔴 Фильтр применяется до нарезки страницы, но `hasMore` считается по
|
|
174
|
+
// СЫРОЙ выборке — иначе пагинация останавливается, пока подходящие посты
|
|
175
|
+
// ещё есть на следующих страницах.
|
|
176
|
+
//
|
|
177
|
+
// Предыдущая редакция (01.09) переносила фильтр вперёд и считала `hasMore`
|
|
178
|
+
// по отфильтрованному: при пяти неудачных постах среди трёхсот выходило
|
|
179
|
+
// «таких постов нет» на первой же странице. Верный образец лежал рядом —
|
|
180
|
+
// `oko_list_notifications` (ревью 02.09, В2).
|
|
177
181
|
const matching = status
|
|
178
182
|
? snap.docs.filter((item) => item.data().status === status)
|
|
179
183
|
: snap.docs;
|
|
180
|
-
const { page, hasMore } = pickPage(matching, max);
|
|
184
|
+
const { page, hasMore: hasMoreInMatches } = pickPage(matching, max);
|
|
185
|
+
const hasMore = hasMoreInMatches || snap.docs.length >= max + 1;
|
|
186
|
+
const cursorSource = hasMoreInMatches ? page.at(-1) : snap.docs.at(-1);
|
|
181
187
|
const posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
182
|
-
const nextCursor = hasMore ? timestampCursorFrom(
|
|
188
|
+
const nextCursor = hasMore ? timestampCursorFrom(cursorSource?.data().createdAt) : null;
|
|
183
189
|
return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
|
|
184
190
|
});
|
|
185
191
|
server.registerTool("oko_cancel_telegram_post", {
|
|
@@ -260,13 +266,20 @@ export function registerTelegramTools(server) {
|
|
|
260
266
|
const postData = {
|
|
261
267
|
eventId: args.eventId,
|
|
262
268
|
ownerId: uid,
|
|
263
|
-
|
|
269
|
+
// почты организатора у MCP нет, но uid лучше пустой строки: веб
|
|
270
|
+
// денормализует сюда автора «для показа в истории», и пустое значение
|
|
271
|
+
// рендерилось как пост без автора (ревью 02.09, В7)
|
|
272
|
+
ownerEmail: uid,
|
|
264
273
|
channelId: args.channelId,
|
|
265
274
|
channelKey: String(args.channelId),
|
|
266
275
|
caption: args.caption.trim(),
|
|
267
276
|
imageUrl: args.imageUrl?.trim() || null,
|
|
268
277
|
buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: args.buttonUrl?.trim() || eventUrl(args.eventId) }] : [],
|
|
269
|
-
|
|
278
|
+
// 🔴 Немедленная отправка — `pending`, как в вебе, а не `draft`. Обрыв
|
|
279
|
+
// между созданием документа и отправкой оставлял пост «черновиком»,
|
|
280
|
+
// неотличимым от никогда не отправлявшегося: CF15 подбирает только
|
|
281
|
+
// `scheduled`, и восстановить его было нечем (ревью 02.09, В6).
|
|
282
|
+
status: scheduled ? "scheduled" : "pending",
|
|
270
283
|
scheduledAt: args.scheduleAt ? Timestamp.fromDate(new Date(args.scheduleAt)) : null,
|
|
271
284
|
sentAt: null,
|
|
272
285
|
telegramMessageId: null,
|
|
@@ -18,13 +18,18 @@ export async function readWorkspaceForOwner(workspaceId, uid) {
|
|
|
18
18
|
throw new Error("Only the workspace owner can manage this workspace.");
|
|
19
19
|
return data;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Права участника — цельная карта всех девяти значений.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 Второй рубеж того же инварианта, что и `workspacePermissionsSchema`:
|
|
25
|
+
* отсутствующее право означает «нет», а не «как было». Прежде функция
|
|
26
|
+
* пропускала ключ, которого нет во входе, и `setDoc(merge:true)` сливал
|
|
27
|
+
* частичную карту с прежней — отзыв права молча не срабатывал.
|
|
28
|
+
*/
|
|
21
29
|
export function normalizeWorkspacePermissions(value) {
|
|
22
30
|
const out = {};
|
|
23
31
|
for (const key of WORKSPACE_PERMISSION_KEYS) {
|
|
24
|
-
|
|
25
|
-
out[key] = true;
|
|
26
|
-
else if (value[key] === false)
|
|
27
|
-
out[key] = false;
|
|
32
|
+
out[key] = value[key] === true;
|
|
28
33
|
}
|
|
29
34
|
return out;
|
|
30
35
|
}
|
package/dist/tools/workspaces.js
CHANGED
|
@@ -158,7 +158,7 @@ export function registerWorkspaceTools(server) {
|
|
|
158
158
|
workspaceId: z.string().min(1),
|
|
159
159
|
uid: z.string().min(1).describe("Member Firebase uid."),
|
|
160
160
|
email: z.string().email().optional().describe("Optional member email cache."),
|
|
161
|
-
comment: z.string().max(500).
|
|
161
|
+
comment: z.string().max(500).optional().describe("Optional member note; omitted keeps the stored one."),
|
|
162
162
|
permissions: workspacePermissionsSchema,
|
|
163
163
|
confirm: z.boolean().optional().describe("Required true for share updates."),
|
|
164
164
|
},
|
|
@@ -168,13 +168,33 @@ export function registerWorkspaceTools(server) {
|
|
|
168
168
|
const { uid } = await authed(["workspace:write"]);
|
|
169
169
|
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
170
170
|
requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
const shareRef = doc(firestore, "workspaces", args.workspaceId, "shares", args.uid);
|
|
172
|
+
const existing = await getDoc(shareRef);
|
|
173
|
+
// 🔴 Пишем ровно то, что прислано. Прежде `email` и `comment` уходили в
|
|
174
|
+
// запись всегда (`?? ""` и `.default("")`), а `sharedAt`/`sharedBy`
|
|
175
|
+
// переставлялись на текущие: обновление одного права стирало почту и
|
|
176
|
+
// комментарий участника и теряло след «кто и когда пригласил». Строка
|
|
177
|
+
// участника на экране команды становилась пустой (ревью 02.09, Б2).
|
|
178
|
+
const payload = {
|
|
174
179
|
permissions: normalizeWorkspacePermissions(args.permissions),
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
};
|
|
181
|
+
const updated = ["permissions"];
|
|
182
|
+
if (args.email !== undefined) {
|
|
183
|
+
payload.email = args.email;
|
|
184
|
+
updated.push("email");
|
|
185
|
+
}
|
|
186
|
+
if (args.comment !== undefined) {
|
|
187
|
+
payload.comment = args.comment.trim();
|
|
188
|
+
updated.push("comment");
|
|
189
|
+
}
|
|
190
|
+
if (!existing.exists()) {
|
|
191
|
+
// Новая доля: авторство выдачи ставится один раз, при создании.
|
|
192
|
+
payload.email = payload.email ?? "";
|
|
193
|
+
payload.comment = payload.comment ?? "";
|
|
194
|
+
payload.sharedAt = serverTimestamp();
|
|
195
|
+
payload.sharedBy = uid;
|
|
196
|
+
}
|
|
197
|
+
await setDoc(shareRef, payload, { merge: true });
|
|
178
198
|
// 🔴 `updateOutputSchema` объявляет `updated` МАССИВОМ имён полей, а не
|
|
179
199
|
// флагом. Пока возвращался `true`, SDK отвергал ответ уже после записи
|
|
180
200
|
// прав: клиент видел ошибку и считал, что доступ не выдан, — а он выдан.
|
|
@@ -183,7 +203,8 @@ export function registerWorkspaceTools(server) {
|
|
|
183
203
|
return textResult({
|
|
184
204
|
workspaceId: args.workspaceId,
|
|
185
205
|
uid: args.uid,
|
|
186
|
-
|
|
206
|
+
created: !existing.exists(),
|
|
207
|
+
updated,
|
|
187
208
|
});
|
|
188
209
|
});
|
|
189
210
|
server.registerTool("oko_remove_workspace_member", {
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/generateMcpVersion.mjs from mcp/package.json. Do not edit.
|
|
2
|
-
export const OKO_MCP_VERSION = "0.12.
|
|
2
|
+
export const OKO_MCP_VERSION = "0.12.2";
|