@okostream/mcp 0.11.2 → 0.12.1
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/dist/tools/chat.js +17 -2
- package/dist/tools/eventCreate.js +10 -4
- package/dist/tools/events.js +38 -11
- package/dist/tools/formatting.js +22 -1
- package/dist/tools/imagePipeline.js +58 -15
- package/dist/tools/images.js +10 -1
- package/dist/tools/notifications.js +21 -4
- package/dist/tools/sessions.js +7 -2
- package/dist/tools/setupValidation.js +7 -0
- package/dist/tools/telegram.js +9 -4
- package/dist/tools/workspaces.js +41 -9
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/tools/chat.js
CHANGED
|
@@ -36,8 +36,13 @@ export function registerChatTools(server) {
|
|
|
36
36
|
? [orderByKey(), endBefore(cursor), limitToLast(max + 1)]
|
|
37
37
|
: [orderByKey(), limitToLast(max + 1)];
|
|
38
38
|
const snap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), ...constraints));
|
|
39
|
+
// 🔴 Побайтовое сравнение, а не `localeCompare`. RTDB упорядочивает
|
|
40
|
+
// push-ключи по кодам символов, а `localeCompare` учитывает локаль и
|
|
41
|
+
// сортирует регистр наоборот: push-ключи содержат и цифры, и обе
|
|
42
|
+
// раскладки букв, поэтому порядок расходился с серверным. Курсор брался
|
|
43
|
+
// не тот, и страницы повторялись или пропускались.
|
|
39
44
|
const entries = Object.entries((snap.val() ?? {}))
|
|
40
|
-
.sort(([a], [b]) =>
|
|
45
|
+
.sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0));
|
|
41
46
|
const { page, hasMore } = pickPage(entries, max);
|
|
42
47
|
const messages = page.map(([id, message]) => ({ id, ...serialize(message) }));
|
|
43
48
|
const nextCursor = hasMore ? messages.at(-1)?.id ?? null : null;
|
|
@@ -68,8 +73,18 @@ export function registerChatTools(server) {
|
|
|
68
73
|
if (!msgRef.key)
|
|
69
74
|
throw new Error("Could not allocate chat message id");
|
|
70
75
|
const timestamp = rtdbServerTimestamp();
|
|
76
|
+
// 🔴 Форма сообщения обязана совпадать с `prepareChatMessage`
|
|
77
|
+
// (`web/src/lib/chatMessages.ts`) — единственным контрактом записи чата
|
|
78
|
+
// по Pattern 26. Импортировать его сюда нельзя: MCP отдельный пакет, у
|
|
79
|
+
// него нет доступа к web. Поэтому копия, и она УЖЕ разошлась: без `trim`
|
|
80
|
+
// сообщение из одних пробелов проходило и публиковалось всем зрителям, а
|
|
81
|
+
// имя уходило без обрезки до 24 символов.
|
|
82
|
+
const messageText = text.trim();
|
|
83
|
+
if (!messageText)
|
|
84
|
+
throw new Error("Chat message text cannot be empty or whitespace only.");
|
|
85
|
+
const messageName = name.trim().slice(0, 24) || "Organizer";
|
|
71
86
|
await update(ref(chatRtdb), {
|
|
72
|
-
[`messages/${eventId}/${msgRef.key}`]: { text, uid, name, role, ts: timestamp },
|
|
87
|
+
[`messages/${eventId}/${msgRef.key}`]: { text: messageText, uid, name: messageName, role, ts: timestamp },
|
|
73
88
|
[`rate_limits/${eventId}/${uid}`]: { ts: timestamp, messageId: msgRef.key },
|
|
74
89
|
});
|
|
75
90
|
await runTransaction(ref(rtdb, `events/${eventId}/agg/chatMessages`), (n) => (n ?? 0) + 1)
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// Создание события: проверка права на пространство и запись с зеркалом RTDB.
|
|
2
2
|
//
|
|
3
3
|
// Зеркало ownerId/workspaceId в основной RTDB — опора правил доступа, и
|
|
4
|
-
// пишет его
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// пишет его сервер, а не клиент. Создание идёт парой: документ Firestore,
|
|
5
|
+
// затем ensureEventRuntime (CF32) — она ставит зеркало сразу, не дожидаясь
|
|
6
|
+
// триггера.
|
|
7
|
+
//
|
|
8
|
+
// 🔴 Отката здесь намеренно НЕТ, хотя прежняя редакция этой шапки его
|
|
9
|
+
// обещала. Сбой CF32 не оставляет событие сломанным: зеркало всё равно
|
|
10
|
+
// поставит CF20 `onEventCreated` — она пишет ownerId/workspaceId сама
|
|
11
|
+
// (`functions/src/eventsLifecycle.ts`) и объявлена с `retry: true`, то есть
|
|
12
|
+
// доставка гарантирована. Удалять документ из-за сбоя быстрого пути значило
|
|
13
|
+
// бы терять событие там, где медленный путь его чинит.
|
|
8
14
|
import { addDoc, collection, doc, getDoc } from "firebase/firestore";
|
|
9
15
|
import { httpsCallable } from "firebase/functions";
|
|
10
16
|
import { firestore, functions } from "../firebase.js";
|
package/dist/tools/events.js
CHANGED
|
@@ -9,7 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { serialize, textResult } from "../utils.js";
|
|
10
10
|
import { authed, eventLinks, randomId } from "./context.js";
|
|
11
11
|
import { cardsConfigOutputSchema, createWebinarOutputSchema, cursorField, eventIdField, eventOutputSchema, eventsOutputSchema, formSchema, limitField, readOnlyTool, setupValidationOutputSchema, toolDescription, updateOutputSchema, writeTool } from "./schemas.js";
|
|
12
|
-
import { buildCardsItems, CARD_IMAGE_RATIOS, cardHasImage, cardItemInputSchema, eventFormIds, MAX_CARD_ITEMS, readCardsConfig } from "./imagePipeline.js";
|
|
12
|
+
import { buildCardsItems, CARD_IMAGE_RATIOS, cardHasImage, cardItemInputSchema, eventFormIds, MAX_CARD_ITEMS, normalizeCardButtonUrl, readCardsConfig, writeEventPatchWithCards } from "./imagePipeline.js";
|
|
13
13
|
import { collection, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, startAfter, Timestamp, updateDoc, where } from "firebase/firestore";
|
|
14
14
|
import { addEventWithRtdbMirror, assertCanCreateInWorkspace } from "./eventCreate.js";
|
|
15
15
|
import { cursorMeta, normalizeVideo, parseIsoBound, parseTimestampCursor, pickPage, timestampCursorFrom } from "./formatting.js";
|
|
@@ -293,8 +293,13 @@ export function registerEventTools(server) {
|
|
|
293
293
|
patch["blocks.chat.visibility"] = args.chat.visibility;
|
|
294
294
|
if (args.promoBanner?.enabled !== undefined)
|
|
295
295
|
patch["blocks.promoBanner.enabled"] = args.promoBanner.enabled;
|
|
296
|
+
// 🔴 Протокол проверяется так же, как у карточек в этом же файле
|
|
297
|
+
// (`normalizeCardButtonUrl`). Оба значения попадают в `href` кнопки на
|
|
298
|
+
// публичной странице: `javascript:` там — исполнение кода у зрителя, а
|
|
299
|
+
// единственным отличием ссылки баннера от ссылки карточки было то, что
|
|
300
|
+
// её никто не проверял.
|
|
296
301
|
if (args.promoBanner?.link !== undefined)
|
|
297
|
-
patch["blocks.promoBanner.link"] = args.promoBanner.link;
|
|
302
|
+
patch["blocks.promoBanner.link"] = normalizeCardButtonUrl(args.promoBanner.link);
|
|
298
303
|
if (args.promoBanner?.visibility !== undefined)
|
|
299
304
|
patch["blocks.promoBanner.visibility"] = args.promoBanner.visibility;
|
|
300
305
|
if (args.promoBanner?.formId !== undefined)
|
|
@@ -310,7 +315,7 @@ export function registerEventTools(server) {
|
|
|
310
315
|
if (args.primaryCta?.buttonLabel !== undefined)
|
|
311
316
|
patch["blocks.primaryCta.buttonLabel"] = args.primaryCta.buttonLabel?.trim() || null;
|
|
312
317
|
if (args.primaryCta?.buttonUrl !== undefined)
|
|
313
|
-
patch["blocks.primaryCta.buttonUrl"] = args.primaryCta.buttonUrl
|
|
318
|
+
patch["blocks.primaryCta.buttonUrl"] = normalizeCardButtonUrl(args.primaryCta.buttonUrl);
|
|
314
319
|
if (args.primaryCta?.formId !== undefined)
|
|
315
320
|
patch["blocks.primaryCta.formId"] = args.primaryCta.formId;
|
|
316
321
|
if (args.videoPlaceholder?.enabled !== undefined)
|
|
@@ -391,6 +396,7 @@ export function registerEventTools(server) {
|
|
|
391
396
|
visibility: args.visibility ?? currentCards.visibility,
|
|
392
397
|
cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
|
|
393
398
|
items: currentCards.items,
|
|
399
|
+
repairedIdPositions: currentCards.repairedIdPositions,
|
|
394
400
|
};
|
|
395
401
|
const generatedCardIds = [];
|
|
396
402
|
const preservedImageCardIds = [];
|
|
@@ -402,27 +408,48 @@ export function registerEventTools(server) {
|
|
|
402
408
|
patch["blocks.cards.visibility"] = args.visibility;
|
|
403
409
|
if (args.cardImageRatio !== undefined)
|
|
404
410
|
patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
|
|
405
|
-
|
|
411
|
+
// 🔴 Карточки пересобираются ВНУТРИ транзакции, из свежего документа.
|
|
412
|
+
// Массив `blocks.cards.items` пишется целиком, а между чтением события и
|
|
413
|
+
// записью успевает отработать CF4 и дописать в него `imageVariants`,
|
|
414
|
+
// `imageUrl` и размеры. Реконструкция из устаревшего снимка их стирала —
|
|
415
|
+
// картинка у зрителя откатывалась к мастеру или пропадала. Это Pattern 23,
|
|
416
|
+
// уже разобранный на веб-стороне и вернувшийся сюда через MCP.
|
|
417
|
+
//
|
|
418
|
+
// Отчёт (что создано, что сохранено, что удалено) тоже считается внутри:
|
|
419
|
+
// иначе он описывал бы не ту запись, которая случилась.
|
|
420
|
+
const buildItems = args.items === undefined ? null : (fresh) => {
|
|
421
|
+
const freshCards = readCardsConfig(fresh);
|
|
406
422
|
const built = buildCardsItems({
|
|
407
423
|
inputs: args.items,
|
|
408
|
-
existing:
|
|
409
|
-
formIds: eventFormIds(
|
|
424
|
+
existing: freshCards.items,
|
|
425
|
+
formIds: eventFormIds(fresh),
|
|
410
426
|
});
|
|
411
427
|
finalCards.items = built.items;
|
|
428
|
+
generatedCardIds.length = 0;
|
|
429
|
+
preservedImageCardIds.length = 0;
|
|
412
430
|
generatedCardIds.push(...built.generatedCardIds);
|
|
413
431
|
preservedImageCardIds.push(...built.preservedImageCardIds);
|
|
414
432
|
const nextIds = new Set(built.items.map((item) => item.id));
|
|
415
|
-
const removed =
|
|
433
|
+
const removed = freshCards.items.filter((item) => !nextIds.has(item.id));
|
|
416
434
|
removedCardIds = removed.map((item) => item.id);
|
|
417
435
|
removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
if (Object.keys(patch).length > 0) {
|
|
421
|
-
await
|
|
436
|
+
return built.items;
|
|
437
|
+
};
|
|
438
|
+
if (Object.keys(patch).length > 0 || buildItems) {
|
|
439
|
+
await writeEventPatchWithCards(args.eventId, patch, buildItems);
|
|
422
440
|
}
|
|
423
441
|
const warnings = removedCardsWithImages.length > 0
|
|
424
442
|
? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
|
|
425
443
|
: [];
|
|
444
|
+
// 🔴 Карточка без пригодного `id` получает подставленный при чтении, и он
|
|
445
|
+
// не совпадает ни с чем в документе: прежняя запись не находится, её
|
|
446
|
+
// картинка обнуляется. Пока это молчало, организатор видел успешный ответ
|
|
447
|
+
// и пропавшее изображение без единого объяснения.
|
|
448
|
+
if (finalCards.repairedIdPositions.length > 0) {
|
|
449
|
+
warnings.push(`Cards at position(s) ${finalCards.repairedIdPositions.join(", ")} had no usable id in Firestore; `
|
|
450
|
+
+ "a temporary one was generated, so their previous images could not be matched and were dropped. "
|
|
451
|
+
+ "Re-upload images for these cards.");
|
|
452
|
+
}
|
|
426
453
|
return textResult({
|
|
427
454
|
eventId: args.eventId,
|
|
428
455
|
updated: Object.keys(patch),
|
package/dist/tools/formatting.js
CHANGED
|
@@ -26,6 +26,27 @@ export function pickPage(items, max) {
|
|
|
26
26
|
hasMore: items.length > max,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Значение `video.embedUrl` попадает прямо в `<iframe src>` на публичной
|
|
31
|
+
* странице события. Набор протоколов здесь строже, чем у кнопок
|
|
32
|
+
* (`normalizeCardButtonUrl`): `mailto:` и `tel:` в кадре бессмысленны, а
|
|
33
|
+
* `javascript:` — исполняемый код в контексте okostream.app у всех зрителей.
|
|
34
|
+
* Для распознанного провайдера адрес строим мы сами, но проверяем всё равно
|
|
35
|
+
* итоговое значение: так защита не зависит от того, какой ветвью оно пришло.
|
|
36
|
+
*/
|
|
37
|
+
function assertEmbeddableUrl(value) {
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(value);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Invalid videoUrl "${value}". Provide an https:// link or a full <iframe> embed code.`);
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
46
|
+
throw new Error(`Unsupported videoUrl protocol "${url.protocol}". Only http(s) links can be embedded.`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
29
50
|
export function normalizeVideo(videoUrl) {
|
|
30
51
|
const raw = videoUrl?.trim();
|
|
31
52
|
if (!raw)
|
|
@@ -35,7 +56,7 @@ export function normalizeVideo(videoUrl) {
|
|
|
35
56
|
if (detected.provider !== "unknown" && !detected.normalizedUrl) {
|
|
36
57
|
throw new Error(`Could not extract a valid ${detected.provider} video id from videoUrl.`);
|
|
37
58
|
}
|
|
38
|
-
return { originalUrl: raw, embedUrl: detected.normalizedUrl ?? effective };
|
|
59
|
+
return { originalUrl: raw, embedUrl: assertEmbeddableUrl(detected.normalizedUrl ?? effective) };
|
|
39
60
|
}
|
|
40
61
|
export function csvRows(headers, rows) {
|
|
41
62
|
return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
|
|
@@ -176,7 +176,13 @@ export async function readRemoteImage(rawUrl, redirects = 0) {
|
|
|
176
176
|
}
|
|
177
177
|
finally {
|
|
178
178
|
clearTimeout(timeout);
|
|
179
|
-
|
|
179
|
+
// 🔴 `destroy()`, а не `close()`. `close()` в undici ЖДЁТ завершения
|
|
180
|
+
// незавершённых запросов — а когда защита по размеру бросает из
|
|
181
|
+
// `responseBufferWithLimit`, тело ответа остаётся непрочитанным. Публичный
|
|
182
|
+
// сервер с открытым chunked-ответом подвешивал инструмент бесконечно
|
|
183
|
+
// именно здесь: ограничение уже сработало, а выйти из функции было
|
|
184
|
+
// нельзя. `destroy()` отменяет то, что не дочитано.
|
|
185
|
+
await dispatcher.destroy();
|
|
180
186
|
}
|
|
181
187
|
}
|
|
182
188
|
export async function readLocalImage(inputPath) {
|
|
@@ -281,12 +287,16 @@ export function readCardsConfig(eventData) {
|
|
|
281
287
|
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
282
288
|
const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
283
289
|
const rawItems = Array.isArray(raw.items) ? raw.items : [];
|
|
290
|
+
const repairedIdPositions = [];
|
|
284
291
|
const items = rawItems
|
|
285
292
|
.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
286
|
-
.map((item) => {
|
|
293
|
+
.map((item, position) => {
|
|
294
|
+
const hasUsableId = typeof item.id === "string" && CARD_ID_PATTERN.test(item.id);
|
|
295
|
+
if (!hasUsableId)
|
|
296
|
+
repairedIdPositions.push(position);
|
|
287
297
|
const fallbackId = randomUUID();
|
|
288
298
|
return {
|
|
289
|
-
id:
|
|
299
|
+
id: hasUsableId ? item.id : fallbackId,
|
|
290
300
|
title: safeString(item.title).trim() || null,
|
|
291
301
|
text: safeString(item.text).trim() || null,
|
|
292
302
|
buttonLabel: safeString(item.buttonLabel).trim() || null,
|
|
@@ -305,6 +315,7 @@ export function readCardsConfig(eventData) {
|
|
|
305
315
|
visibility: raw.visibility === "hidden" ? "hidden" : "always",
|
|
306
316
|
cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
|
|
307
317
|
items,
|
|
318
|
+
repairedIdPositions,
|
|
308
319
|
};
|
|
309
320
|
}
|
|
310
321
|
export function normalizeNullableText(value) {
|
|
@@ -435,6 +446,37 @@ export async function updateCardImageInTransaction(eventId, cardId, patch) {
|
|
|
435
446
|
});
|
|
436
447
|
});
|
|
437
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Записывает патч события, пересобирая массив карточек из СВЕЖЕГО документа.
|
|
451
|
+
*
|
|
452
|
+
* 🔴 Зачем транзакция. `blocks.cards.items` — массив, и запись его целиком
|
|
453
|
+
* затирает всё, чего не было в прочитанном снимке. Между чтением события и
|
|
454
|
+
* записью успевает отработать CF4: она дописывает `imageVariants`, `imageUrl`
|
|
455
|
+
* и размеры по стабильному id карточки. Реконструкция из устаревшего снимка
|
|
456
|
+
* стирала эти поля — картинка у зрителя откатывалась к мастеру или пропадала.
|
|
457
|
+
*
|
|
458
|
+
* Это Pattern 23, разобранный на веб-стороне (там Setup уже сливает
|
|
459
|
+
* метаданные транзакционно) и вернувшийся через MCP: то же место, тот же
|
|
460
|
+
* массив, только другой клиент.
|
|
461
|
+
*
|
|
462
|
+
* `buildItems` получает свежие данные документа и обязан строить итоговый
|
|
463
|
+
* массив из них, а не из снимка, снятого до транзакции.
|
|
464
|
+
*/
|
|
465
|
+
export async function writeEventPatchWithCards(eventId, basePatch, buildItems) {
|
|
466
|
+
const eventRef = doc(firestore, "events", eventId);
|
|
467
|
+
await runFirestoreTransaction(firestore, async (transaction) => {
|
|
468
|
+
const snap = await transaction.get(eventRef);
|
|
469
|
+
if (!snap.exists())
|
|
470
|
+
throw new Error("Event not found.");
|
|
471
|
+
const patch = { ...basePatch };
|
|
472
|
+
if (buildItems) {
|
|
473
|
+
patch["blocks.cards.items"] = buildItems(snap.data());
|
|
474
|
+
}
|
|
475
|
+
if (Object.keys(patch).length === 0)
|
|
476
|
+
return;
|
|
477
|
+
transaction.update(eventRef, patch);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
438
480
|
export function imageVariantReason(ref) {
|
|
439
481
|
if (!ref.imageMasterUrl && !ref.imageUrl)
|
|
440
482
|
return "image_missing";
|
|
@@ -501,23 +543,24 @@ export async function uploadEventImageForUser(uid, eventId, kind, cardId, source
|
|
|
501
543
|
const deleteWarnings = [];
|
|
502
544
|
let deletesAttempted = 0;
|
|
503
545
|
const expectedCardPath = kind === "card" ? imageStoragePath(eventId, uid, kind, cardId) : null;
|
|
546
|
+
// 🔴 Сначала загрузка, потом удаление старого. Прежде варианты карточки
|
|
547
|
+
// стирались ДО `uploadMasterImage`, и упавшая загрузка оставляла документ
|
|
548
|
+
// Firestore со ссылками на удалённые объекты — зритель получал 404, а
|
|
549
|
+
// повтор восстановить их уже не мог. Соседняя ветка для не-карточек (ниже)
|
|
550
|
+
// всегда делала правильно; расходились только они.
|
|
551
|
+
//
|
|
552
|
+
// Порядок безопасен: новый master пишется по тому же пути, и CF4
|
|
553
|
+
// перезаписывает все три варианта заново. Худшее, что даёт отказ CF4, —
|
|
554
|
+
// варианты от прежней картинки рядом с новым мастером, и это заметно
|
|
555
|
+
// меньшая беда, чем битые ссылки.
|
|
556
|
+
const uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
|
|
557
|
+
if (!uploaded)
|
|
558
|
+
throw new Error("Image upload did not return a result.");
|
|
504
559
|
if (expectedCardPath && previousMasterPath === expectedCardPath) {
|
|
505
560
|
const variantPaths = variantPathsFromMasterPath(expectedCardPath);
|
|
506
561
|
deletesAttempted += variantPaths.length;
|
|
507
562
|
deleteWarnings.push(...await deleteStoragePaths(eventId, variantPaths));
|
|
508
563
|
}
|
|
509
|
-
let uploaded = null;
|
|
510
|
-
try {
|
|
511
|
-
uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
|
|
512
|
-
}
|
|
513
|
-
catch (err) {
|
|
514
|
-
if (uploaded) {
|
|
515
|
-
await deleteStoragePaths(eventId, [uploaded.storagePath, ...variantPathsFromMasterPath(uploaded.storagePath)]).catch(() => []);
|
|
516
|
-
}
|
|
517
|
-
throw err;
|
|
518
|
-
}
|
|
519
|
-
if (!uploaded)
|
|
520
|
-
throw new Error("Image upload did not return a result.");
|
|
521
564
|
if (kind !== "card" || (previousMasterPath && previousMasterPath !== uploaded.storagePath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
522
565
|
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
523
566
|
deletesAttempted += cleanup.attempted;
|
package/dist/tools/images.js
CHANGED
|
@@ -165,7 +165,12 @@ export function registerImageTools(server) {
|
|
|
165
165
|
const event = await readEvent(eventId, uid, "edit");
|
|
166
166
|
const previous = readImageRef(event, kind, cardId);
|
|
167
167
|
requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
|
|
168
|
-
|
|
168
|
+
// 🔴 Сначала снимаем ссылки, потом удаляем объекты. Прежде порядок был
|
|
169
|
+
// обратный: упавший `updateDoc` оставлял документ со ссылками на уже
|
|
170
|
+
// удалённые файлы — зритель видел 404, а повтор восстановить их не мог,
|
|
171
|
+
// потому что объектов больше нет. В обратном порядке отказ Storage
|
|
172
|
+
// оставляет осиротевшие файлы, и это поправимо: их подберёт CF31 при
|
|
173
|
+
// удалении события, а ссылок на них уже нет.
|
|
169
174
|
if (kind === "promoBanner") {
|
|
170
175
|
await updateDoc(doc(firestore, "events", eventId), {
|
|
171
176
|
"blocks.promoBanner.imageMasterUrl": deleteField(),
|
|
@@ -193,6 +198,10 @@ export function registerImageTools(server) {
|
|
|
193
198
|
imageHeight: null,
|
|
194
199
|
});
|
|
195
200
|
}
|
|
201
|
+
// Удаление объектов — best-effort и уже после того, как на них никто не
|
|
202
|
+
// ссылается: его отказ попадёт в предупреждения ответа, а не в битые
|
|
203
|
+
// ссылки у зрителя.
|
|
204
|
+
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
196
205
|
return textResult({
|
|
197
206
|
eventId,
|
|
198
207
|
kind,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Документы создают только Cloud Functions; отсюда доступны чтение, отметка
|
|
4
4
|
// и удаление своих. Текст уведомления не хранится — клиент собирает его из
|
|
5
5
|
// типа и meta, поэтому наружу уходит структура, а не готовая строка.
|
|
6
|
-
import { collection, doc, getDocs, limit, orderBy, query, startAfter, updateDoc
|
|
6
|
+
import { collection, doc, getDocs, limit, orderBy, query, startAfter, updateDoc } from "firebase/firestore";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { firestore } from "../firebase.js";
|
|
9
9
|
import { serialize, textResult } from "../utils.js";
|
|
@@ -73,14 +73,31 @@ export function registerNotificationTools(server) {
|
|
|
73
73
|
outputSchema: notificationsMarkOutputSchema,
|
|
74
74
|
annotations: idempotentWriteTool,
|
|
75
75
|
}, async ({ notificationIds, allUnread, limit: max }) => {
|
|
76
|
-
|
|
76
|
+
// 🔴 Гейт намеренно на запись, хотя инструмент читается как «пометить
|
|
77
|
+
// прочитанным». Он делает `updateDoc` и сам помечен `idempotentWriteTool`;
|
|
78
|
+
// пока гейтом стоял `workspace:read`, ключ пресета `readOnly`
|
|
79
|
+
// (`events:read · analytics:read · workspace:read · leads:read`) мог
|
|
80
|
+
// погасить до 200 уведомлений организатора. Единственный смысл пресета
|
|
81
|
+
// «только чтение» — что он не пишет. Ущерб здесь не в чужих данных
|
|
82
|
+
// (пишем только в свои), а в нарушенном обещании: организатор не увидит
|
|
83
|
+
// уведомлений о выданных правах и созданных событиях.
|
|
84
|
+
const { uid } = await authed(["workspace:write"]);
|
|
77
85
|
if (Boolean(notificationIds?.length) === allUnread) {
|
|
78
86
|
throw new Error("Provide either notificationIds or allUnread=true.");
|
|
79
87
|
}
|
|
80
88
|
let ids = [...new Set(notificationIds ?? [])];
|
|
81
89
|
if (allUnread) {
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
// 🔴 Отбор обязан совпадать с тем, что список ПОКАЗЫВАЕТ как
|
|
91
|
+
// непрочитанное, иначе получается неустранимая петля: список считает
|
|
92
|
+
// непрочитанным всё, где `read !== true` (в том числе документ без
|
|
93
|
+
// поля), а `where("read", "==", false)` документ без поля не находит —
|
|
94
|
+
// и не находит его же `where("read", "!=", true)`, потому что запросы
|
|
95
|
+
// с неравенством в Firestore пропускают документы без поля вовсе.
|
|
96
|
+
// Такое уведомление висело бы вечно, а модель не могла бы его погасить.
|
|
97
|
+
// Поэтому фильтруем клиентом тем же предикатом, что и список, набирая
|
|
98
|
+
// с запасом — приём уже применён в `oko_list_notifications` выше.
|
|
99
|
+
const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), limit(Math.min(max * 5 + 1, 501))));
|
|
100
|
+
ids = snap.docs.filter((item) => item.data().read !== true).slice(0, max).map((item) => item.id);
|
|
84
101
|
}
|
|
85
102
|
await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
|
|
86
103
|
return textResult({ markedRead: ids.length, notificationIds: ids });
|
package/dist/tools/sessions.js
CHANGED
|
@@ -35,7 +35,10 @@ export function registerSessionTools(server) {
|
|
|
35
35
|
throw new Error("An analytics session is already active.");
|
|
36
36
|
const start = httpsCallable(functions, "startAnalyticsSessionV3");
|
|
37
37
|
const result = await start({ eventId });
|
|
38
|
-
|
|
38
|
+
// 🔴 `eventId` обязателен по `basicEventOutputSchema`. Без него SDK
|
|
39
|
+
// отвергал ответ ПОСЛЕ того, как сессия уже стартовала: клиент видел
|
|
40
|
+
// ошибку схемы и считал, что запуска не было, — Pattern 18.
|
|
41
|
+
return textResult({ eventId, started: true, ...result.data });
|
|
39
42
|
});
|
|
40
43
|
server.registerTool("oko_end_session", {
|
|
41
44
|
title: "End analytics session",
|
|
@@ -58,7 +61,9 @@ export function registerSessionTools(server) {
|
|
|
58
61
|
requireConfirm(confirm, "oko_end_session", { eventId, sessionId });
|
|
59
62
|
const end = httpsCallable(functions, "endAnalyticsSessionV3");
|
|
60
63
|
const result = await end({ eventId, sessionId });
|
|
61
|
-
|
|
64
|
+
// Тот же случай, что у запуска: сессия уже завершена и отчёт сохранён,
|
|
65
|
+
// а ответ без `eventId` не проходил собственную схему.
|
|
66
|
+
return textResult({ eventId, ...result.data });
|
|
62
67
|
});
|
|
63
68
|
server.registerTool("oko_fix_orphan_session", {
|
|
64
69
|
title: "Fix orphan analytics session",
|
|
@@ -93,6 +93,13 @@ export function validateEventSetup(eventId, event) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
const cards = readCardsConfig(event);
|
|
96
|
+
// 🔴 Карточка без пригодного `id` — это ошибка данных, а не мелочь: id
|
|
97
|
+
// связывает запись с её картинкой в Storage и с обратной записью CF4.
|
|
98
|
+
// Пока подстановка молчала, проверка не могла назвать причину, по которой
|
|
99
|
+
// у карточки пропадает изображение при каждой правке.
|
|
100
|
+
cards.repairedIdPositions.forEach((position) => {
|
|
101
|
+
add(errors, "card_id_missing", `blocks.cards.items[${position}].id`, "Card has no usable id in Firestore; its image cannot be matched and will be dropped on the next update.");
|
|
102
|
+
});
|
|
96
103
|
if (cards.enabled) {
|
|
97
104
|
if (cards.items.length === 0)
|
|
98
105
|
add(warnings, "cards_empty", "blocks.cards.items", "Cards block is enabled but has no cards.");
|
package/dist/tools/telegram.js
CHANGED
|
@@ -170,10 +170,15 @@ 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
|
+
// 🔴 Фильтр применяется ДО нарезки страницы. Пока он стоял после,
|
|
174
|
+
// `hasMore` и `count` считались по нефильтрованной выборке: пустой
|
|
175
|
+
// результат приходил с `hasMore: false`, и это читается как «постов с
|
|
176
|
+
// таким статусом нет», хотя они есть на следующих страницах.
|
|
177
|
+
const matching = status
|
|
178
|
+
? snap.docs.filter((item) => item.data().status === status)
|
|
179
|
+
: snap.docs;
|
|
180
|
+
const { page, hasMore } = pickPage(matching, max);
|
|
181
|
+
const posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
177
182
|
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
|
|
178
183
|
return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
|
|
179
184
|
});
|
package/dist/tools/workspaces.js
CHANGED
|
@@ -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,
|
|
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";
|
|
@@ -87,11 +87,27 @@ export function registerWorkspaceTools(server) {
|
|
|
87
87
|
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
88
88
|
if (workspace.personal === true)
|
|
89
89
|
throw new Error("Personal workspace cannot be deleted.");
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
// 🔴 Спрашиваем индекс доступа, а не коллекцию событий. `allow list` по
|
|
91
|
+
// `events` требует `hasPlatformOwnerRole()`, который у MCP-сессии ложен по
|
|
92
|
+
// построению (`mcp_<keyId>` выпускается с `owner: false`) — то есть
|
|
93
|
+
// прежний запрос падал `permission-denied` ВСЕГДА, и инструмент не мог
|
|
94
|
+
// удалить ни одного пространства. Индекс `eventAccess/{uid}/grants` — тот
|
|
95
|
+
// же источник, которым пользуется `oko_list_my_events`.
|
|
96
|
+
//
|
|
97
|
+
// Ограничение названо честно: grants перечисляют события, к которым
|
|
98
|
+
// доступ есть у ЭТОГО организатора. Событие в пространстве, к которому он
|
|
99
|
+
// доступа не имеет, проверка не увидит — но владелец пространства
|
|
100
|
+
// получает grant на все его события через CF18, так что для владельца,
|
|
101
|
+
// а только он сюда и доходит (`readWorkspaceForOwner` выше), совпадает.
|
|
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 });
|
|
95
111
|
});
|
|
96
112
|
server.registerTool("oko_list_workspace_access", {
|
|
97
113
|
title: "List workspace access",
|
|
@@ -159,7 +175,16 @@ export function registerWorkspaceTools(server) {
|
|
|
159
175
|
sharedAt: serverTimestamp(),
|
|
160
176
|
sharedBy: uid,
|
|
161
177
|
}, { merge: true });
|
|
162
|
-
|
|
178
|
+
// 🔴 `updateOutputSchema` объявляет `updated` МАССИВОМ имён полей, а не
|
|
179
|
+
// флагом. Пока возвращался `true`, SDK отвергал ответ уже после записи
|
|
180
|
+
// прав: клиент видел ошибку и считал, что доступ не выдан, — а он выдан.
|
|
181
|
+
// Из всех мест Pattern 18 это самое опасное: расходятся представления о
|
|
182
|
+
// том, кто что может.
|
|
183
|
+
return textResult({
|
|
184
|
+
workspaceId: args.workspaceId,
|
|
185
|
+
uid: args.uid,
|
|
186
|
+
updated: ["email", "comment", "permissions"],
|
|
187
|
+
});
|
|
163
188
|
});
|
|
164
189
|
server.registerTool("oko_remove_workspace_member", {
|
|
165
190
|
title: "Remove workspace member",
|
|
@@ -252,7 +277,7 @@ export function registerWorkspaceTools(server) {
|
|
|
252
277
|
summary: "Create a workspace invite for a known OKO workspace.",
|
|
253
278
|
args: [
|
|
254
279
|
"workspaceId: workspace owned by the authenticated organizer.",
|
|
255
|
-
"code: optional invite code,
|
|
280
|
+
"code: optional invite code, 16-64 chars; omitted means generated (recommended).",
|
|
256
281
|
"email: optional intended recipient email.",
|
|
257
282
|
"permissions: optional whitelist of workspace permission booleans.",
|
|
258
283
|
"confirm: must be true.",
|
|
@@ -262,7 +287,14 @@ export function registerWorkspaceTools(server) {
|
|
|
262
287
|
}),
|
|
263
288
|
inputSchema: {
|
|
264
289
|
workspaceId: z.string().min(1),
|
|
265
|
-
|
|
290
|
+
// 🔴 Минимум 16, а не 4: код приглашения — предъявительский секрет.
|
|
291
|
+
// Правило Firestore допускает `get` любого приглашения по коду, а CF19
|
|
292
|
+
// выдаёт по нему права, то есть короткий код перебирается. Генератор
|
|
293
|
+
// платформы даёт 16 шестнадцатеричных знаков — заданный вручную не
|
|
294
|
+
// должен быть слабее. У соседних инструментов (`claim`, `revoke`)
|
|
295
|
+
// минимум остаётся 4 намеренно: они принимают УЖЕ существующие коды,
|
|
296
|
+
// включая исторические шестисимвольные.
|
|
297
|
+
code: z.string().min(16).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
|
|
266
298
|
email: z.string().email().optional(),
|
|
267
299
|
comment: z.string().max(500).default(""),
|
|
268
300
|
permissions: workspacePermissionsSchema,
|
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.
|
|
2
|
+
export const OKO_MCP_VERSION = "0.12.1";
|