@okostream/mcp 0.12.2 → 0.12.3
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 +38 -0
- package/dist/server.js +120452 -41
- package/package.json +8 -9
- package/dist/auth.js +0 -115
- package/dist/env.js +0 -71
- package/dist/firebase.js +0 -31
- package/dist/firebaseDefaults.js +0 -11
- package/dist/guides.js +0 -266
- package/dist/networkPolicy.js +0 -18
- package/dist/onboarding.js +0 -126
- package/dist/scopes.js +0 -22
- package/dist/sessionRepair.js +0 -43
- package/dist/telegramChannelSchema.js +0 -13
- package/dist/tools/account.js +0 -77
- package/dist/tools/audit.js +0 -44
- package/dist/tools/chat.js +0 -228
- package/dist/tools/context.js +0 -40
- package/dist/tools/eventAccess.js +0 -65
- package/dist/tools/eventCreate.js +0 -43
- package/dist/tools/events.js +0 -497
- package/dist/tools/formatting.js +0 -216
- package/dist/tools/imagePipeline.js +0 -597
- package/dist/tools/images.js +0 -243
- package/dist/tools/instrumentation.js +0 -40
- package/dist/tools/leads.js +0 -85
- package/dist/tools/lifecycle.js +0 -168
- package/dist/tools/live.js +0 -227
- package/dist/tools/notifications.js +0 -110
- package/dist/tools/schemas.js +0 -329
- package/dist/tools/sessions.js +0 -313
- package/dist/tools/setupValidation.js +0 -144
- package/dist/tools/telegram.js +0 -297
- package/dist/tools/workspaceAccess.js +0 -60
- package/dist/tools/workspaces.js +0 -345
- package/dist/tools.js +0 -38
- package/dist/utils.js +0 -76
- package/dist/version.js +0 -2
- package/dist/videoUrl.js +0 -166
|
@@ -1,597 +0,0 @@
|
|
|
1
|
-
// Конвейер изображений события: приём источника, загрузка оригинала,
|
|
2
|
-
// ожидание вариантов, удаление.
|
|
3
|
-
//
|
|
4
|
-
// Чтение картинки по ссылке — единственное место, где MCP ходит в чужую сеть,
|
|
5
|
-
// поэтому здесь же живёт вся защита: разрешённые типы, потолок байтов,
|
|
6
|
-
// ограничение редиректов и запрет частных адресов (SSRF). Локальные файлы
|
|
7
|
-
// читаются только из явного списка каталогов организатора.
|
|
8
|
-
//
|
|
9
|
-
// Варианты 320/800/1600 делает CF4 после появления master.webp. Клиент их не
|
|
10
|
-
// пишет и не может: он лишь ждёт, пока сервер допишет метаданные в документ.
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { lookup } from "node:dns/promises";
|
|
13
|
-
import { stat, readFile, realpath } from "node:fs/promises";
|
|
14
|
-
import { homedir } from "node:os";
|
|
15
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
|
-
import { Agent, fetch as undiciFetch } from "undici";
|
|
17
|
-
import { doc, runTransaction as runFirestoreTransaction } from "firebase/firestore";
|
|
18
|
-
import { httpsCallable } from "firebase/functions";
|
|
19
|
-
import { z } from "zod";
|
|
20
|
-
import { env } from "../env.js";
|
|
21
|
-
import { firestore, functions } from "../firebase.js";
|
|
22
|
-
import { ipLiteralFamily, isPublicUnicastAddress, normalizeIpLiteralHostname } from "../networkPolicy.js";
|
|
23
|
-
import { safeString } from "../utils.js";
|
|
24
|
-
import { readEvent } from "./eventAccess.js";
|
|
25
|
-
export const IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
|
|
26
|
-
export const IMAGE_VARIANT_NAMES = ["thumb_320.webp", "card_800.webp", "hero_1600.webp"];
|
|
27
|
-
export const CARD_IMAGE_RATIOS = ["auto", "1:1", "4:3", "3:2", "16:9", "2:3", "3:4"];
|
|
28
|
-
export const CARD_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
|
|
29
|
-
export const MAX_CARD_ITEMS = 6;
|
|
30
|
-
export const MAX_CARD_TITLE_LENGTH = 160;
|
|
31
|
-
export const MAX_CARD_TEXT_LENGTH = 500;
|
|
32
|
-
export const MAX_CARD_BUTTON_LABEL_LENGTH = 80;
|
|
33
|
-
export const MAX_CARD_BUTTON_URL_LENGTH = 1000;
|
|
34
|
-
export const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
35
|
-
export const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
|
|
36
|
-
export const REMOTE_IMAGE_MAX_REDIRECTS = 3;
|
|
37
|
-
export const ALLOWED_REMOTE_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
|
38
|
-
export function imageStoragePath(eventId, uid, kind, imageId) {
|
|
39
|
-
return `events/${eventId}/${uid}/images/${kind}/${imageId}/master.webp`;
|
|
40
|
-
}
|
|
41
|
-
export function storagePathFromDownloadUrl(downloadUrl) {
|
|
42
|
-
if (!downloadUrl)
|
|
43
|
-
return null;
|
|
44
|
-
const match = downloadUrl.match(/\/o\/([^?]+)/);
|
|
45
|
-
if (!match)
|
|
46
|
-
return null;
|
|
47
|
-
const encodedPath = match[1];
|
|
48
|
-
if (!encodedPath)
|
|
49
|
-
return null;
|
|
50
|
-
try {
|
|
51
|
-
return decodeURIComponent(encodedPath);
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
export function variantPathsFromMasterPath(masterPath) {
|
|
58
|
-
if (!masterPath.endsWith("/master.webp"))
|
|
59
|
-
return [];
|
|
60
|
-
return IMAGE_VARIANT_NAMES.map((name) => masterPath.replace(/master\.webp$/, name));
|
|
61
|
-
}
|
|
62
|
-
export function resolveLocalImagePath(inputPath) {
|
|
63
|
-
const expanded = inputPath === "~" || inputPath.startsWith("~/")
|
|
64
|
-
? join(homedir(), inputPath.slice(2))
|
|
65
|
-
: inputPath;
|
|
66
|
-
return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
|
|
67
|
-
}
|
|
68
|
-
export function isBlockedHostname(hostname) {
|
|
69
|
-
const host = hostname.toLowerCase();
|
|
70
|
-
return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local");
|
|
71
|
-
}
|
|
72
|
-
export async function resolveRemoteImageAddress(url) {
|
|
73
|
-
if (url.protocol !== "https:") {
|
|
74
|
-
throw new Error("Image URL must use https://.");
|
|
75
|
-
}
|
|
76
|
-
if (url.username || url.password) {
|
|
77
|
-
throw new Error("Image URL must not include embedded credentials.");
|
|
78
|
-
}
|
|
79
|
-
if (isBlockedHostname(url.hostname)) {
|
|
80
|
-
throw new Error("Image URL host is not allowed. Use a public HTTPS image URL.");
|
|
81
|
-
}
|
|
82
|
-
const literalAddress = normalizeIpLiteralHostname(url.hostname);
|
|
83
|
-
const literalFamily = ipLiteralFamily(literalAddress);
|
|
84
|
-
if (literalFamily !== 0) {
|
|
85
|
-
if (!isPublicUnicastAddress(literalAddress)) {
|
|
86
|
-
throw new Error("Image URL points to a private/local IP address, which is not allowed.");
|
|
87
|
-
}
|
|
88
|
-
return { address: literalAddress, family: literalFamily };
|
|
89
|
-
}
|
|
90
|
-
const addresses = await lookup(url.hostname, { all: true });
|
|
91
|
-
if (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address))) {
|
|
92
|
-
throw new Error("Image URL resolves to a non-public IP address, which is not allowed.");
|
|
93
|
-
}
|
|
94
|
-
const selected = addresses[0];
|
|
95
|
-
if (!selected || (selected.family !== 4 && selected.family !== 6)) {
|
|
96
|
-
throw new Error("Image URL host could not be resolved safely.");
|
|
97
|
-
}
|
|
98
|
-
return { address: selected.address, family: selected.family };
|
|
99
|
-
}
|
|
100
|
-
export async function responseBufferWithLimit(response, maxBytes) {
|
|
101
|
-
const body = response.body;
|
|
102
|
-
if (!body) {
|
|
103
|
-
const bytes = Buffer.from(await response.arrayBuffer());
|
|
104
|
-
if (bytes.byteLength > maxBytes)
|
|
105
|
-
throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
|
|
106
|
-
return bytes;
|
|
107
|
-
}
|
|
108
|
-
const reader = body.getReader();
|
|
109
|
-
const chunks = [];
|
|
110
|
-
let total = 0;
|
|
111
|
-
try {
|
|
112
|
-
while (true) {
|
|
113
|
-
const { done, value } = await reader.read();
|
|
114
|
-
if (done)
|
|
115
|
-
break;
|
|
116
|
-
const chunk = Buffer.from(value);
|
|
117
|
-
total += chunk.byteLength;
|
|
118
|
-
if (total > maxBytes) {
|
|
119
|
-
throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
|
|
120
|
-
}
|
|
121
|
-
chunks.push(chunk);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
finally {
|
|
125
|
-
reader.releaseLock();
|
|
126
|
-
}
|
|
127
|
-
return Buffer.concat(chunks, total);
|
|
128
|
-
}
|
|
129
|
-
export async function readRemoteImage(rawUrl, redirects = 0) {
|
|
130
|
-
if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
|
|
131
|
-
throw new Error("Image URL has too many redirects.");
|
|
132
|
-
}
|
|
133
|
-
const url = new URL(rawUrl);
|
|
134
|
-
const pinned = await resolveRemoteImageAddress(url);
|
|
135
|
-
const dispatcher = new Agent({
|
|
136
|
-
connect: {
|
|
137
|
-
lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family),
|
|
138
|
-
},
|
|
139
|
-
});
|
|
140
|
-
const controller = new AbortController();
|
|
141
|
-
const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
|
|
142
|
-
try {
|
|
143
|
-
const response = await undiciFetch(url, {
|
|
144
|
-
redirect: "manual",
|
|
145
|
-
signal: controller.signal,
|
|
146
|
-
headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
|
|
147
|
-
dispatcher,
|
|
148
|
-
});
|
|
149
|
-
if (response.status >= 300 && response.status < 400) {
|
|
150
|
-
const location = response.headers.get("location");
|
|
151
|
-
if (!location)
|
|
152
|
-
throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
|
|
153
|
-
return readRemoteImage(new URL(location, url).toString(), redirects + 1);
|
|
154
|
-
}
|
|
155
|
-
if (!response.ok) {
|
|
156
|
-
throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
|
|
157
|
-
}
|
|
158
|
-
const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
|
|
159
|
-
if (!contentType || !contentType.startsWith("image/")) {
|
|
160
|
-
throw new Error("Image URL must return Content-Type image/*.");
|
|
161
|
-
}
|
|
162
|
-
if (!ALLOWED_REMOTE_IMAGE_TYPES.has(contentType)) {
|
|
163
|
-
throw new Error(`Image URL Content-Type ${contentType} is not supported. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
|
|
164
|
-
}
|
|
165
|
-
const contentLength = Number(response.headers.get("content-length") ?? 0);
|
|
166
|
-
if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
|
|
167
|
-
throw new Error("Image URL is larger than 15MB.");
|
|
168
|
-
}
|
|
169
|
-
return responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES);
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
173
|
-
throw new Error("Image URL fetch timed out.");
|
|
174
|
-
}
|
|
175
|
-
throw err;
|
|
176
|
-
}
|
|
177
|
-
finally {
|
|
178
|
-
clearTimeout(timeout);
|
|
179
|
-
// 🔴 `destroy()`, а не `close()`. `close()` в undici ЖДЁТ завершения
|
|
180
|
-
// незавершённых запросов — а когда защита по размеру бросает из
|
|
181
|
-
// `responseBufferWithLimit`, тело ответа остаётся непрочитанным. Публичный
|
|
182
|
-
// сервер с открытым chunked-ответом подвешивал инструмент бесконечно
|
|
183
|
-
// именно здесь: ограничение уже сработало, а выйти из функции было
|
|
184
|
-
// нельзя. `destroy()` отменяет то, что не дочитано.
|
|
185
|
-
await dispatcher.destroy();
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
export async function readLocalImage(inputPath) {
|
|
189
|
-
if (env.imageRoots.length === 0) {
|
|
190
|
-
throw new Error("Local image reads are disabled. Set OKO_IMAGE_ROOTS to an explicit trusted directory and restart the MCP client.");
|
|
191
|
-
}
|
|
192
|
-
const filePath = await realpath(resolveLocalImagePath(inputPath));
|
|
193
|
-
const roots = await Promise.all(env.imageRoots.map(async (root) => {
|
|
194
|
-
try {
|
|
195
|
-
return await realpath(root);
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return resolve(root);
|
|
199
|
-
}
|
|
200
|
-
}));
|
|
201
|
-
const allowed = roots.some((root) => {
|
|
202
|
-
const child = relative(root, filePath);
|
|
203
|
-
return child === "" || (!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child));
|
|
204
|
-
});
|
|
205
|
-
if (!allowed) {
|
|
206
|
-
throw new Error("Image path is outside OKO_IMAGE_ROOTS. Add a trusted root to the MCP client environment.");
|
|
207
|
-
}
|
|
208
|
-
const info = await stat(filePath);
|
|
209
|
-
if (!info.isFile())
|
|
210
|
-
throw new Error("Image path must point to a regular file.");
|
|
211
|
-
if (info.size > MAX_SOURCE_IMAGE_BYTES)
|
|
212
|
-
throw new Error("Image file is larger than 15MB.");
|
|
213
|
-
return readFile(filePath);
|
|
214
|
-
}
|
|
215
|
-
export async function readImageSource(source) {
|
|
216
|
-
if (source.path)
|
|
217
|
-
return readLocalImage(source.path);
|
|
218
|
-
if (source.url)
|
|
219
|
-
return readRemoteImage(source.url);
|
|
220
|
-
throw new Error("Provide image source path or url.");
|
|
221
|
-
}
|
|
222
|
-
export function readImageRef(eventData, kind, cardId) {
|
|
223
|
-
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
224
|
-
if (kind === "promoBanner") {
|
|
225
|
-
const banner = (blocks.promoBanner && typeof blocks.promoBanner === "object" ? blocks.promoBanner : {});
|
|
226
|
-
return {
|
|
227
|
-
imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
228
|
-
imageUrl: safeString(banner.imageUrl) || null,
|
|
229
|
-
imageMasterUrl: safeString(banner.imageMasterUrl) || null,
|
|
230
|
-
imageVariants: readImageVariants(banner.imageVariants),
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
if (kind === "videoPlaceholder") {
|
|
234
|
-
const video = (blocks.video && typeof blocks.video === "object" ? blocks.video : {});
|
|
235
|
-
const placeholder = (video.placeholder && typeof video.placeholder === "object" ? video.placeholder : {});
|
|
236
|
-
return {
|
|
237
|
-
imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
238
|
-
imageUrl: safeString(placeholder.imageUrl) || null,
|
|
239
|
-
imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
|
|
240
|
-
imageVariants: readImageVariants(placeholder.imageVariants),
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
if (!cardId)
|
|
244
|
-
throw new Error("cardId is required when kind is card.");
|
|
245
|
-
const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
246
|
-
const items = Array.isArray(cards.items) ? cards.items : [];
|
|
247
|
-
const item = items.find((raw) => Boolean(raw) && typeof raw === "object" && raw.id === cardId);
|
|
248
|
-
if (!item)
|
|
249
|
-
throw new Error(`Card ${cardId} not found in blocks.cards.items. Create/configure the card before uploading its image.`);
|
|
250
|
-
return {
|
|
251
|
-
imageId: cardId,
|
|
252
|
-
imageUrl: safeString(item.imageUrl) || null,
|
|
253
|
-
imageMasterUrl: safeString(item.imageMasterUrl) || null,
|
|
254
|
-
imageVariants: readImageVariants(item.imageVariants),
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
export function cardItemsWithImage(eventData, cardId, patch) {
|
|
258
|
-
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
259
|
-
const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
260
|
-
const items = Array.isArray(cards.items) ? cards.items : [];
|
|
261
|
-
let found = false;
|
|
262
|
-
const next = items.map((raw) => {
|
|
263
|
-
const item = (raw && typeof raw === "object" ? raw : {});
|
|
264
|
-
if (item.id !== cardId)
|
|
265
|
-
return item;
|
|
266
|
-
found = true;
|
|
267
|
-
return { ...item, ...patch };
|
|
268
|
-
});
|
|
269
|
-
if (!found)
|
|
270
|
-
throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
|
|
271
|
-
return next;
|
|
272
|
-
}
|
|
273
|
-
export function readImageVariants(value) {
|
|
274
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
275
|
-
return null;
|
|
276
|
-
const data = value;
|
|
277
|
-
if (typeof data.thumbUrl !== "string" || typeof data.cardUrl !== "string" || typeof data.heroUrl !== "string") {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
return {
|
|
281
|
-
thumbUrl: data.thumbUrl,
|
|
282
|
-
cardUrl: data.cardUrl,
|
|
283
|
-
heroUrl: data.heroUrl,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
export function readCardsConfig(eventData) {
|
|
287
|
-
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
288
|
-
const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
289
|
-
const rawItems = Array.isArray(raw.items) ? raw.items : [];
|
|
290
|
-
const repairedIdPositions = [];
|
|
291
|
-
// 🔴 Позиция считается по ИСХОДНОМУ массиву, а не по отфильтрованному:
|
|
292
|
-
// иначе путь `blocks.cards.items[N]` в ошибке валидации указывает не на ту
|
|
293
|
-
// карточку. Фича существует ровно для битых массивов, то есть для случая,
|
|
294
|
-
// где отсев и создаёт сдвиг (ревью 02.09, Б14).
|
|
295
|
-
const items = rawItems
|
|
296
|
-
.map((item, position) => ({ item, position }))
|
|
297
|
-
.filter((entry) => Boolean(entry.item) && typeof entry.item === "object" && !Array.isArray(entry.item))
|
|
298
|
-
.map(({ item, position }) => {
|
|
299
|
-
const hasUsableId = typeof item.id === "string" && CARD_ID_PATTERN.test(item.id);
|
|
300
|
-
if (!hasUsableId)
|
|
301
|
-
repairedIdPositions.push(position);
|
|
302
|
-
return {
|
|
303
|
-
// 🔴 UUID генерируется только когда он нужен: прежде crypto-RNG
|
|
304
|
-
// дёргался для каждой карточки при каждом чтении, а значение
|
|
305
|
-
// отбрасывалось (ревью 02.09, В14)
|
|
306
|
-
id: hasUsableId ? item.id : randomUUID(),
|
|
307
|
-
title: safeString(item.title).trim() || null,
|
|
308
|
-
text: safeString(item.text).trim() || null,
|
|
309
|
-
buttonLabel: safeString(item.buttonLabel).trim() || null,
|
|
310
|
-
buttonUrl: safeString(item.buttonUrl).trim() || null,
|
|
311
|
-
imageUrl: safeString(item.imageUrl).trim() || null,
|
|
312
|
-
imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
|
|
313
|
-
imageVariants: readImageVariants(item.imageVariants),
|
|
314
|
-
imageWidth: typeof item.imageWidth === "number" ? item.imageWidth : null,
|
|
315
|
-
imageHeight: typeof item.imageHeight === "number" ? item.imageHeight : null,
|
|
316
|
-
formId: safeString(item.formId).trim() || null,
|
|
317
|
-
};
|
|
318
|
-
});
|
|
319
|
-
const ratio = safeString(raw.cardImageRatio);
|
|
320
|
-
return {
|
|
321
|
-
enabled: raw.enabled === true,
|
|
322
|
-
visibility: raw.visibility === "hidden" ? "hidden" : "always",
|
|
323
|
-
cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
|
|
324
|
-
items,
|
|
325
|
-
repairedIdPositions,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
export function normalizeNullableText(value) {
|
|
329
|
-
if (value == null)
|
|
330
|
-
return null;
|
|
331
|
-
return value.trim() || null;
|
|
332
|
-
}
|
|
333
|
-
export function normalizeCardButtonUrl(value) {
|
|
334
|
-
const trimmed = normalizeNullableText(value);
|
|
335
|
-
if (!trimmed)
|
|
336
|
-
return null;
|
|
337
|
-
let url;
|
|
338
|
-
try {
|
|
339
|
-
url = new URL(trimmed);
|
|
340
|
-
}
|
|
341
|
-
catch {
|
|
342
|
-
throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
|
|
343
|
-
}
|
|
344
|
-
if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
|
|
345
|
-
throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
|
|
346
|
-
}
|
|
347
|
-
return trimmed;
|
|
348
|
-
}
|
|
349
|
-
export function eventFormIds(eventData) {
|
|
350
|
-
const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
|
|
351
|
-
return new Set(rawForms
|
|
352
|
-
.map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "")
|
|
353
|
-
.filter(Boolean));
|
|
354
|
-
}
|
|
355
|
-
export function cardHasImage(item) {
|
|
356
|
-
return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
|
|
357
|
-
}
|
|
358
|
-
export const cardIdSchema = z.string()
|
|
359
|
-
.regex(CARD_ID_PATTERN, "Use 1-80 characters: letters, numbers, underscore, or hyphen.")
|
|
360
|
-
.describe("Stable card id. Omit to generate one; keep it to preserve images and analytics continuity.");
|
|
361
|
-
export const cardItemInputSchema = z.object({
|
|
362
|
-
id: cardIdSchema.optional(),
|
|
363
|
-
title: z.string().max(MAX_CARD_TITLE_LENGTH).nullable().optional(),
|
|
364
|
-
text: z.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
|
|
365
|
-
buttonLabel: z.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
|
|
366
|
-
buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
|
|
367
|
-
formId: cardIdSchema.nullable().optional(),
|
|
368
|
-
}).strict();
|
|
369
|
-
export const imageSourceSchema = z.object({
|
|
370
|
-
path: z.string().min(1).optional().describe("Local image path on the MCP client machine."),
|
|
371
|
-
url: z.string().url().optional().describe("HTTPS image URL to fetch from the MCP client machine."),
|
|
372
|
-
}).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
|
|
373
|
-
message: "Provide exactly one image source: path or url.",
|
|
374
|
-
});
|
|
375
|
-
export function buildCardsItems({ inputs, existing, formIds, }) {
|
|
376
|
-
const existingById = new Map(existing.map((item) => [item.id, item]));
|
|
377
|
-
const seen = new Set();
|
|
378
|
-
const generatedCardIds = [];
|
|
379
|
-
const preservedImageCardIds = [];
|
|
380
|
-
const items = inputs.map((input) => {
|
|
381
|
-
const id = input.id ?? randomUUID();
|
|
382
|
-
if (seen.has(id))
|
|
383
|
-
throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
|
|
384
|
-
seen.add(id);
|
|
385
|
-
if (!CARD_ID_PATTERN.test(id))
|
|
386
|
-
throw new Error(`Invalid card id "${id}". Use 1-80 characters: letters, numbers, underscore, or hyphen.`);
|
|
387
|
-
if (!input.id)
|
|
388
|
-
generatedCardIds.push(id);
|
|
389
|
-
const formId = normalizeNullableText(input.formId);
|
|
390
|
-
if (formId && !formIds.has(formId)) {
|
|
391
|
-
throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
|
|
392
|
-
}
|
|
393
|
-
const previous = existingById.get(id);
|
|
394
|
-
if (previous && cardHasImage(previous))
|
|
395
|
-
preservedImageCardIds.push(id);
|
|
396
|
-
return {
|
|
397
|
-
id,
|
|
398
|
-
title: normalizeNullableText(input.title),
|
|
399
|
-
text: normalizeNullableText(input.text),
|
|
400
|
-
buttonLabel: normalizeNullableText(input.buttonLabel),
|
|
401
|
-
buttonUrl: normalizeCardButtonUrl(input.buttonUrl),
|
|
402
|
-
imageUrl: previous?.imageUrl ?? null,
|
|
403
|
-
imageMasterUrl: previous?.imageMasterUrl ?? null,
|
|
404
|
-
imageVariants: previous?.imageVariants ?? null,
|
|
405
|
-
imageWidth: previous?.imageWidth ?? null,
|
|
406
|
-
imageHeight: previous?.imageHeight ?? null,
|
|
407
|
-
formId,
|
|
408
|
-
};
|
|
409
|
-
});
|
|
410
|
-
return { items, generatedCardIds, preservedImageCardIds };
|
|
411
|
-
}
|
|
412
|
-
export async function deleteStoragePaths(eventId, paths) {
|
|
413
|
-
const uniquePaths = [...new Set(paths.filter(Boolean))];
|
|
414
|
-
if (uniquePaths.length === 0)
|
|
415
|
-
return [];
|
|
416
|
-
const removeImages = httpsCallable(functions, "deleteMcpImages");
|
|
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
|
-
}
|
|
430
|
-
}
|
|
431
|
-
export async function deleteStorageUrls(eventId, masterUrl, fallbackUrl) {
|
|
432
|
-
const paths = [];
|
|
433
|
-
const masterPath = storagePathFromDownloadUrl(masterUrl);
|
|
434
|
-
if (masterPath) {
|
|
435
|
-
paths.push(masterPath, ...variantPathsFromMasterPath(masterPath));
|
|
436
|
-
}
|
|
437
|
-
else {
|
|
438
|
-
const fallbackPath = storagePathFromDownloadUrl(fallbackUrl);
|
|
439
|
-
if (fallbackPath)
|
|
440
|
-
paths.push(fallbackPath);
|
|
441
|
-
}
|
|
442
|
-
const warnings = await deleteStoragePaths(eventId, paths);
|
|
443
|
-
return { attempted: [...new Set(paths)].length, warnings };
|
|
444
|
-
}
|
|
445
|
-
export async function uploadMasterImage(eventId, kind, cardId, image) {
|
|
446
|
-
const uploadImage = httpsCallable(functions, "uploadMcpImage");
|
|
447
|
-
const result = await uploadImage({
|
|
448
|
-
eventId,
|
|
449
|
-
kind,
|
|
450
|
-
...(cardId ? { cardId } : {}),
|
|
451
|
-
imageBase64: image.toString("base64"),
|
|
452
|
-
});
|
|
453
|
-
return result.data;
|
|
454
|
-
}
|
|
455
|
-
export async function updateCardImageInTransaction(eventId, cardId, patch) {
|
|
456
|
-
const eventRef = doc(firestore, "events", eventId);
|
|
457
|
-
await runFirestoreTransaction(firestore, async (transaction) => {
|
|
458
|
-
const snap = await transaction.get(eventRef);
|
|
459
|
-
if (!snap.exists())
|
|
460
|
-
throw new Error("Event not found.");
|
|
461
|
-
const data = snap.data();
|
|
462
|
-
transaction.update(eventRef, {
|
|
463
|
-
"blocks.cards.items": cardItemsWithImage(data, cardId, patch),
|
|
464
|
-
});
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* Записывает патч события, пересобирая массив карточек из СВЕЖЕГО документа.
|
|
469
|
-
*
|
|
470
|
-
* 🔴 Зачем транзакция. `blocks.cards.items` — массив, и запись его целиком
|
|
471
|
-
* затирает всё, чего не было в прочитанном снимке. Между чтением события и
|
|
472
|
-
* записью успевает отработать CF4: она дописывает `imageVariants`, `imageUrl`
|
|
473
|
-
* и размеры по стабильному id карточки. Реконструкция из устаревшего снимка
|
|
474
|
-
* стирала эти поля — картинка у зрителя откатывалась к мастеру или пропадала.
|
|
475
|
-
*
|
|
476
|
-
* Это Pattern 23, разобранный на веб-стороне (там Setup уже сливает
|
|
477
|
-
* метаданные транзакционно) и вернувшийся через MCP: то же место, тот же
|
|
478
|
-
* массив, только другой клиент.
|
|
479
|
-
*
|
|
480
|
-
* `buildItems` получает свежие данные документа и обязан строить итоговый
|
|
481
|
-
* массив из них, а не из снимка, снятого до транзакции.
|
|
482
|
-
*/
|
|
483
|
-
export async function writeEventPatchWithCards(eventId, basePatch, buildItems) {
|
|
484
|
-
const eventRef = doc(firestore, "events", eventId);
|
|
485
|
-
await runFirestoreTransaction(firestore, async (transaction) => {
|
|
486
|
-
const snap = await transaction.get(eventRef);
|
|
487
|
-
if (!snap.exists())
|
|
488
|
-
throw new Error("Event not found.");
|
|
489
|
-
const patch = { ...basePatch };
|
|
490
|
-
if (buildItems) {
|
|
491
|
-
patch["blocks.cards.items"] = buildItems(snap.data());
|
|
492
|
-
}
|
|
493
|
-
if (Object.keys(patch).length === 0)
|
|
494
|
-
return;
|
|
495
|
-
transaction.update(eventRef, patch);
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
export function imageVariantReason(ref) {
|
|
499
|
-
if (!ref.imageMasterUrl && !ref.imageUrl)
|
|
500
|
-
return "image_missing";
|
|
501
|
-
if (!ref.imageMasterUrl)
|
|
502
|
-
return "master_missing";
|
|
503
|
-
if (!ref.imageVariants)
|
|
504
|
-
return "variants_pending";
|
|
505
|
-
if (!ref.imageUrl)
|
|
506
|
-
return "image_url_pending";
|
|
507
|
-
return null;
|
|
508
|
-
}
|
|
509
|
-
export function imageVariantStatus(eventId, eventData, kind, cardId, elapsedMs) {
|
|
510
|
-
const ref = readImageRef(eventData, kind, cardId ?? undefined);
|
|
511
|
-
const reason = imageVariantReason(ref);
|
|
512
|
-
return {
|
|
513
|
-
eventId,
|
|
514
|
-
kind,
|
|
515
|
-
cardId,
|
|
516
|
-
imageId: ref.imageId,
|
|
517
|
-
imageUrl: ref.imageUrl,
|
|
518
|
-
imageMasterUrl: ref.imageMasterUrl,
|
|
519
|
-
imageVariants: ref.imageVariants,
|
|
520
|
-
ready: reason === null,
|
|
521
|
-
reason,
|
|
522
|
-
elapsedMs,
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
export function sleep(ms) {
|
|
526
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
527
|
-
}
|
|
528
|
-
export async function waitForImageVariantTargets(uid, eventId, targets, timeoutSec) {
|
|
529
|
-
const startedAt = Date.now();
|
|
530
|
-
const deadline = startedAt + timeoutSec * 1000;
|
|
531
|
-
// Мёртвая заготовка снята: цикл ниже выполняется хотя бы раз и
|
|
532
|
-
// перезаписывает `latest` до любого чтения, так что наблюдать её было
|
|
533
|
-
// нечем — а `imageId: target.cardId` в ней ещё и вводил в заблуждение
|
|
534
|
-
// (ревью 02.09, В15).
|
|
535
|
-
let latest;
|
|
536
|
-
while (true) {
|
|
537
|
-
const elapsedMs = Date.now() - startedAt;
|
|
538
|
-
const event = await readEvent(eventId, uid, "read");
|
|
539
|
-
latest = targets.map((target) => imageVariantStatus(eventId, event, target.kind, target.cardId, elapsedMs));
|
|
540
|
-
if (latest.every((item) => item.ready) || Date.now() >= deadline)
|
|
541
|
-
return latest;
|
|
542
|
-
await sleep(Math.min(2000, Math.max(250, deadline - Date.now())));
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
export async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
546
|
-
if (kind === "card" && !cardId)
|
|
547
|
-
throw new Error("cardId is required when kind is card.");
|
|
548
|
-
if (kind !== "card" && cardId)
|
|
549
|
-
throw new Error("cardId is only valid when kind is card.");
|
|
550
|
-
const event = await readEvent(eventId, uid, "edit");
|
|
551
|
-
const previous = readImageRef(event, kind, cardId);
|
|
552
|
-
const sourceBuffer = await readImageSource(source);
|
|
553
|
-
const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
|
|
554
|
-
const deleteWarnings = [];
|
|
555
|
-
let deletesAttempted = 0;
|
|
556
|
-
// 🔴 Сначала загрузка, потом удаление старого. Прежде варианты карточки
|
|
557
|
-
// стирались ДО `uploadMasterImage`, и упавшая загрузка оставляла документ
|
|
558
|
-
// Firestore со ссылками на удалённые объекты — зритель получал 404, а
|
|
559
|
-
// повтор восстановить их уже не мог. Соседняя ветка для не-карточек (ниже)
|
|
560
|
-
// всегда делала правильно; расходились только они.
|
|
561
|
-
//
|
|
562
|
-
// Порядок безопасен: новый master пишется по тому же пути, и CF4
|
|
563
|
-
// перезаписывает все три варианта заново. Худшее, что даёт отказ CF4, —
|
|
564
|
-
// варианты от прежней картинки рядом с новым мастером, и это заметно
|
|
565
|
-
// меньшая беда, чем битые ссылки.
|
|
566
|
-
const uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
|
|
567
|
-
if (!uploaded)
|
|
568
|
-
throw new Error("Image upload did not return a result.");
|
|
569
|
-
// 🔴 Старые варианты по ТОМУ ЖЕ пути не удаляются — и не нужны.
|
|
570
|
-
//
|
|
571
|
-
// Новый master пишется по тому же адресу и запускает CF4, которая
|
|
572
|
-
// перезаписывает все три варианта. Удаление рядом с этим было гонкой: если
|
|
573
|
-
// CF4 успевала первой, мы сносили объекты, на которые Firestore уже
|
|
574
|
-
// ссылается, а повторно она не запустится — нового мастера не будет. Зритель
|
|
575
|
-
// получал 404 навсегда. Прежний комментарий рассуждал только о случае
|
|
576
|
-
// «CF4 упала» и не рассматривал «CF4 успела» (ревью 02.09, В5).
|
|
577
|
-
if (kind !== "card" || (previousMasterPath && previousMasterPath !== uploaded.storagePath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
578
|
-
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
579
|
-
deletesAttempted += cleanup.attempted;
|
|
580
|
-
deleteWarnings.push(...cleanup.warnings);
|
|
581
|
-
}
|
|
582
|
-
return {
|
|
583
|
-
eventId,
|
|
584
|
-
kind,
|
|
585
|
-
imageId: uploaded.imageId,
|
|
586
|
-
storagePath: uploaded.storagePath,
|
|
587
|
-
masterUrl: uploaded.masterUrl,
|
|
588
|
-
variantsPending: true,
|
|
589
|
-
sourceBytes: sourceBuffer.byteLength,
|
|
590
|
-
masterBytes: uploaded.masterBytes,
|
|
591
|
-
imageWidth: uploaded.imageWidth,
|
|
592
|
-
imageHeight: uploaded.imageHeight,
|
|
593
|
-
note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
|
|
594
|
-
storageDeletesAttempted: deletesAttempted,
|
|
595
|
-
storageDeleteWarnings: deleteWarnings,
|
|
596
|
-
};
|
|
597
|
-
}
|