@okostream/mcp 0.12.11 → 0.12.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +240 -75
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -116291,7 +116291,7 @@ function sanitizeScopes(scopes) {
|
|
|
116291
116291
|
}
|
|
116292
116292
|
|
|
116293
116293
|
// build/version.js
|
|
116294
|
-
var OKO_MCP_VERSION = "0.12.
|
|
116294
|
+
var OKO_MCP_VERSION = "0.12.13";
|
|
116295
116295
|
|
|
116296
116296
|
// build/auth.js
|
|
116297
116297
|
var state = null;
|
|
@@ -116416,9 +116416,8 @@ async function exchangeApiKey() {
|
|
|
116416
116416
|
}
|
|
116417
116417
|
throw status === 401 ? new OkoAuthError("invalid_key", "OKO API key is invalid or revoked. Create a fresh key in the OKO MCP install center and replace OKO_API_KEY.") : new OkoAuthError("unavailable", [
|
|
116418
116418
|
`OKO organizer authentication failed with HTTP ${status}.`,
|
|
116419
|
-
serverRefusalNote(errorBody)
|
|
116420
|
-
|
|
116421
|
-
].filter(Boolean).join(" "));
|
|
116419
|
+
serverRefusalNote(errorBody) ?? "Check your connection and try again."
|
|
116420
|
+
].join(" "));
|
|
116422
116421
|
}
|
|
116423
116422
|
const data = await response.json();
|
|
116424
116423
|
if (typeof data.customToken !== "string") {
|
|
@@ -116631,6 +116630,20 @@ var EVENT_ACCESS_FIELD_BY_MODE = {
|
|
|
116631
116630
|
moderate: "chatModerators",
|
|
116632
116631
|
notify: "notificationSenders"
|
|
116633
116632
|
};
|
|
116633
|
+
var EVENT_ACCESS_COMPOSITION_FIELDS = [
|
|
116634
|
+
...Object.values(EVENT_ACCESS_FIELD_BY_MODE),
|
|
116635
|
+
"moderators",
|
|
116636
|
+
"participantUids"
|
|
116637
|
+
];
|
|
116638
|
+
function eventDocumentForOutput(data) {
|
|
116639
|
+
const projected = {};
|
|
116640
|
+
for (const [key, value] of Object.entries(data)) {
|
|
116641
|
+
if (EVENT_ACCESS_COMPOSITION_FIELDS.includes(key))
|
|
116642
|
+
continue;
|
|
116643
|
+
projected[key] = value;
|
|
116644
|
+
}
|
|
116645
|
+
return projected;
|
|
116646
|
+
}
|
|
116634
116647
|
function grantAllowsMode(data, mode) {
|
|
116635
116648
|
const field2 = EVENT_ACCESS_FIELD_BY_MODE[mode];
|
|
116636
116649
|
return asRecord(data.capabilities)[field2] === true;
|
|
@@ -116689,7 +116702,16 @@ var MAX_CARD_BUTTON_LABEL_LENGTH = 80;
|
|
|
116689
116702
|
var MAX_CARD_BUTTON_URL_LENGTH = 1e3;
|
|
116690
116703
|
var MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
116691
116704
|
var REMOTE_IMAGE_TIMEOUT_MS = 15e3;
|
|
116705
|
+
var REMOTE_IMAGE_TOTAL_TIMEOUT_MS = 4e4;
|
|
116692
116706
|
var REMOTE_IMAGE_MAX_REDIRECTS = 3;
|
|
116707
|
+
function remoteImageHopBudget(deadlineAt, now, perHopMs) {
|
|
116708
|
+
const left = deadlineAt - now;
|
|
116709
|
+
if (left <= 0)
|
|
116710
|
+
return { kind: "exhausted" };
|
|
116711
|
+
if (left < perHopMs)
|
|
116712
|
+
return { kind: "budget", ms: left, capped: true };
|
|
116713
|
+
return { kind: "budget", ms: perHopMs, capped: false };
|
|
116714
|
+
}
|
|
116693
116715
|
var ALLOWED_REMOTE_IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
|
116694
116716
|
function storagePathFromDownloadUrl(downloadUrl) {
|
|
116695
116717
|
if (!downloadUrl)
|
|
@@ -116803,7 +116825,7 @@ function shareLinkHint(url) {
|
|
|
116803
116825
|
}
|
|
116804
116826
|
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.";
|
|
116805
116827
|
}
|
|
116806
|
-
async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
|
|
116828
|
+
async function readRemoteImage(rawUrl, redirects = 0, notes = [], deadlineAt = Date.now() + REMOTE_IMAGE_TOTAL_TIMEOUT_MS) {
|
|
116807
116829
|
if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
|
|
116808
116830
|
throw new Error("Image URL has too many redirects.");
|
|
116809
116831
|
}
|
|
@@ -116814,21 +116836,28 @@ async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
|
|
|
116814
116836
|
}
|
|
116815
116837
|
let lastConnectError = null;
|
|
116816
116838
|
for (const candidate of pinned.addresses) {
|
|
116817
|
-
const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects);
|
|
116839
|
+
const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt);
|
|
116818
116840
|
if ("buffer" in attempt)
|
|
116819
116841
|
return attempt.buffer;
|
|
116820
116842
|
if ("redirectTo" in attempt)
|
|
116821
|
-
return readRemoteImage(attempt.redirectTo, redirects + 1, notes);
|
|
116843
|
+
return readRemoteImage(attempt.redirectTo, redirects + 1, notes, deadlineAt);
|
|
116822
116844
|
lastConnectError = attempt.connectError;
|
|
116823
116845
|
}
|
|
116824
116846
|
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"}.`);
|
|
116825
116847
|
}
|
|
116826
|
-
|
|
116848
|
+
function totalBudgetMessage() {
|
|
116849
|
+
return `Image URL did not finish loading within ${REMOTE_IMAGE_TOTAL_TIMEOUT_MS / 1e3} seconds, including redirects.`;
|
|
116850
|
+
}
|
|
116851
|
+
async function readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt) {
|
|
116852
|
+
const budget = remoteImageHopBudget(deadlineAt, Date.now(), REMOTE_IMAGE_TIMEOUT_MS);
|
|
116853
|
+
if (budget.kind === "exhausted") {
|
|
116854
|
+
throw new Error(totalBudgetMessage());
|
|
116855
|
+
}
|
|
116827
116856
|
const dispatcher = new import_undici.Agent({
|
|
116828
116857
|
connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
|
|
116829
116858
|
});
|
|
116830
116859
|
const controller = new AbortController();
|
|
116831
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
116860
|
+
const timeout = setTimeout(() => controller.abort(), budget.ms);
|
|
116832
116861
|
try {
|
|
116833
116862
|
let response;
|
|
116834
116863
|
try {
|
|
@@ -116840,7 +116869,7 @@ async function readRemoteImageFrom(url, rawUrl, candidate, redirects) {
|
|
|
116840
116869
|
});
|
|
116841
116870
|
} catch (err) {
|
|
116842
116871
|
if (err instanceof Error && err.name === "AbortError") {
|
|
116843
|
-
throw new Error(
|
|
116872
|
+
throw new Error(budget.capped ? totalBudgetMessage() : `Image URL did not respond within ${REMOTE_IMAGE_TIMEOUT_MS / 1e3} seconds.`);
|
|
116844
116873
|
}
|
|
116845
116874
|
return { connectError: err instanceof Error ? err : new Error(String(err)) };
|
|
116846
116875
|
}
|
|
@@ -118034,6 +118063,27 @@ function markdownMetric(label, value, suffix = "") {
|
|
|
118034
118063
|
const metric = value === null || value === void 0 || value === "" ? "n/a" : String(value);
|
|
118035
118064
|
return `- ${label}: ${metric}${suffix}`;
|
|
118036
118065
|
}
|
|
118066
|
+
function reportQualityCaveats(dataQuality) {
|
|
118067
|
+
if (!dataQuality || typeof dataQuality !== "object" || Array.isArray(dataQuality))
|
|
118068
|
+
return [];
|
|
118069
|
+
const marks = dataQuality;
|
|
118070
|
+
const caveats = [];
|
|
118071
|
+
if (marks.baselineSource === "none")
|
|
118072
|
+
caveats.push("baselineMissing");
|
|
118073
|
+
if (marks.startedAtSource === "end_time")
|
|
118074
|
+
caveats.push("startTimeUnknown");
|
|
118075
|
+
if (marks.watchTime === "unavailable")
|
|
118076
|
+
caveats.push("watchTimeUnmeasured");
|
|
118077
|
+
if (marks.watchTime === "unknown_duration")
|
|
118078
|
+
caveats.push("watchTimeUncapped");
|
|
118079
|
+
return caveats;
|
|
118080
|
+
}
|
|
118081
|
+
var REPORT_QUALITY_CAVEAT_TEXT = {
|
|
118082
|
+
baselineMissing: "The session baseline was not saved: every figure below is counted from zero and includes everything the event accumulated before this broadcast.",
|
|
118083
|
+
startTimeUnknown: "The broadcast start time was not saved: the report is stamped with the moment it ended and its duration reads zero.",
|
|
118084
|
+
watchTimeUnmeasured: "Watch time was not measured: the engagement snapshot could not be read.",
|
|
118085
|
+
watchTimeUncapped: "Watch time is not capped by the broadcast length \u2014 it is unknown, so nothing vouches for the total, average and median times."
|
|
118086
|
+
};
|
|
118037
118087
|
function sessionReportMarkdown({ eventId, event, sessionId, session }) {
|
|
118038
118088
|
const lines = [
|
|
118039
118089
|
`# OKO session report`,
|
|
@@ -118063,6 +118113,12 @@ function sessionReportMarkdown({ eventId, event, sessionId, session }) {
|
|
|
118063
118113
|
markdownMetric("CTA clicks", session.ctaClicks),
|
|
118064
118114
|
markdownMetric("Cards clicks", session.cardsClicks)
|
|
118065
118115
|
];
|
|
118116
|
+
const caveats = reportQualityCaveats(session.dataQuality);
|
|
118117
|
+
if (caveats.length > 0) {
|
|
118118
|
+
lines.push("", "## What this report did not measure");
|
|
118119
|
+
for (const caveat of caveats)
|
|
118120
|
+
lines.push(`- ${REPORT_QUALITY_CAVEAT_TEXT[caveat]}`);
|
|
118121
|
+
}
|
|
118066
118122
|
return lines.join("\n");
|
|
118067
118123
|
}
|
|
118068
118124
|
var TELEGRAM_COPYABLE_KEYS = ["enabled", "channelIds", "autoReminder"];
|
|
@@ -118421,10 +118477,32 @@ async function addEventWithRtdbMirror(eventData) {
|
|
|
118421
118477
|
try {
|
|
118422
118478
|
const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
|
|
118423
118479
|
await ensureRuntime({ eventId: eventRef.id });
|
|
118480
|
+
return { ref: eventRef, runtime: { status: "ready" } };
|
|
118424
118481
|
} catch (error3) {
|
|
118425
|
-
|
|
118482
|
+
const rawCode = error3?.code;
|
|
118483
|
+
const code = typeof rawCode === "string" ? rawCode : "unknown";
|
|
118484
|
+
console.error(`[OKO MCP] ensureEventRuntime failed for event ${eventRef.id}:`, error3);
|
|
118485
|
+
return {
|
|
118486
|
+
ref: eventRef,
|
|
118487
|
+
runtime: {
|
|
118488
|
+
status: "failed",
|
|
118489
|
+
code,
|
|
118490
|
+
message: error3 instanceof Error ? error3.message : String(error3)
|
|
118491
|
+
}
|
|
118492
|
+
};
|
|
118426
118493
|
}
|
|
118427
|
-
|
|
118494
|
+
}
|
|
118495
|
+
function eventRuntimeHint(runtime) {
|
|
118496
|
+
if (runtime.status === "ready")
|
|
118497
|
+
return [];
|
|
118498
|
+
if (runtime.code === "functions/not-found") {
|
|
118499
|
+
return [
|
|
118500
|
+
"The event document was created, but the server reports the event as already gone and could not confirm its runtime. Do not hand the links out: re-read it with oko_get_event and tell the organizer the creation did not complete."
|
|
118501
|
+
];
|
|
118502
|
+
}
|
|
118503
|
+
return [
|
|
118504
|
+
`The event was created, but the server could not confirm its runtime mirrors (${runtime.code}). Chat and access rules may lag behind: run oko_validate_event_setup before relying on the links, and say so to the organizer.`
|
|
118505
|
+
];
|
|
118428
118506
|
}
|
|
118429
118507
|
|
|
118430
118508
|
// build/tools/setupValidation.js
|
|
@@ -118576,17 +118654,48 @@ function assertScheduleHasDate(fresh, patch) {
|
|
|
118576
118654
|
return;
|
|
118577
118655
|
throw new Error("sessionMode 'schedule' requires meta.eventDate: without a date OKO can never start the session automatically. Set eventDate in the same call, or switch sessionMode to 'manual' in the same call to clear the date.");
|
|
118578
118656
|
}
|
|
118657
|
+
var BLOCK_FORM_BINDING_FIELDS = [
|
|
118658
|
+
"blocks.promoBanner.formId",
|
|
118659
|
+
"blocks.primaryCta.formId"
|
|
118660
|
+
];
|
|
118661
|
+
function formIdSet(formsValue) {
|
|
118662
|
+
return new Set((Array.isArray(formsValue) ? formsValue : []).map((form) => typeof form?.id === "string" ? form.id : null).filter((id) => Boolean(id)));
|
|
118663
|
+
}
|
|
118664
|
+
function formBindingsAfterPatch(fresh, patch) {
|
|
118665
|
+
const bindings = [];
|
|
118666
|
+
for (const field2 of BLOCK_FORM_BINDING_FIELDS) {
|
|
118667
|
+
const value = valueAfterPatch(fresh, patch, field2);
|
|
118668
|
+
if (typeof value === "string" && value)
|
|
118669
|
+
bindings.push({ field: field2, formId: value });
|
|
118670
|
+
}
|
|
118671
|
+
const cards = valueAfterPatch(fresh, patch, "blocks.cards.items");
|
|
118672
|
+
if (Array.isArray(cards)) {
|
|
118673
|
+
cards.forEach((card, index) => {
|
|
118674
|
+
const formId = card && typeof card === "object" && !Array.isArray(card) ? card.formId : null;
|
|
118675
|
+
if (typeof formId === "string" && formId) {
|
|
118676
|
+
bindings.push({ field: `blocks.cards.items[${index}].formId`, formId });
|
|
118677
|
+
}
|
|
118678
|
+
});
|
|
118679
|
+
}
|
|
118680
|
+
return bindings;
|
|
118681
|
+
}
|
|
118579
118682
|
function assertFormBindingsExist(fresh, patch) {
|
|
118580
|
-
const
|
|
118581
|
-
|
|
118582
|
-
for (const field2 of ["blocks.promoBanner.formId", "blocks.primaryCta.formId"]) {
|
|
118683
|
+
const formsAfter = formIdSet(valueAfterPatch(fresh, patch, "forms"));
|
|
118684
|
+
for (const field2 of BLOCK_FORM_BINDING_FIELDS) {
|
|
118583
118685
|
if (!Object.prototype.hasOwnProperty.call(patch, field2))
|
|
118584
118686
|
continue;
|
|
118585
118687
|
const value = patch[field2];
|
|
118586
|
-
if (typeof value === "string" && value && !
|
|
118688
|
+
if (typeof value === "string" && value && !formsAfter.has(value)) {
|
|
118587
118689
|
throw new Error(`${field2}: form "${value}" not found in event.forms. Create the form in the same call (or beforehand) \u2014 a button bound to a missing form does nothing for viewers.`);
|
|
118588
118690
|
}
|
|
118589
118691
|
}
|
|
118692
|
+
const formsBefore = formIdSet(valueAfterPatch(fresh, {}, "forms"));
|
|
118693
|
+
const orphaned = formBindingsAfterPatch(fresh, patch).filter((binding) => !formsAfter.has(binding.formId) && formsBefore.has(binding.formId));
|
|
118694
|
+
if (orphaned.length === 0)
|
|
118695
|
+
return;
|
|
118696
|
+
const listed = orphaned.map((binding) => `${binding.field} -> "${binding.formId}"`).join(", ");
|
|
118697
|
+
const cardsAffected = orphaned.some((binding) => binding.field.startsWith("blocks.cards."));
|
|
118698
|
+
throw new Error(`This call removes a form that buttons still point at: ${listed}. A button bound to a missing form does nothing for viewers, so the removal is refused while something still points at it. Unbind them first: pass promoBanner: {formId: null} and/or primaryCta: {formId: null} in this same call` + (cardsAffected ? ", and clear the card bindings with oko_configure_cards before removing the form." : "."));
|
|
118590
118699
|
}
|
|
118591
118700
|
function configureBlocksScopes(args) {
|
|
118592
118701
|
return Object.keys(telegramBlocksPatch(args)).length > 0 ? ["setup:write", "telegram:write"] : ["setup:write"];
|
|
@@ -118600,7 +118709,7 @@ function registerEventTools(server2) {
|
|
|
118600
118709
|
"limit: maximum events to return, default 30, max 100.",
|
|
118601
118710
|
"cursor: pagination cursor returned as nextCursor by the previous page; reuse it with the same filters.",
|
|
118602
118711
|
"workspaceId: optional workspace filter.",
|
|
118603
|
-
'detail: "compact" (default) returns the light card projection; "full" returns
|
|
118712
|
+
'detail: "compact" (default) returns the light card projection; "full" returns event documents without the access composition, same shape as oko_get_event.',
|
|
118604
118713
|
"from/to: optional ISO timestamps bounding meta.eventDate; sorting switches to eventDate descending and undated events are excluded."
|
|
118605
118714
|
],
|
|
118606
118715
|
returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
@@ -118648,7 +118757,10 @@ function registerEventTools(server2) {
|
|
|
118648
118757
|
let events;
|
|
118649
118758
|
if (args.detail === "full") {
|
|
118650
118759
|
const eventDocuments = await Promise.all(pageItems.map((item) => getDoc(doc(firestore, "events", item.id))));
|
|
118651
|
-
events = eventDocuments.flatMap((item) => item.exists() ? [{
|
|
118760
|
+
events = eventDocuments.flatMap((item) => item.exists() ? [{
|
|
118761
|
+
id: item.id,
|
|
118762
|
+
...serialize(eventDocumentForOutput(item.data()))
|
|
118763
|
+
}] : []);
|
|
118652
118764
|
} else {
|
|
118653
118765
|
events = pageItems.map((item) => {
|
|
118654
118766
|
const data = serialize(item.data());
|
|
@@ -118668,7 +118780,7 @@ function registerEventTools(server2) {
|
|
|
118668
118780
|
description: toolDescription({
|
|
118669
118781
|
summary: "Read one OKO event visible to the organizer.",
|
|
118670
118782
|
args: ["eventId: OKO event document id."],
|
|
118671
|
-
returns: "
|
|
118783
|
+
returns: "event document fields plus id and public event URL. The event's access composition (the historical uid arrays) is never returned; ask oko_list_workspace_access for who has access.",
|
|
118672
118784
|
examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
|
|
118673
118785
|
notes: [
|
|
118674
118786
|
"blocks.chat.mode and blocks.chat.visibility are both legacy: the event page reads neither \u2014 the chat panel exists exactly when blocks.chat.enabled is true, and sending is opened or closed live with oko_set_chat_mode. Ignore both fields."
|
|
@@ -118680,7 +118792,11 @@ function registerEventTools(server2) {
|
|
|
118680
118792
|
}, async ({ eventId }) => {
|
|
118681
118793
|
const { uid } = await authed(["events:read"]);
|
|
118682
118794
|
const data = await readEvent(eventId, uid, "read");
|
|
118683
|
-
return textResult({
|
|
118795
|
+
return textResult({
|
|
118796
|
+
id: eventId,
|
|
118797
|
+
...serialize(eventDocumentForOutput(data)),
|
|
118798
|
+
...eventLinks(eventId)
|
|
118799
|
+
});
|
|
118684
118800
|
});
|
|
118685
118801
|
server2.registerTool("oko_validate_event_setup", {
|
|
118686
118802
|
title: "Validate event setup",
|
|
@@ -118736,7 +118852,7 @@ function registerEventTools(server2) {
|
|
|
118736
118852
|
const workspaceId = args.workspaceId ?? uid;
|
|
118737
118853
|
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
118738
118854
|
const video = normalizeVideo(args.videoUrl);
|
|
118739
|
-
const
|
|
118855
|
+
const created = await addEventWithRtdbMirror(buildCreateWebinarDocument({
|
|
118740
118856
|
uid,
|
|
118741
118857
|
workspaceId,
|
|
118742
118858
|
title: args.title,
|
|
@@ -118747,11 +118863,14 @@ function registerEventTools(server2) {
|
|
|
118747
118863
|
chatEnabled: args.chatEnabled,
|
|
118748
118864
|
createdAt: serverTimestamp2()
|
|
118749
118865
|
}));
|
|
118866
|
+
const docRef = created.ref;
|
|
118750
118867
|
return textResult({
|
|
118751
118868
|
id: docRef.id,
|
|
118752
118869
|
...eventLinks(docRef.id),
|
|
118753
118870
|
workspaceId,
|
|
118871
|
+
runtime: created.runtime,
|
|
118754
118872
|
hints: [
|
|
118873
|
+
...eventRuntimeHint(created.runtime),
|
|
118755
118874
|
"The page is live at publicUrl immediately; OKO has no draft state.",
|
|
118756
118875
|
"Next: oko_configure_blocks for chat/CTA/placeholder, oko_configure_cards for cards, image uploads, then oko_validate_event_setup.",
|
|
118757
118876
|
'The video placeholder (countdown/cover) is only shown to viewers after oko_set_block_visibility with block "placeholder" and visible true; enabling it in oko_configure_blocks alone keeps it hidden.'
|
|
@@ -118830,7 +118949,7 @@ function registerEventTools(server2) {
|
|
|
118830
118949
|
args: [
|
|
118831
118950
|
"eventId: event to edit.",
|
|
118832
118951
|
"chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
|
|
118833
|
-
"forms: replaces configured lead forms when provided.",
|
|
118952
|
+
"forms: replaces configured lead forms when provided; removing a form that a button still points at is refused.",
|
|
118834
118953
|
"telegramChannelIds: replaces configured Telegram channels when provided; needs the telegram:write scope AND the notification permission on the event, and is refused before anything is written when either is missing."
|
|
118835
118954
|
],
|
|
118836
118955
|
returns: "event id and the list of Firestore fields updated.",
|
|
@@ -118840,7 +118959,8 @@ function registerEventTools(server2) {
|
|
|
118840
118959
|
"promoBanner has no text field by design: the banner is image+link. blocks.promoBanner.text seen in event documents is legacy and is never rendered on the event page.",
|
|
118841
118960
|
'videoPlaceholder.enabled only allows the placeholder; viewers see it after oko_set_block_visibility with block "placeholder" and visible true (a live override, stored outside the event document). Chat, CTA, banner and cards appear as soon as they are enabled here.',
|
|
118842
118961
|
"Setup toggle vs live override: enabled=false removes a block from the page for everyone and persists (its uploaded image stays); oko_set_block_visibility hides or shows an enabled block temporarily and is the tool for the live show. Uploading an image never enables a block.",
|
|
118843
|
-
"telegramChannelIds is the only field here that is not a page setting: it decides which channel carries this event's messages, and the automatic reminder goes out under the event owner's name. Omit the field to configure blocks with setup:write alone; pass it only with a key that holds telegram:write and for an organizer who may send notifications for this event."
|
|
118962
|
+
"telegramChannelIds is the only field here that is not a page setting: it decides which channel carries this event's messages, and the automatic reminder goes out under the event owner's name. Omit the field to configure blocks with setup:write alone; pass it only with a key that holds telegram:write and for an organizer who may send notifications for this event.",
|
|
118963
|
+
"forms and the buttons bound to them travel together in both directions: binding a button to a form that does not exist is refused, and so is replacing forms in a way that removes a form the banner, the CTA or a card button still points at. Unbind in the same call with promoBanner/primaryCta formId:null; card bindings are cleared with oko_configure_cards first. A binding that was already broken before the call is not touched and does not block it."
|
|
118844
118964
|
]
|
|
118845
118965
|
}),
|
|
118846
118966
|
inputSchema: {
|
|
@@ -119265,7 +119385,6 @@ var MCP_AUDIT_TARGET_KEYS = /* @__PURE__ */ new Set([
|
|
|
119265
119385
|
"postId",
|
|
119266
119386
|
"messageId",
|
|
119267
119387
|
"leadId",
|
|
119268
|
-
"participantUid",
|
|
119269
119388
|
"uid",
|
|
119270
119389
|
"channelId",
|
|
119271
119390
|
"cardId",
|
|
@@ -119681,7 +119800,7 @@ function registerLifecycleTools(server2) {
|
|
|
119681
119800
|
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
119682
119801
|
const sourceHasTelegram = !!source.telegram && typeof source.telegram === "object";
|
|
119683
119802
|
const mayCopyTelegram = sourceHasTelegram && currentScopes().includes("telegram:write") && await eventGrantsMode(eventId, uid, "notify");
|
|
119684
|
-
const
|
|
119803
|
+
const duplicated = await addEventWithRtdbMirror({
|
|
119685
119804
|
ownerId: uid,
|
|
119686
119805
|
workspaceId,
|
|
119687
119806
|
createdAt: serverTimestamp2(),
|
|
@@ -119698,14 +119817,17 @@ function registerLifecycleTools(server2) {
|
|
|
119698
119817
|
// (review 02.09, R3b-5).
|
|
119699
119818
|
...mayCopyTelegram ? { telegram: copyableTelegram(source.telegram) } : {}
|
|
119700
119819
|
});
|
|
119820
|
+
const newRef = duplicated.ref;
|
|
119701
119821
|
return textResult({
|
|
119702
119822
|
id: newRef.id,
|
|
119703
119823
|
sourceEventId: eventId,
|
|
119704
119824
|
workspaceId,
|
|
119825
|
+
runtime: duplicated.runtime,
|
|
119705
119826
|
...eventLinks(newRef.id),
|
|
119706
119827
|
liveStateNotCopied: ["chatMode", "placeholderVisible", "liveBadge", "block visibility overrides"],
|
|
119707
119828
|
telegramCopied: mayCopyTelegram,
|
|
119708
119829
|
hints: [
|
|
119830
|
+
...eventRuntimeHint(duplicated.runtime),
|
|
119709
119831
|
...sourceHasTelegram && !mayCopyTelegram ? ["Telegram config was NOT copied: this key needs telegram:write and the notification permission on the source event. The copy starts with Telegram off; ask the event owner to grant the permission, then configure channels on the copy."] : [],
|
|
119710
119832
|
"Live state was not copied: chat mode is public and the placeholder is hidden on the copy. If the source had chat off or the placeholder shown, set them again with oko_set_chat_mode and oko_set_block_visibility on the new event.",
|
|
119711
119833
|
"The copy is live at publicUrl immediately; edit title, eventDate, and video with oko_update_event, then run oko_validate_event_setup."
|
|
@@ -119792,6 +119914,7 @@ function registerLiveTools(server2) {
|
|
|
119792
119914
|
examples: ["Use visible=false to hide the CTA live; use visible=null to return to setup defaults."],
|
|
119793
119915
|
notes: [
|
|
119794
119916
|
"block=chat with visible=false PAUSES message input: viewers keep seeing the chat history but cannot send. It does not hide the chat panel.",
|
|
119917
|
+
"Chat is the only block whose override the SERVER enforces: it is written as the chat mode and mirrored into the chat security rules, so a paused chat refuses messages from every client. The other four overrides are page presentation only \u2014 they change what viewers see, not what the server accepts.",
|
|
119795
119918
|
"Live overrides sit on top of setup: a block disabled in setup (oko_configure_blocks enabled=false) stays absent whatever the override; visible=null returns an enabled block to its setup default. Use overrides for the live show, setup toggles for permanent changes."
|
|
119796
119919
|
]
|
|
119797
119920
|
}),
|
|
@@ -119846,10 +119969,10 @@ function registerLiveTools(server2) {
|
|
|
119846
119969
|
server2.registerTool("oko_refresh_viewers", {
|
|
119847
119970
|
title: "Refresh viewers",
|
|
119848
119971
|
description: toolDescription({
|
|
119849
|
-
summary: "
|
|
119972
|
+
summary: "Refresh what viewers see: recount the online viewers and make open event pages re-read their config.",
|
|
119850
119973
|
args: ["eventId: event whose viewers should refresh.", "confirm: must be true."],
|
|
119851
|
-
returns: "eventId
|
|
119852
|
-
examples: ["Use after a setup/live change if viewers should reload state immediately."]
|
|
119974
|
+
returns: "eventId, refreshed=true, and online \u2014 the recounted number of viewers present.",
|
|
119975
|
+
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."]
|
|
119853
119976
|
}),
|
|
119854
119977
|
inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to refresh connected viewers.") },
|
|
119855
119978
|
outputSchema: basicEventOutputSchema,
|
|
@@ -119858,8 +119981,13 @@ function registerLiveTools(server2) {
|
|
|
119858
119981
|
const { uid } = await authed(["live:control"]);
|
|
119859
119982
|
await readEvent(eventId, uid, "live");
|
|
119860
119983
|
requireConfirm(confirm, "oko_refresh_viewers", { eventId });
|
|
119861
|
-
|
|
119862
|
-
|
|
119984
|
+
const resyncPresence = httpsCallable(functions, "resyncPresence");
|
|
119985
|
+
const [resynced] = await Promise.all([
|
|
119986
|
+
resyncPresence({ eventId }),
|
|
119987
|
+
runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1)
|
|
119988
|
+
]);
|
|
119989
|
+
const online = typeof resynced.data?.online === "number" ? resynced.data.online : null;
|
|
119990
|
+
return textResult({ eventId, refreshed: true, online });
|
|
119863
119991
|
});
|
|
119864
119992
|
server2.registerTool("oko_set_live_badge", {
|
|
119865
119993
|
title: "Set live badge",
|
|
@@ -120074,6 +120202,63 @@ function summarizeSessionRepair(input) {
|
|
|
120074
120202
|
}
|
|
120075
120203
|
|
|
120076
120204
|
// build/tools/sessions.js
|
|
120205
|
+
function sessionBaselineFromRead(read) {
|
|
120206
|
+
if (!read.ok)
|
|
120207
|
+
return "unread";
|
|
120208
|
+
return read.exists ? "present" : "absent";
|
|
120209
|
+
}
|
|
120210
|
+
function sessionRepairAction(input) {
|
|
120211
|
+
if (input.liveSessionBaseline === "unread")
|
|
120212
|
+
return "unknown";
|
|
120213
|
+
return input.liveSessionBaseline === "present" ? "end" : "release";
|
|
120214
|
+
}
|
|
120215
|
+
function sessionRepairRefusal(action) {
|
|
120216
|
+
if (action === "end") {
|
|
120217
|
+
return "This session still has working state in RTDB, so clearing its pointers would leave a running broadcast without a report. End it instead: oko_end_session saves the report and clears the pointers itself.";
|
|
120218
|
+
}
|
|
120219
|
+
if (action === "unknown") {
|
|
120220
|
+
return "Could not read the session working state (events/{eventId}/sessions/{sessionId}/baseline), so it is unknown whether the session is still running. Nothing was changed; retry, or end the session with oko_end_session.";
|
|
120221
|
+
}
|
|
120222
|
+
return null;
|
|
120223
|
+
}
|
|
120224
|
+
async function assertSessionHasNothingToEnd(eventId, sessionId) {
|
|
120225
|
+
if (!sessionId)
|
|
120226
|
+
return;
|
|
120227
|
+
let read;
|
|
120228
|
+
try {
|
|
120229
|
+
const snap = await get(ref(rtdb, `events/${eventId}/sessions/${sessionId}/baseline`));
|
|
120230
|
+
read = { ok: true, exists: snap.exists() };
|
|
120231
|
+
} catch {
|
|
120232
|
+
read = { ok: false };
|
|
120233
|
+
}
|
|
120234
|
+
const refusal = sessionRepairRefusal(sessionRepairAction({ liveSessionBaseline: sessionBaselineFromRead(read) }));
|
|
120235
|
+
if (refusal)
|
|
120236
|
+
throw new Error(refusal);
|
|
120237
|
+
}
|
|
120238
|
+
async function attemptCleanup(path, run) {
|
|
120239
|
+
try {
|
|
120240
|
+
await run();
|
|
120241
|
+
return { path, status: "fulfilled" };
|
|
120242
|
+
} catch (error3) {
|
|
120243
|
+
return { path, status: "rejected", error: error3 instanceof Error ? error3.message : String(error3) };
|
|
120244
|
+
}
|
|
120245
|
+
}
|
|
120246
|
+
async function releaseSessionPointers(eventId, sessionId) {
|
|
120247
|
+
const rtdbUpdates = {
|
|
120248
|
+
[`events/${eventId}/ctrl/activeSessionId`]: null
|
|
120249
|
+
};
|
|
120250
|
+
if (sessionId)
|
|
120251
|
+
rtdbUpdates[`activeSessions/${sessionId}`] = null;
|
|
120252
|
+
const rtdbPaths = sessionId ? ["ctrl/activeSessionId", `activeSessions/${sessionId}`] : ["ctrl/activeSessionId"];
|
|
120253
|
+
const rtdbOutcome = await attemptCleanup("rtdb", () => update(ref(rtdb), rtdbUpdates));
|
|
120254
|
+
const attempts = rtdbPaths.map((path) => ({ ...rtdbOutcome, path }));
|
|
120255
|
+
if (rtdbOutcome.status === "rejected") {
|
|
120256
|
+
attempts.push({ path: "events.activeAnalyticsSessionId", status: "rejected", error: "skipped: RTDB pointers were not released" });
|
|
120257
|
+
return attempts;
|
|
120258
|
+
}
|
|
120259
|
+
attempts.push(await attemptCleanup("events.activeAnalyticsSessionId", () => updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() })));
|
|
120260
|
+
return attempts;
|
|
120261
|
+
}
|
|
120077
120262
|
function registerSessionTools(server2) {
|
|
120078
120263
|
server2.registerTool("oko_start_session", {
|
|
120079
120264
|
title: "Start analytics session",
|
|
@@ -120135,7 +120320,11 @@ function registerSessionTools(server2) {
|
|
|
120135
120320
|
],
|
|
120136
120321
|
returns: "eventId, cleared sessionId if found, and cleaned paths.",
|
|
120137
120322
|
examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
|
|
120138
|
-
notes: [
|
|
120323
|
+
notes: [
|
|
120324
|
+
"This does not stop the video stream; OKO Start/End controls analytics sessions only.",
|
|
120325
|
+
"Refuses when the session still has working state in RTDB: clearing the pointers would leave a running broadcast without a report. Use oko_end_session for that case.",
|
|
120326
|
+
"Both RTDB pointers are cleared in one atomic update, and the Firestore pointer only after it succeeds: the repair leaves either all pointers cleared or none."
|
|
120327
|
+
]
|
|
120139
120328
|
}),
|
|
120140
120329
|
inputSchema: {
|
|
120141
120330
|
eventId: eventIdField,
|
|
@@ -120150,41 +120339,11 @@ function registerSessionTools(server2) {
|
|
|
120150
120339
|
const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
|
|
120151
120340
|
const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
|
|
120152
120341
|
requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
|
|
120153
|
-
|
|
120154
|
-
|
|
120155
|
-
path: "events.activeAnalyticsSessionId",
|
|
120156
|
-
promise: updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() })
|
|
120157
|
-
},
|
|
120158
|
-
{
|
|
120159
|
-
path: "ctrl/activeSessionId",
|
|
120160
|
-
promise: remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`))
|
|
120161
|
-
}
|
|
120162
|
-
];
|
|
120163
|
-
if (sessionId) {
|
|
120164
|
-
cleanup.push({
|
|
120165
|
-
path: `activeSessions/${sessionId}`,
|
|
120166
|
-
promise: remove(ref(rtdb, `activeSessions/${sessionId}`))
|
|
120167
|
-
});
|
|
120168
|
-
}
|
|
120342
|
+
await assertSessionHasNothingToEnd(eventId, sessionId);
|
|
120343
|
+
const attempts = await releaseSessionPointers(eventId, sessionId);
|
|
120169
120344
|
if (sessionId && deleteRtdbSessionData) {
|
|
120170
|
-
|
|
120171
|
-
path: `events/${eventId}/sessions/${sessionId}`,
|
|
120172
|
-
promise: remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`))
|
|
120173
|
-
});
|
|
120345
|
+
attempts.push(await attemptCleanup(`events/${eventId}/sessions/${sessionId}`, () => remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`))));
|
|
120174
120346
|
}
|
|
120175
|
-
const settled = await Promise.allSettled(cleanup.map((operation) => operation.promise));
|
|
120176
|
-
const attempts = settled.map((result, index) => {
|
|
120177
|
-
const operation = cleanup[index];
|
|
120178
|
-
if (!operation)
|
|
120179
|
-
throw new Error("Session repair result has no matching cleanup operation.");
|
|
120180
|
-
return {
|
|
120181
|
-
path: operation.path,
|
|
120182
|
-
status: result.status,
|
|
120183
|
-
...result.status === "rejected" ? {
|
|
120184
|
-
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
120185
|
-
} : {}
|
|
120186
|
-
};
|
|
120187
|
-
});
|
|
120188
120347
|
let firestorePointer = null;
|
|
120189
120348
|
let rtdbPointer = null;
|
|
120190
120349
|
let verificationError;
|
|
@@ -120220,7 +120379,11 @@ function registerSessionTools(server2) {
|
|
|
120220
120379
|
],
|
|
120221
120380
|
returns: "eventId, sessionId, deleted=true, and cleaned paths.",
|
|
120222
120381
|
examples: ["Use only after confirming the exact session id with the organizer."],
|
|
120223
|
-
notes: [
|
|
120382
|
+
notes: [
|
|
120383
|
+
"Destructive. If the session is active, all three pointers are released first \u2014 atomically in RTDB, then in Firestore \u2014 and only then is the document deleted; the platform refuses to delete the report of a session it still considers active.",
|
|
120384
|
+
"Refuses an active session that still has working state in RTDB: end it with oko_end_session, which saves the report.",
|
|
120385
|
+
"RTDB session data cleanup is best-effort because rules may deny deleting historical session snapshots."
|
|
120386
|
+
]
|
|
120224
120387
|
}),
|
|
120225
120388
|
inputSchema: {
|
|
120226
120389
|
eventId: eventIdField,
|
|
@@ -120235,17 +120398,19 @@ function registerSessionTools(server2) {
|
|
|
120235
120398
|
const event = await readEvent(eventId, uid, "session");
|
|
120236
120399
|
requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
|
|
120237
120400
|
const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
|
|
120238
|
-
const
|
|
120401
|
+
const warnings = [];
|
|
120239
120402
|
if (isActive) {
|
|
120240
|
-
|
|
120241
|
-
|
|
120242
|
-
|
|
120243
|
-
|
|
120244
|
-
|
|
120245
|
-
|
|
120246
|
-
|
|
120247
|
-
|
|
120248
|
-
|
|
120403
|
+
await assertSessionHasNothingToEnd(eventId, sessionId);
|
|
120404
|
+
const attempts = await releaseSessionPointers(eventId, sessionId);
|
|
120405
|
+
warnings.push(...attempts.filter((attempt) => attempt.status === "rejected").map((attempt) => `${attempt.path}: ${attempt.error || "cleanup failed"}`));
|
|
120406
|
+
if (warnings.length > 0)
|
|
120407
|
+
throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
|
|
120408
|
+
}
|
|
120409
|
+
try {
|
|
120410
|
+
await deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId));
|
|
120411
|
+
} catch (error3) {
|
|
120412
|
+
throw new Error(`Session delete partially failed: ${error3 instanceof Error ? error3.message : String(error3)}`);
|
|
120413
|
+
}
|
|
120249
120414
|
const rtdbCleanupWarnings = [];
|
|
120250
120415
|
if (deleteRtdbData) {
|
|
120251
120416
|
try {
|