@okostream/mcp 0.12.0 → 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 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
- access is currently provided by invitation rather than public self-service
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
- export const chatRtdb = env.firebase.chatDatabaseURL
20
- ? getDatabase(firebaseApp, env.firebase.chatDatabaseURL)
21
- : getDatabase(firebaseApp);
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. If you already have an invitation, sign in or register at " + OKO_ONBOARDING_URLS.signIn + ".",
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`.",
@@ -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
- accessModel: "Organizer access is currently provided by invitation. An invitation is required before a new organizer can create an API key.",
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",
@@ -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
- remainingPointers: {
33
- firestore: input.firestorePointer,
34
- rtdb: input.rtdbPointer,
35
- },
38
+ verified,
39
+ remainingPointers: verified
40
+ ? { firestore: input.firestorePointer, rtdb: input.rtdbPointer }
41
+ : null,
36
42
  };
37
43
  }
@@ -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
  });
@@ -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
  });
@@ -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
- .filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
293
- .map((item, position) => {
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
- id: hasUsableId ? item.id : fallbackId,
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
- const result = await removeImages({ eventId, paths: uniquePaths });
411
- return Array.isArray(result.data.warnings) ? result.data.warnings : [];
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
- let latest = targets.map((target) => ({
514
- eventId,
515
- kind: target.kind,
516
- cardId: target.cardId,
517
- imageId: target.cardId,
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
- if (expectedCardPath && previousMasterPath === expectedCardPath) {
560
- const variantPaths = variantPathsFromMasterPath(expectedCardPath);
561
- deletesAttempted += variantPaths.length;
562
- deleteWarnings.push(...await deleteStoragePaths(eventId, variantPaths));
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;
@@ -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: z.string().min(1).optional().describe("Required when kind=card."),
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
- uploads.push(await uploadEventImageForUser(uid, eventId, "card", image.cardId, image.source));
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 variants = await waitForImageVariantTargets(uid, eventId, images.map((image) => ({ kind: "card", cardId: image.cardId })), timeoutSec);
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
- ready: variants.every((item) => item.ready),
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: z.string().min(1).optional().describe("Required when kind=card."),
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: z.string().min(1).optional().describe("Required when kind=card."),
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,
@@ -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
- ...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
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
  });
@@ -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
- const value = { mode, durationMinutes: durationMinutes ?? 120 };
118
- await set(ref(rtdb, `events/${eventId}/ctrl/liveBadge`), value);
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
- const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), limit(Math.min(max * 5 + 1, 501))));
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 })));
@@ -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
- export const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed", "delivery_unknown"];
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().optional(),
265
- editSetup: z.boolean().optional(),
266
- deleteEvents: z.boolean().optional(),
267
- controlLive: z.boolean().optional(),
268
- moderateChat: z.boolean().optional(),
269
- startSession: z.boolean().optional(),
270
- viewAnalytics: z.boolean().optional(),
271
- downloadReports: z.boolean().optional(),
272
- sendNotifications: z.boolean().optional(),
273
- }).strict().default({});
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",
@@ -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: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true for ending the active session.") },
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
- const result = await end({ eventId, sessionId });
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 });
@@ -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
- // `hasMore` и `count` считались по нефильтрованной выборке: пустой
175
- // результат приходил с `hasMore: false`, и это читается как «постов с
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(page.at(-1)?.data().createdAt) : null;
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
- ownerEmail: "",
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
- status: scheduled ? "scheduled" : "draft",
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
- if (value[key] === true)
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
  }
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  import { requireConfirm, safeString, serialize, textResult } from "../utils.js";
11
11
  import { authed, inviteUrl } from "./context.js";
12
12
  import { destructiveTool, idempotentWriteTool, MAX_WORKSPACE_INVITE_CODE_LENGTH, MAX_WORKSPACE_NAME_LENGTH, readOnlyTool, toolDescription, unknownRecordSchema, updateOutputSchema, workspaceAccessOutputSchema, workspacePermissionsSchema, workspacesOutputSchema, writeTool } from "./schemas.js";
13
- import { addDoc, collection, deleteDoc, doc, getDoc, getDocs, limit, query, serverTimestamp, setDoc, updateDoc, where } from "firebase/firestore";
13
+ import { addDoc, collection, deleteDoc, doc, getDoc, getDocs, query, serverTimestamp, setDoc, updateDoc, where } from "firebase/firestore";
14
14
  import { firestore, functions } from "../firebase.js";
15
15
  import { httpsCallable } from "firebase/functions";
16
16
  import { listVisibleWorkspaces, normalizeWorkspacePermissions, randomInviteCode, readWorkspaceForOwner } from "./workspaceAccess.js";
@@ -99,11 +99,15 @@ export function registerWorkspaceTools(server) {
99
99
  // доступа не имеет, проверка не увидит — но владелец пространства
100
100
  // получает grant на все его события через CF18, так что для владельца,
101
101
  // а только он сюда и доходит (`readWorkspaceForOwner` выше), совпадает.
102
- const eventsSnap = await getDocs(query(collection(firestore, "eventAccess", uid, "grants"), where("workspaceId", "==", workspaceId), limit(1)));
103
- if (!eventsSnap.empty)
104
- throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
105
- await deleteDoc(doc(firestore, "workspaces", workspaceId));
106
- return textResult({ workspaceId, deleted: true });
102
+ // 🔴 Удаление идёт через CF57, а не `deleteDoc`: голое удаление корневого
103
+ // документа оставляло живыми подколлекцию shares, зеркала
104
+ // workspaceMembers и grants — участники сохраняли доступ, а убрать это
105
+ // было уже нельзя, потому что правило shares проверяет владельца через
106
+ // get() исчезнувшего родителя. Проверку «в пространстве нет событий»
107
+ // функция делает сама, полным запросом, а не по индексу этого ключа.
108
+ const removeWorkspace = httpsCallable(functions, "deleteWorkspaceCompletely");
109
+ const removed = await removeWorkspace({ workspaceId });
110
+ return textResult({ ...removed.data, workspaceId, deleted: true });
107
111
  });
108
112
  server.registerTool("oko_list_workspace_access", {
109
113
  title: "List workspace access",
@@ -154,7 +158,7 @@ export function registerWorkspaceTools(server) {
154
158
  workspaceId: z.string().min(1),
155
159
  uid: z.string().min(1).describe("Member Firebase uid."),
156
160
  email: z.string().email().optional().describe("Optional member email cache."),
157
- comment: z.string().max(500).default(""),
161
+ comment: z.string().max(500).optional().describe("Optional member note; omitted keeps the stored one."),
158
162
  permissions: workspacePermissionsSchema,
159
163
  confirm: z.boolean().optional().describe("Required true for share updates."),
160
164
  },
@@ -164,13 +168,33 @@ export function registerWorkspaceTools(server) {
164
168
  const { uid } = await authed(["workspace:write"]);
165
169
  const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
166
170
  requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
167
- await setDoc(doc(firestore, "workspaces", args.workspaceId, "shares", args.uid), {
168
- email: args.email ?? "",
169
- comment: args.comment.trim(),
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 = {
170
179
  permissions: normalizeWorkspacePermissions(args.permissions),
171
- sharedAt: serverTimestamp(),
172
- sharedBy: uid,
173
- }, { merge: true });
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 });
174
198
  // 🔴 `updateOutputSchema` объявляет `updated` МАССИВОМ имён полей, а не
175
199
  // флагом. Пока возвращался `true`, SDK отвергал ответ уже после записи
176
200
  // прав: клиент видел ошибку и считал, что доступ не выдан, — а он выдан.
@@ -179,7 +203,8 @@ export function registerWorkspaceTools(server) {
179
203
  return textResult({
180
204
  workspaceId: args.workspaceId,
181
205
  uid: args.uid,
182
- updated: ["email", "comment", "permissions"],
206
+ created: !existing.exists(),
207
+ updated,
183
208
  });
184
209
  });
185
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.0";
2
+ export const OKO_MCP_VERSION = "0.12.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {