@okostream/mcp 0.15.0 → 0.15.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.
Files changed (2) hide show
  1. package/dist/server.js +1559 -1483
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -116310,7 +116310,7 @@ function sanitizeScopes(scopes) {
116310
116310
  }
116311
116311
 
116312
116312
  // build/version.js
116313
- var OKO_MCP_VERSION = "0.15.0";
116313
+ var OKO_MCP_VERSION = "0.15.1";
116314
116314
 
116315
116315
  // build/auth.js
116316
116316
  var state = null;
@@ -116646,1563 +116646,1583 @@ function pinnedLookup(address, family) {
116646
116646
  };
116647
116647
  }
116648
116648
 
116649
- // build/tools/eventAccess.js
116650
- var EVENT_ACCESS_FIELD_BY_MODE = {
116651
- read: "accessibleBy",
116652
- edit: "editors",
116653
- delete: "deleters",
116654
- live: "liveControllers",
116655
- session: "sessionManagers",
116656
- analytics: "analyticsViewers",
116657
- report: "reportDownloaders",
116658
- moderate: "chatModerators",
116659
- notify: "notificationSenders"
116660
- };
116661
- var EVENT_ACCESS_COMPOSITION_FIELDS = [
116662
- ...Object.values(EVENT_ACCESS_FIELD_BY_MODE),
116663
- "moderators",
116664
- "participantUids"
116665
- ];
116666
- function eventDocumentForOutput(data) {
116667
- const projected = {};
116668
- for (const [key, value] of Object.entries(data)) {
116669
- if (EVENT_ACCESS_COMPOSITION_FIELDS.includes(key))
116670
- continue;
116671
- projected[key] = value;
116672
- }
116673
- return projected;
116674
- }
116675
- function grantAllowsMode(data, mode) {
116676
- const field2 = EVENT_ACCESS_FIELD_BY_MODE[mode];
116677
- return asRecord(data.capabilities)[field2] === true;
116678
- }
116679
- function legacyEventAllowsMode(data, uid, mode) {
116680
- if (data.ownerId === uid)
116681
- return true;
116682
- const fields = mode === "read" ? ["accessibleBy", "editors"] : [EVENT_ACCESS_FIELD_BY_MODE[mode]];
116683
- return fields.some((field2) => Array.isArray(data[field2]) && data[field2].includes(uid));
116684
- }
116685
- async function readEventAccess(eventId, uid, mode) {
116686
- const [snap, grant] = await Promise.all([
116687
- getDoc(doc(firestore, "events", eventId)),
116688
- getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error3) => {
116689
- if (!isFirestorePermissionDenied(error3))
116690
- throw error3;
116691
- return null;
116692
- })
116693
- ]);
116694
- const data = snap.data() ?? {};
116695
- const holds = (wanted) => grant === null ? legacyEventAllowsMode(data, uid, wanted) : data.ownerId === uid || grant.exists() && grantAllowsMode(grant.data(), wanted);
116696
- return { data, exists: snap.exists(), visible: holds("read"), allowed: holds(mode) };
116697
- }
116698
- async function eventGrantsMode(eventId, uid, mode) {
116699
- return (await readEventAccess(eventId, uid, mode)).allowed;
116700
- }
116701
- var EVENT_NOT_FOUND_MESSAGE = "Event not found.";
116702
- async function readEvent(eventId, uid, mode = "read") {
116703
- const { data, exists, visible, allowed } = await readEventAccess(eventId, uid, mode);
116704
- if (!visible || !exists) {
116705
- throw new Error(EVENT_NOT_FOUND_MESSAGE);
116706
- }
116707
- if (!allowed) {
116708
- throw new Error(`Event does not grant ${mode} access to this organizer.`);
116709
- }
116710
- if (data.trashedAt && mode !== "read" && mode !== "delete") {
116711
- throw new Error("Event is in trash. Restore it before making changes or live actions.");
116712
- }
116713
- return data;
116714
- }
116715
-
116716
- // build/tools/imagePipeline.js
116717
- var IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
116718
- var IMAGE_ADVICE = {
116719
- promoBanner: "Banner advice: a wide landscape image at least 1600 px wide (3:1 to 5:1 works best, for example 1600\xD7400); keep text inside the middle 80% so it survives mobile scaling; JPEG/PNG/WebP/AVIF up to 15 MB, stored as WebP with 320/800/1600 px variants.",
116720
- videoPlaceholder: "Placeholder advice: it covers the 16:9 video slot, so use a 16:9 image at least 1600\xD7900 px; avoid small text; JPEG/PNG/WebP/AVIF up to 15 MB, stored as WebP with 320/800/1600 px variants.",
116721
- card: "Card advice: match the cardImageRatio chosen in oko_configure_cards (1:1 is the safest), at least 800 px on the short side; JPEG/PNG/WebP/AVIF up to 15 MB, stored as WebP with 320/800/1600 px variants."
116722
- };
116723
- var IMAGE_VARIANT_NAMES = ["thumb_320.webp", "card_800.webp", "hero_1600.webp"];
116724
- var CARD_IMAGE_RATIOS = ["auto", "1:1", "4:3", "3:2", "16:9", "2:3", "3:4"];
116725
- var CARD_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
116726
- var MAX_CARD_ITEMS = 6;
116727
- var MAX_CARD_TITLE_LENGTH = 160;
116728
- var MAX_CARD_TEXT_LENGTH = 500;
116729
- var MAX_CARD_BUTTON_LABEL_LENGTH = 80;
116730
- var MAX_CARD_BUTTON_URL_LENGTH = 1e3;
116731
- var MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
116732
- var REMOTE_IMAGE_TIMEOUT_MS = 15e3;
116733
- var REMOTE_IMAGE_TOTAL_TIMEOUT_MS = 4e4;
116734
- var REMOTE_IMAGE_MAX_REDIRECTS = 3;
116735
- function remoteImageHopBudget(deadlineAt, now, perHopMs) {
116736
- const left = deadlineAt - now;
116737
- if (left <= 0)
116738
- return { kind: "exhausted" };
116739
- if (left < perHopMs)
116740
- return { kind: "budget", ms: left, capped: true };
116741
- return { kind: "budget", ms: perHopMs, capped: false };
116742
- }
116743
- var ALLOWED_REMOTE_IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
116744
- function storagePathFromDownloadUrl(downloadUrl) {
116745
- if (!downloadUrl)
116746
- return null;
116747
- const match = downloadUrl.match(/\/o\/([^?]+)/);
116748
- if (!match)
116749
- return null;
116750
- const encodedPath = match[1];
116751
- if (!encodedPath)
116649
+ // build/videoUrl.js
116650
+ var TWITCH_PARENT = "okostream.app";
116651
+ function extractIframeSrc(input) {
116652
+ const trimmed = input.trim();
116653
+ if (!trimmed.startsWith("<iframe"))
116752
116654
  return null;
116655
+ const match = trimmed.match(/\bsrc=["']([^"']+)["']/i);
116656
+ return match?.[1] ?? null;
116657
+ }
116658
+ var YT_ID_RE = /^[\w-]{11}$/;
116659
+ function extractYouTubeId(url) {
116660
+ let parsed;
116753
116661
  try {
116754
- return decodeURIComponent(encodedPath);
116662
+ parsed = new URL(url);
116755
116663
  } catch {
116756
116664
  return null;
116757
116665
  }
116758
- }
116759
- function variantPathsFromMasterPath(masterPath) {
116760
- if (!masterPath.endsWith("/master.webp"))
116761
- return [];
116762
- return IMAGE_VARIANT_NAMES.map((name7) => masterPath.replace(/master\.webp$/, name7));
116763
- }
116764
- function resolveLocalImagePath(inputPath) {
116765
- const expanded = inputPath === "~" || inputPath.startsWith("~/") ? join2(homedir2(), inputPath.slice(2)) : inputPath;
116766
- return isAbsolute2(expanded) ? resolve2(expanded) : resolve2(process.cwd(), expanded);
116767
- }
116768
- function isBlockedHostname(hostname2) {
116769
- const host = hostname2.toLowerCase();
116770
- return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local");
116771
- }
116772
- function nonPublicResolutionMessage(hostname2, addresses) {
116773
- const list = addresses.length ? addresses.join(", ") : "no address";
116774
- const fakeIp = addresses.some((address) => /^198\.1[89]\./.test(address));
116775
- const environmentHint = fakeIp ? "Addresses in 198.18.0.0/15 mean a VPN or proxy with DNS interception (fake-IP mode) is active on this machine: every public host resolves there, so no remote URL can be verified." : "If other public hosts resolve to private addresses too, a VPN or proxy with DNS interception is active on this machine.";
116776
- return `Image URL resolves to a non-public IP address (${hostname2} \u2192 ${list}), which is not allowed. ${environmentHint} Workarounds: upload the file from disk via source.path (allowed directories come from OKO_IMAGE_ROOTS; the organizer can download the image first), or disable the VPN/proxy for this upload.`;
116777
- }
116778
- async function resolveRemoteImageAddress(url) {
116779
- if (url.protocol !== "https:") {
116780
- throw new Error("Image URL must use https://.");
116781
- }
116782
- if (url.username || url.password) {
116783
- throw new Error("Image URL must not include embedded credentials.");
116666
+ const host = parsed.hostname.replace(/^www\./, "");
116667
+ if (host === "youtu.be") {
116668
+ const id = parsed.pathname.slice(1).split("/")[0] ?? "";
116669
+ return YT_ID_RE.test(id) ? id : null;
116784
116670
  }
116785
- if (isBlockedHostname(url.hostname)) {
116786
- throw new Error(`Image URL host "${url.hostname}" is not allowed (local names are blocked). Use a public HTTPS image URL, or upload the file from disk via source.path (requires OKO_IMAGE_ROOTS).`);
116671
+ if (host !== "youtube.com" && host !== "m.youtube.com")
116672
+ return null;
116673
+ const path = parsed.pathname;
116674
+ if (path.startsWith("/embed/")) {
116675
+ const id = path.slice(7).split("/")[0] ?? "";
116676
+ return YT_ID_RE.test(id) ? id : null;
116787
116677
  }
116788
- const literalAddress = normalizeIpLiteralHostname(url.hostname);
116789
- const literalFamily = ipLiteralFamily(literalAddress);
116790
- if (literalFamily !== 0) {
116791
- if (!isPublicUnicastAddress(literalAddress)) {
116792
- throw new Error(`Image URL points to a private/local IP address (${literalAddress}), which is not allowed. Use a public HTTPS image URL, or upload the file from disk via source.path (requires OKO_IMAGE_ROOTS).`);
116793
- }
116794
- return { addresses: [{ address: literalAddress, family: literalFamily }], transparentProxy: false };
116678
+ if (path.startsWith("/shorts/")) {
116679
+ const id = path.slice(8).split("/")[0] ?? "";
116680
+ return YT_ID_RE.test(id) ? id : null;
116795
116681
  }
116796
- const addresses = await lookup(url.hostname, { all: true });
116797
- const transparentProxy = addresses.length > 0 && addresses.every((item) => isTransparentProxyAddress(item.address));
116798
- if (!transparentProxy && (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address)))) {
116799
- throw new Error(nonPublicResolutionMessage(url.hostname, addresses.map((item) => item.address)));
116682
+ if (path.startsWith("/live/")) {
116683
+ const id = path.slice(6).split("/")[0] ?? "";
116684
+ return YT_ID_RE.test(id) ? id : null;
116800
116685
  }
116801
- const candidates = orderAddressCandidates(addresses);
116802
- if (candidates.length === 0) {
116803
- throw new Error("Image URL host could not be resolved safely.");
116686
+ if (path === "/watch") {
116687
+ const id = parsed.searchParams.get("v") ?? "";
116688
+ return YT_ID_RE.test(id) ? id : null;
116804
116689
  }
116805
- return { addresses: candidates, transparentProxy };
116690
+ return null;
116806
116691
  }
116807
- async function responseBufferWithLimit(response, maxBytes) {
116808
- const body = response.body;
116809
- if (!body) {
116810
- const bytes = Buffer.from(await response.arrayBuffer());
116811
- if (bytes.byteLength > maxBytes)
116812
- throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
116813
- return bytes;
116814
- }
116815
- const reader = body.getReader();
116816
- const chunks = [];
116817
- let total = 0;
116692
+ function hostOf(url) {
116818
116693
  try {
116819
- while (true) {
116820
- const { done, value } = await reader.read();
116821
- if (done)
116822
- break;
116823
- const chunk = Buffer.from(value);
116824
- total += chunk.byteLength;
116825
- if (total > maxBytes) {
116826
- throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
116827
- }
116828
- chunks.push(chunk);
116829
- }
116830
- } finally {
116831
- reader.releaseLock();
116694
+ return new URL(url).hostname.replace(/^www\./, "").replace(/^m\./, "");
116695
+ } catch {
116696
+ return null;
116832
116697
  }
116833
- return Buffer.concat(chunks, total);
116834
116698
  }
116835
- function shareLinkHint(url) {
116836
- let host = "";
116699
+ function extractVkVideoIds(url) {
116700
+ let parsed;
116837
116701
  try {
116838
- host = new URL(url).hostname.toLowerCase();
116702
+ parsed = new URL(url);
116839
116703
  } catch {
116840
- return "";
116841
- }
116842
- if (host === "drive.google.com" || host === "docs.google.com") {
116843
- return " This is a Google Drive page, not the file: share it as 'anyone with the link' and use https://drive.google.com/uc?export=download&id=<fileId>, or download the file and pass source.path.";
116704
+ return null;
116844
116705
  }
116845
- if (host.endsWith("disk.yandex.ru") || host.endsWith("disk.yandex.com") || host === "yadi.sk" || host.endsWith("disk.360.yandex.ru")) {
116846
- return " This is a Yandex Disk page, not the file: use the direct download link from the page (\u0421\u043A\u0430\u0447\u0430\u0442\u044C \u2192 copy link), or download the file and pass source.path.";
116706
+ if (parsed.pathname.includes("video_ext.php")) {
116707
+ const oid = parsed.searchParams.get("oid");
116708
+ const id = parsed.searchParams.get("id");
116709
+ return oid && id ? { oid, id } : null;
116847
116710
  }
116848
- if (host === "dropbox.com" || host.endsWith(".dropbox.com")) {
116849
- return " This is a Dropbox page: replace ?dl=0 with ?dl=1 in the link, or download the file and pass source.path.";
116711
+ const match = parsed.pathname.match(/\/video(-?\d+)_(\d+)/);
116712
+ return match ? { oid: match[1] ?? "", id: match[2] ?? "" } : null;
116713
+ }
116714
+ function extractPathId(url, host, patterns) {
116715
+ let parsed;
116716
+ try {
116717
+ parsed = new URL(url);
116718
+ } catch {
116719
+ return null;
116850
116720
  }
116851
- if (host.endsWith("onedrive.live.com") || host === "1drv.ms" || host.endsWith("sharepoint.com")) {
116852
- return " This is a OneDrive page: use its direct download link, or download the file and pass source.path.";
116721
+ if (parsed.hostname.replace(/^www\./, "") !== host && !parsed.hostname.endsWith(`.${host}`))
116722
+ return null;
116723
+ for (const pattern of patterns) {
116724
+ const match = parsed.pathname.match(pattern);
116725
+ if (match?.[1])
116726
+ return match[1];
116853
116727
  }
116854
- return " Use a direct link to the image file (right-click the image \u2192 copy image address), or download the file and pass source.path.";
116728
+ return null;
116855
116729
  }
116856
- async function readRemoteImage(rawUrl, redirects = 0, notes = [], deadlineAt = Date.now() + REMOTE_IMAGE_TOTAL_TIMEOUT_MS) {
116857
- if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
116858
- throw new Error("Image URL has too many redirects.");
116730
+ function extractTwitchEmbed(url) {
116731
+ let parsed;
116732
+ try {
116733
+ parsed = new URL(url);
116734
+ } catch {
116735
+ return null;
116859
116736
  }
116860
- const url = new URL(rawUrl);
116861
- const pinned = await resolveRemoteImageAddress(url);
116862
- if (pinned.transparentProxy && redirects === 0) {
116863
- notes.push(`Image fetched through a VPN/proxy with DNS interception: ${url.hostname} resolved to ${pinned.addresses[0]?.address}, so OKO could not verify the destination address itself; the connection went through the proxy. This is expected on such machines and fine for public image links.`);
116737
+ const host = parsed.hostname.replace(/^www\./, "");
116738
+ if (host === "player.twitch.tv") {
116739
+ const channel = parsed.searchParams.get("channel");
116740
+ const video = parsed.searchParams.get("video");
116741
+ if (channel)
116742
+ return `https://player.twitch.tv/?channel=${channel}&parent=${TWITCH_PARENT}`;
116743
+ if (video)
116744
+ return `https://player.twitch.tv/?video=${video}&parent=${TWITCH_PARENT}`;
116864
116745
  }
116865
- let lastConnectError = null;
116866
- for (const candidate of pinned.addresses) {
116867
- const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt);
116868
- if ("buffer" in attempt)
116869
- return attempt.buffer;
116870
- if ("redirectTo" in attempt)
116871
- return readRemoteImage(attempt.redirectTo, redirects + 1, notes, deadlineAt);
116872
- lastConnectError = attempt.connectError;
116746
+ if (host === "twitch.tv") {
116747
+ const vodMatch = parsed.pathname.match(/\/videos\/(\d+)/);
116748
+ if (vodMatch?.[1])
116749
+ return `https://player.twitch.tv/?video=v${vodMatch[1]}&parent=${TWITCH_PARENT}`;
116750
+ const channelMatch = parsed.pathname.match(/^\/([a-zA-Z0-9_]+)$/);
116751
+ if (channelMatch?.[1])
116752
+ return `https://player.twitch.tv/?channel=${channelMatch[1]}&parent=${TWITCH_PARENT}`;
116873
116753
  }
116874
- throw new Error(`Image URL host ${url.hostname} could not be reached at any of its addresses (${pinned.addresses.map((item) => item.address).join(", ")}): ${lastConnectError?.message ?? "connection failed"}.`);
116875
- }
116876
- function totalBudgetMessage() {
116877
- return `Image URL did not finish loading within ${REMOTE_IMAGE_TOTAL_TIMEOUT_MS / 1e3} seconds, including redirects.`;
116754
+ return null;
116878
116755
  }
116879
- async function readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt) {
116880
- const budget = remoteImageHopBudget(deadlineAt, Date.now(), REMOTE_IMAGE_TIMEOUT_MS);
116881
- if (budget.kind === "exhausted") {
116882
- throw new Error(totalBudgetMessage());
116756
+ function detectAndNormalize(url) {
116757
+ const trimmed = url.trim();
116758
+ if (!trimmed)
116759
+ return { provider: "unknown", normalizedUrl: null };
116760
+ const host = hostOf(trimmed);
116761
+ if (host === "youtube.com" || host === "youtu.be") {
116762
+ const id = extractYouTubeId(trimmed);
116763
+ return { provider: "youtube", normalizedUrl: id ? `https://www.youtube.com/embed/${id}` : null };
116883
116764
  }
116884
- const dispatcher = new import_undici.Agent({
116885
- connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
116886
- });
116887
- const controller = new AbortController();
116888
- const timeout = setTimeout(() => controller.abort(), budget.ms);
116889
- try {
116890
- let response;
116891
- try {
116892
- response = await (0, import_undici.fetch)(url, {
116893
- redirect: "manual",
116894
- signal: controller.signal,
116895
- headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
116896
- dispatcher
116897
- });
116898
- } catch (err) {
116899
- if (err instanceof Error && err.name === "AbortError") {
116900
- throw new Error(budget.capped ? totalBudgetMessage() : `Image URL did not respond within ${REMOTE_IMAGE_TIMEOUT_MS / 1e3} seconds.`);
116901
- }
116902
- return { connectError: err instanceof Error ? err : new Error(String(err)) };
116903
- }
116904
- if (response.status >= 300 && response.status < 400) {
116905
- const location2 = response.headers.get("location");
116906
- if (!location2)
116907
- throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
116908
- return { redirectTo: new URL(location2, url).toString() };
116909
- }
116910
- if (!response.ok) {
116911
- throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
116912
- }
116913
- const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
116914
- if (!contentType || !contentType.startsWith("image/")) {
116915
- throw new Error(`Image URL must return Content-Type image/* (got ${contentType || "none"}).${shareLinkHint(rawUrl)}`);
116916
- }
116917
- if (!ALLOWED_REMOTE_IMAGE_TYPES.has(contentType)) {
116918
- throw new Error(`Image URL Content-Type ${contentType} is not supported. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
116919
- }
116920
- const contentLength = Number(response.headers.get("content-length") ?? 0);
116921
- if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
116922
- throw new Error("Image URL is larger than 15MB.");
116923
- }
116924
- return { buffer: await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES) };
116925
- } catch (err) {
116926
- if (err instanceof Error && err.name === "AbortError") {
116927
- throw new Error("Image URL fetch timed out.");
116928
- }
116929
- throw err;
116930
- } finally {
116931
- clearTimeout(timeout);
116932
- await dispatcher.destroy();
116765
+ if (host === "vk.com" || host === "vkvideo.ru") {
116766
+ const ids = extractVkVideoIds(trimmed);
116767
+ return { provider: "vk", normalizedUrl: ids ? `https://vk.com/video_ext.php?oid=${ids.oid}&id=${ids.id}&hd=2` : null };
116933
116768
  }
116769
+ const rutubeId = extractPathId(trimmed, "rutube.ru", [/\/play\/embed\/([a-f0-9]+)/i, /\/video\/([a-f0-9]+)/i]);
116770
+ if (rutubeId)
116771
+ return { provider: "rutube", normalizedUrl: `https://rutube.ru/play/embed/${rutubeId}` };
116772
+ if (trimmed.includes("rutube.ru"))
116773
+ return { provider: "rutube", normalizedUrl: null };
116774
+ const vimeoId = extractPathId(trimmed, "vimeo.com", [/\/video\/(\d+)/, /^\/(\d+)/]);
116775
+ if (vimeoId)
116776
+ return { provider: "vimeo", normalizedUrl: `https://player.vimeo.com/video/${vimeoId}` };
116777
+ if (trimmed.includes("vimeo.com"))
116778
+ return { provider: "vimeo", normalizedUrl: null };
116779
+ const kinescopeId = extractPathId(trimmed, "kinescope.io", [/\/embed\/([a-zA-Z0-9]+)/, /^\/([a-zA-Z0-9]+)$/]);
116780
+ if (kinescopeId)
116781
+ return { provider: "kinescope", normalizedUrl: `https://kinescope.io/embed/${kinescopeId}` };
116782
+ if (trimmed.includes("kinescope.io"))
116783
+ return { provider: "kinescope", normalizedUrl: null };
116784
+ const dzenId = extractPathId(trimmed, "dzen.ru", [/\/embed\/([a-zA-Z0-9_-]+)/, /\/video\/watch\/([a-zA-Z0-9_-]+)/]);
116785
+ if (dzenId)
116786
+ return { provider: "dzen", normalizedUrl: `https://dzen.ru/embed/${dzenId}` };
116787
+ if (trimmed.includes("dzen.ru"))
116788
+ return { provider: "dzen", normalizedUrl: null };
116789
+ const twitchEmbed = extractTwitchEmbed(trimmed);
116790
+ if (twitchEmbed)
116791
+ return { provider: "twitch", normalizedUrl: twitchEmbed };
116792
+ if (trimmed.includes("twitch.tv"))
116793
+ return { provider: "twitch", normalizedUrl: null };
116794
+ const dailymotionId = extractPathId(trimmed, "dailymotion.com", [/\/embed\/video\/([a-zA-Z0-9]+)/, /\/video\/([a-zA-Z0-9]+)/]) ?? extractPathId(trimmed, "dai.ly", [/^\/([a-zA-Z0-9]+)/]);
116795
+ if (dailymotionId)
116796
+ return { provider: "dailymotion", normalizedUrl: `https://www.dailymotion.com/embed/video/${dailymotionId}` };
116797
+ if (trimmed.includes("dailymotion.com") || trimmed.includes("dai.ly"))
116798
+ return { provider: "dailymotion", normalizedUrl: null };
116799
+ const wistiaId = extractPathId(trimmed, "wistia.com", [/\/medias\/([a-zA-Z0-9]+)/]) ?? extractPathId(trimmed, "fast.wistia.net", [/\/embed\/iframe\/([a-zA-Z0-9]+)/]);
116800
+ if (wistiaId)
116801
+ return { provider: "wistia", normalizedUrl: `https://fast.wistia.net/embed/iframe/${wistiaId}` };
116802
+ if (trimmed.includes("wistia.com") || trimmed.includes("wistia.net"))
116803
+ return { provider: "wistia", normalizedUrl: null };
116804
+ return { provider: "unknown", normalizedUrl: null };
116934
116805
  }
116935
- async function readLocalImage(inputPath) {
116936
- if (env.imageRoots.length === 0) {
116937
- throw new Error("Local image reads are disabled. Set OKO_IMAGE_ROOTS to an explicit trusted directory and restart the MCP client.");
116806
+
116807
+ // build/tools/formatting.js
116808
+ var RETIRED_SESSION_FIELDS = ["nmoEvaluation"];
116809
+ function cursorMeta(count, max, hasMore, nextCursor) {
116810
+ return {
116811
+ count,
116812
+ limit: max,
116813
+ hasMore,
116814
+ nextCursor,
116815
+ has_more: hasMore,
116816
+ next_cursor: nextCursor
116817
+ };
116818
+ }
116819
+ function pickPage(items, max) {
116820
+ return {
116821
+ page: items.slice(0, max),
116822
+ hasMore: items.length > max
116823
+ };
116824
+ }
116825
+ function assertEmbeddableUrl(value) {
116826
+ let url;
116827
+ try {
116828
+ url = new URL(value);
116829
+ } catch {
116830
+ throw new Error(`Invalid videoUrl "${value}". Provide an https:// link or a full <iframe> embed code.`);
116938
116831
  }
116939
- const filePath = await realpath(resolveLocalImagePath(inputPath));
116940
- const roots = await Promise.all(env.imageRoots.map(async (root) => {
116941
- try {
116942
- return await realpath(root);
116943
- } catch {
116944
- return resolve2(root);
116945
- }
116946
- }));
116947
- const allowed = roots.some((root) => {
116948
- const child2 = relative(root, filePath);
116949
- return child2 === "" || !child2.startsWith(`..${sep}`) && child2 !== ".." && !isAbsolute2(child2);
116950
- });
116951
- if (!allowed) {
116952
- throw new Error("Image path is outside OKO_IMAGE_ROOTS. Add a trusted root to the MCP client environment.");
116832
+ if (url.protocol !== "https:") {
116833
+ throw new Error(`Unsupported videoUrl protocol "${url.protocol}". The player must be served over https:// \u2014 http:// links are refused by the platform.`);
116953
116834
  }
116954
- const info = await stat(filePath);
116955
- if (!info.isFile())
116956
- throw new Error("Image path must point to a regular file.");
116957
- if (info.size > MAX_SOURCE_IMAGE_BYTES)
116958
- throw new Error("Image file is larger than 15MB.");
116959
- return readFile(filePath);
116835
+ return value;
116960
116836
  }
116961
- function serverFetchRefusal(error3) {
116962
- if (!error3 || typeof error3 !== "object")
116963
- return null;
116964
- const details = error3.details;
116965
- if (details && typeof details === "object" && details.code === "REMOTE_FETCH_FAILED") {
116966
- const record3 = details;
116967
- const fallbackMessage = String(error3.message ?? "the server could not fetch the URL");
116968
- return {
116969
- reason: typeof record3.reason === "string" ? record3.reason : "unknown",
116970
- message: typeof record3.message === "string" ? record3.message : fallbackMessage
116971
- };
116972
- }
116973
- const code = error3.code;
116974
- const message = String(error3.message ?? "");
116975
- if (code === "functions/invalid-argument" && message.includes("Image payload is empty or too large")) {
116976
- return { reason: "server_without_url_support", message: "The OKO server release in use does not fetch URLs yet." };
116837
+ function assertNotNullLiteral(value, field2) {
116838
+ if (typeof value === "string" && value.trim().toLowerCase() === "null") {
116839
+ throw new Error(`${field2}: received the literal text "null". To clear the field pass an empty string "" (or JSON null); to keep it unchanged, omit the parameter.`);
116977
116840
  }
116978
- if (code === "functions/deadline-exceeded") {
116979
- return { reason: "server_timeout", message: "The OKO server did not finish fetching the URL within its time limit." };
116841
+ }
116842
+ function normalizeVideo(videoUrl) {
116843
+ const raw = videoUrl?.trim();
116844
+ if (!raw)
116845
+ return { originalUrl: null, embedUrl: null };
116846
+ const effective = extractIframeSrc(raw) ?? raw;
116847
+ const detected = detectAndNormalize(effective);
116848
+ if (detected.provider !== "unknown" && !detected.normalizedUrl) {
116849
+ throw new Error(`Could not extract a valid ${detected.provider} video id from videoUrl.`);
116980
116850
  }
116981
- if (code === "functions/internal") {
116982
- return { reason: "server_error", message: message && message !== "internal" ? message : "The OKO server failed while fetching the URL." };
116851
+ return { originalUrl: raw, embedUrl: assertEmbeddableUrl(detected.normalizedUrl ?? effective) };
116852
+ }
116853
+ function csvRows(headers, rows) {
116854
+ return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
116855
+ }
116856
+ function toMillis(value) {
116857
+ if (typeof value === "number" && Number.isFinite(value))
116858
+ return value;
116859
+ if (value && typeof value === "object" && typeof value.toMillis === "function") {
116860
+ return value.toMillis();
116983
116861
  }
116984
116862
  return null;
116985
116863
  }
116986
- var SERVER_FETCH_NOTE = "Image fetched by the OKO server directly from the URL; this machine's network was not involved.";
116987
- function localFallbackNote(serverFailure) {
116988
- return `The OKO server could not fetch the URL (${serverFailure.reason}: ${serverFailure.message}), so the image was fetched from this machine instead.`;
116864
+ function parseTimestampCursor(cursor) {
116865
+ const separator = cursor.indexOf("|");
116866
+ const head = separator === -1 ? cursor : cursor.slice(0, separator);
116867
+ const docId = separator === -1 ? "" : cursor.slice(separator + 1);
116868
+ const millis2 = Number(head);
116869
+ const at = Number.isFinite(millis2) && millis2 > 0 ? millis2 : Date.parse(head);
116870
+ if (!Number.isFinite(at)) {
116871
+ throw new Error("Invalid cursor. Use the nextCursor value returned by the previous page.");
116872
+ }
116873
+ const boundary = Timestamp.fromMillis(at);
116874
+ return docId ? [boundary, docId] : [boundary];
116989
116875
  }
116990
- function remoteImageUnavailableMessage(url, serverFailure, localError) {
116991
- const localMessage = localError instanceof Error ? localError.message : String(localError);
116992
- return `Image URL could not be fetched by the OKO server or from this machine. Server (${serverFailure.reason}): ${serverFailure.message} This machine: ${localMessage} Ask the organizer to download the image and pass its path via source.path (allowed directories come from OKO_IMAGE_ROOTS), or to share a public HTTPS link that returns the image file directly. URL: ${url}`;
116876
+ function timestampCursorFrom(value, docId) {
116877
+ const millis2 = toMillis(value);
116878
+ if (millis2 == null)
116879
+ return null;
116880
+ return docId ? `${millis2}|${docId}` : String(millis2);
116993
116881
  }
116994
- async function uploadImageFromSource(eventId, kind, cardId, source) {
116995
- if (source.path) {
116996
- const buffer2 = await readLocalImage(source.path);
116997
- const uploaded2 = await uploadMasterImage(eventId, kind, cardId, { imageBase64: buffer2.toString("base64") });
116998
- return { uploaded: uploaded2, sourceBytes: buffer2.byteLength, sourceVia: "client", notes: [] };
116999
- }
117000
- if (!source.url)
117001
- throw new Error("Provide image source path or url.");
117002
- let serverFailure;
117003
- try {
117004
- const uploaded2 = await uploadMasterImage(eventId, kind, cardId, { sourceUrl: source.url });
117005
- return { uploaded: uploaded2, sourceBytes: uploaded2.sourceBytes ?? 0, sourceVia: "server", notes: [SERVER_FETCH_NOTE] };
117006
- } catch (error3) {
117007
- const refusal3 = serverFetchRefusal(error3);
117008
- if (!refusal3)
117009
- throw error3;
117010
- serverFailure = refusal3;
117011
- }
117012
- let buffer;
117013
- const fetchNotes = [];
117014
- try {
117015
- buffer = await readRemoteImage(source.url, 0, fetchNotes);
117016
- } catch (localError) {
117017
- throw new Error(remoteImageUnavailableMessage(source.url, serverFailure, localError));
117018
- }
117019
- const uploaded = await uploadMasterImage(eventId, kind, cardId, { imageBase64: buffer.toString("base64") });
117020
- return {
117021
- uploaded,
117022
- sourceBytes: buffer.byteLength,
117023
- sourceVia: "client",
117024
- notes: [localFallbackNote(serverFailure), ...fetchNotes]
117025
- };
117026
- }
117027
- function readImageRef(eventData, kind, cardId) {
117028
- const blocks = eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {};
117029
- if (kind === "promoBanner") {
117030
- const banner = blocks.promoBanner && typeof blocks.promoBanner === "object" ? blocks.promoBanner : {};
117031
- return {
117032
- imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
117033
- imageUrl: safeString(banner.imageUrl) || null,
117034
- imageMasterUrl: safeString(banner.imageMasterUrl) || null,
117035
- imageVariants: readImageVariants(banner.imageVariants)
117036
- };
117037
- }
117038
- if (kind === "videoPlaceholder") {
117039
- const video = blocks.video && typeof blocks.video === "object" ? blocks.video : {};
117040
- const placeholder = video.placeholder && typeof video.placeholder === "object" ? video.placeholder : {};
117041
- return {
117042
- imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
117043
- imageUrl: safeString(placeholder.imageUrl) || null,
117044
- imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
117045
- imageVariants: readImageVariants(placeholder.imageVariants)
117046
- };
117047
- }
117048
- if (!cardId)
117049
- throw new Error("cardId is required when kind is card.");
117050
- const cards = blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {};
117051
- const items = Array.isArray(cards.items) ? cards.items : [];
117052
- const item = items.find((raw) => Boolean(raw) && typeof raw === "object" && raw.id === cardId);
117053
- if (!item)
117054
- throw new Error(`Card ${cardId} not found in blocks.cards.items. Create/configure the card before uploading its image.`);
117055
- return {
117056
- imageId: cardId,
117057
- imageUrl: safeString(item.imageUrl) || null,
117058
- imageMasterUrl: safeString(item.imageMasterUrl) || null,
117059
- imageVariants: readImageVariants(item.imageVariants)
117060
- };
117061
- }
117062
- function cardItemsWithImage(eventData, cardId, patch) {
117063
- const blocks = eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {};
117064
- const cards = blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {};
117065
- const items = Array.isArray(cards.items) ? cards.items : [];
117066
- let found = false;
117067
- const next = items.map((raw) => {
117068
- const item = raw && typeof raw === "object" ? raw : {};
117069
- if (item.id !== cardId)
117070
- return item;
117071
- found = true;
117072
- return { ...item, ...patch };
117073
- });
117074
- if (!found)
117075
- throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
117076
- return next;
117077
- }
117078
- function readImageVariants(value) {
117079
- if (!value || typeof value !== "object" || Array.isArray(value))
117080
- return null;
117081
- const data = value;
117082
- if (typeof data.thumbUrl !== "string" || typeof data.cardUrl !== "string" || typeof data.heroUrl !== "string") {
116882
+ var ISO_WITH_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/;
116883
+ function parseIsoBound(value, label) {
116884
+ if (value === void 0)
117083
116885
  return null;
116886
+ const parsed = Date.parse(value);
116887
+ if (!Number.isFinite(parsed) || !ISO_WITH_OFFSET.test(value.trim())) {
116888
+ throw new Error(`Invalid ${label}: expected an ISO timestamp with an explicit offset, such as 2026-08-28T00:00:00+03:00 or 2026-08-27T21:00:00Z.`);
117084
116889
  }
117085
- return {
117086
- thumbUrl: data.thumbUrl,
117087
- cardUrl: data.cardUrl,
117088
- heroUrl: data.heroUrl
117089
- };
117090
- }
117091
- function readCardsConfig(eventData) {
117092
- const blocks = eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {};
117093
- const raw = blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {};
117094
- const rawItems = Array.isArray(raw.items) ? raw.items : [];
117095
- const repairedIdPositions = [];
117096
- const items = rawItems.map((item, position) => ({ item, position })).filter((entry) => Boolean(entry.item) && typeof entry.item === "object" && !Array.isArray(entry.item)).map(({ item, position }) => {
117097
- const hasUsableId = typeof item.id === "string" && CARD_ID_PATTERN.test(item.id);
117098
- if (!hasUsableId)
117099
- repairedIdPositions.push(position);
117100
- return {
117101
- // 🔴 The UUID is generated only when it is needed: previously the
117102
- // crypto RNG was invoked for every card on every read, and the value
117103
- // was discarded (review 02.09, R3c-14)
117104
- id: hasUsableId ? item.id : randomUUID2(),
117105
- title: safeString(item.title).trim() || null,
117106
- text: safeString(item.text).trim() || null,
117107
- buttonLabel: safeString(item.buttonLabel).trim() || null,
117108
- buttonUrl: safeString(item.buttonUrl).trim() || null,
117109
- imageUrl: safeString(item.imageUrl).trim() || null,
117110
- imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
117111
- imageVariants: readImageVariants(item.imageVariants),
117112
- imageWidth: typeof item.imageWidth === "number" ? item.imageWidth : null,
117113
- imageHeight: typeof item.imageHeight === "number" ? item.imageHeight : null,
117114
- formId: safeString(item.formId).trim() || null
117115
- };
117116
- });
117117
- const ratio = safeString(raw.cardImageRatio);
117118
- return {
117119
- enabled: raw.enabled === true,
117120
- visibility: raw.visibility === "hidden" ? "hidden" : "always",
117121
- cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
117122
- items,
117123
- repairedIdPositions
117124
- };
117125
- }
117126
- function normalizeNullableText(value) {
117127
- if (value == null)
117128
- return null;
117129
- return value.trim() || null;
116890
+ return Timestamp.fromMillis(parsed);
117130
116891
  }
117131
- var CARD_LINK_SCHEME = /^(https?:\/\/|mailto:|tel:|\/(?!\/))/i;
117132
- var CARD_BARE_DOMAIN = /^[\p{L}\d-]+(\.[\p{L}\d-]+)+(:\d+)?(\/\S*)?$/u;
117133
- function normalizeCardButtonUrl(value) {
117134
- const trimmed = normalizeNullableText(value);
116892
+ function absoluteHttpUrl(value, label) {
116893
+ const trimmed = (value ?? "").trim();
117135
116894
  if (!trimmed)
117136
116895
  return null;
117137
- if (CARD_LINK_SCHEME.test(trimmed))
117138
- return trimmed;
117139
- if (CARD_BARE_DOMAIN.test(trimmed))
117140
- return `https://${trimmed}`;
117141
116896
  let url;
117142
116897
  try {
117143
116898
  url = new URL(trimmed);
117144
116899
  } catch {
117145
- throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
116900
+ throw new Error(`Invalid ${label} "${trimmed}": Telegram needs an absolute http(s) URL.`);
117146
116901
  }
117147
- throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
117148
- }
117149
- function eventFormIds(eventData) {
117150
- const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
117151
- return new Set(rawForms.map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "").filter(Boolean));
117152
- }
117153
- function cardHasImage(item) {
117154
- return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
116902
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
116903
+ throw new Error(`Unsupported ${label} protocol "${url.protocol}": Telegram needs an absolute http(s) URL.`);
116904
+ }
116905
+ return trimmed;
117155
116906
  }
117156
- var cardIdSchema = external_exports.string().regex(CARD_ID_PATTERN, "Use 1-80 characters: letters, numbers, underscore, or hyphen.").describe("Stable card id. Omit to generate one; keep it to preserve images and analytics continuity.");
117157
- var cardItemInputSchema = external_exports.object({
117158
- id: cardIdSchema.optional(),
117159
- title: external_exports.string().max(MAX_CARD_TITLE_LENGTH).nullable().optional(),
117160
- text: external_exports.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
117161
- buttonLabel: external_exports.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
117162
- buttonUrl: external_exports.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
117163
- formId: cardIdSchema.nullable().optional()
117164
- }).strict();
117165
- var imageSourceSchema = external_exports.object({
117166
- path: external_exports.string().min(1).optional().describe("Local image path on the MCP client machine (inside OKO_IMAGE_ROOTS)."),
117167
- url: external_exports.string().url().optional().describe("Public HTTPS image URL. The OKO server fetches it first (works behind VPNs and proxies); if the server cannot reach it, the MCP client fetches it from this machine; if both fail, ask the organizer for a local file via path.")
117168
- }).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
117169
- message: "Provide exactly one image source: path or url."
117170
- });
117171
- function buildCardsItems({ inputs, existing, formIds }) {
117172
- const existingById = new Map(existing.map((item) => [item.id, item]));
117173
- const seen = /* @__PURE__ */ new Set();
117174
- const generatedCardIds = [];
117175
- const preservedImageCardIds = [];
117176
- const items = inputs.map((input) => {
117177
- const id = input.id ?? randomUUID2();
117178
- if (seen.has(id))
117179
- throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
117180
- seen.add(id);
117181
- if (!CARD_ID_PATTERN.test(id))
117182
- throw new Error(`Invalid card id "${id}". Use 1-80 characters: letters, numbers, underscore, or hyphen.`);
117183
- if (!input.id)
117184
- generatedCardIds.push(id);
117185
- const formId = normalizeNullableText(input.formId);
117186
- if (formId && !formIds.has(formId)) {
117187
- throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
116907
+ function compactAnalyticsSession(raw, options2) {
116908
+ const session = serialize(raw);
116909
+ const timeline = Array.isArray(session.timeline) ? session.timeline : null;
116910
+ if (timeline) {
116911
+ session.timelineBucketCount = timeline.length;
116912
+ if (options2.includeTimeline) {
116913
+ const page = timeline.slice(-options2.timelineLimit);
116914
+ session.timeline = page;
116915
+ session.timelineTruncated = timeline.length > page.length;
116916
+ session.timelineReturned = page.length;
116917
+ } else {
116918
+ delete session.timeline;
116919
+ session.timelineOmitted = true;
117188
116920
  }
117189
- const previous = existingById.get(id);
117190
- if (previous && cardHasImage(previous))
117191
- preservedImageCardIds.push(id);
117192
- return {
117193
- id,
117194
- title: normalizeNullableText(input.title),
117195
- text: normalizeNullableText(input.text),
117196
- buttonLabel: normalizeNullableText(input.buttonLabel),
117197
- buttonUrl: normalizeCardButtonUrl(input.buttonUrl),
117198
- imageUrl: previous?.imageUrl ?? null,
117199
- imageMasterUrl: previous?.imageMasterUrl ?? null,
117200
- imageVariants: previous?.imageVariants ?? null,
117201
- imageWidth: previous?.imageWidth ?? null,
117202
- imageHeight: previous?.imageHeight ?? null,
117203
- formId
117204
- };
117205
- });
117206
- return { items, generatedCardIds, preservedImageCardIds };
117207
- }
117208
- async function deleteStoragePaths(eventId, paths) {
117209
- const uniquePaths = [...new Set(paths.filter(Boolean))];
117210
- if (uniquePaths.length === 0)
117211
- return [];
117212
- const removeImages = httpsCallable(functions, "deleteMcpImages");
117213
- try {
117214
- const result = await removeImages({ eventId, paths: uniquePaths });
117215
- return Array.isArray(result.data.warnings) ? result.data.warnings : [];
117216
- } catch (error3) {
117217
- return [`storage cleanup failed: ${error3 instanceof Error ? error3.message : String(error3)}`];
117218
116921
  }
117219
- }
117220
- async function deleteStorageUrls(eventId, masterUrl, fallbackUrl) {
117221
- const paths = [];
117222
- const masterPath = storagePathFromDownloadUrl(masterUrl);
117223
- if (masterPath) {
117224
- paths.push(masterPath, ...variantPathsFromMasterPath(masterPath));
117225
- } else {
117226
- const fallbackPath = storagePathFromDownloadUrl(fallbackUrl);
117227
- if (fallbackPath)
117228
- paths.push(fallbackPath);
116922
+ if (!options2.includeSnapshots) {
116923
+ if (session.baselineSnapshot !== void 0) {
116924
+ delete session.baselineSnapshot;
116925
+ session.baselineSnapshotOmitted = true;
116926
+ }
116927
+ if (session.finalSnapshot !== void 0) {
116928
+ delete session.finalSnapshot;
116929
+ session.finalSnapshotOmitted = true;
116930
+ }
117229
116931
  }
117230
- const warnings = await deleteStoragePaths(eventId, paths);
117231
- return { attempted: [...new Set(paths)].length, warnings };
116932
+ for (const field2 of RETIRED_SESSION_FIELDS)
116933
+ delete session[field2];
116934
+ const warnings = sessionQualityWarnings(session.dataQuality);
116935
+ if (warnings.length > 0)
116936
+ session.dataQualityWarnings = warnings;
116937
+ return session;
117232
116938
  }
117233
- async function uploadMasterImage(eventId, kind, cardId, source) {
117234
- const uploadImage = httpsCallable(functions, "uploadMcpImage");
117235
- const result = await uploadImage({
117236
- eventId,
117237
- kind,
117238
- ...cardId ? { cardId } : {},
117239
- ...source.sourceUrl ? { sourceUrl: source.sourceUrl } : { imageBase64: source.imageBase64 }
117240
- });
117241
- return result.data;
116939
+ function numberMetric(value) {
116940
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
117242
116941
  }
117243
- function imageReplacedSinceRead(fresh, expected) {
117244
- return fresh.imageMasterUrl !== expected.imageMasterUrl || fresh.imageUrl !== expected.imageUrl;
116942
+ function formatSeconds(value) {
116943
+ const seconds = numberMetric(value);
116944
+ if (seconds == null)
116945
+ return "n/a";
116946
+ const total = Math.max(0, Math.round(seconds));
116947
+ const h = Math.floor(total / 3600);
116948
+ const m = Math.floor(total % 3600 / 60);
116949
+ const s = total % 60;
116950
+ if (h > 0)
116951
+ return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
116952
+ return `${m}m ${String(s).padStart(2, "0")}s`;
117245
116953
  }
117246
- async function clearEventImageInTransaction(eventId, kind, cardId, expected) {
117247
- const eventRef = doc(firestore, "events", eventId);
117248
- await runTransaction2(firestore, async (transaction) => {
117249
- const snap = await transaction.get(eventRef);
117250
- if (!snap.exists())
117251
- throw new Error("Event not found.");
117252
- const data = snap.data();
117253
- const fresh = readImageRef(data, kind, cardId);
117254
- if (imageReplacedSinceRead(fresh, expected)) {
117255
- throw new Error("The image changed between reading it and deleting it \u2014 someone replaced it in the meantime. Nothing was deleted and no file was removed. Read the event again and repeat the call if the new image should go too.");
117256
- }
117257
- if (kind === "card") {
117258
- transaction.update(eventRef, {
117259
- "blocks.cards.items": cardItemsWithImage(data, cardId, {
117260
- imageUrl: null,
117261
- imageMasterUrl: null,
117262
- imageVariants: null,
117263
- imageWidth: null,
117264
- imageHeight: null
117265
- })
117266
- });
117267
- return;
117268
- }
117269
- const prefix = kind === "promoBanner" ? "blocks.promoBanner" : "blocks.video.placeholder";
117270
- transaction.update(eventRef, {
117271
- [`${prefix}.imageMasterUrl`]: deleteField(),
117272
- [`${prefix}.imageUrl`]: deleteField(),
117273
- [`${prefix}.imageVariants`]: deleteField(),
117274
- [`${prefix}.imageWidth`]: deleteField(),
117275
- [`${prefix}.imageHeight`]: deleteField()
117276
- });
117277
- });
116954
+ function formatDateTime(value) {
116955
+ const millis2 = toMillis(value);
116956
+ if (millis2 == null)
116957
+ return "n/a";
116958
+ return new Date(millis2).toISOString();
117278
116959
  }
117279
- function descendPath(root, segments) {
117280
- let current = root;
117281
- for (const segment of segments) {
117282
- if (current === null || typeof current !== "object")
117283
- return void 0;
117284
- current = current[segment];
117285
- }
117286
- return current;
116960
+ function markdownMetric(label, value, suffix = "") {
116961
+ const metric = value === null || value === void 0 || value === "" ? "n/a" : String(value);
116962
+ return `- ${label}: ${metric}${suffix}`;
117287
116963
  }
117288
- function valueAfterPatch(fresh, patch, path) {
117289
- if (Object.prototype.hasOwnProperty.call(patch, path))
117290
- return patch[path];
117291
- const segments = path.split(".");
117292
- for (let cut = segments.length - 1; cut > 0; cut--) {
117293
- const ancestor = segments.slice(0, cut).join(".");
117294
- if (!Object.prototype.hasOwnProperty.call(patch, ancestor))
117295
- continue;
117296
- return descendPath(patch[ancestor], segments.slice(cut));
117297
- }
117298
- return descendPath(fresh, segments);
116964
+ function reportQualityCaveats(dataQuality) {
116965
+ if (!dataQuality || typeof dataQuality !== "object" || Array.isArray(dataQuality))
116966
+ return [];
116967
+ const marks = dataQuality;
116968
+ const caveats = [];
116969
+ if (marks.baselineSource === "none")
116970
+ caveats.push("baselineMissing");
116971
+ if (marks.startedAtSource === "end_time")
116972
+ caveats.push("startTimeUnknown");
116973
+ if (marks.watchTime === "unavailable")
116974
+ caveats.push("watchTimeUnmeasured");
116975
+ if (marks.watchTime === "unknown_duration")
116976
+ caveats.push("watchTimeUncapped");
116977
+ return caveats;
117299
116978
  }
117300
- function scheduleMissesDate(sessionMode, eventDate) {
117301
- return sessionMode === "schedule" && !eventDate;
116979
+ var REPORT_QUALITY_CAVEAT_TEXT = {
116980
+ baselineMissing: "The session baseline was not saved: every figure below is counted from zero and includes everything the event accumulated before this broadcast.",
116981
+ startTimeUnknown: "The broadcast start time was not saved: the report is stamped with the moment it ended and its duration reads zero.",
116982
+ watchTimeUnmeasured: "Watch time was not measured: the engagement snapshot could not be read.",
116983
+ watchTimeUncapped: "Watch time is not capped by the broadcast length \u2014 it is unknown, so nothing vouches for the total, average and median times."
116984
+ };
116985
+ function sessionQualityWarnings(dataQuality) {
116986
+ return reportQualityCaveats(dataQuality).map((caveat) => REPORT_QUALITY_CAVEAT_TEXT[caveat]);
117302
116987
  }
117303
- async function writeEventPatchWithCards(eventId, basePatch, buildItems, validate = null) {
117304
- const eventRef = doc(firestore, "events", eventId);
117305
- await runTransaction2(firestore, async (transaction) => {
117306
- const snap = await transaction.get(eventRef);
117307
- if (!snap.exists())
117308
- throw new Error("Event not found.");
117309
- const fresh = snap.data();
117310
- const patch = { ...basePatch };
117311
- if (buildItems) {
117312
- patch["blocks.cards.items"] = buildItems(fresh);
117313
- }
117314
- if (validate)
117315
- validate(fresh, patch);
117316
- if (Object.keys(patch).length === 0)
117317
- return;
117318
- transaction.update(eventRef, patch);
117319
- });
116988
+ function sessionReportMarkdown({ eventId, event, sessionId, session }) {
116989
+ const lines = [
116990
+ `# OKO session report`,
116991
+ "",
116992
+ `Event: ${safeString(event.title, eventId)}`,
116993
+ `Event URL: ${eventUrl(eventId)}`,
116994
+ `Report URL: ${sessionReportUrl(eventId, sessionId)}`,
116995
+ `Session: ${sessionId}`,
116996
+ "",
116997
+ "## Timing",
116998
+ markdownMetric("Started", formatDateTime(session.startedAt)),
116999
+ markdownMetric("Ended", formatDateTime(session.endedAt)),
117000
+ markdownMetric("Duration", formatSeconds(session.durationSec)),
117001
+ "",
117002
+ "## Audience",
117003
+ markdownMetric("Peak viewers", session.peakViewers),
117004
+ markdownMetric("Unique visitors", session.uniqueVisitors),
117005
+ markdownMetric("Views", session.viewCount),
117006
+ markdownMetric("Average watch time", formatSeconds(session.avgTimeSec)),
117007
+ markdownMetric("Median watch time", formatSeconds(session.medianTimeSec)),
117008
+ "",
117009
+ "## Engagement",
117010
+ markdownMetric("Chat messages", session.chatMessages),
117011
+ markdownMetric("Banner impressions", session.bannerImpressions),
117012
+ markdownMetric("Banner clicks", session.bannerClicks),
117013
+ markdownMetric("Banner CTR", session.bannerCtr, session.bannerCtr == null ? "" : "%"),
117014
+ markdownMetric("CTA clicks", session.ctaClicks),
117015
+ markdownMetric("Cards clicks", session.cardsClicks)
117016
+ ];
117017
+ const warnings = sessionQualityWarnings(session.dataQuality);
117018
+ if (warnings.length > 0) {
117019
+ lines.push("", "## What this report did not measure");
117020
+ for (const warning of warnings)
117021
+ lines.push(`- ${warning}`);
117022
+ }
117023
+ return lines.join("\n");
117320
117024
  }
117321
- function imageVariantReason(ref2) {
117322
- if (!ref2.imageMasterUrl && !ref2.imageUrl)
117323
- return "image_missing";
117324
- if (!ref2.imageMasterUrl)
117325
- return "master_missing";
117326
- if (!ref2.imageVariants)
117327
- return "variants_pending";
117328
- if (!ref2.imageUrl)
117329
- return "image_url_pending";
117330
- return null;
117025
+ var TELEGRAM_COPYABLE_KEYS = ["enabled", "channelIds", "autoReminder"];
117026
+ function copyableTelegram(source) {
117027
+ const out = {};
117028
+ for (const key of TELEGRAM_COPYABLE_KEYS) {
117029
+ if (source[key] !== void 0)
117030
+ out[key] = source[key];
117031
+ }
117032
+ return out;
117331
117033
  }
117332
- function imageVariantStatus(eventId, eventData, kind, cardId, elapsedMs) {
117333
- const ref2 = readImageRef(eventData, kind, cardId ?? void 0);
117334
- const reason = imageVariantReason(ref2);
117335
- return {
117336
- eventId,
117337
- kind,
117338
- cardId,
117339
- imageId: ref2.imageId,
117340
- imageUrl: ref2.imageUrl,
117341
- imageMasterUrl: ref2.imageMasterUrl,
117342
- imageVariants: ref2.imageVariants,
117343
- ready: reason === null,
117344
- reason,
117345
- elapsedMs
117346
- };
117034
+
117035
+ // build/tools/eventAccess.js
117036
+ var EVENT_ACCESS_FIELD_BY_MODE = {
117037
+ read: "accessibleBy",
117038
+ edit: "editors",
117039
+ delete: "deleters",
117040
+ live: "liveControllers",
117041
+ session: "sessionManagers",
117042
+ analytics: "analyticsViewers",
117043
+ report: "reportDownloaders",
117044
+ moderate: "chatModerators",
117045
+ notify: "notificationSenders"
117046
+ };
117047
+ var EVENT_ACCESS_COMPOSITION_FIELDS = [
117048
+ ...Object.values(EVENT_ACCESS_FIELD_BY_MODE),
117049
+ "moderators",
117050
+ "participantUids"
117051
+ ];
117052
+ function isPlainRecord(value) {
117053
+ return value !== null && typeof value === "object" && !Array.isArray(value);
117347
117054
  }
117348
- function sleep(ms) {
117349
- return new Promise((resolve4) => setTimeout(resolve4, ms));
117055
+ var LAST_SESSION_SUMMARY_QUALITY_UNKNOWN_WARNING = "Data quality for this last-session snapshot is unknown: it was saved before CF49 started stamping dataQuality here. Call oko_get_session_report for this session before quoting these numbers.";
117056
+ function lastSessionSummaryForOutput(value) {
117057
+ if (!isPlainRecord(value))
117058
+ return value;
117059
+ const { dataQuality, ...rest } = value;
117060
+ if (dataQuality === void 0) {
117061
+ return { ...rest, dataQualityWarnings: [LAST_SESSION_SUMMARY_QUALITY_UNKNOWN_WARNING] };
117062
+ }
117063
+ const warnings = sessionQualityWarnings(dataQuality);
117064
+ return warnings.length > 0 ? { ...rest, dataQuality, dataQualityWarnings: warnings } : { ...rest, dataQuality };
117350
117065
  }
117351
- async function waitForImageVariantTargets(uid, eventId, targets, timeoutSec) {
117352
- const startedAt = Date.now();
117353
- const deadline = startedAt + timeoutSec * 1e3;
117354
- let latest;
117355
- while (true) {
117356
- const elapsedMs = Date.now() - startedAt;
117357
- const event = await readEvent(eventId, uid, "read");
117358
- latest = targets.map((target) => imageVariantStatus(eventId, event, target.kind, target.cardId, elapsedMs));
117359
- if (latest.every((item) => item.ready) || Date.now() >= deadline)
117360
- return latest;
117361
- await sleep(Math.min(2e3, Math.max(250, deadline - Date.now())));
117066
+ function eventDocumentForOutput(data) {
117067
+ const projected = {};
117068
+ for (const [key, value] of Object.entries(data)) {
117069
+ if (EVENT_ACCESS_COMPOSITION_FIELDS.includes(key))
117070
+ continue;
117071
+ projected[key] = value;
117072
+ }
117073
+ if (isPlainRecord(projected.analytics) && projected.analytics.lastSessionSummary !== void 0) {
117074
+ projected.analytics = {
117075
+ ...projected.analytics,
117076
+ lastSessionSummary: lastSessionSummaryForOutput(projected.analytics.lastSessionSummary)
117077
+ };
117362
117078
  }
117079
+ return projected;
117363
117080
  }
117364
- async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
117365
- if (kind === "card" && !cardId)
117366
- throw new Error("cardId is required when kind is card.");
117367
- if (kind !== "card" && cardId)
117368
- throw new Error("cardId is only valid when kind is card.");
117369
- const event = await readEvent(eventId, uid, "edit");
117370
- const previous = readImageRef(event, kind, cardId);
117371
- const eventBlocks = event.blocks && typeof event.blocks === "object" ? event.blocks : {};
117372
- const placeholderCfg = eventBlocks.video?.placeholder && typeof eventBlocks.video.placeholder === "object" ? eventBlocks.video.placeholder : {};
117373
- const blockEnabled = kind === "promoBanner" ? eventBlocks.promoBanner?.enabled === true : kind === "videoPlaceholder" ? placeholderCfg.enabled === true : readCardsConfig(event).enabled;
117374
- const enableInstruction = kind === "promoBanner" ? "oko_configure_blocks promoBanner.enabled=true" : kind === "videoPlaceholder" ? "oko_configure_blocks videoPlaceholder.enabled=true" : "oko_configure_cards enabled=true";
117375
- const uploadHints = [
117376
- blockEnabled ? `The ${kind} block is enabled: the new image appears on the page as soon as the variants are ready.` : `The ${kind} block is disabled in setup, so viewers will not see this image until it is enabled (${enableInstruction}). Uploading never enables a block by itself.`,
117377
- ...kind === "videoPlaceholder" && placeholderCfg.mode !== "image" ? ['The placeholder mode is not "image": set oko_configure_blocks videoPlaceholder.mode="image" for this picture to be used.'] : [],
117378
- ...kind === "videoPlaceholder" ? ['The placeholder is shown to viewers only while its live switch is on: oko_set_block_visibility block "placeholder" visible=true.'] : [],
117379
- IMAGE_ADVICE[kind]
117380
- ];
117381
- const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
117382
- const deleteWarnings = [];
117383
- let deletesAttempted = 0;
117384
- const { uploaded, sourceBytes, sourceVia, notes: sourceNotes } = await uploadImageFromSource(eventId, kind, cardId, source);
117385
- if (!uploaded)
117386
- throw new Error("Image upload did not return a result.");
117387
- if (kind !== "card" || previousMasterPath && previousMasterPath !== uploaded.storagePath || !previous.imageMasterUrl && previous.imageUrl) {
117388
- const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
117389
- deletesAttempted += cleanup.attempted;
117390
- deleteWarnings.push(...cleanup.warnings);
117081
+ function grantAllowsMode(data, mode) {
117082
+ const field2 = EVENT_ACCESS_FIELD_BY_MODE[mode];
117083
+ return asRecord(data.capabilities)[field2] === true;
117084
+ }
117085
+ function legacyEventAllowsMode(data, uid, mode) {
117086
+ if (data.ownerId === uid)
117087
+ return true;
117088
+ const fields = mode === "read" ? ["accessibleBy", "editors"] : [EVENT_ACCESS_FIELD_BY_MODE[mode]];
117089
+ return fields.some((field2) => Array.isArray(data[field2]) && data[field2].includes(uid));
117090
+ }
117091
+ async function readEventAccess(eventId, uid, mode) {
117092
+ const [snap, grant] = await Promise.all([
117093
+ getDoc(doc(firestore, "events", eventId)),
117094
+ getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error3) => {
117095
+ if (!isFirestorePermissionDenied(error3))
117096
+ throw error3;
117097
+ return null;
117098
+ })
117099
+ ]);
117100
+ const data = snap.data() ?? {};
117101
+ const holds = (wanted) => grant === null ? legacyEventAllowsMode(data, uid, wanted) : data.ownerId === uid || grant.exists() && grantAllowsMode(grant.data(), wanted);
117102
+ return { data, exists: snap.exists(), visible: holds("read"), allowed: holds(mode) };
117103
+ }
117104
+ async function eventGrantsMode(eventId, uid, mode) {
117105
+ return (await readEventAccess(eventId, uid, mode)).allowed;
117106
+ }
117107
+ var EVENT_NOT_FOUND_MESSAGE = "Event not found.";
117108
+ async function readEvent(eventId, uid, mode = "read") {
117109
+ const { data, exists, visible, allowed } = await readEventAccess(eventId, uid, mode);
117110
+ if (!visible || !exists) {
117111
+ throw new Error(EVENT_NOT_FOUND_MESSAGE);
117391
117112
  }
117392
- return {
117393
- eventId,
117394
- kind,
117395
- imageId: uploaded.imageId,
117396
- storagePath: uploaded.storagePath,
117397
- masterUrl: uploaded.masterUrl,
117398
- variantsPending: true,
117399
- sourceBytes,
117400
- sourceVia,
117401
- masterBytes: uploaded.masterBytes,
117402
- imageWidth: uploaded.imageWidth,
117403
- imageHeight: uploaded.imageHeight,
117404
- note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
117405
- storageDeletesAttempted: deletesAttempted,
117406
- storageDeleteWarnings: deleteWarnings,
117407
- hints: [...sourceNotes, ...uploadHints]
117408
- };
117113
+ if (!allowed) {
117114
+ throw new Error(`Event does not grant ${mode} access to this organizer.`);
117115
+ }
117116
+ if (data.trashedAt && mode !== "read" && mode !== "delete") {
117117
+ throw new Error("Event is in trash. Restore it before making changes or live actions.");
117118
+ }
117119
+ return data;
117409
117120
  }
117410
117121
 
117411
- // build/tools/schemas.js
117412
- var boolOrNull = external_exports.boolean().nullable();
117413
- var DEFAULT_LIST_LIMIT = 30;
117414
- var MAX_LIST_LIMIT = 100;
117415
- var MAX_EXPORT_LIMIT = 200;
117416
- var ANALYTICS_DEFAULT_LIMIT = 10;
117417
- var ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
117418
- var MAX_WORKSPACE_NAME_LENGTH = 120;
117419
- var MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
117420
- var TELEGRAM_POST_STATUSES = ["draft", "pending", "scheduled", "sent", "failed", "delivery_unknown"];
117421
- var readOnlyTool = {
117422
- readOnlyHint: true,
117423
- destructiveHint: false,
117424
- idempotentHint: true,
117425
- openWorldHint: true
117426
- };
117427
- var writeTool = {
117428
- readOnlyHint: false,
117429
- destructiveHint: false,
117430
- idempotentHint: false,
117431
- openWorldHint: true
117432
- };
117433
- var idempotentWriteTool = {
117434
- readOnlyHint: false,
117435
- destructiveHint: false,
117436
- idempotentHint: true,
117437
- openWorldHint: true
117438
- };
117439
- var destructiveTool = {
117440
- readOnlyHint: false,
117441
- destructiveHint: true,
117442
- idempotentHint: false,
117443
- openWorldHint: true
117444
- };
117445
- var unknownRecordSchema = external_exports.record(external_exports.unknown());
117446
- var basicEventOutputSchema = external_exports.object({
117447
- eventId: external_exports.string()
117448
- }).passthrough();
117449
- var updateOutputSchema = external_exports.object({
117450
- id: external_exports.string().optional(),
117451
- eventId: external_exports.string().optional(),
117452
- updated: external_exports.array(external_exports.string()).optional()
117453
- }).passthrough();
117454
- var eventLinksOutputShape = {
117455
- url: external_exports.string().url(),
117456
- setupUrl: external_exports.string().url(),
117457
- analyticsUrl: external_exports.string().url()
117458
- };
117459
- var createWebinarOutputSchema = external_exports.object({
117460
- id: external_exports.string(),
117461
- workspaceId: external_exports.string(),
117462
- sourceEventId: external_exports.string().optional(),
117463
- ...eventLinksOutputShape
117464
- }).passthrough();
117465
- var updateStatusSchema = external_exports.object({
117466
- current: external_exports.string(),
117467
- latest: external_exports.string().nullable(),
117468
- updateAvailable: external_exports.boolean(),
117469
- message: external_exports.string().optional(),
117470
- howTo: external_exports.array(external_exports.string()).optional()
117471
- });
117472
- var connectionStatusOutputSchema = external_exports.object({
117473
- connected: external_exports.boolean(),
117474
- status: external_exports.enum(OKO_CONNECTION_STATUSES),
117475
- errorCode: external_exports.string().nullable(),
117476
- message: external_exports.string(),
117477
- product: external_exports.object({
117478
- name: external_exports.string(),
117479
- summary: external_exports.string(),
117480
- videoBoundary: external_exports.string(),
117481
- accountRequirement: external_exports.string(),
117482
- accessModel: external_exports.string()
117483
- }),
117484
- organizerAccountRequired: external_exports.boolean(),
117485
- credentialConfigured: external_exports.boolean(),
117486
- links: external_exports.object({
117487
- website: external_exports.string().url(),
117488
- signIn: external_exports.string().url(),
117489
- installCenter: external_exports.string().url()
117490
- }),
117491
- nextSteps: external_exports.array(external_exports.object({
117492
- id: external_exports.string(),
117493
- label: external_exports.string(),
117494
- url: external_exports.string().url().optional(),
117495
- command: external_exports.string().optional()
117496
- })),
117497
- minimumVersion: external_exports.string().optional(),
117498
- update: updateStatusSchema.optional(),
117499
- uid: external_exports.string().optional(),
117500
- scopes: external_exports.array(external_exports.string()).optional()
117501
- });
117502
- var cursorMetaSchema = {
117503
- count: external_exports.number().int().nonnegative(),
117504
- limit: external_exports.number().int().positive(),
117505
- hasMore: external_exports.boolean(),
117506
- nextCursor: external_exports.string().nullable(),
117507
- has_more: external_exports.boolean(),
117508
- next_cursor: external_exports.string().nullable()
117122
+ // build/tools/imagePipeline.js
117123
+ var IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
117124
+ var IMAGE_ADVICE = {
117125
+ promoBanner: "Banner advice: a wide landscape image at least 1600 px wide (3:1 to 5:1 works best, for example 1600\xD7400); keep text inside the middle 80% so it survives mobile scaling; JPEG/PNG/WebP/AVIF up to 15 MB, stored as WebP with 320/800/1600 px variants.",
117126
+ videoPlaceholder: "Placeholder advice: it covers the 16:9 video slot, so use a 16:9 image at least 1600\xD7900 px; avoid small text; JPEG/PNG/WebP/AVIF up to 15 MB, stored as WebP with 320/800/1600 px variants.",
117127
+ card: "Card advice: match the cardImageRatio chosen in oko_configure_cards (1:1 is the safest), at least 800 px on the short side; JPEG/PNG/WebP/AVIF up to 15 MB, stored as WebP with 320/800/1600 px variants."
117509
117128
  };
117510
- var workspacesOutputSchema = external_exports.object({
117511
- workspaces: external_exports.array(unknownRecordSchema),
117512
- count: external_exports.number().int().nonnegative()
117513
- }).passthrough();
117514
- var eventsOutputSchema = external_exports.object({
117515
- events: external_exports.array(unknownRecordSchema),
117516
- ...cursorMetaSchema
117517
- }).passthrough();
117518
- var eventOutputSchema = external_exports.object({
117519
- id: external_exports.string(),
117520
- ...eventLinksOutputShape
117521
- }).passthrough();
117522
- var liveStateOutputSchema = external_exports.object({
117523
- eventId: external_exports.string(),
117524
- agg: unknownRecordSchema,
117525
- ctrl: unknownRecordSchema,
117526
- chat: unknownRecordSchema
117527
- }).passthrough();
117528
- var analyticsOutputSchema = external_exports.object({
117529
- sessions: external_exports.array(unknownRecordSchema),
117530
- ...cursorMetaSchema
117531
- }).passthrough();
117532
- var chatOutputSchema = external_exports.object({
117533
- messages: external_exports.array(unknownRecordSchema),
117534
- ...cursorMetaSchema
117535
- }).passthrough();
117536
- var leadsOutputSchema = external_exports.object({
117537
- leads: external_exports.array(unknownRecordSchema).optional(),
117538
- csv: external_exports.string().optional(),
117539
- ...cursorMetaSchema
117540
- }).passthrough();
117541
- var imageOutputSchema = external_exports.object({
117542
- eventId: external_exports.string(),
117543
- kind: external_exports.enum(IMAGE_KINDS),
117544
- imageId: external_exports.string(),
117545
- storagePath: external_exports.string(),
117546
- masterUrl: external_exports.string().url(),
117547
- variantsPending: external_exports.boolean(),
117548
- sourceBytes: external_exports.number().int().nonnegative(),
117549
- sourceVia: external_exports.enum(["server", "client"]).optional(),
117550
- masterBytes: external_exports.number().int().nonnegative(),
117551
- imageWidth: external_exports.number().int().positive(),
117552
- imageHeight: external_exports.number().int().positive(),
117553
- note: external_exports.string()
117554
- }).passthrough();
117555
- var deleteImageOutputSchema = external_exports.object({
117556
- eventId: external_exports.string(),
117557
- kind: external_exports.enum(IMAGE_KINDS),
117558
- imageId: external_exports.string().nullable(),
117559
- deleted: external_exports.boolean(),
117560
- storageDeletesAttempted: external_exports.number().int().nonnegative(),
117561
- storageDeleteWarnings: external_exports.array(external_exports.string())
117562
- }).passthrough();
117563
- var imageVariantsOutputSchema = external_exports.object({
117564
- eventId: external_exports.string(),
117565
- kind: external_exports.enum(IMAGE_KINDS),
117566
- cardId: external_exports.string().nullable(),
117567
- imageId: external_exports.string().nullable(),
117568
- imageUrl: external_exports.string().url().nullable(),
117569
- imageMasterUrl: external_exports.string().url().nullable(),
117570
- imageVariants: unknownRecordSchema.nullable(),
117571
- ready: external_exports.boolean(),
117572
- reason: external_exports.string().nullable(),
117573
- elapsedMs: external_exports.number().int().nonnegative()
117574
- }).passthrough();
117575
- var cardImagesBatchOutputSchema = external_exports.object({
117576
- eventId: external_exports.string(),
117577
- uploads: external_exports.array(unknownRecordSchema),
117578
- variants: external_exports.array(unknownRecordSchema),
117579
- ready: external_exports.boolean(),
117580
- warnings: external_exports.array(external_exports.string())
117581
- }).passthrough();
117582
- var setupValidationOutputSchema = external_exports.object({
117583
- eventId: external_exports.string(),
117584
- ok: external_exports.boolean(),
117585
- score: external_exports.number().int().min(0).max(100),
117586
- errors: external_exports.array(unknownRecordSchema),
117587
- warnings: external_exports.array(unknownRecordSchema),
117588
- suggestions: external_exports.array(unknownRecordSchema),
117589
- nextActions: external_exports.array(external_exports.string()),
117590
- links: unknownRecordSchema
117591
- }).passthrough();
117592
- var workspaceAccessOutputSchema = external_exports.object({
117593
- workspaceId: external_exports.string(),
117594
- workspace: unknownRecordSchema,
117595
- members: external_exports.array(unknownRecordSchema),
117596
- invites: external_exports.array(unknownRecordSchema)
117597
- }).passthrough();
117598
- var telegramChannelsOutputSchema = external_exports.object({
117599
- channels: external_exports.array(canonicalTelegramChannelSchema),
117600
- count: external_exports.number().int().nonnegative()
117601
- }).passthrough();
117602
- var telegramPostsOutputSchema = external_exports.object({
117603
- posts: external_exports.array(unknownRecordSchema),
117604
- ...cursorMetaSchema
117605
- }).passthrough();
117606
- var sessionReportOutputSchema = external_exports.object({
117607
- eventId: external_exports.string(),
117608
- sessionId: external_exports.string(),
117609
- reportUrl: external_exports.string().url(),
117610
- markdown: external_exports.string(),
117611
- session: unknownRecordSchema
117612
- }).passthrough();
117613
- var profileOutputSchema = external_exports.object({
117614
- uid: external_exports.string(),
117615
- profile: unknownRecordSchema,
117616
- updated: external_exports.array(external_exports.string())
117617
- }).passthrough();
117618
- var deleteLeadOutputSchema = external_exports.object({
117619
- eventId: external_exports.string(),
117620
- leadId: external_exports.string(),
117621
- deleted: external_exports.boolean()
117622
- }).passthrough();
117623
- var notificationsOutputSchema = external_exports.object({
117624
- notifications: external_exports.array(unknownRecordSchema),
117625
- unreadCountOnPage: external_exports.number().int().nonnegative(),
117626
- ...cursorMetaSchema
117627
- }).passthrough();
117628
- var notificationsMarkOutputSchema = external_exports.object({
117629
- markedRead: external_exports.number().int().nonnegative(),
117630
- notificationIds: external_exports.array(external_exports.string())
117631
- }).passthrough();
117632
- var FORM_FIELD_TYPES = ["text", "tel", "email", "textarea", "select", "checkbox"];
117633
- var formFieldSchema = external_exports.object({
117634
- id: external_exports.string().min(1).optional(),
117635
- label: external_exports.string().max(120),
117636
- type: external_exports.enum(FORM_FIELD_TYPES).default("text").describe("text | tel | email | textarea answer with free text; select (one answer) and checkbox (several) answer with one of `options`."),
117637
- required: external_exports.boolean().default(false),
117638
- placeholder: external_exports.string().max(160).nullable().optional(),
117639
- options: external_exports.array(external_exports.string().trim().min(1).max(80)).max(20).optional().describe("Choices of a select or checkbox field: 1\u201320 unique option texts. The lead stores the option text itself \u2014 a string for select, an array of strings for checkbox.")
117640
- }).strict().superRefine((field2, ctx) => {
117641
- const choice = field2.type === "select" || field2.type === "checkbox";
117642
- const count = new Set(field2.options ?? []).size;
117643
- if (choice && count === 0) {
117644
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `A ${field2.type} field needs at least one option.` });
117129
+ var IMAGE_VARIANT_NAMES = ["thumb_320.webp", "card_800.webp", "hero_1600.webp"];
117130
+ var CARD_IMAGE_RATIOS = ["auto", "1:1", "4:3", "3:2", "16:9", "2:3", "3:4"];
117131
+ var CARD_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
117132
+ var MAX_CARD_ITEMS = 6;
117133
+ var MAX_CARD_TITLE_LENGTH = 160;
117134
+ var MAX_CARD_TEXT_LENGTH = 500;
117135
+ var MAX_CARD_BUTTON_LABEL_LENGTH = 80;
117136
+ var MAX_CARD_BUTTON_URL_LENGTH = 1e3;
117137
+ var MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
117138
+ var REMOTE_IMAGE_TIMEOUT_MS = 15e3;
117139
+ var REMOTE_IMAGE_TOTAL_TIMEOUT_MS = 4e4;
117140
+ var REMOTE_IMAGE_MAX_REDIRECTS = 3;
117141
+ function remoteImageHopBudget(deadlineAt, now, perHopMs) {
117142
+ const left = deadlineAt - now;
117143
+ if (left <= 0)
117144
+ return { kind: "exhausted" };
117145
+ if (left < perHopMs)
117146
+ return { kind: "budget", ms: left, capped: true };
117147
+ return { kind: "budget", ms: perHopMs, capped: false };
117148
+ }
117149
+ var ALLOWED_REMOTE_IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
117150
+ function storagePathFromDownloadUrl(downloadUrl) {
117151
+ if (!downloadUrl)
117152
+ return null;
117153
+ const match = downloadUrl.match(/\/o\/([^?]+)/);
117154
+ if (!match)
117155
+ return null;
117156
+ const encodedPath = match[1];
117157
+ if (!encodedPath)
117158
+ return null;
117159
+ try {
117160
+ return decodeURIComponent(encodedPath);
117161
+ } catch {
117162
+ return null;
117163
+ }
117164
+ }
117165
+ function variantPathsFromMasterPath(masterPath) {
117166
+ if (!masterPath.endsWith("/master.webp"))
117167
+ return [];
117168
+ return IMAGE_VARIANT_NAMES.map((name7) => masterPath.replace(/master\.webp$/, name7));
117169
+ }
117170
+ function resolveLocalImagePath(inputPath) {
117171
+ const expanded = inputPath === "~" || inputPath.startsWith("~/") ? join2(homedir2(), inputPath.slice(2)) : inputPath;
117172
+ return isAbsolute2(expanded) ? resolve2(expanded) : resolve2(process.cwd(), expanded);
117173
+ }
117174
+ function isBlockedHostname(hostname2) {
117175
+ const host = hostname2.toLowerCase();
117176
+ return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local");
117177
+ }
117178
+ function nonPublicResolutionMessage(hostname2, addresses) {
117179
+ const list = addresses.length ? addresses.join(", ") : "no address";
117180
+ const fakeIp = addresses.some((address) => /^198\.1[89]\./.test(address));
117181
+ const environmentHint = fakeIp ? "Addresses in 198.18.0.0/15 mean a VPN or proxy with DNS interception (fake-IP mode) is active on this machine: every public host resolves there, so no remote URL can be verified." : "If other public hosts resolve to private addresses too, a VPN or proxy with DNS interception is active on this machine.";
117182
+ return `Image URL resolves to a non-public IP address (${hostname2} \u2192 ${list}), which is not allowed. ${environmentHint} Workarounds: upload the file from disk via source.path (allowed directories come from OKO_IMAGE_ROOTS; the organizer can download the image first), or disable the VPN/proxy for this upload.`;
117183
+ }
117184
+ async function resolveRemoteImageAddress(url) {
117185
+ if (url.protocol !== "https:") {
117186
+ throw new Error("Image URL must use https://.");
117187
+ }
117188
+ if (url.username || url.password) {
117189
+ throw new Error("Image URL must not include embedded credentials.");
117190
+ }
117191
+ if (isBlockedHostname(url.hostname)) {
117192
+ throw new Error(`Image URL host "${url.hostname}" is not allowed (local names are blocked). Use a public HTTPS image URL, or upload the file from disk via source.path (requires OKO_IMAGE_ROOTS).`);
117193
+ }
117194
+ const literalAddress = normalizeIpLiteralHostname(url.hostname);
117195
+ const literalFamily = ipLiteralFamily(literalAddress);
117196
+ if (literalFamily !== 0) {
117197
+ if (!isPublicUnicastAddress(literalAddress)) {
117198
+ throw new Error(`Image URL points to a private/local IP address (${literalAddress}), which is not allowed. Use a public HTTPS image URL, or upload the file from disk via source.path (requires OKO_IMAGE_ROOTS).`);
117199
+ }
117200
+ return { addresses: [{ address: literalAddress, family: literalFamily }], transparentProxy: false };
117201
+ }
117202
+ const addresses = await lookup(url.hostname, { all: true });
117203
+ const transparentProxy = addresses.length > 0 && addresses.every((item) => isTransparentProxyAddress(item.address));
117204
+ if (!transparentProxy && (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address)))) {
117205
+ throw new Error(nonPublicResolutionMessage(url.hostname, addresses.map((item) => item.address)));
117645
117206
  }
117646
- if (!choice && field2.options !== void 0) {
117647
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `options belong to select or checkbox fields only; this field is ${field2.type}.` });
117207
+ const candidates = orderAddressCandidates(addresses);
117208
+ if (candidates.length === 0) {
117209
+ throw new Error("Image URL host could not be resolved safely.");
117648
117210
  }
117649
- });
117650
- var formSchema = external_exports.object({
117651
- id: external_exports.string().min(1).optional(),
117652
- title: external_exports.string().max(160).nullable().optional(),
117653
- description: external_exports.string().max(1e3).nullable().optional(),
117654
- submitLabel: external_exports.string().max(80).nullable().optional(),
117655
- successText: external_exports.string().max(500).nullable().optional(),
117656
- consentText: external_exports.string().max(500).nullable().optional(),
117657
- consentRequired: external_exports.boolean().default(true),
117658
- fields: external_exports.array(formFieldSchema).max(30)
117659
- }).strict();
117660
- var profileDisplayNameSchema = external_exports.string().max(40).nullable();
117661
- var timezoneSchema = external_exports.string().min(1).max(100).regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.").refine((value) => {
117211
+ return { addresses: candidates, transparentProxy };
117212
+ }
117213
+ async function responseBufferWithLimit(response, maxBytes) {
117214
+ const body = response.body;
117215
+ if (!body) {
117216
+ const bytes = Buffer.from(await response.arrayBuffer());
117217
+ if (bytes.byteLength > maxBytes)
117218
+ throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
117219
+ return bytes;
117220
+ }
117221
+ const reader = body.getReader();
117222
+ const chunks = [];
117223
+ let total = 0;
117662
117224
  try {
117663
- new Intl.DateTimeFormat("en-CA", { timeZone: value });
117664
- return true;
117665
- } catch {
117666
- return false;
117225
+ while (true) {
117226
+ const { done, value } = await reader.read();
117227
+ if (done)
117228
+ break;
117229
+ const chunk = Buffer.from(value);
117230
+ total += chunk.byteLength;
117231
+ if (total > maxBytes) {
117232
+ throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
117233
+ }
117234
+ chunks.push(chunk);
117235
+ }
117236
+ } finally {
117237
+ reader.releaseLock();
117667
117238
  }
117668
- }, "Unknown IANA timezone: use a value such as Europe/Moscow, America/New_York or UTC.");
117669
- var cardsConfigOutputSchema = external_exports.object({
117670
- eventId: external_exports.string(),
117671
- updated: external_exports.array(external_exports.string()),
117672
- cards: external_exports.object({
117673
- enabled: external_exports.boolean(),
117674
- visibility: external_exports.enum(["always", "hidden"]),
117675
- cardImageRatio: external_exports.enum(CARD_IMAGE_RATIOS),
117676
- items: external_exports.array(unknownRecordSchema)
117677
- }).passthrough(),
117678
- generatedCardIds: external_exports.array(external_exports.string()),
117679
- removedCardIds: external_exports.array(external_exports.string()),
117680
- removedCardsWithImages: external_exports.array(external_exports.string()),
117681
- preservedImageCardIds: external_exports.array(external_exports.string()),
117682
- warnings: external_exports.array(external_exports.string())
117683
- }).passthrough();
117684
- var workspacePermissionsSchema = external_exports.object({
117685
- createEvents: external_exports.boolean(),
117686
- editSetup: external_exports.boolean(),
117687
- deleteEvents: external_exports.boolean(),
117688
- controlLive: external_exports.boolean(),
117689
- moderateChat: external_exports.boolean(),
117690
- startSession: external_exports.boolean(),
117691
- viewAnalytics: external_exports.boolean(),
117692
- downloadReports: external_exports.boolean(),
117693
- sendNotifications: external_exports.boolean()
117694
- }).strict();
117695
- var WORKSPACE_PERMISSION_KEYS = [
117696
- "createEvents",
117697
- "editSetup",
117698
- "deleteEvents",
117699
- "controlLive",
117700
- "moderateChat",
117701
- "startSession",
117702
- "viewAnalytics",
117703
- "downloadReports",
117704
- "sendNotifications"
117705
- ];
117706
- var eventIdField = external_exports.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
117707
- var limitField = (max = MAX_LIST_LIMIT, defaultValue = DEFAULT_LIST_LIMIT) => external_exports.number().int().min(1).max(max).default(defaultValue).describe(`Maximum items to return, 1-${max}.`);
117708
- var cursorField = external_exports.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
117709
- function toolDescription({ summary, args = [], returns, examples = [], notes = [], exampleCall }) {
117710
- return [
117711
- summary,
117712
- args.length ? `Args:
117713
- ${args.map((item) => `- ${item}`).join("\n")}` : "",
117714
- `Returns:
117715
- - ${returns}`,
117716
- exampleCall ? `Example call (arguments JSON):
117717
- ${JSON.stringify(exampleCall)}` : "",
117718
- examples.length ? `Examples:
117719
- ${examples.map((item) => `- ${item}`).join("\n")}` : "",
117720
- notes.length ? `Notes:
117721
- ${notes.map((item) => `- ${item}`).join("\n")}` : ""
117722
- ].filter(Boolean).join("\n\n");
117239
+ return Buffer.concat(chunks, total);
117723
117240
  }
117724
-
117725
- // build/tools/account.js
117726
- var SCOPE_UNLOCKS = {
117727
- "events:read": "reading events, setup, and validation",
117728
- "events:write": "creating, editing, duplicating events and configuring blocks",
117729
- "events:delete": "moving events to trash and deleting them permanently",
117730
- "setup:write": "image uploads and setup-only changes",
117731
- "analytics:read": "analytics, sessions, and reports",
117732
- "analytics:write": "starting, ending, and repairing analytics sessions",
117733
- "live:control": "live block visibility, live badge, viewer refresh",
117734
- "chat:write": "sending organizer chat messages",
117735
- "chat:moderate": "chat mode, moderation, bans and mutes",
117736
- "telegram:write": "claiming channels and publishing Telegram posts",
117737
- "workspace:read": "listing workspaces, members, invites, notifications",
117738
- "workspace:write": "workspace management, invites, profile edits",
117739
- "leads:read": "reading and exporting leads"
117740
- };
117741
- function presetsWithScope(scope) {
117742
- return Object.entries(MCP_PRESETS).filter(([, scopes]) => scopes.includes(scope)).map(([name7]) => name7);
117241
+ function shareLinkHint(url) {
117242
+ let host = "";
117243
+ try {
117244
+ host = new URL(url).hostname.toLowerCase();
117245
+ } catch {
117246
+ return "";
117247
+ }
117248
+ if (host === "drive.google.com" || host === "docs.google.com") {
117249
+ return " This is a Google Drive page, not the file: share it as 'anyone with the link' and use https://drive.google.com/uc?export=download&id=<fileId>, or download the file and pass source.path.";
117250
+ }
117251
+ if (host.endsWith("disk.yandex.ru") || host.endsWith("disk.yandex.com") || host === "yadi.sk" || host.endsWith("disk.360.yandex.ru")) {
117252
+ return " This is a Yandex Disk page, not the file: use the direct download link from the page (\u0421\u043A\u0430\u0447\u0430\u0442\u044C \u2192 copy link), or download the file and pass source.path.";
117253
+ }
117254
+ if (host === "dropbox.com" || host.endsWith(".dropbox.com")) {
117255
+ return " This is a Dropbox page: replace ?dl=0 with ?dl=1 in the link, or download the file and pass source.path.";
117256
+ }
117257
+ if (host.endsWith("onedrive.live.com") || host === "1drv.ms" || host.endsWith("sharepoint.com")) {
117258
+ return " This is a OneDrive page: use its direct download link, or download the file and pass source.path.";
117259
+ }
117260
+ return " Use a direct link to the image file (right-click the image \u2192 copy image address), or download the file and pass source.path.";
117743
117261
  }
117744
- function registerAccountTools(server2) {
117745
- server2.registerTool("oko_connection_status", {
117746
- title: "Check OKO connection",
117747
- description: toolDescription({
117748
- summary: "Check whether this MCP process is connected to an active OKO organizer account.",
117749
- returns: "A factual connection status, product boundary, and setup links. This tool works without OKO_API_KEY and never changes data.",
117750
- examples: [
117751
- "Use when an OKO request is made and the organizer connection state is unknown.",
117752
- "Use after installing the package without a key to explain the exact next setup step."
117753
- ],
117754
- notes: ["This tool does not create accounts, invitations, or API keys."]
117755
- }),
117756
- outputSchema: connectionStatusOutputSchema,
117757
- annotations: readOnlyTool
117758
- }, async () => textResult(await getConnectionStatus()));
117759
- server2.registerTool("oko_whoami", {
117760
- title: "Who am I",
117761
- description: toolDescription({
117762
- summary: "Return the authenticated OKO organizer uid, granted MCP scopes, missing scopes, and the client update status.",
117763
- returns: "uid, scopes granted by OKO API key exchange, missingScopes with the tools they unlock, and update (current vs latest published client version). Use this before write workflows to check permissions.",
117764
- examples: ['Use when starting a session: call first, then choose tools allowed by "scopes". If update.updateAvailable is true, tell the organizer how to update before continuing.']
117765
- }),
117766
- outputSchema: {
117767
- uid: external_exports.string(),
117768
- scopes: external_exports.array(external_exports.string()),
117769
- missingScopes: external_exports.array(external_exports.object({ scope: external_exports.string(), unlocks: external_exports.string(), presets: external_exports.array(external_exports.string()) })),
117770
- update: updateStatusSchema
117771
- },
117772
- annotations: readOnlyTool
117773
- }, async () => {
117774
- const auth2 = await authed([]);
117775
- const scopes = currentScopes();
117776
- return textResult({
117777
- uid: auth2.uid,
117778
- scopes,
117779
- missingScopes: MCP_SCOPES.filter((scope) => !scopes.includes(scope)).map((scope) => ({
117780
- scope,
117781
- unlocks: SCOPE_UNLOCKS[scope] ?? "",
117782
- presets: presetsWithScope(scope)
117783
- })),
117784
- update: updateStatus()
117785
- });
117786
- });
117787
- server2.registerTool("oko_update_profile", {
117788
- title: "Update profile",
117789
- description: toolDescription({
117790
- summary: "Update the authenticated organizer profile fields used by OKO UI.",
117791
- args: [
117792
- "displayName: optional display name, max 40 chars; null clears it.",
117793
- "timezone: optional IANA timezone such as Europe/Moscow or UTC."
117794
- ],
117795
- returns: "uid, updated field names, and the resulting profile snapshot.",
117796
- examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
117797
- notes: ["Edits the organizer profile that owns this key. Requires workspace:write: the key holder is not necessarily the organizer, so a read-only key must not rewrite the owner's name, avatar or timezone."]
117798
- }),
117799
- inputSchema: {
117800
- displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
117801
- timezone: timezoneSchema.optional().describe("IANA timezone, for example Europe/Moscow or UTC.")
117802
- },
117803
- outputSchema: profileOutputSchema,
117804
- annotations: idempotentWriteTool
117805
- }, async ({ displayName, timezone }) => {
117806
- const { uid } = await authed(["workspace:write"]);
117807
- const patch = { updatedAt: serverTimestamp2() };
117808
- if (displayName !== void 0)
117809
- patch.displayName = displayName?.trim() || null;
117810
- if (timezone !== void 0)
117811
- patch.timezone = timezone.trim();
117812
- const updated = Object.keys(patch).filter((key) => key !== "updatedAt");
117813
- if (updated.length === 0)
117814
- throw new Error("Provide displayName and/or timezone.");
117815
- const profileRef = doc(firestore, "userProfiles", uid);
117816
- await setDoc(profileRef, patch, { merge: true });
117817
- const profileSnap = await getDoc(profileRef);
117818
- return textResult({ uid, updated, profile: profileSnap.exists() ? serialize(profileSnap.data()) : {} });
117819
- });
117262
+ async function readRemoteImage(rawUrl, redirects = 0, notes = [], deadlineAt = Date.now() + REMOTE_IMAGE_TOTAL_TIMEOUT_MS) {
117263
+ if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
117264
+ throw new Error("Image URL has too many redirects.");
117265
+ }
117266
+ const url = new URL(rawUrl);
117267
+ const pinned = await resolveRemoteImageAddress(url);
117268
+ if (pinned.transparentProxy && redirects === 0) {
117269
+ notes.push(`Image fetched through a VPN/proxy with DNS interception: ${url.hostname} resolved to ${pinned.addresses[0]?.address}, so OKO could not verify the destination address itself; the connection went through the proxy. This is expected on such machines and fine for public image links.`);
117270
+ }
117271
+ let lastConnectError = null;
117272
+ for (const candidate of pinned.addresses) {
117273
+ const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt);
117274
+ if ("buffer" in attempt)
117275
+ return attempt.buffer;
117276
+ if ("redirectTo" in attempt)
117277
+ return readRemoteImage(attempt.redirectTo, redirects + 1, notes, deadlineAt);
117278
+ lastConnectError = attempt.connectError;
117279
+ }
117280
+ throw new Error(`Image URL host ${url.hostname} could not be reached at any of its addresses (${pinned.addresses.map((item) => item.address).join(", ")}): ${lastConnectError?.message ?? "connection failed"}.`);
117820
117281
  }
117821
-
117822
- // build/videoUrl.js
117823
- var TWITCH_PARENT = "okostream.app";
117824
- function extractIframeSrc(input) {
117825
- const trimmed = input.trim();
117826
- if (!trimmed.startsWith("<iframe"))
117827
- return null;
117828
- const match = trimmed.match(/\bsrc=["']([^"']+)["']/i);
117829
- return match?.[1] ?? null;
117282
+ function totalBudgetMessage() {
117283
+ return `Image URL did not finish loading within ${REMOTE_IMAGE_TOTAL_TIMEOUT_MS / 1e3} seconds, including redirects.`;
117830
117284
  }
117831
- var YT_ID_RE = /^[\w-]{11}$/;
117832
- function extractYouTubeId(url) {
117833
- let parsed;
117285
+ async function readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt) {
117286
+ const budget = remoteImageHopBudget(deadlineAt, Date.now(), REMOTE_IMAGE_TIMEOUT_MS);
117287
+ if (budget.kind === "exhausted") {
117288
+ throw new Error(totalBudgetMessage());
117289
+ }
117290
+ const dispatcher = new import_undici.Agent({
117291
+ connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
117292
+ });
117293
+ const controller = new AbortController();
117294
+ const timeout = setTimeout(() => controller.abort(), budget.ms);
117834
117295
  try {
117835
- parsed = new URL(url);
117836
- } catch {
117837
- return null;
117296
+ let response;
117297
+ try {
117298
+ response = await (0, import_undici.fetch)(url, {
117299
+ redirect: "manual",
117300
+ signal: controller.signal,
117301
+ headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
117302
+ dispatcher
117303
+ });
117304
+ } catch (err) {
117305
+ if (err instanceof Error && err.name === "AbortError") {
117306
+ throw new Error(budget.capped ? totalBudgetMessage() : `Image URL did not respond within ${REMOTE_IMAGE_TIMEOUT_MS / 1e3} seconds.`);
117307
+ }
117308
+ return { connectError: err instanceof Error ? err : new Error(String(err)) };
117309
+ }
117310
+ if (response.status >= 300 && response.status < 400) {
117311
+ const location2 = response.headers.get("location");
117312
+ if (!location2)
117313
+ throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
117314
+ return { redirectTo: new URL(location2, url).toString() };
117315
+ }
117316
+ if (!response.ok) {
117317
+ throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
117318
+ }
117319
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
117320
+ if (!contentType || !contentType.startsWith("image/")) {
117321
+ throw new Error(`Image URL must return Content-Type image/* (got ${contentType || "none"}).${shareLinkHint(rawUrl)}`);
117322
+ }
117323
+ if (!ALLOWED_REMOTE_IMAGE_TYPES.has(contentType)) {
117324
+ throw new Error(`Image URL Content-Type ${contentType} is not supported. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
117325
+ }
117326
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
117327
+ if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
117328
+ throw new Error("Image URL is larger than 15MB.");
117329
+ }
117330
+ return { buffer: await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES) };
117331
+ } catch (err) {
117332
+ if (err instanceof Error && err.name === "AbortError") {
117333
+ throw new Error("Image URL fetch timed out.");
117334
+ }
117335
+ throw err;
117336
+ } finally {
117337
+ clearTimeout(timeout);
117338
+ await dispatcher.destroy();
117838
117339
  }
117839
- const host = parsed.hostname.replace(/^www\./, "");
117840
- if (host === "youtu.be") {
117841
- const id = parsed.pathname.slice(1).split("/")[0] ?? "";
117842
- return YT_ID_RE.test(id) ? id : null;
117340
+ }
117341
+ async function readLocalImage(inputPath) {
117342
+ if (env.imageRoots.length === 0) {
117343
+ throw new Error("Local image reads are disabled. Set OKO_IMAGE_ROOTS to an explicit trusted directory and restart the MCP client.");
117843
117344
  }
117844
- if (host !== "youtube.com" && host !== "m.youtube.com")
117345
+ const filePath = await realpath(resolveLocalImagePath(inputPath));
117346
+ const roots = await Promise.all(env.imageRoots.map(async (root) => {
117347
+ try {
117348
+ return await realpath(root);
117349
+ } catch {
117350
+ return resolve2(root);
117351
+ }
117352
+ }));
117353
+ const allowed = roots.some((root) => {
117354
+ const child2 = relative(root, filePath);
117355
+ return child2 === "" || !child2.startsWith(`..${sep}`) && child2 !== ".." && !isAbsolute2(child2);
117356
+ });
117357
+ if (!allowed) {
117358
+ throw new Error("Image path is outside OKO_IMAGE_ROOTS. Add a trusted root to the MCP client environment.");
117359
+ }
117360
+ const info = await stat(filePath);
117361
+ if (!info.isFile())
117362
+ throw new Error("Image path must point to a regular file.");
117363
+ if (info.size > MAX_SOURCE_IMAGE_BYTES)
117364
+ throw new Error("Image file is larger than 15MB.");
117365
+ return readFile(filePath);
117366
+ }
117367
+ function serverFetchRefusal(error3) {
117368
+ if (!error3 || typeof error3 !== "object")
117845
117369
  return null;
117846
- const path = parsed.pathname;
117847
- if (path.startsWith("/embed/")) {
117848
- const id = path.slice(7).split("/")[0] ?? "";
117849
- return YT_ID_RE.test(id) ? id : null;
117370
+ const details = error3.details;
117371
+ if (details && typeof details === "object" && details.code === "REMOTE_FETCH_FAILED") {
117372
+ const record3 = details;
117373
+ const fallbackMessage = String(error3.message ?? "the server could not fetch the URL");
117374
+ return {
117375
+ reason: typeof record3.reason === "string" ? record3.reason : "unknown",
117376
+ message: typeof record3.message === "string" ? record3.message : fallbackMessage
117377
+ };
117850
117378
  }
117851
- if (path.startsWith("/shorts/")) {
117852
- const id = path.slice(8).split("/")[0] ?? "";
117853
- return YT_ID_RE.test(id) ? id : null;
117379
+ const code = error3.code;
117380
+ const message = String(error3.message ?? "");
117381
+ if (code === "functions/invalid-argument" && message.includes("Image payload is empty or too large")) {
117382
+ return { reason: "server_without_url_support", message: "The OKO server release in use does not fetch URLs yet." };
117854
117383
  }
117855
- if (path.startsWith("/live/")) {
117856
- const id = path.slice(6).split("/")[0] ?? "";
117857
- return YT_ID_RE.test(id) ? id : null;
117384
+ if (code === "functions/deadline-exceeded") {
117385
+ return { reason: "server_timeout", message: "The OKO server did not finish fetching the URL within its time limit." };
117858
117386
  }
117859
- if (path === "/watch") {
117860
- const id = parsed.searchParams.get("v") ?? "";
117861
- return YT_ID_RE.test(id) ? id : null;
117387
+ if (code === "functions/internal") {
117388
+ return { reason: "server_error", message: message && message !== "internal" ? message : "The OKO server failed while fetching the URL." };
117862
117389
  }
117863
117390
  return null;
117864
117391
  }
117865
- function hostOf(url) {
117392
+ var SERVER_FETCH_NOTE = "Image fetched by the OKO server directly from the URL; this machine's network was not involved.";
117393
+ function localFallbackNote(serverFailure) {
117394
+ return `The OKO server could not fetch the URL (${serverFailure.reason}: ${serverFailure.message}), so the image was fetched from this machine instead.`;
117395
+ }
117396
+ function remoteImageUnavailableMessage(url, serverFailure, localError) {
117397
+ const localMessage = localError instanceof Error ? localError.message : String(localError);
117398
+ return `Image URL could not be fetched by the OKO server or from this machine. Server (${serverFailure.reason}): ${serverFailure.message} This machine: ${localMessage} Ask the organizer to download the image and pass its path via source.path (allowed directories come from OKO_IMAGE_ROOTS), or to share a public HTTPS link that returns the image file directly. URL: ${url}`;
117399
+ }
117400
+ async function uploadImageFromSource(eventId, kind, cardId, source) {
117401
+ if (source.path) {
117402
+ const buffer2 = await readLocalImage(source.path);
117403
+ const uploaded2 = await uploadMasterImage(eventId, kind, cardId, { imageBase64: buffer2.toString("base64") });
117404
+ return { uploaded: uploaded2, sourceBytes: buffer2.byteLength, sourceVia: "client", notes: [] };
117405
+ }
117406
+ if (!source.url)
117407
+ throw new Error("Provide image source path or url.");
117408
+ let serverFailure;
117866
117409
  try {
117867
- return new URL(url).hostname.replace(/^www\./, "").replace(/^m\./, "");
117868
- } catch {
117410
+ const uploaded2 = await uploadMasterImage(eventId, kind, cardId, { sourceUrl: source.url });
117411
+ return { uploaded: uploaded2, sourceBytes: uploaded2.sourceBytes ?? 0, sourceVia: "server", notes: [SERVER_FETCH_NOTE] };
117412
+ } catch (error3) {
117413
+ const refusal3 = serverFetchRefusal(error3);
117414
+ if (!refusal3)
117415
+ throw error3;
117416
+ serverFailure = refusal3;
117417
+ }
117418
+ let buffer;
117419
+ const fetchNotes = [];
117420
+ try {
117421
+ buffer = await readRemoteImage(source.url, 0, fetchNotes);
117422
+ } catch (localError) {
117423
+ throw new Error(remoteImageUnavailableMessage(source.url, serverFailure, localError));
117424
+ }
117425
+ const uploaded = await uploadMasterImage(eventId, kind, cardId, { imageBase64: buffer.toString("base64") });
117426
+ return {
117427
+ uploaded,
117428
+ sourceBytes: buffer.byteLength,
117429
+ sourceVia: "client",
117430
+ notes: [localFallbackNote(serverFailure), ...fetchNotes]
117431
+ };
117432
+ }
117433
+ function readImageRef(eventData, kind, cardId) {
117434
+ const blocks = eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {};
117435
+ if (kind === "promoBanner") {
117436
+ const banner = blocks.promoBanner && typeof blocks.promoBanner === "object" ? blocks.promoBanner : {};
117437
+ return {
117438
+ imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
117439
+ imageUrl: safeString(banner.imageUrl) || null,
117440
+ imageMasterUrl: safeString(banner.imageMasterUrl) || null,
117441
+ imageVariants: readImageVariants(banner.imageVariants)
117442
+ };
117443
+ }
117444
+ if (kind === "videoPlaceholder") {
117445
+ const video = blocks.video && typeof blocks.video === "object" ? blocks.video : {};
117446
+ const placeholder = video.placeholder && typeof video.placeholder === "object" ? video.placeholder : {};
117447
+ return {
117448
+ imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
117449
+ imageUrl: safeString(placeholder.imageUrl) || null,
117450
+ imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
117451
+ imageVariants: readImageVariants(placeholder.imageVariants)
117452
+ };
117453
+ }
117454
+ if (!cardId)
117455
+ throw new Error("cardId is required when kind is card.");
117456
+ const cards = blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {};
117457
+ const items = Array.isArray(cards.items) ? cards.items : [];
117458
+ const item = items.find((raw) => Boolean(raw) && typeof raw === "object" && raw.id === cardId);
117459
+ if (!item)
117460
+ throw new Error(`Card ${cardId} not found in blocks.cards.items. Create/configure the card before uploading its image.`);
117461
+ return {
117462
+ imageId: cardId,
117463
+ imageUrl: safeString(item.imageUrl) || null,
117464
+ imageMasterUrl: safeString(item.imageMasterUrl) || null,
117465
+ imageVariants: readImageVariants(item.imageVariants)
117466
+ };
117467
+ }
117468
+ function cardItemsWithImage(eventData, cardId, patch) {
117469
+ const blocks = eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {};
117470
+ const cards = blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {};
117471
+ const items = Array.isArray(cards.items) ? cards.items : [];
117472
+ let found = false;
117473
+ const next = items.map((raw) => {
117474
+ const item = raw && typeof raw === "object" ? raw : {};
117475
+ if (item.id !== cardId)
117476
+ return item;
117477
+ found = true;
117478
+ return { ...item, ...patch };
117479
+ });
117480
+ if (!found)
117481
+ throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
117482
+ return next;
117483
+ }
117484
+ function readImageVariants(value) {
117485
+ if (!value || typeof value !== "object" || Array.isArray(value))
117486
+ return null;
117487
+ const data = value;
117488
+ if (typeof data.thumbUrl !== "string" || typeof data.cardUrl !== "string" || typeof data.heroUrl !== "string") {
117869
117489
  return null;
117870
117490
  }
117491
+ return {
117492
+ thumbUrl: data.thumbUrl,
117493
+ cardUrl: data.cardUrl,
117494
+ heroUrl: data.heroUrl
117495
+ };
117496
+ }
117497
+ function readCardsConfig(eventData) {
117498
+ const blocks = eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {};
117499
+ const raw = blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {};
117500
+ const rawItems = Array.isArray(raw.items) ? raw.items : [];
117501
+ const repairedIdPositions = [];
117502
+ const items = rawItems.map((item, position) => ({ item, position })).filter((entry) => Boolean(entry.item) && typeof entry.item === "object" && !Array.isArray(entry.item)).map(({ item, position }) => {
117503
+ const hasUsableId = typeof item.id === "string" && CARD_ID_PATTERN.test(item.id);
117504
+ if (!hasUsableId)
117505
+ repairedIdPositions.push(position);
117506
+ return {
117507
+ // 🔴 The UUID is generated only when it is needed: previously the
117508
+ // crypto RNG was invoked for every card on every read, and the value
117509
+ // was discarded (review 02.09, R3c-14)
117510
+ id: hasUsableId ? item.id : randomUUID2(),
117511
+ title: safeString(item.title).trim() || null,
117512
+ text: safeString(item.text).trim() || null,
117513
+ buttonLabel: safeString(item.buttonLabel).trim() || null,
117514
+ buttonUrl: safeString(item.buttonUrl).trim() || null,
117515
+ imageUrl: safeString(item.imageUrl).trim() || null,
117516
+ imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
117517
+ imageVariants: readImageVariants(item.imageVariants),
117518
+ imageWidth: typeof item.imageWidth === "number" ? item.imageWidth : null,
117519
+ imageHeight: typeof item.imageHeight === "number" ? item.imageHeight : null,
117520
+ formId: safeString(item.formId).trim() || null
117521
+ };
117522
+ });
117523
+ const ratio = safeString(raw.cardImageRatio);
117524
+ return {
117525
+ enabled: raw.enabled === true,
117526
+ visibility: raw.visibility === "hidden" ? "hidden" : "always",
117527
+ cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
117528
+ items,
117529
+ repairedIdPositions
117530
+ };
117871
117531
  }
117872
- function extractVkVideoIds(url) {
117873
- let parsed;
117874
- try {
117875
- parsed = new URL(url);
117876
- } catch {
117532
+ function normalizeNullableText(value) {
117533
+ if (value == null)
117877
117534
  return null;
117878
- }
117879
- if (parsed.pathname.includes("video_ext.php")) {
117880
- const oid = parsed.searchParams.get("oid");
117881
- const id = parsed.searchParams.get("id");
117882
- return oid && id ? { oid, id } : null;
117883
- }
117884
- const match = parsed.pathname.match(/\/video(-?\d+)_(\d+)/);
117885
- return match ? { oid: match[1] ?? "", id: match[2] ?? "" } : null;
117535
+ return value.trim() || null;
117886
117536
  }
117887
- function extractPathId(url, host, patterns) {
117888
- let parsed;
117537
+ var CARD_LINK_SCHEME = /^(https?:\/\/|mailto:|tel:|\/(?!\/))/i;
117538
+ var CARD_BARE_DOMAIN = /^[\p{L}\d-]+(\.[\p{L}\d-]+)+(:\d+)?(\/\S*)?$/u;
117539
+ function normalizeCardButtonUrl(value) {
117540
+ const trimmed = normalizeNullableText(value);
117541
+ if (!trimmed)
117542
+ return null;
117543
+ if (CARD_LINK_SCHEME.test(trimmed))
117544
+ return trimmed;
117545
+ if (CARD_BARE_DOMAIN.test(trimmed))
117546
+ return `https://${trimmed}`;
117547
+ let url;
117889
117548
  try {
117890
- parsed = new URL(url);
117549
+ url = new URL(trimmed);
117891
117550
  } catch {
117892
- return null;
117893
- }
117894
- if (parsed.hostname.replace(/^www\./, "") !== host && !parsed.hostname.endsWith(`.${host}`))
117895
- return null;
117896
- for (const pattern of patterns) {
117897
- const match = parsed.pathname.match(pattern);
117898
- if (match?.[1])
117899
- return match[1];
117551
+ throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
117900
117552
  }
117901
- return null;
117553
+ throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
117902
117554
  }
117903
- function extractTwitchEmbed(url) {
117904
- let parsed;
117555
+ function eventFormIds(eventData) {
117556
+ const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
117557
+ return new Set(rawForms.map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "").filter(Boolean));
117558
+ }
117559
+ function cardHasImage(item) {
117560
+ return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
117561
+ }
117562
+ var cardIdSchema = external_exports.string().regex(CARD_ID_PATTERN, "Use 1-80 characters: letters, numbers, underscore, or hyphen.").describe("Stable card id. Omit to generate one; keep it to preserve images and analytics continuity.");
117563
+ var cardItemInputSchema = external_exports.object({
117564
+ id: cardIdSchema.optional(),
117565
+ title: external_exports.string().max(MAX_CARD_TITLE_LENGTH).nullable().optional(),
117566
+ text: external_exports.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
117567
+ buttonLabel: external_exports.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
117568
+ buttonUrl: external_exports.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
117569
+ formId: cardIdSchema.nullable().optional()
117570
+ }).strict();
117571
+ var imageSourceSchema = external_exports.object({
117572
+ path: external_exports.string().min(1).optional().describe("Local image path on the MCP client machine (inside OKO_IMAGE_ROOTS)."),
117573
+ url: external_exports.string().url().optional().describe("Public HTTPS image URL. The OKO server fetches it first (works behind VPNs and proxies); if the server cannot reach it, the MCP client fetches it from this machine; if both fail, ask the organizer for a local file via path.")
117574
+ }).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
117575
+ message: "Provide exactly one image source: path or url."
117576
+ });
117577
+ function buildCardsItems({ inputs, existing, formIds }) {
117578
+ const existingById = new Map(existing.map((item) => [item.id, item]));
117579
+ const seen = /* @__PURE__ */ new Set();
117580
+ const generatedCardIds = [];
117581
+ const preservedImageCardIds = [];
117582
+ const items = inputs.map((input) => {
117583
+ const id = input.id ?? randomUUID2();
117584
+ if (seen.has(id))
117585
+ throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
117586
+ seen.add(id);
117587
+ if (!CARD_ID_PATTERN.test(id))
117588
+ throw new Error(`Invalid card id "${id}". Use 1-80 characters: letters, numbers, underscore, or hyphen.`);
117589
+ if (!input.id)
117590
+ generatedCardIds.push(id);
117591
+ const formId = normalizeNullableText(input.formId);
117592
+ if (formId && !formIds.has(formId)) {
117593
+ throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
117594
+ }
117595
+ const previous = existingById.get(id);
117596
+ if (previous && cardHasImage(previous))
117597
+ preservedImageCardIds.push(id);
117598
+ return {
117599
+ id,
117600
+ title: normalizeNullableText(input.title),
117601
+ text: normalizeNullableText(input.text),
117602
+ buttonLabel: normalizeNullableText(input.buttonLabel),
117603
+ buttonUrl: normalizeCardButtonUrl(input.buttonUrl),
117604
+ imageUrl: previous?.imageUrl ?? null,
117605
+ imageMasterUrl: previous?.imageMasterUrl ?? null,
117606
+ imageVariants: previous?.imageVariants ?? null,
117607
+ imageWidth: previous?.imageWidth ?? null,
117608
+ imageHeight: previous?.imageHeight ?? null,
117609
+ formId
117610
+ };
117611
+ });
117612
+ return { items, generatedCardIds, preservedImageCardIds };
117613
+ }
117614
+ async function deleteStoragePaths(eventId, paths) {
117615
+ const uniquePaths = [...new Set(paths.filter(Boolean))];
117616
+ if (uniquePaths.length === 0)
117617
+ return [];
117618
+ const removeImages = httpsCallable(functions, "deleteMcpImages");
117905
117619
  try {
117906
- parsed = new URL(url);
117907
- } catch {
117908
- return null;
117909
- }
117910
- const host = parsed.hostname.replace(/^www\./, "");
117911
- if (host === "player.twitch.tv") {
117912
- const channel = parsed.searchParams.get("channel");
117913
- const video = parsed.searchParams.get("video");
117914
- if (channel)
117915
- return `https://player.twitch.tv/?channel=${channel}&parent=${TWITCH_PARENT}`;
117916
- if (video)
117917
- return `https://player.twitch.tv/?video=${video}&parent=${TWITCH_PARENT}`;
117620
+ const result = await removeImages({ eventId, paths: uniquePaths });
117621
+ return Array.isArray(result.data.warnings) ? result.data.warnings : [];
117622
+ } catch (error3) {
117623
+ return [`storage cleanup failed: ${error3 instanceof Error ? error3.message : String(error3)}`];
117918
117624
  }
117919
- if (host === "twitch.tv") {
117920
- const vodMatch = parsed.pathname.match(/\/videos\/(\d+)/);
117921
- if (vodMatch?.[1])
117922
- return `https://player.twitch.tv/?video=v${vodMatch[1]}&parent=${TWITCH_PARENT}`;
117923
- const channelMatch = parsed.pathname.match(/^\/([a-zA-Z0-9_]+)$/);
117924
- if (channelMatch?.[1])
117925
- return `https://player.twitch.tv/?channel=${channelMatch[1]}&parent=${TWITCH_PARENT}`;
117625
+ }
117626
+ async function deleteStorageUrls(eventId, masterUrl, fallbackUrl) {
117627
+ const paths = [];
117628
+ const masterPath = storagePathFromDownloadUrl(masterUrl);
117629
+ if (masterPath) {
117630
+ paths.push(masterPath, ...variantPathsFromMasterPath(masterPath));
117631
+ } else {
117632
+ const fallbackPath = storagePathFromDownloadUrl(fallbackUrl);
117633
+ if (fallbackPath)
117634
+ paths.push(fallbackPath);
117926
117635
  }
117927
- return null;
117636
+ const warnings = await deleteStoragePaths(eventId, paths);
117637
+ return { attempted: [...new Set(paths)].length, warnings };
117928
117638
  }
117929
- function detectAndNormalize(url) {
117930
- const trimmed = url.trim();
117931
- if (!trimmed)
117932
- return { provider: "unknown", normalizedUrl: null };
117933
- const host = hostOf(trimmed);
117934
- if (host === "youtube.com" || host === "youtu.be") {
117935
- const id = extractYouTubeId(trimmed);
117936
- return { provider: "youtube", normalizedUrl: id ? `https://www.youtube.com/embed/${id}` : null };
117639
+ async function uploadMasterImage(eventId, kind, cardId, source) {
117640
+ const uploadImage = httpsCallable(functions, "uploadMcpImage");
117641
+ const result = await uploadImage({
117642
+ eventId,
117643
+ kind,
117644
+ ...cardId ? { cardId } : {},
117645
+ ...source.sourceUrl ? { sourceUrl: source.sourceUrl } : { imageBase64: source.imageBase64 }
117646
+ });
117647
+ return result.data;
117648
+ }
117649
+ function imageReplacedSinceRead(fresh, expected) {
117650
+ return fresh.imageMasterUrl !== expected.imageMasterUrl || fresh.imageUrl !== expected.imageUrl;
117651
+ }
117652
+ async function clearEventImageInTransaction(eventId, kind, cardId, expected) {
117653
+ const eventRef = doc(firestore, "events", eventId);
117654
+ await runTransaction2(firestore, async (transaction) => {
117655
+ const snap = await transaction.get(eventRef);
117656
+ if (!snap.exists())
117657
+ throw new Error("Event not found.");
117658
+ const data = snap.data();
117659
+ const fresh = readImageRef(data, kind, cardId);
117660
+ if (imageReplacedSinceRead(fresh, expected)) {
117661
+ throw new Error("The image changed between reading it and deleting it \u2014 someone replaced it in the meantime. Nothing was deleted and no file was removed. Read the event again and repeat the call if the new image should go too.");
117662
+ }
117663
+ if (kind === "card") {
117664
+ transaction.update(eventRef, {
117665
+ "blocks.cards.items": cardItemsWithImage(data, cardId, {
117666
+ imageUrl: null,
117667
+ imageMasterUrl: null,
117668
+ imageVariants: null,
117669
+ imageWidth: null,
117670
+ imageHeight: null
117671
+ })
117672
+ });
117673
+ return;
117674
+ }
117675
+ const prefix = kind === "promoBanner" ? "blocks.promoBanner" : "blocks.video.placeholder";
117676
+ transaction.update(eventRef, {
117677
+ [`${prefix}.imageMasterUrl`]: deleteField(),
117678
+ [`${prefix}.imageUrl`]: deleteField(),
117679
+ [`${prefix}.imageVariants`]: deleteField(),
117680
+ [`${prefix}.imageWidth`]: deleteField(),
117681
+ [`${prefix}.imageHeight`]: deleteField()
117682
+ });
117683
+ });
117684
+ }
117685
+ function descendPath(root, segments) {
117686
+ let current = root;
117687
+ for (const segment of segments) {
117688
+ if (current === null || typeof current !== "object")
117689
+ return void 0;
117690
+ current = current[segment];
117937
117691
  }
117938
- if (host === "vk.com" || host === "vkvideo.ru") {
117939
- const ids = extractVkVideoIds(trimmed);
117940
- return { provider: "vk", normalizedUrl: ids ? `https://vk.com/video_ext.php?oid=${ids.oid}&id=${ids.id}&hd=2` : null };
117692
+ return current;
117693
+ }
117694
+ function valueAfterPatch(fresh, patch, path) {
117695
+ if (Object.prototype.hasOwnProperty.call(patch, path))
117696
+ return patch[path];
117697
+ const segments = path.split(".");
117698
+ for (let cut = segments.length - 1; cut > 0; cut--) {
117699
+ const ancestor = segments.slice(0, cut).join(".");
117700
+ if (!Object.prototype.hasOwnProperty.call(patch, ancestor))
117701
+ continue;
117702
+ return descendPath(patch[ancestor], segments.slice(cut));
117941
117703
  }
117942
- const rutubeId = extractPathId(trimmed, "rutube.ru", [/\/play\/embed\/([a-f0-9]+)/i, /\/video\/([a-f0-9]+)/i]);
117943
- if (rutubeId)
117944
- return { provider: "rutube", normalizedUrl: `https://rutube.ru/play/embed/${rutubeId}` };
117945
- if (trimmed.includes("rutube.ru"))
117946
- return { provider: "rutube", normalizedUrl: null };
117947
- const vimeoId = extractPathId(trimmed, "vimeo.com", [/\/video\/(\d+)/, /^\/(\d+)/]);
117948
- if (vimeoId)
117949
- return { provider: "vimeo", normalizedUrl: `https://player.vimeo.com/video/${vimeoId}` };
117950
- if (trimmed.includes("vimeo.com"))
117951
- return { provider: "vimeo", normalizedUrl: null };
117952
- const kinescopeId = extractPathId(trimmed, "kinescope.io", [/\/embed\/([a-zA-Z0-9]+)/, /^\/([a-zA-Z0-9]+)$/]);
117953
- if (kinescopeId)
117954
- return { provider: "kinescope", normalizedUrl: `https://kinescope.io/embed/${kinescopeId}` };
117955
- if (trimmed.includes("kinescope.io"))
117956
- return { provider: "kinescope", normalizedUrl: null };
117957
- const dzenId = extractPathId(trimmed, "dzen.ru", [/\/embed\/([a-zA-Z0-9_-]+)/, /\/video\/watch\/([a-zA-Z0-9_-]+)/]);
117958
- if (dzenId)
117959
- return { provider: "dzen", normalizedUrl: `https://dzen.ru/embed/${dzenId}` };
117960
- if (trimmed.includes("dzen.ru"))
117961
- return { provider: "dzen", normalizedUrl: null };
117962
- const twitchEmbed = extractTwitchEmbed(trimmed);
117963
- if (twitchEmbed)
117964
- return { provider: "twitch", normalizedUrl: twitchEmbed };
117965
- if (trimmed.includes("twitch.tv"))
117966
- return { provider: "twitch", normalizedUrl: null };
117967
- const dailymotionId = extractPathId(trimmed, "dailymotion.com", [/\/embed\/video\/([a-zA-Z0-9]+)/, /\/video\/([a-zA-Z0-9]+)/]) ?? extractPathId(trimmed, "dai.ly", [/^\/([a-zA-Z0-9]+)/]);
117968
- if (dailymotionId)
117969
- return { provider: "dailymotion", normalizedUrl: `https://www.dailymotion.com/embed/video/${dailymotionId}` };
117970
- if (trimmed.includes("dailymotion.com") || trimmed.includes("dai.ly"))
117971
- return { provider: "dailymotion", normalizedUrl: null };
117972
- const wistiaId = extractPathId(trimmed, "wistia.com", [/\/medias\/([a-zA-Z0-9]+)/]) ?? extractPathId(trimmed, "fast.wistia.net", [/\/embed\/iframe\/([a-zA-Z0-9]+)/]);
117973
- if (wistiaId)
117974
- return { provider: "wistia", normalizedUrl: `https://fast.wistia.net/embed/iframe/${wistiaId}` };
117975
- if (trimmed.includes("wistia.com") || trimmed.includes("wistia.net"))
117976
- return { provider: "wistia", normalizedUrl: null };
117977
- return { provider: "unknown", normalizedUrl: null };
117704
+ return descendPath(fresh, segments);
117978
117705
  }
117979
-
117980
- // build/tools/formatting.js
117981
- var RETIRED_SESSION_FIELDS = ["nmoEvaluation"];
117982
- function cursorMeta(count, max, hasMore, nextCursor) {
117983
- return {
117984
- count,
117985
- limit: max,
117986
- hasMore,
117987
- nextCursor,
117988
- has_more: hasMore,
117989
- next_cursor: nextCursor
117990
- };
117706
+ function scheduleMissesDate(sessionMode, eventDate) {
117707
+ return sessionMode === "schedule" && !eventDate;
117991
117708
  }
117992
- function pickPage(items, max) {
117709
+ async function writeEventPatchWithCards(eventId, basePatch, buildItems, validate = null) {
117710
+ const eventRef = doc(firestore, "events", eventId);
117711
+ await runTransaction2(firestore, async (transaction) => {
117712
+ const snap = await transaction.get(eventRef);
117713
+ if (!snap.exists())
117714
+ throw new Error("Event not found.");
117715
+ const fresh = snap.data();
117716
+ const patch = { ...basePatch };
117717
+ if (buildItems) {
117718
+ patch["blocks.cards.items"] = buildItems(fresh);
117719
+ }
117720
+ if (validate)
117721
+ validate(fresh, patch);
117722
+ if (Object.keys(patch).length === 0)
117723
+ return;
117724
+ transaction.update(eventRef, patch);
117725
+ });
117726
+ }
117727
+ function imageVariantReason(ref2) {
117728
+ if (!ref2.imageMasterUrl && !ref2.imageUrl)
117729
+ return "image_missing";
117730
+ if (!ref2.imageMasterUrl)
117731
+ return "master_missing";
117732
+ if (!ref2.imageVariants)
117733
+ return "variants_pending";
117734
+ if (!ref2.imageUrl)
117735
+ return "image_url_pending";
117736
+ return null;
117737
+ }
117738
+ function imageVariantStatus(eventId, eventData, kind, cardId, elapsedMs) {
117739
+ const ref2 = readImageRef(eventData, kind, cardId ?? void 0);
117740
+ const reason = imageVariantReason(ref2);
117993
117741
  return {
117994
- page: items.slice(0, max),
117995
- hasMore: items.length > max
117742
+ eventId,
117743
+ kind,
117744
+ cardId,
117745
+ imageId: ref2.imageId,
117746
+ imageUrl: ref2.imageUrl,
117747
+ imageMasterUrl: ref2.imageMasterUrl,
117748
+ imageVariants: ref2.imageVariants,
117749
+ ready: reason === null,
117750
+ reason,
117751
+ elapsedMs
117996
117752
  };
117997
117753
  }
117998
- function assertEmbeddableUrl(value) {
117999
- let url;
118000
- try {
118001
- url = new URL(value);
118002
- } catch {
118003
- throw new Error(`Invalid videoUrl "${value}". Provide an https:// link or a full <iframe> embed code.`);
118004
- }
118005
- if (url.protocol !== "https:") {
118006
- throw new Error(`Unsupported videoUrl protocol "${url.protocol}". The player must be served over https:// \u2014 http:// links are refused by the platform.`);
118007
- }
118008
- return value;
118009
- }
118010
- function assertNotNullLiteral(value, field2) {
118011
- if (typeof value === "string" && value.trim().toLowerCase() === "null") {
118012
- throw new Error(`${field2}: received the literal text "null". To clear the field pass an empty string "" (or JSON null); to keep it unchanged, omit the parameter.`);
118013
- }
117754
+ function sleep(ms) {
117755
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
118014
117756
  }
118015
- function normalizeVideo(videoUrl) {
118016
- const raw = videoUrl?.trim();
118017
- if (!raw)
118018
- return { originalUrl: null, embedUrl: null };
118019
- const effective = extractIframeSrc(raw) ?? raw;
118020
- const detected = detectAndNormalize(effective);
118021
- if (detected.provider !== "unknown" && !detected.normalizedUrl) {
118022
- throw new Error(`Could not extract a valid ${detected.provider} video id from videoUrl.`);
117757
+ async function waitForImageVariantTargets(uid, eventId, targets, timeoutSec) {
117758
+ const startedAt = Date.now();
117759
+ const deadline = startedAt + timeoutSec * 1e3;
117760
+ let latest;
117761
+ while (true) {
117762
+ const elapsedMs = Date.now() - startedAt;
117763
+ const event = await readEvent(eventId, uid, "read");
117764
+ latest = targets.map((target) => imageVariantStatus(eventId, event, target.kind, target.cardId, elapsedMs));
117765
+ if (latest.every((item) => item.ready) || Date.now() >= deadline)
117766
+ return latest;
117767
+ await sleep(Math.min(2e3, Math.max(250, deadline - Date.now())));
118023
117768
  }
118024
- return { originalUrl: raw, embedUrl: assertEmbeddableUrl(detected.normalizedUrl ?? effective) };
118025
- }
118026
- function csvRows(headers, rows) {
118027
- return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
118028
117769
  }
118029
- function toMillis(value) {
118030
- if (typeof value === "number" && Number.isFinite(value))
118031
- return value;
118032
- if (value && typeof value === "object" && typeof value.toMillis === "function") {
118033
- return value.toMillis();
117770
+ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
117771
+ if (kind === "card" && !cardId)
117772
+ throw new Error("cardId is required when kind is card.");
117773
+ if (kind !== "card" && cardId)
117774
+ throw new Error("cardId is only valid when kind is card.");
117775
+ const event = await readEvent(eventId, uid, "edit");
117776
+ const previous = readImageRef(event, kind, cardId);
117777
+ const eventBlocks = event.blocks && typeof event.blocks === "object" ? event.blocks : {};
117778
+ const placeholderCfg = eventBlocks.video?.placeholder && typeof eventBlocks.video.placeholder === "object" ? eventBlocks.video.placeholder : {};
117779
+ const blockEnabled = kind === "promoBanner" ? eventBlocks.promoBanner?.enabled === true : kind === "videoPlaceholder" ? placeholderCfg.enabled === true : readCardsConfig(event).enabled;
117780
+ const enableInstruction = kind === "promoBanner" ? "oko_configure_blocks promoBanner.enabled=true" : kind === "videoPlaceholder" ? "oko_configure_blocks videoPlaceholder.enabled=true" : "oko_configure_cards enabled=true";
117781
+ const uploadHints = [
117782
+ blockEnabled ? `The ${kind} block is enabled: the new image appears on the page as soon as the variants are ready.` : `The ${kind} block is disabled in setup, so viewers will not see this image until it is enabled (${enableInstruction}). Uploading never enables a block by itself.`,
117783
+ ...kind === "videoPlaceholder" && placeholderCfg.mode !== "image" ? ['The placeholder mode is not "image": set oko_configure_blocks videoPlaceholder.mode="image" for this picture to be used.'] : [],
117784
+ ...kind === "videoPlaceholder" ? ['The placeholder is shown to viewers only while its live switch is on: oko_set_block_visibility block "placeholder" visible=true.'] : [],
117785
+ IMAGE_ADVICE[kind]
117786
+ ];
117787
+ const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
117788
+ const deleteWarnings = [];
117789
+ let deletesAttempted = 0;
117790
+ const { uploaded, sourceBytes, sourceVia, notes: sourceNotes } = await uploadImageFromSource(eventId, kind, cardId, source);
117791
+ if (!uploaded)
117792
+ throw new Error("Image upload did not return a result.");
117793
+ if (kind !== "card" || previousMasterPath && previousMasterPath !== uploaded.storagePath || !previous.imageMasterUrl && previous.imageUrl) {
117794
+ const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
117795
+ deletesAttempted += cleanup.attempted;
117796
+ deleteWarnings.push(...cleanup.warnings);
118034
117797
  }
118035
- return null;
117798
+ return {
117799
+ eventId,
117800
+ kind,
117801
+ imageId: uploaded.imageId,
117802
+ storagePath: uploaded.storagePath,
117803
+ masterUrl: uploaded.masterUrl,
117804
+ variantsPending: true,
117805
+ sourceBytes,
117806
+ sourceVia,
117807
+ masterBytes: uploaded.masterBytes,
117808
+ imageWidth: uploaded.imageWidth,
117809
+ imageHeight: uploaded.imageHeight,
117810
+ note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
117811
+ storageDeletesAttempted: deletesAttempted,
117812
+ storageDeleteWarnings: deleteWarnings,
117813
+ hints: [...sourceNotes, ...uploadHints]
117814
+ };
118036
117815
  }
118037
- function parseTimestampCursor(cursor) {
118038
- const separator = cursor.indexOf("|");
118039
- const head = separator === -1 ? cursor : cursor.slice(0, separator);
118040
- const docId = separator === -1 ? "" : cursor.slice(separator + 1);
118041
- const millis2 = Number(head);
118042
- const at = Number.isFinite(millis2) && millis2 > 0 ? millis2 : Date.parse(head);
118043
- if (!Number.isFinite(at)) {
118044
- throw new Error("Invalid cursor. Use the nextCursor value returned by the previous page.");
117816
+
117817
+ // build/tools/schemas.js
117818
+ var boolOrNull = external_exports.boolean().nullable();
117819
+ var DEFAULT_LIST_LIMIT = 30;
117820
+ var MAX_LIST_LIMIT = 100;
117821
+ var MAX_EXPORT_LIMIT = 200;
117822
+ var ANALYTICS_DEFAULT_LIMIT = 10;
117823
+ var ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
117824
+ var MAX_WORKSPACE_NAME_LENGTH = 120;
117825
+ var MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
117826
+ var TELEGRAM_POST_STATUSES = ["draft", "pending", "scheduled", "sent", "failed", "delivery_unknown"];
117827
+ var readOnlyTool = {
117828
+ readOnlyHint: true,
117829
+ destructiveHint: false,
117830
+ idempotentHint: true,
117831
+ openWorldHint: true
117832
+ };
117833
+ var writeTool = {
117834
+ readOnlyHint: false,
117835
+ destructiveHint: false,
117836
+ idempotentHint: false,
117837
+ openWorldHint: true
117838
+ };
117839
+ var idempotentWriteTool = {
117840
+ readOnlyHint: false,
117841
+ destructiveHint: false,
117842
+ idempotentHint: true,
117843
+ openWorldHint: true
117844
+ };
117845
+ var destructiveTool = {
117846
+ readOnlyHint: false,
117847
+ destructiveHint: true,
117848
+ idempotentHint: false,
117849
+ openWorldHint: true
117850
+ };
117851
+ var unknownRecordSchema = external_exports.record(external_exports.unknown());
117852
+ var basicEventOutputSchema = external_exports.object({
117853
+ eventId: external_exports.string()
117854
+ }).passthrough();
117855
+ var updateOutputSchema = external_exports.object({
117856
+ id: external_exports.string().optional(),
117857
+ eventId: external_exports.string().optional(),
117858
+ updated: external_exports.array(external_exports.string()).optional()
117859
+ }).passthrough();
117860
+ var eventLinksOutputShape = {
117861
+ url: external_exports.string().url(),
117862
+ setupUrl: external_exports.string().url(),
117863
+ analyticsUrl: external_exports.string().url()
117864
+ };
117865
+ var createWebinarOutputSchema = external_exports.object({
117866
+ id: external_exports.string(),
117867
+ workspaceId: external_exports.string(),
117868
+ sourceEventId: external_exports.string().optional(),
117869
+ ...eventLinksOutputShape
117870
+ }).passthrough();
117871
+ var updateStatusSchema = external_exports.object({
117872
+ current: external_exports.string(),
117873
+ latest: external_exports.string().nullable(),
117874
+ updateAvailable: external_exports.boolean(),
117875
+ message: external_exports.string().optional(),
117876
+ howTo: external_exports.array(external_exports.string()).optional()
117877
+ });
117878
+ var connectionStatusOutputSchema = external_exports.object({
117879
+ connected: external_exports.boolean(),
117880
+ status: external_exports.enum(OKO_CONNECTION_STATUSES),
117881
+ errorCode: external_exports.string().nullable(),
117882
+ message: external_exports.string(),
117883
+ product: external_exports.object({
117884
+ name: external_exports.string(),
117885
+ summary: external_exports.string(),
117886
+ videoBoundary: external_exports.string(),
117887
+ accountRequirement: external_exports.string(),
117888
+ accessModel: external_exports.string()
117889
+ }),
117890
+ organizerAccountRequired: external_exports.boolean(),
117891
+ credentialConfigured: external_exports.boolean(),
117892
+ links: external_exports.object({
117893
+ website: external_exports.string().url(),
117894
+ signIn: external_exports.string().url(),
117895
+ installCenter: external_exports.string().url()
117896
+ }),
117897
+ nextSteps: external_exports.array(external_exports.object({
117898
+ id: external_exports.string(),
117899
+ label: external_exports.string(),
117900
+ url: external_exports.string().url().optional(),
117901
+ command: external_exports.string().optional()
117902
+ })),
117903
+ minimumVersion: external_exports.string().optional(),
117904
+ update: updateStatusSchema.optional(),
117905
+ uid: external_exports.string().optional(),
117906
+ scopes: external_exports.array(external_exports.string()).optional()
117907
+ });
117908
+ var cursorMetaSchema = {
117909
+ count: external_exports.number().int().nonnegative(),
117910
+ limit: external_exports.number().int().positive(),
117911
+ hasMore: external_exports.boolean(),
117912
+ nextCursor: external_exports.string().nullable(),
117913
+ has_more: external_exports.boolean(),
117914
+ next_cursor: external_exports.string().nullable()
117915
+ };
117916
+ var workspacesOutputSchema = external_exports.object({
117917
+ workspaces: external_exports.array(unknownRecordSchema),
117918
+ count: external_exports.number().int().nonnegative()
117919
+ }).passthrough();
117920
+ var eventsOutputSchema = external_exports.object({
117921
+ events: external_exports.array(unknownRecordSchema),
117922
+ ...cursorMetaSchema
117923
+ }).passthrough();
117924
+ var eventOutputSchema = external_exports.object({
117925
+ id: external_exports.string(),
117926
+ ...eventLinksOutputShape
117927
+ }).passthrough();
117928
+ var liveStateOutputSchema = external_exports.object({
117929
+ eventId: external_exports.string(),
117930
+ agg: unknownRecordSchema,
117931
+ ctrl: unknownRecordSchema,
117932
+ chat: unknownRecordSchema
117933
+ }).passthrough();
117934
+ var analyticsOutputSchema = external_exports.object({
117935
+ sessions: external_exports.array(unknownRecordSchema),
117936
+ ...cursorMetaSchema
117937
+ }).passthrough();
117938
+ var chatOutputSchema = external_exports.object({
117939
+ messages: external_exports.array(unknownRecordSchema),
117940
+ ...cursorMetaSchema
117941
+ }).passthrough();
117942
+ var leadsOutputSchema = external_exports.object({
117943
+ leads: external_exports.array(unknownRecordSchema).optional(),
117944
+ csv: external_exports.string().optional(),
117945
+ ...cursorMetaSchema
117946
+ }).passthrough();
117947
+ var imageOutputSchema = external_exports.object({
117948
+ eventId: external_exports.string(),
117949
+ kind: external_exports.enum(IMAGE_KINDS),
117950
+ imageId: external_exports.string(),
117951
+ storagePath: external_exports.string(),
117952
+ masterUrl: external_exports.string().url(),
117953
+ variantsPending: external_exports.boolean(),
117954
+ sourceBytes: external_exports.number().int().nonnegative(),
117955
+ sourceVia: external_exports.enum(["server", "client"]).optional(),
117956
+ masterBytes: external_exports.number().int().nonnegative(),
117957
+ imageWidth: external_exports.number().int().positive(),
117958
+ imageHeight: external_exports.number().int().positive(),
117959
+ note: external_exports.string()
117960
+ }).passthrough();
117961
+ var deleteImageOutputSchema = external_exports.object({
117962
+ eventId: external_exports.string(),
117963
+ kind: external_exports.enum(IMAGE_KINDS),
117964
+ imageId: external_exports.string().nullable(),
117965
+ deleted: external_exports.boolean(),
117966
+ storageDeletesAttempted: external_exports.number().int().nonnegative(),
117967
+ storageDeleteWarnings: external_exports.array(external_exports.string())
117968
+ }).passthrough();
117969
+ var imageVariantsOutputSchema = external_exports.object({
117970
+ eventId: external_exports.string(),
117971
+ kind: external_exports.enum(IMAGE_KINDS),
117972
+ cardId: external_exports.string().nullable(),
117973
+ imageId: external_exports.string().nullable(),
117974
+ imageUrl: external_exports.string().url().nullable(),
117975
+ imageMasterUrl: external_exports.string().url().nullable(),
117976
+ imageVariants: unknownRecordSchema.nullable(),
117977
+ ready: external_exports.boolean(),
117978
+ reason: external_exports.string().nullable(),
117979
+ elapsedMs: external_exports.number().int().nonnegative()
117980
+ }).passthrough();
117981
+ var cardImagesBatchOutputSchema = external_exports.object({
117982
+ eventId: external_exports.string(),
117983
+ uploads: external_exports.array(unknownRecordSchema),
117984
+ variants: external_exports.array(unknownRecordSchema),
117985
+ ready: external_exports.boolean(),
117986
+ warnings: external_exports.array(external_exports.string())
117987
+ }).passthrough();
117988
+ var setupValidationOutputSchema = external_exports.object({
117989
+ eventId: external_exports.string(),
117990
+ ok: external_exports.boolean(),
117991
+ score: external_exports.number().int().min(0).max(100),
117992
+ errors: external_exports.array(unknownRecordSchema),
117993
+ warnings: external_exports.array(unknownRecordSchema),
117994
+ suggestions: external_exports.array(unknownRecordSchema),
117995
+ nextActions: external_exports.array(external_exports.string()),
117996
+ links: unknownRecordSchema
117997
+ }).passthrough();
117998
+ var workspaceAccessOutputSchema = external_exports.object({
117999
+ workspaceId: external_exports.string(),
118000
+ workspace: unknownRecordSchema,
118001
+ members: external_exports.array(unknownRecordSchema),
118002
+ invites: external_exports.array(unknownRecordSchema)
118003
+ }).passthrough();
118004
+ var telegramChannelsOutputSchema = external_exports.object({
118005
+ channels: external_exports.array(canonicalTelegramChannelSchema),
118006
+ count: external_exports.number().int().nonnegative()
118007
+ }).passthrough();
118008
+ var telegramPostsOutputSchema = external_exports.object({
118009
+ posts: external_exports.array(unknownRecordSchema),
118010
+ ...cursorMetaSchema
118011
+ }).passthrough();
118012
+ var sessionReportOutputSchema = external_exports.object({
118013
+ eventId: external_exports.string(),
118014
+ sessionId: external_exports.string(),
118015
+ reportUrl: external_exports.string().url(),
118016
+ markdown: external_exports.string(),
118017
+ session: unknownRecordSchema
118018
+ }).passthrough();
118019
+ var profileOutputSchema = external_exports.object({
118020
+ uid: external_exports.string(),
118021
+ profile: unknownRecordSchema,
118022
+ updated: external_exports.array(external_exports.string())
118023
+ }).passthrough();
118024
+ var deleteLeadOutputSchema = external_exports.object({
118025
+ eventId: external_exports.string(),
118026
+ leadId: external_exports.string(),
118027
+ deleted: external_exports.boolean()
118028
+ }).passthrough();
118029
+ var notificationsOutputSchema = external_exports.object({
118030
+ notifications: external_exports.array(unknownRecordSchema),
118031
+ unreadCountOnPage: external_exports.number().int().nonnegative(),
118032
+ ...cursorMetaSchema
118033
+ }).passthrough();
118034
+ var notificationsMarkOutputSchema = external_exports.object({
118035
+ markedRead: external_exports.number().int().nonnegative(),
118036
+ notificationIds: external_exports.array(external_exports.string())
118037
+ }).passthrough();
118038
+ var FORM_FIELD_TYPES = ["text", "tel", "email", "textarea", "select", "checkbox"];
118039
+ var formFieldSchema = external_exports.object({
118040
+ id: external_exports.string().min(1).optional(),
118041
+ label: external_exports.string().max(120),
118042
+ type: external_exports.enum(FORM_FIELD_TYPES).default("text").describe("text | tel | email | textarea answer with free text; select (one answer) and checkbox (several) answer with one of `options`."),
118043
+ required: external_exports.boolean().default(false),
118044
+ placeholder: external_exports.string().max(160).nullable().optional(),
118045
+ options: external_exports.array(external_exports.string().trim().min(1).max(80)).max(20).optional().describe("Choices of a select or checkbox field: 1\u201320 unique option texts. The lead stores the option text itself \u2014 a string for select, an array of strings for checkbox.")
118046
+ }).strict().superRefine((field2, ctx) => {
118047
+ const choice = field2.type === "select" || field2.type === "checkbox";
118048
+ const count = new Set(field2.options ?? []).size;
118049
+ if (choice && count === 0) {
118050
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `A ${field2.type} field needs at least one option.` });
118045
118051
  }
118046
- const boundary = Timestamp.fromMillis(at);
118047
- return docId ? [boundary, docId] : [boundary];
118048
- }
118049
- function timestampCursorFrom(value, docId) {
118050
- const millis2 = toMillis(value);
118051
- if (millis2 == null)
118052
- return null;
118053
- return docId ? `${millis2}|${docId}` : String(millis2);
118054
- }
118055
- var ISO_WITH_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/;
118056
- function parseIsoBound(value, label) {
118057
- if (value === void 0)
118058
- return null;
118059
- const parsed = Date.parse(value);
118060
- if (!Number.isFinite(parsed) || !ISO_WITH_OFFSET.test(value.trim())) {
118061
- throw new Error(`Invalid ${label}: expected an ISO timestamp with an explicit offset, such as 2026-08-28T00:00:00+03:00 or 2026-08-27T21:00:00Z.`);
118052
+ if (!choice && field2.options !== void 0) {
118053
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `options belong to select or checkbox fields only; this field is ${field2.type}.` });
118062
118054
  }
118063
- return Timestamp.fromMillis(parsed);
118064
- }
118065
- function absoluteHttpUrl(value, label) {
118066
- const trimmed = (value ?? "").trim();
118067
- if (!trimmed)
118068
- return null;
118069
- let url;
118055
+ });
118056
+ var formSchema = external_exports.object({
118057
+ id: external_exports.string().min(1).optional(),
118058
+ title: external_exports.string().max(160).nullable().optional(),
118059
+ description: external_exports.string().max(1e3).nullable().optional(),
118060
+ submitLabel: external_exports.string().max(80).nullable().optional(),
118061
+ successText: external_exports.string().max(500).nullable().optional(),
118062
+ consentText: external_exports.string().max(500).nullable().optional(),
118063
+ consentRequired: external_exports.boolean().default(true),
118064
+ fields: external_exports.array(formFieldSchema).max(30)
118065
+ }).strict();
118066
+ var profileDisplayNameSchema = external_exports.string().max(40).nullable();
118067
+ var timezoneSchema = external_exports.string().min(1).max(100).regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.").refine((value) => {
118070
118068
  try {
118071
- url = new URL(trimmed);
118069
+ new Intl.DateTimeFormat("en-CA", { timeZone: value });
118070
+ return true;
118072
118071
  } catch {
118073
- throw new Error(`Invalid ${label} "${trimmed}": Telegram needs an absolute http(s) URL.`);
118074
- }
118075
- if (url.protocol !== "http:" && url.protocol !== "https:") {
118076
- throw new Error(`Unsupported ${label} protocol "${url.protocol}": Telegram needs an absolute http(s) URL.`);
118077
- }
118078
- return trimmed;
118079
- }
118080
- function compactAnalyticsSession(raw, options2) {
118081
- const session = serialize(raw);
118082
- const timeline = Array.isArray(session.timeline) ? session.timeline : null;
118083
- if (timeline) {
118084
- session.timelineBucketCount = timeline.length;
118085
- if (options2.includeTimeline) {
118086
- const page = timeline.slice(-options2.timelineLimit);
118087
- session.timeline = page;
118088
- session.timelineTruncated = timeline.length > page.length;
118089
- session.timelineReturned = page.length;
118090
- } else {
118091
- delete session.timeline;
118092
- session.timelineOmitted = true;
118093
- }
118094
- }
118095
- if (!options2.includeSnapshots) {
118096
- if (session.baselineSnapshot !== void 0) {
118097
- delete session.baselineSnapshot;
118098
- session.baselineSnapshotOmitted = true;
118099
- }
118100
- if (session.finalSnapshot !== void 0) {
118101
- delete session.finalSnapshot;
118102
- session.finalSnapshotOmitted = true;
118103
- }
118104
- }
118105
- for (const field2 of RETIRED_SESSION_FIELDS)
118106
- delete session[field2];
118107
- const warnings = sessionQualityWarnings(session.dataQuality);
118108
- if (warnings.length > 0)
118109
- session.dataQualityWarnings = warnings;
118110
- return session;
118111
- }
118112
- function numberMetric(value) {
118113
- return typeof value === "number" && Number.isFinite(value) ? value : null;
118114
- }
118115
- function formatSeconds(value) {
118116
- const seconds = numberMetric(value);
118117
- if (seconds == null)
118118
- return "n/a";
118119
- const total = Math.max(0, Math.round(seconds));
118120
- const h = Math.floor(total / 3600);
118121
- const m = Math.floor(total % 3600 / 60);
118122
- const s = total % 60;
118123
- if (h > 0)
118124
- return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
118125
- return `${m}m ${String(s).padStart(2, "0")}s`;
118126
- }
118127
- function formatDateTime(value) {
118128
- const millis2 = toMillis(value);
118129
- if (millis2 == null)
118130
- return "n/a";
118131
- return new Date(millis2).toISOString();
118132
- }
118133
- function markdownMetric(label, value, suffix = "") {
118134
- const metric = value === null || value === void 0 || value === "" ? "n/a" : String(value);
118135
- return `- ${label}: ${metric}${suffix}`;
118136
- }
118137
- function reportQualityCaveats(dataQuality) {
118138
- if (!dataQuality || typeof dataQuality !== "object" || Array.isArray(dataQuality))
118139
- return [];
118140
- const marks = dataQuality;
118141
- const caveats = [];
118142
- if (marks.baselineSource === "none")
118143
- caveats.push("baselineMissing");
118144
- if (marks.startedAtSource === "end_time")
118145
- caveats.push("startTimeUnknown");
118146
- if (marks.watchTime === "unavailable")
118147
- caveats.push("watchTimeUnmeasured");
118148
- if (marks.watchTime === "unknown_duration")
118149
- caveats.push("watchTimeUncapped");
118150
- return caveats;
118072
+ return false;
118073
+ }
118074
+ }, "Unknown IANA timezone: use a value such as Europe/Moscow, America/New_York or UTC.");
118075
+ var cardsConfigOutputSchema = external_exports.object({
118076
+ eventId: external_exports.string(),
118077
+ updated: external_exports.array(external_exports.string()),
118078
+ cards: external_exports.object({
118079
+ enabled: external_exports.boolean(),
118080
+ visibility: external_exports.enum(["always", "hidden"]),
118081
+ cardImageRatio: external_exports.enum(CARD_IMAGE_RATIOS),
118082
+ items: external_exports.array(unknownRecordSchema)
118083
+ }).passthrough(),
118084
+ generatedCardIds: external_exports.array(external_exports.string()),
118085
+ removedCardIds: external_exports.array(external_exports.string()),
118086
+ removedCardsWithImages: external_exports.array(external_exports.string()),
118087
+ preservedImageCardIds: external_exports.array(external_exports.string()),
118088
+ warnings: external_exports.array(external_exports.string())
118089
+ }).passthrough();
118090
+ var workspacePermissionsSchema = external_exports.object({
118091
+ createEvents: external_exports.boolean(),
118092
+ editSetup: external_exports.boolean(),
118093
+ deleteEvents: external_exports.boolean(),
118094
+ controlLive: external_exports.boolean(),
118095
+ moderateChat: external_exports.boolean(),
118096
+ startSession: external_exports.boolean(),
118097
+ viewAnalytics: external_exports.boolean(),
118098
+ downloadReports: external_exports.boolean(),
118099
+ sendNotifications: external_exports.boolean()
118100
+ }).strict();
118101
+ var WORKSPACE_PERMISSION_KEYS = [
118102
+ "createEvents",
118103
+ "editSetup",
118104
+ "deleteEvents",
118105
+ "controlLive",
118106
+ "moderateChat",
118107
+ "startSession",
118108
+ "viewAnalytics",
118109
+ "downloadReports",
118110
+ "sendNotifications"
118111
+ ];
118112
+ var eventIdField = external_exports.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
118113
+ var limitField = (max = MAX_LIST_LIMIT, defaultValue = DEFAULT_LIST_LIMIT) => external_exports.number().int().min(1).max(max).default(defaultValue).describe(`Maximum items to return, 1-${max}.`);
118114
+ var cursorField = external_exports.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
118115
+ function toolDescription({ summary, args = [], returns, examples = [], notes = [], exampleCall }) {
118116
+ return [
118117
+ summary,
118118
+ args.length ? `Args:
118119
+ ${args.map((item) => `- ${item}`).join("\n")}` : "",
118120
+ `Returns:
118121
+ - ${returns}`,
118122
+ exampleCall ? `Example call (arguments JSON):
118123
+ ${JSON.stringify(exampleCall)}` : "",
118124
+ examples.length ? `Examples:
118125
+ ${examples.map((item) => `- ${item}`).join("\n")}` : "",
118126
+ notes.length ? `Notes:
118127
+ ${notes.map((item) => `- ${item}`).join("\n")}` : ""
118128
+ ].filter(Boolean).join("\n\n");
118151
118129
  }
118152
- var REPORT_QUALITY_CAVEAT_TEXT = {
118153
- baselineMissing: "The session baseline was not saved: every figure below is counted from zero and includes everything the event accumulated before this broadcast.",
118154
- startTimeUnknown: "The broadcast start time was not saved: the report is stamped with the moment it ended and its duration reads zero.",
118155
- watchTimeUnmeasured: "Watch time was not measured: the engagement snapshot could not be read.",
118156
- watchTimeUncapped: "Watch time is not capped by the broadcast length \u2014 it is unknown, so nothing vouches for the total, average and median times."
118130
+
118131
+ // build/tools/account.js
118132
+ var SCOPE_UNLOCKS = {
118133
+ "events:read": "reading events, setup, and validation",
118134
+ "events:write": "creating, editing, duplicating events and configuring blocks",
118135
+ "events:delete": "moving events to trash and deleting them permanently",
118136
+ "setup:write": "image uploads and setup-only changes",
118137
+ "analytics:read": "analytics, sessions, and reports",
118138
+ "analytics:write": "starting, ending, and repairing analytics sessions",
118139
+ "live:control": "live block visibility, live badge, viewer refresh",
118140
+ "chat:write": "sending organizer chat messages",
118141
+ "chat:moderate": "chat mode, moderation, bans and mutes",
118142
+ "telegram:write": "claiming channels and publishing Telegram posts",
118143
+ "workspace:read": "listing workspaces, members, invites, notifications",
118144
+ "workspace:write": "workspace management, invites, profile edits",
118145
+ "leads:read": "reading and exporting leads"
118157
118146
  };
118158
- function sessionQualityWarnings(dataQuality) {
118159
- return reportQualityCaveats(dataQuality).map((caveat) => REPORT_QUALITY_CAVEAT_TEXT[caveat]);
118160
- }
118161
- function sessionReportMarkdown({ eventId, event, sessionId, session }) {
118162
- const lines = [
118163
- `# OKO session report`,
118164
- "",
118165
- `Event: ${safeString(event.title, eventId)}`,
118166
- `Event URL: ${eventUrl(eventId)}`,
118167
- `Report URL: ${sessionReportUrl(eventId, sessionId)}`,
118168
- `Session: ${sessionId}`,
118169
- "",
118170
- "## Timing",
118171
- markdownMetric("Started", formatDateTime(session.startedAt)),
118172
- markdownMetric("Ended", formatDateTime(session.endedAt)),
118173
- markdownMetric("Duration", formatSeconds(session.durationSec)),
118174
- "",
118175
- "## Audience",
118176
- markdownMetric("Peak viewers", session.peakViewers),
118177
- markdownMetric("Unique visitors", session.uniqueVisitors),
118178
- markdownMetric("Views", session.viewCount),
118179
- markdownMetric("Average watch time", formatSeconds(session.avgTimeSec)),
118180
- markdownMetric("Median watch time", formatSeconds(session.medianTimeSec)),
118181
- "",
118182
- "## Engagement",
118183
- markdownMetric("Chat messages", session.chatMessages),
118184
- markdownMetric("Banner impressions", session.bannerImpressions),
118185
- markdownMetric("Banner clicks", session.bannerClicks),
118186
- markdownMetric("Banner CTR", session.bannerCtr, session.bannerCtr == null ? "" : "%"),
118187
- markdownMetric("CTA clicks", session.ctaClicks),
118188
- markdownMetric("Cards clicks", session.cardsClicks)
118189
- ];
118190
- const warnings = sessionQualityWarnings(session.dataQuality);
118191
- if (warnings.length > 0) {
118192
- lines.push("", "## What this report did not measure");
118193
- for (const warning of warnings)
118194
- lines.push(`- ${warning}`);
118195
- }
118196
- return lines.join("\n");
118147
+ function presetsWithScope(scope) {
118148
+ return Object.entries(MCP_PRESETS).filter(([, scopes]) => scopes.includes(scope)).map(([name7]) => name7);
118197
118149
  }
118198
- var TELEGRAM_COPYABLE_KEYS = ["enabled", "channelIds", "autoReminder"];
118199
- function copyableTelegram(source) {
118200
- const out = {};
118201
- for (const key of TELEGRAM_COPYABLE_KEYS) {
118202
- if (source[key] !== void 0)
118203
- out[key] = source[key];
118204
- }
118205
- return out;
118150
+ function registerAccountTools(server2) {
118151
+ server2.registerTool("oko_connection_status", {
118152
+ title: "Check OKO connection",
118153
+ description: toolDescription({
118154
+ summary: "Check whether this MCP process is connected to an active OKO organizer account.",
118155
+ returns: "A factual connection status, product boundary, and setup links. This tool works without OKO_API_KEY and never changes data.",
118156
+ examples: [
118157
+ "Use when an OKO request is made and the organizer connection state is unknown.",
118158
+ "Use after installing the package without a key to explain the exact next setup step."
118159
+ ],
118160
+ notes: ["This tool does not create accounts, invitations, or API keys."]
118161
+ }),
118162
+ outputSchema: connectionStatusOutputSchema,
118163
+ annotations: readOnlyTool
118164
+ }, async () => textResult(await getConnectionStatus()));
118165
+ server2.registerTool("oko_whoami", {
118166
+ title: "Who am I",
118167
+ description: toolDescription({
118168
+ summary: "Return the authenticated OKO organizer uid, granted MCP scopes, missing scopes, and the client update status.",
118169
+ returns: "uid, scopes granted by OKO API key exchange, missingScopes with the tools they unlock, and update (current vs latest published client version). Use this before write workflows to check permissions.",
118170
+ examples: ['Use when starting a session: call first, then choose tools allowed by "scopes". If update.updateAvailable is true, tell the organizer how to update before continuing.']
118171
+ }),
118172
+ outputSchema: {
118173
+ uid: external_exports.string(),
118174
+ scopes: external_exports.array(external_exports.string()),
118175
+ missingScopes: external_exports.array(external_exports.object({ scope: external_exports.string(), unlocks: external_exports.string(), presets: external_exports.array(external_exports.string()) })),
118176
+ update: updateStatusSchema
118177
+ },
118178
+ annotations: readOnlyTool
118179
+ }, async () => {
118180
+ const auth2 = await authed([]);
118181
+ const scopes = currentScopes();
118182
+ return textResult({
118183
+ uid: auth2.uid,
118184
+ scopes,
118185
+ missingScopes: MCP_SCOPES.filter((scope) => !scopes.includes(scope)).map((scope) => ({
118186
+ scope,
118187
+ unlocks: SCOPE_UNLOCKS[scope] ?? "",
118188
+ presets: presetsWithScope(scope)
118189
+ })),
118190
+ update: updateStatus()
118191
+ });
118192
+ });
118193
+ server2.registerTool("oko_update_profile", {
118194
+ title: "Update profile",
118195
+ description: toolDescription({
118196
+ summary: "Update the authenticated organizer profile fields used by OKO UI.",
118197
+ args: [
118198
+ "displayName: optional display name, max 40 chars; null clears it.",
118199
+ "timezone: optional IANA timezone such as Europe/Moscow or UTC."
118200
+ ],
118201
+ returns: "uid, updated field names, and the resulting profile snapshot.",
118202
+ examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
118203
+ notes: ["Edits the organizer profile that owns this key. Requires workspace:write: the key holder is not necessarily the organizer, so a read-only key must not rewrite the owner's name, avatar or timezone."]
118204
+ }),
118205
+ inputSchema: {
118206
+ displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
118207
+ timezone: timezoneSchema.optional().describe("IANA timezone, for example Europe/Moscow or UTC.")
118208
+ },
118209
+ outputSchema: profileOutputSchema,
118210
+ annotations: idempotentWriteTool
118211
+ }, async ({ displayName, timezone }) => {
118212
+ const { uid } = await authed(["workspace:write"]);
118213
+ const patch = { updatedAt: serverTimestamp2() };
118214
+ if (displayName !== void 0)
118215
+ patch.displayName = displayName?.trim() || null;
118216
+ if (timezone !== void 0)
118217
+ patch.timezone = timezone.trim();
118218
+ const updated = Object.keys(patch).filter((key) => key !== "updatedAt");
118219
+ if (updated.length === 0)
118220
+ throw new Error("Provide displayName and/or timezone.");
118221
+ const profileRef = doc(firestore, "userProfiles", uid);
118222
+ await setDoc(profileRef, patch, { merge: true });
118223
+ const profileSnap = await getDoc(profileRef);
118224
+ return textResult({ uid, updated, profile: profileSnap.exists() ? serialize(profileSnap.data()) : {} });
118225
+ });
118206
118226
  }
118207
118227
 
118208
118228
  // build/tools/workspaceAccess.js
@@ -118453,6 +118473,35 @@ function livePermissionRejectionResult(toolName, args) {
118453
118473
  isError: true
118454
118474
  };
118455
118475
  }
118476
+ function mirrorOutOfSyncRejectionResult(toolName, args, mode) {
118477
+ const ids = idsFromArgs(args);
118478
+ const uid = currentUid();
118479
+ const at = (/* @__PURE__ */ new Date()).toISOString();
118480
+ return {
118481
+ ...textResult({
118482
+ errorCode: "MIRROR_OUT_OF_SYNC",
118483
+ tool: toolName,
118484
+ ...ids,
118485
+ message: `OKO refused this write, but the "${mode}" right it needs was confirmed for this organizer moments earlier, in this same call, from the Firestore grant. The refusal did not come from that grant: it came from a different copy of the same right \u2014 the realtime-database mirror the rule actually checks \u2014 which a background function updates asynchronously after a rights change (adding a workspace member, moving the event, restoring it from the trash) and which can briefly lag behind. Nothing was changed.`,
118486
+ whatToDo: [
118487
+ "Wait a few seconds and repeat the exact same call: the mirror normally catches up within seconds of the change that granted this right.",
118488
+ "Do not tell the organizer they lack the permission, and do not send them to change workspace access \u2014 the grant was just confirmed; there is nothing to grant.",
118489
+ `If the same call still refuses after several minutes, this is a platform inconsistency, not a missing permission: ask the organizer to report it at ${REPORT_URL} with the "report" block below.`
118490
+ ],
118491
+ report: { tool: toolName, ...ids, uid, at }
118492
+ }),
118493
+ isError: true
118494
+ };
118495
+ }
118496
+ async function confirmedGrantWrite(toolName, args, mode, action) {
118497
+ try {
118498
+ return await action();
118499
+ } catch (error3) {
118500
+ if (!isRtdbPermissionDenied(error3))
118501
+ throw error3;
118502
+ throw new ToolRefusal(mirrorOutOfSyncRejectionResult(toolName, args, mode));
118503
+ }
118504
+ }
118456
118505
  function platformRejectionResult(toolName, args, readOnly) {
118457
118506
  const ids = idsFromArgs(args);
118458
118507
  const at = (/* @__PURE__ */ new Date()).toISOString();
@@ -118592,7 +118641,7 @@ function chatSendRefusalResult(eventId, facts) {
118592
118641
  message: "OKO refused the message because the event is in the trash, and a trashed event's chat accepts no messages. This is the event's state, not a missing permission. Nothing was sent.",
118593
118642
  whatToDo: [
118594
118643
  "Tell the organizer the event is in the trash.",
118595
- "If they want its chat back, restore it with oko_restore_event (after their confirmation), then send the message again.",
118644
+ "Restoring needs the event owner or a workspace member with the deleteEvents permission (`deleters`) on this event; if that's the organizer behind this key, call oko_restore_event after their confirmation, then send the message again.",
118596
118645
  "Do not retry as is: the answer will not change while the event is in the trash."
118597
118646
  ]
118598
118647
  });
@@ -118620,8 +118669,10 @@ function chatSendRefusalResult(eventId, facts) {
118620
118669
  });
118621
118670
  }
118622
118671
  const exempt = EXEMPT_ROLES.has(role);
118623
- const ruledOut = ["The event is not in the trash."];
118624
- const mayBeCausedBy = [];
118672
+ const ruledOut = [];
118673
+ const mayBeCausedBy = [
118674
+ "A lagging trash mirror: Firestore, read before the write, has this event out of the trash, so the trash is likely not the cause \u2014 but not certainly, because the chat rule checks a separate RTDB mirror this key cannot re-read here, and that mirror can lag Firestore by hundreds of milliseconds right after a trash or a restore under concurrent access. If this is the cause, it clears itself: wait about a minute and send the message again."
118675
+ ];
118625
118676
  if (modeReading.read === "ok") {
118626
118677
  ruledOut.push(`A paused chat: the chat mode, re-read right after the refusal, is ${modeReading.value === null || modeReading.value === void 0 ? "not set, which the chat treats as open" : JSON.stringify(modeReading.value)}.`, "Access to this event's chat: the same key could read the chat state, which the same account, key and access check guards.");
118627
118678
  } else {
@@ -118641,6 +118692,7 @@ function chatSendRefusalResult(eventId, facts) {
118641
118692
  mayBeCausedBy,
118642
118693
  whatToDo: [
118643
118694
  "Tell the organizer the chat refused the message, that the cause could not be established, and name the remaining possibilities as possibilities.",
118695
+ "If the event was trashed or restored in roughly the last minute, wait about a minute and send the message again: the chat's copy of that state can lag Firestore briefly.",
118644
118696
  ...exempt ? [] : [
118645
118697
  "A moderator or the event owner can check this organizer for a mute or a ban in the chat panel and lift it (oko_unmute_user / oko_unban_user). If the text contains a link, the organizer may send it without the link, or a moderator can allow viewer links (oko_set_chat_settings allowViewerLinks=true)."
118646
118698
  ],
@@ -118757,7 +118809,7 @@ function registerChatTools(server2) {
118757
118809
  const { uid } = await authed(["chat:moderate"]);
118758
118810
  await readEvent(eventId, uid, "moderate");
118759
118811
  requireConfirm(confirm, "oko_delete_chat_message", { eventId, messageId });
118760
- await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: await serverNowMs(), by: uid, reason: "manual" });
118812
+ await confirmedGrantWrite("oko_delete_chat_message", { eventId }, "moderate", async () => set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: await serverNowMs(), by: uid, reason: "manual" }));
118761
118813
  return textResult({ eventId, messageId, deleted: true });
118762
118814
  });
118763
118815
  server2.registerTool("oko_mute_user", {
@@ -118775,7 +118827,7 @@ function registerChatTools(server2) {
118775
118827
  const actor = await authed(["chat:moderate"]);
118776
118828
  await readEvent(eventId, actor.uid, "moderate");
118777
118829
  requireConfirm(confirm, "oko_mute_user", { eventId, targetUid, minutes });
118778
- await set(ref(chatRtdb, `mutes/${eventId}/${targetUid}`), { until: await serverNowMs() + minutes * 6e4, by: actor.uid, reason: "manual" });
118830
+ await confirmedGrantWrite("oko_mute_user", { eventId }, "moderate", async () => set(ref(chatRtdb, `mutes/${eventId}/${targetUid}`), { until: await serverNowMs() + minutes * 6e4, by: actor.uid, reason: "manual" }));
118779
118831
  return textResult({ eventId, targetUid, mutedMinutes: minutes });
118780
118832
  });
118781
118833
  server2.registerTool("oko_ban_user", {
@@ -118793,7 +118845,7 @@ function registerChatTools(server2) {
118793
118845
  const actor = await authed(["chat:moderate"]);
118794
118846
  await readEvent(eventId, actor.uid, "moderate");
118795
118847
  requireConfirm(confirm, "oko_ban_user", { eventId, targetUid });
118796
- await set(ref(chatRtdb, `bans/${eventId}/${targetUid}`), true);
118848
+ await confirmedGrantWrite("oko_ban_user", { eventId }, "moderate", () => set(ref(chatRtdb, `bans/${eventId}/${targetUid}`), true));
118797
118849
  const messagesSnap = await get(query(ref(chatRtdb, `messages/${eventId}`), limitToLast(500)));
118798
118850
  const messages = messagesSnap.val() ?? {};
118799
118851
  const deletes = {};
@@ -118802,8 +118854,9 @@ function registerChatTools(server2) {
118802
118854
  if (message.uid === targetUid)
118803
118855
  deletes[`mod/deleted/${eventId}/${messageId}`] = { deletedAt: serverTimestamp(), ts: nowMs, by: actor.uid, reason: "ban" };
118804
118856
  }
118805
- if (Object.keys(deletes).length > 0)
118806
- await update(ref(chatRtdb), deletes);
118857
+ if (Object.keys(deletes).length > 0) {
118858
+ await confirmedGrantWrite("oko_ban_user", { eventId }, "moderate", () => update(ref(chatRtdb), deletes));
118859
+ }
118807
118860
  return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
118808
118861
  });
118809
118862
  server2.registerTool("oko_unmute_user", {
@@ -118821,7 +118874,7 @@ function registerChatTools(server2) {
118821
118874
  const actor = await authed(["chat:moderate"]);
118822
118875
  await readEvent(eventId, actor.uid, "moderate");
118823
118876
  requireConfirm(confirm, "oko_unmute_user", { eventId, targetUid });
118824
- await remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`));
118877
+ await confirmedGrantWrite("oko_unmute_user", { eventId }, "moderate", () => remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`)));
118825
118878
  return textResult({ eventId, targetUid, unmuted: true });
118826
118879
  });
118827
118880
  server2.registerTool("oko_unban_user", {
@@ -118839,7 +118892,7 @@ function registerChatTools(server2) {
118839
118892
  const actor = await authed(["chat:moderate"]);
118840
118893
  await readEvent(eventId, actor.uid, "moderate");
118841
118894
  requireConfirm(confirm, "oko_unban_user", { eventId, targetUid });
118842
- await remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`));
118895
+ await confirmedGrantWrite("oko_unban_user", { eventId }, "moderate", () => remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`)));
118843
118896
  return textResult({ eventId, targetUid, unbanned: true });
118844
118897
  });
118845
118898
  server2.registerTool("oko_set_chat_settings", {
@@ -118873,7 +118926,7 @@ function registerChatTools(server2) {
118873
118926
  if (Object.keys(patch).length === 0) {
118874
118927
  throw new Error("Provide slowModeMs and/or automodEnabled to change chat settings.");
118875
118928
  }
118876
- await update(ref(chatRtdb), patch);
118929
+ await confirmedGrantWrite("oko_set_chat_settings", { eventId }, "moderate", () => update(ref(chatRtdb), patch));
118877
118930
  return textResult({ eventId, updated: Object.keys(patch) });
118878
118931
  });
118879
118932
  }
@@ -120352,14 +120405,11 @@ function registerLiveTools(server2) {
120352
120405
  placeholder: "placeholderVisible"
120353
120406
  };
120354
120407
  if (block === "chat") {
120355
- await writeChatControl(eventId, visible === null ? null : visible ? "public" : "off");
120408
+ await confirmedGrantWrite("oko_set_block_visibility", { eventId, block }, "live", () => writeChatControl(eventId, visible === null ? null : visible ? "public" : "off"));
120356
120409
  return textResult({ eventId, block, visible, hints });
120357
120410
  }
120358
120411
  const target = ref(rtdb, `events/${eventId}/ctrl/${paths[block]}`);
120359
- if (visible === null)
120360
- await remove(target);
120361
- else
120362
- await set(target, visible);
120412
+ await confirmedGrantWrite("oko_set_block_visibility", { eventId, block }, "live", () => visible === null ? remove(target) : set(target, visible));
120363
120413
  return textResult({ eventId, block, visible, hints });
120364
120414
  });
120365
120415
  server2.registerTool("oko_refresh_viewers", {
@@ -120367,7 +120417,7 @@ function registerLiveTools(server2) {
120367
120417
  description: toolDescription({
120368
120418
  summary: "Refresh what viewers see: recount the online viewers and make open event pages re-read their config.",
120369
120419
  args: ["eventId: event whose viewers should refresh.", "confirm: must be true."],
120370
- returns: "eventId, refreshed=true, and online \u2014 the recounted number of viewers present.",
120420
+ returns: "eventId, refreshed=true, online \u2014 the recounted number of viewers present, and analyticsPaused \u2014 true/false, or null when the platform's kill switch could not be read.",
120371
120421
  examples: ["Use after a setup/live change if viewers should reload state immediately.", "Use when the online counter looks wrong: it is recounted from the actual presence nodes."]
120372
120422
  }),
120373
120423
  inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to refresh connected viewers.") },
@@ -120378,12 +120428,20 @@ function registerLiveTools(server2) {
120378
120428
  await readEvent(eventId, uid, "live");
120379
120429
  requireConfirm(confirm, "oko_refresh_viewers", { eventId });
120380
120430
  const resyncPresence = httpsCallable(functions, "resyncPresence");
120381
- const [resynced] = await Promise.all([
120431
+ const [resynced, , killSwitch] = await Promise.all([
120382
120432
  resyncPresence({ eventId }),
120383
- runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1)
120433
+ confirmedGrantWrite("oko_refresh_viewers", { eventId }, "live", () => runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1)),
120434
+ readKillSwitch(eventId)
120384
120435
  ]);
120385
120436
  const online = typeof resynced.data?.online === "number" ? resynced.data.online : null;
120386
- return textResult({ eventId, refreshed: true, online });
120437
+ const killSwitchNote = killSwitchHint(killSwitch);
120438
+ return textResult({
120439
+ eventId,
120440
+ refreshed: true,
120441
+ online,
120442
+ analyticsPaused: killSwitchFlag(killSwitch),
120443
+ ...killSwitchNote ? { hints: [killSwitchNote] } : {}
120444
+ });
120387
120445
  });
120388
120446
  server2.registerTool("oko_set_live_badge", {
120389
120447
  title: "Set live badge",
@@ -120423,7 +120481,7 @@ function registerLiveTools(server2) {
120423
120481
  storedDuration = stored.durationMinutes;
120424
120482
  }
120425
120483
  const value = { mode, durationMinutes: durationMinutes ?? storedDuration ?? 120 };
120426
- await set(badgeRef, value);
120484
+ await confirmedGrantWrite("oko_set_live_badge", { eventId }, "live", () => set(badgeRef, value));
120427
120485
  const hints = mode === "manual" ? ["Manual mode has no automatic end: the badge stays visible until this tool changes the mode again. durationMinutes is stored but has no effect in manual mode \u2014 only mode=schedule uses it as a time window."] : void 0;
120428
120486
  return textResult({ eventId, ...value, ...hints ? { hints } : {} });
120429
120487
  });
@@ -120459,7 +120517,7 @@ function registerLiveTools(server2) {
120459
120517
  const { uid } = await authed(["live:control"]);
120460
120518
  await readEvent(eventId, uid, "live");
120461
120519
  requireConfirm(confirm, "oko_set_chat_mode", { eventId, mode });
120462
- await writeChatControl(eventId, mode);
120520
+ await confirmedGrantWrite("oko_set_chat_mode", { eventId }, "live", () => writeChatControl(eventId, mode));
120463
120521
  return textResult({ eventId, mode });
120464
120522
  });
120465
120523
  server2.registerTool("oko_force_viewer_reload", {
@@ -120578,12 +120636,40 @@ function registerNotificationTools(server2) {
120578
120636
 
120579
120637
  // build/tools/publication.js
120580
120638
  var SERVER_REFUSAL_CODES = /* @__PURE__ */ new Set(["ENTITLEMENT_REQUIRED", "INSUFFICIENT_TOKENS"]);
120581
- function isToolResult(value) {
120582
- return Array.isArray(value.content);
120583
- }
120584
120639
  function refusal2(eventId, body) {
120585
120640
  return { ...textResult({ tool: "oko_publish_event", eventId, ...body }), isError: true };
120586
120641
  }
120642
+ function editPermissionRefusal(eventId) {
120643
+ return refusal2(eventId, {
120644
+ errorCode: "EDIT_PERMISSION_REQUIRED",
120645
+ message: "Publishing needs the right to edit this event's setup, and the organizer behind this key does not hold it. Nothing was charged.",
120646
+ whatToDo: [
120647
+ "Ask the event owner to grant this organizer the edit-setup permission in the event's workspace (OKO cabinet \u2192 workspace \u2192 members), then repeat.",
120648
+ "Do not retry as is: rights do not change between attempts."
120649
+ ]
120650
+ });
120651
+ }
120652
+ function eventInTrashRefusal(eventId) {
120653
+ return refusal2(eventId, {
120654
+ errorCode: "EVENT_IN_TRASH",
120655
+ message: "The event is in the trash. Nothing was charged.",
120656
+ whatToDo: [
120657
+ "Restoring needs the event owner or a workspace member with the deleteEvents permission (`deleters`) on this event; if that's the organizer behind this key, call oko_restore_event after their confirmation, then ask for the bill again.",
120658
+ "Otherwise, ask the event owner or that workspace member to restore it, then ask for the bill again."
120659
+ ]
120660
+ });
120661
+ }
120662
+ function readEventRefusal(eventId, error3) {
120663
+ if (!(error3 instanceof Error))
120664
+ return null;
120665
+ if (error3.message === "Event does not grant edit access to this organizer.") {
120666
+ return editPermissionRefusal(eventId);
120667
+ }
120668
+ if (error3.message === "Event is in trash. Restore it before making changes or live actions.") {
120669
+ return eventInTrashRefusal(eventId);
120670
+ }
120671
+ return null;
120672
+ }
120587
120673
  function publicationRefusalResult(eventId, error3) {
120588
120674
  if (!error3 || typeof error3 !== "object")
120589
120675
  return null;
@@ -120618,14 +120704,7 @@ function publicationRefusalResult(eventId, error3) {
120618
120704
  });
120619
120705
  }
120620
120706
  if (code === "functions/permission-denied" && /editSetup/.test(text)) {
120621
- return refusal2(eventId, {
120622
- errorCode: "EDIT_PERMISSION_REQUIRED",
120623
- message: "Publishing needs the right to edit this event's setup, and the organizer behind this key does not hold it. Nothing was charged.",
120624
- whatToDo: [
120625
- "Ask the event owner to grant this organizer the edit-setup permission in the event's workspace (OKO cabinet \u2192 workspace \u2192 members), then repeat.",
120626
- "Do not retry as is: rights do not change between attempts."
120627
- ]
120628
- });
120707
+ return editPermissionRefusal(eventId);
120629
120708
  }
120630
120709
  if (code === "functions/not-found") {
120631
120710
  return refusal2(eventId, {
@@ -120635,11 +120714,7 @@ function publicationRefusalResult(eventId, error3) {
120635
120714
  });
120636
120715
  }
120637
120716
  if (code === "functions/failed-precondition" && /trash/i.test(text)) {
120638
- return refusal2(eventId, {
120639
- errorCode: "EVENT_IN_TRASH",
120640
- message: "The event is in the trash. Nothing was charged.",
120641
- whatToDo: ["Restore it with oko_restore_event first, then ask for the bill again."]
120642
- });
120717
+ return eventInTrashRefusal(eventId);
120643
120718
  }
120644
120719
  return null;
120645
120720
  }
@@ -120682,7 +120757,12 @@ function registerPublicationTools(server2) {
120682
120757
  annotations: idempotentWriteTool
120683
120758
  }, async ({ eventId, controlRoom, confirm }) => {
120684
120759
  const { uid } = await authed(["events:write"]);
120685
- const event = await readEvent(eventId, uid, "edit");
120760
+ const event = await readEvent(eventId, uid, "edit").catch((error3) => {
120761
+ const named = readEventRefusal(eventId, error3);
120762
+ if (named)
120763
+ throw new ToolRefusal(named);
120764
+ throw error3;
120765
+ });
120686
120766
  const title = safeString(event.title);
120687
120767
  const publish = httpsCallable(functions, "publishEvent");
120688
120768
  const call2 = async (confirmed) => {
@@ -120691,19 +120771,17 @@ function registerPublicationTools(server2) {
120691
120771
  } catch (error3) {
120692
120772
  const named = publicationRefusalResult(eventId, error3);
120693
120773
  if (named)
120694
- return named;
120774
+ throw new ToolRefusal(named);
120695
120775
  throw error3;
120696
120776
  }
120697
120777
  };
120698
120778
  const published = (data) => textResult({ ...data, title, ...eventLinks(eventId), nextStep: nextStep(data) });
120699
120779
  if (confirm !== true) {
120700
120780
  const quote = await call2(false);
120701
- if (isToolResult(quote))
120702
- return quote;
120703
120781
  if (quote.status === "published")
120704
120782
  return published(quote);
120705
120783
  if (quote.refusal && typeof quote.refusal === "object") {
120706
- return refusal2(eventId, { ...quote.refusal, invoice: quote.invoice ?? null });
120784
+ throw new ToolRefusal(refusal2(eventId, { ...quote.refusal, invoice: quote.invoice ?? null }));
120707
120785
  }
120708
120786
  requireConfirm(confirm, "oko_publish_event", {
120709
120787
  eventId,
@@ -120717,8 +120795,6 @@ function registerPublicationTools(server2) {
120717
120795
  });
120718
120796
  }
120719
120797
  const outcome = await call2(true);
120720
- if (isToolResult(outcome))
120721
- return outcome;
120722
120798
  return published(outcome);
120723
120799
  });
120724
120800
  }