@okostream/mcp 0.18.2 → 0.19.0

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 (3) hide show
  1. package/README.md +41 -15
  2. package/dist/server.js +1049 -135
  3. package/package.json +2 -2
package/dist/server.js CHANGED
@@ -70598,6 +70598,7 @@ function buildConnectionStatus(status, options2 = {}) {
70598
70598
  nextSteps: nextSteps(status, options2.minimumVersion, options2.latestVersion),
70599
70599
  ...options2.minimumVersion ? { minimumVersion: options2.minimumVersion } : {},
70600
70600
  ...options2.latestVersion ? { latestVersion: options2.latestVersion } : {},
70601
+ ...options2.detail && !connected ? { detail: options2.detail } : {},
70601
70602
  ...options2.uid ? { uid: options2.uid } : {},
70602
70603
  ...options2.scopes ? { scopes: options2.scopes } : {},
70603
70604
  ...options2.update ? { update: options2.update } : {}
@@ -70659,7 +70660,7 @@ var GUIDE_RESOURCES = [
70659
70660
  "1. Call `oko_whoami` and inspect scopes.",
70660
70661
  "2. If the workspace is unclear, call `oko_list_workspaces`.",
70661
70662
  "3. Call `oko_create_webinar` with title/date/workspace and video URL when known.",
70662
- "4. Call `oko_configure_blocks` for video, forms, CTA, banner, and Telegram settings (Telegram channels there need telegram:write plus the notification permission on the event).",
70663
+ "4. Call `oko_configure_blocks` for video, forms, polls, CTA, banner, and Telegram settings (Telegram channels there need telegram:write plus the notification permission on the event).",
70663
70664
  "5. Call `oko_configure_cards` with stable card ids before uploading card images.",
70664
70665
  "6. Upload images with `oko_upload_card_images_batch` or `oko_upload_image`, then wait for variants.",
70665
70666
  "7. Call `oko_validate_event_setup` and fix blocking findings.",
@@ -70677,7 +70678,7 @@ var GUIDE_RESOURCES = [
70677
70678
  text: [
70678
70679
  "# OKO Platform Invariants",
70679
70680
  "",
70680
- "OKO does not host or encode video. An organizer brings a source such as YouTube or an iframe player. OKO builds the webinar experience around it: an event page, configurable blocks, cards, forms, chat, analytics, and integrations.",
70681
+ "OKO does not host or encode video. An organizer brings a source such as YouTube or an iframe player. OKO builds the webinar experience around it: an event page, configurable blocks, cards, forms, polls, chat, analytics, and integrations.",
70681
70682
  "",
70682
70683
  "- OKO events have no `draft`, `live`, or `ended` status field.",
70683
70684
  "- Public event pages render whenever the event exists and normal access rules allow it.",
@@ -70710,15 +70711,15 @@ var GUIDE_RESOURCES = [
70710
70711
  "- `events:read`: list and read events.",
70711
70712
  "- `events:write`: create, duplicate, move, and update events.",
70712
70713
  "- `events:delete`: trash, restore, and permanently delete already-trashed events.",
70713
- "- `setup:write`: configure blocks, forms, cards, and images.",
70714
+ "- `setup:write`: configure blocks, forms, polls, cards, and images.",
70714
70715
  "- `analytics:read`: read live state, sessions, reports, and chat history.",
70715
70716
  "- `analytics:write` and `live:control`: manage analytics sessions.",
70716
- "- `live:control`: change live visibility and refresh viewers.",
70717
+ "- `live:control`: change live visibility, refresh viewers and command polls (`oko_control_poll`).",
70717
70718
  "- `chat:write` and `chat:moderate`: publish and moderate event chat.",
70718
70719
  "- `telegram:write`: link channels and manage Telegram posts.",
70719
70720
  "- `workspace:read` and `workspace:write`: workspaces, members, invites, and notifications.",
70720
70721
  "- `leads:read`: read form submissions; deletion also requires `setup:write`.",
70721
- "- `wallet:read`: read the lumen balance, grants and ledger of the organizer's own wallet (`oko_wallet`); nothing can be bought, granted or spent with it.",
70722
+ "- `wallet:read`: read the plan, the lumen balance, grants and ledger of the organizer's own wallet (`oko_wallet`) and the balance of the wallet that pays for a workspace (`oko_workspace_wallet`); nothing can be bought, changed, granted or spent with it.",
70722
70723
  "",
70723
70724
  "Payment and terms are web-only: no tool collects a password, card or bank details, or agreement to terms on anyone's behalf. Plans and lumens are bought, and terms accepted, only in the OKO web cabinet."
70724
70725
  ].join("\n")
@@ -70735,11 +70736,12 @@ var GUIDE_RESOURCES = [
70735
70736
  "- `meta.eventDate`: scheduled date.",
70736
70737
  "- `video.originalUrl` / `video.embedUrl`: raw and normalized video URLs.",
70737
70738
  "- `forms[]`: lead forms with stable ids, fields, consent, submit, and success copy.",
70739
+ "- `polls[]`: polls with stable ids \u2014 a question, 2 to 10 options with their own ids, one answer or several. A separate list beside `forms[]`: writing one never touches the other.",
70738
70740
  "- `blocks.primaryCta`, `blocks.promoBanner`, and `blocks.cards`: audience content and live visibility.",
70739
70741
  "- `telegram.channelIds`: claimed Telegram channels for notifications.",
70740
70742
  "- Access mirrors are server-owned and must not be written directly.",
70741
70743
  "",
70742
- "A `formId` on a CTA, banner, or card opens that event form in a popup. Use `buttonUrl` only for navigation and set exactly one target in new writes.",
70744
+ "A `formId` on a CTA, banner, or card opens that event form in a popup; a `pollId` opens that poll. Use `buttonUrl` only for navigation and set exactly one target in new writes.",
70743
70745
  "",
70744
70746
  "Do not remove unknown fields or write access mirrors. Use dedicated MCP tools instead of whole-document rewrites."
70745
70747
  ].join("\n")
@@ -116320,7 +116322,7 @@ function sanitizeScopes(scopes) {
116320
116322
  }
116321
116323
 
116322
116324
  // build/version.js
116323
- var OKO_MCP_VERSION = "0.18.2";
116325
+ var OKO_MCP_VERSION = "0.19.0";
116324
116326
 
116325
116327
  // build/auth.js
116326
116328
  var state = null;
@@ -116526,14 +116528,19 @@ async function getConnectionStatus() {
116526
116528
  }
116527
116529
  const detail = error3 instanceof Error ? `${error3.name}: ${error3.message}` : String(error3);
116528
116530
  console.error(`[oko-mcp] connection status: unexpected error \u2014 ${detail}`);
116529
- return buildConnectionStatus("unavailable", { serverMessage: `Unexpected client-side error: ${detail}` });
116531
+ return buildConnectionStatus("unavailable", { detail: `Unexpected client-side error: ${detail}` });
116530
116532
  }
116531
116533
  }
116532
116534
  function authErrorOptions(error3) {
116533
116535
  return {
116534
116536
  minimumVersion: error3.minimumVersion,
116535
116537
  latestVersion: error3.latestVersion,
116536
- serverMessage: error3.serverMessage
116538
+ serverMessage: error3.serverMessage,
116539
+ // 🔴 An `unavailable` error carries its cause in `message` (the transport failure, the HTTP status with the
116540
+ // server's note, the client-side initialisation error). Until 0.19.0 only the constant sentence of the status
116541
+ // went out, and a failed smoke could not say why (28.09: `oko_whoami` failed after a 200 of the exchange, the
116542
+ // retry passed, and the first cause was lost). The other statuses say all there is in their fixed wording.
116543
+ ...error3.status === "unavailable" ? { detail: error3.message } : {}
116537
116544
  };
116538
116545
  }
116539
116546
  function authErrorResult(error3) {
@@ -116565,6 +116572,9 @@ function sessionReportUrl(eventId, sessionId) {
116565
116572
  function inviteUrl(code) {
116566
116573
  return `${APP_ORIGIN}/invite/${code}`;
116567
116574
  }
116575
+ function billingUrl() {
116576
+ return `${APP_ORIGIN}/app/billing`;
116577
+ }
116568
116578
  function eventLinks(eventId) {
116569
116579
  return {
116570
116580
  url: eventUrl(eventId),
@@ -116592,7 +116602,7 @@ var canonicalTelegramChannelSchema = telegramChannelSchema.extend({
116592
116602
 
116593
116603
  // build/tools/imagePipeline.js
116594
116604
  var import_undici = __toESM(require_undici(), 1);
116595
- import { randomUUID as randomUUID2 } from "node:crypto";
116605
+ import { randomUUID as randomUUID3 } from "node:crypto";
116596
116606
  import { lookup } from "node:dns/promises";
116597
116607
  import { stat, readFile, realpath } from "node:fs/promises";
116598
116608
  import { homedir as homedir2 } from "node:os";
@@ -116814,6 +116824,207 @@ function detectAndNormalize(url) {
116814
116824
  return { provider: "unknown", normalizedUrl: null };
116815
116825
  }
116816
116826
 
116827
+ // build/tools/pollModel.js
116828
+ import { randomUUID as randomUUID2 } from "node:crypto";
116829
+ var POLL_ID_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
116830
+ var POLL_OPTION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,31}$/;
116831
+ var RESERVED_IDS = new Set(Object.getOwnPropertyNames(Object.prototype));
116832
+ function isSafeId(id, pattern) {
116833
+ return typeof id === "string" && pattern.test(id) && !RESERVED_IDS.has(id);
116834
+ }
116835
+ var MAX_POLLS = 10;
116836
+ var MIN_POLL_OPTIONS = 2;
116837
+ var MAX_POLL_OPTIONS = 10;
116838
+ var POLL_QUESTION_MAX = 300;
116839
+ var POLL_OPTION_TEXT_MAX = 200;
116840
+ var POLL_TITLE_MAX = 200;
116841
+ var POLL_LABEL_MAX = 60;
116842
+ var POLL_THANKS_MAX = 500;
116843
+ var DEFAULT_POLL_CTRL = {
116844
+ closed: false,
116845
+ live: false,
116846
+ liveAt: null,
116847
+ resultsShown: false,
116848
+ round: 1
116849
+ };
116850
+ var POLL_ACTIONS = ["close", "open", "show_results", "hide_results", "launch", "stop", "reset"];
116851
+ var CONTROL_ROOM_POLL_ACTIONS = ["launch", "reset"];
116852
+ function isRecord(value) {
116853
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
116854
+ }
116855
+ function boundedText(value, max) {
116856
+ if (typeof value !== "string")
116857
+ return null;
116858
+ const trimmed = value.trim();
116859
+ return trimmed && trimmed.length <= max ? trimmed : null;
116860
+ }
116861
+ function parsePoll(raw) {
116862
+ if (!isRecord(raw))
116863
+ return null;
116864
+ const id = typeof raw.id === "string" ? raw.id : "";
116865
+ const question = boundedText(raw.question, POLL_QUESTION_MAX);
116866
+ if (!isSafeId(id, POLL_ID_PATTERN) || !question || !Array.isArray(raw.options))
116867
+ return null;
116868
+ const options2 = [];
116869
+ const seen = /* @__PURE__ */ new Set();
116870
+ for (const item of raw.options) {
116871
+ if (!isRecord(item))
116872
+ return null;
116873
+ const optionId = typeof item.id === "string" ? item.id : "";
116874
+ const text = boundedText(item.text, POLL_OPTION_TEXT_MAX);
116875
+ if (!isSafeId(optionId, POLL_OPTION_ID_PATTERN) || !text || seen.has(optionId))
116876
+ return null;
116877
+ seen.add(optionId);
116878
+ options2.push({ id: optionId, text });
116879
+ }
116880
+ if (options2.length < MIN_POLL_OPTIONS || options2.length > MAX_POLL_OPTIONS)
116881
+ return null;
116882
+ return {
116883
+ id,
116884
+ question,
116885
+ options: options2,
116886
+ multiple: raw.multiple === true,
116887
+ title: boundedText(raw.title, POLL_TITLE_MAX),
116888
+ submitLabel: boundedText(raw.submitLabel, POLL_LABEL_MAX),
116889
+ thanksText: boundedText(raw.thanksText, POLL_THANKS_MAX)
116890
+ };
116891
+ }
116892
+ function parseEventPolls(raw) {
116893
+ if (!Array.isArray(raw))
116894
+ return [];
116895
+ const polls = [];
116896
+ const ids = /* @__PURE__ */ new Set();
116897
+ for (const item of raw.slice(0, MAX_POLLS)) {
116898
+ const poll = parsePoll(item);
116899
+ if (poll && !ids.has(poll.id)) {
116900
+ ids.add(poll.id);
116901
+ polls.push(poll);
116902
+ }
116903
+ }
116904
+ return polls;
116905
+ }
116906
+ function pollIdSet(polls) {
116907
+ return new Set((Array.isArray(polls) ? polls : []).map((poll) => isRecord(poll) && typeof poll.id === "string" ? poll.id.trim() : "").filter(Boolean));
116908
+ }
116909
+ function readPollState(node) {
116910
+ const raw = isRecord(node) ? node : {};
116911
+ const round = typeof raw.round === "number" && Number.isInteger(raw.round) && raw.round >= 1 ? raw.round : 1;
116912
+ return {
116913
+ closed: raw.closed === true,
116914
+ live: raw.live === true,
116915
+ liveAt: typeof raw.liveAt === "number" && Number.isFinite(raw.liveAt) ? raw.liveAt : null,
116916
+ resultsShown: raw.resultsShown === true,
116917
+ round
116918
+ };
116919
+ }
116920
+ function readPollTally(node) {
116921
+ const raw = isRecord(node) ? node : {};
116922
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
116923
+ const options2 = {};
116924
+ if (isRecord(raw.options)) {
116925
+ for (const [optionId, value] of Object.entries(raw.options)) {
116926
+ if (isSafeId(optionId, POLL_OPTION_ID_PATTERN))
116927
+ options2[optionId] = count(value);
116928
+ }
116929
+ }
116930
+ return { total: count(raw.total), options: options2 };
116931
+ }
116932
+ function readSessionPolls(raw) {
116933
+ if (!Array.isArray(raw))
116934
+ return [];
116935
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
116936
+ const rounds = [];
116937
+ for (const item of raw) {
116938
+ if (!isRecord(item))
116939
+ continue;
116940
+ if (typeof item.pollId !== "string" || !item.pollId)
116941
+ continue;
116942
+ if (typeof item.round !== "number" || !Number.isInteger(item.round) || item.round < 1)
116943
+ continue;
116944
+ const options2 = Array.isArray(item.options) ? item.options.flatMap((option) => isRecord(option) && typeof option.id === "string" ? [{ id: option.id, text: typeof option.text === "string" ? option.text : option.id, count: count(option.count) }] : []) : [];
116945
+ rounds.push({
116946
+ pollId: item.pollId,
116947
+ question: typeof item.question === "string" ? item.question : "",
116948
+ round: item.round,
116949
+ multiple: item.multiple === true,
116950
+ options: options2,
116951
+ total: count(item.total)
116952
+ });
116953
+ }
116954
+ return rounds;
116955
+ }
116956
+ var OPTION_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
116957
+ function newPollId() {
116958
+ return `p-${randomUUID2()}`;
116959
+ }
116960
+ function newOptionId(taken = /* @__PURE__ */ new Set()) {
116961
+ for (let attempt = 0; attempt < 8; attempt += 1) {
116962
+ const chars = Array.from(randomUUID2().replace(/-/g, "").slice(0, 8), (char) => OPTION_ID_ALPHABET[parseInt(char, 16) % OPTION_ID_ALPHABET.length]);
116963
+ const id = `o${chars.join("")}`;
116964
+ if (!taken.has(id))
116965
+ return id;
116966
+ }
116967
+ return `o${randomUUID2().replace(/-/g, "").slice(0, 16)}`;
116968
+ }
116969
+ var orNull = (value) => value?.trim() || null;
116970
+ function buildPollsForWrite(inputs, ids = { poll: newPollId, option: newOptionId }) {
116971
+ if (inputs.length > MAX_POLLS)
116972
+ throw new Error(`At most ${MAX_POLLS} polls fit on a page.`);
116973
+ const seenPolls = /* @__PURE__ */ new Set();
116974
+ return inputs.map((input, index) => {
116975
+ const where2 = `polls[${index}]`;
116976
+ const id = input.id ?? ids.poll();
116977
+ if (!isSafeId(id, POLL_ID_PATTERN)) {
116978
+ throw new Error(`${where2}.id "${id}" is not a usable poll id: a letter first, then letters, digits or hyphens, up to 64 characters.`);
116979
+ }
116980
+ if (seenPolls.has(id))
116981
+ throw new Error(`${where2}.id "${id}" is used by another poll in this call: every poll needs its own id.`);
116982
+ seenPolls.add(id);
116983
+ const question = input.question.trim();
116984
+ if (!question)
116985
+ throw new Error(`${where2}.question is empty.`);
116986
+ if (question.length > POLL_QUESTION_MAX)
116987
+ throw new Error(`${where2}.question is longer than ${POLL_QUESTION_MAX} characters.`);
116988
+ const taken = new Set(input.options.flatMap((option) => option.id ? [option.id] : []));
116989
+ const seenOptions = /* @__PURE__ */ new Set();
116990
+ const options2 = input.options.map((option, optionIndex) => {
116991
+ const optionWhere = `${where2}.options[${optionIndex}]`;
116992
+ const optionId = option.id ?? ids.option(taken);
116993
+ taken.add(optionId);
116994
+ if (!isSafeId(optionId, POLL_OPTION_ID_PATTERN)) {
116995
+ throw new Error(`${optionWhere}.id "${optionId}" is not a usable option id: a letter first, then letters, digits or hyphens, up to 32 characters.`);
116996
+ }
116997
+ if (seenOptions.has(optionId))
116998
+ throw new Error(`${optionWhere}.id "${optionId}" is used by another option of the same poll.`);
116999
+ seenOptions.add(optionId);
117000
+ const text = option.text.trim();
117001
+ if (!text)
117002
+ throw new Error(`${optionWhere}.text is empty.`);
117003
+ if (text.length > POLL_OPTION_TEXT_MAX)
117004
+ throw new Error(`${optionWhere}.text is longer than ${POLL_OPTION_TEXT_MAX} characters.`);
117005
+ return { id: optionId, text };
117006
+ });
117007
+ if (options2.length < MIN_POLL_OPTIONS || options2.length > MAX_POLL_OPTIONS) {
117008
+ throw new Error(`${where2} needs ${MIN_POLL_OPTIONS} to ${MAX_POLL_OPTIONS} options, got ${options2.length}.`);
117009
+ }
117010
+ const capped = (name7, max) => {
117011
+ const value = orNull(input[name7]);
117012
+ if (value !== null && value.length > max)
117013
+ throw new Error(`${where2}.${name7} is longer than ${max} characters.`);
117014
+ return value;
117015
+ };
117016
+ return {
117017
+ id,
117018
+ question,
117019
+ options: options2,
117020
+ multiple: input.multiple === true,
117021
+ title: capped("title", POLL_TITLE_MAX),
117022
+ submitLabel: capped("submitLabel", POLL_LABEL_MAX),
117023
+ thanksText: capped("thanksText", POLL_THANKS_MAX)
117024
+ };
117025
+ });
117026
+ }
117027
+
116817
117028
  // build/tools/formatting.js
116818
117029
  var RETIRED_SESSION_FIELDS = ["nmoEvaluation"];
116819
117030
  function cursorMeta(count, max, hasMore, nextCursor) {
@@ -116984,17 +117195,31 @@ function reportQualityCaveats(dataQuality) {
116984
117195
  caveats.push("watchTimeUnmeasured");
116985
117196
  if (marks.watchTime === "unknown_duration")
116986
117197
  caveats.push("watchTimeUncapped");
117198
+ if (marks.polls === "unmeasured")
117199
+ caveats.push("pollsUnmeasured");
116987
117200
  return caveats;
116988
117201
  }
116989
117202
  var REPORT_QUALITY_CAVEAT_TEXT = {
116990
117203
  baselineMissing: "The session baseline was not saved: every figure below is counted from zero and includes everything the event accumulated before this broadcast.",
116991
117204
  startTimeUnknown: "The broadcast start time was not saved: the report is stamped with the moment it ended and its duration reads zero.",
116992
117205
  watchTimeUnmeasured: "Watch time was not measured: the engagement snapshot could not be read.",
116993
- watchTimeUncapped: "Watch time is not capped by the broadcast length \u2014 it is unknown, so nothing vouches for the total, average and median times."
117206
+ watchTimeUncapped: "Watch time is not capped by the broadcast length \u2014 it is unknown, so nothing vouches for the total, average and median times.",
117207
+ pollsUnmeasured: "The votes of this session could not be counted: an empty polls section means \u201Cnot measured\u201D, not \u201Cnobody voted\u201D."
116994
117208
  };
116995
117209
  function sessionQualityWarnings(dataQuality) {
116996
117210
  return reportQualityCaveats(dataQuality).map((caveat) => REPORT_QUALITY_CAVEAT_TEXT[caveat]);
116997
117211
  }
117212
+ function pollRoundMarkdown(round) {
117213
+ const name7 = round.question || "(a poll no longer on the page)";
117214
+ const several = round.multiple ? ", several answers allowed" : "";
117215
+ const voters = `${round.total} ${round.total === 1 ? "voter" : "voters"}`;
117216
+ const lines = [`- ${name7} \u2014 round ${round.round}${several}: ${voters}`];
117217
+ for (const option of round.options) {
117218
+ const share = round.total > 0 ? ` (${Math.round(option.count / round.total * 1e3) / 10}%)` : "";
117219
+ lines.push(` - ${option.text}: ${option.count}${share}`);
117220
+ }
117221
+ return lines;
117222
+ }
116998
117223
  function sessionReportMarkdown({ eventId, event, sessionId, session }) {
116999
117224
  const lines = [
117000
117225
  `# OKO session report`,
@@ -117024,6 +117249,12 @@ function sessionReportMarkdown({ eventId, event, sessionId, session }) {
117024
117249
  markdownMetric("CTA clicks", session.ctaClicks),
117025
117250
  markdownMetric("Cards clicks", session.cardsClicks)
117026
117251
  ];
117252
+ const pollRounds = readSessionPolls(session.polls);
117253
+ if (pollRounds.length > 0) {
117254
+ lines.push("", "## Polls", "Votes cast during this session. A round that ran through several sessions is split between their reports.");
117255
+ for (const round of pollRounds)
117256
+ lines.push(...pollRoundMarkdown(round));
117257
+ }
117027
117258
  const warnings = sessionQualityWarnings(session.dataQuality);
117028
117259
  if (warnings.length > 0) {
117029
117260
  lines.push("", "## What this report did not measure");
@@ -117420,10 +117651,10 @@ async function uploadImageFromSource(eventId, kind, cardId, source) {
117420
117651
  const uploaded2 = await uploadMasterImage(eventId, kind, cardId, { sourceUrl: source.url });
117421
117652
  return { uploaded: uploaded2, sourceBytes: uploaded2.sourceBytes ?? 0, sourceVia: "server", notes: [SERVER_FETCH_NOTE] };
117422
117653
  } catch (error3) {
117423
- const refusal6 = serverFetchRefusal(error3);
117424
- if (!refusal6)
117654
+ const refusal7 = serverFetchRefusal(error3);
117655
+ if (!refusal7)
117425
117656
  throw error3;
117426
- serverFailure = refusal6;
117657
+ serverFailure = refusal7;
117427
117658
  }
117428
117659
  let buffer;
117429
117660
  const fetchNotes = [];
@@ -117517,7 +117748,7 @@ function readCardsConfig(eventData) {
117517
117748
  // 🔴 The UUID is generated only when it is needed: previously the
117518
117749
  // crypto RNG was invoked for every card on every read, and the value
117519
117750
  // was discarded (review 02.09, R3c-14)
117520
- id: hasUsableId ? item.id : randomUUID2(),
117751
+ id: hasUsableId ? item.id : randomUUID3(),
117521
117752
  title: safeString(item.title).trim() || null,
117522
117753
  text: safeString(item.text).trim() || null,
117523
117754
  buttonLabel: safeString(item.buttonLabel).trim() || null,
@@ -117527,7 +117758,8 @@ function readCardsConfig(eventData) {
117527
117758
  imageVariants: readImageVariants(item.imageVariants),
117528
117759
  imageWidth: typeof item.imageWidth === "number" ? item.imageWidth : null,
117529
117760
  imageHeight: typeof item.imageHeight === "number" ? item.imageHeight : null,
117530
- formId: safeString(item.formId).trim() || null
117761
+ formId: safeString(item.formId).trim() || null,
117762
+ pollId: safeString(item.pollId).trim() || null
117531
117763
  };
117532
117764
  });
117533
117765
  const ratio = safeString(raw.cardImageRatio);
@@ -117566,6 +117798,9 @@ function eventFormIds(eventData) {
117566
117798
  const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
117567
117799
  return new Set(rawForms.map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "").filter(Boolean));
117568
117800
  }
117801
+ function eventPollIds(eventData) {
117802
+ return pollIdSet(eventData.polls);
117803
+ }
117569
117804
  function cardHasImage(item) {
117570
117805
  return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
117571
117806
  }
@@ -117576,7 +117811,8 @@ var cardItemInputSchema = external_exports.object({
117576
117811
  text: external_exports.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
117577
117812
  buttonLabel: external_exports.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
117578
117813
  buttonUrl: external_exports.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
117579
- formId: cardIdSchema.nullable().optional()
117814
+ formId: cardIdSchema.nullable().optional(),
117815
+ pollId: cardIdSchema.nullable().optional().describe("Opens this event poll (an id of oko_configure_blocks polls) instead of a link or a form. A button has one action: set at most one of buttonUrl, formId, pollId.")
117580
117816
  }).strict();
117581
117817
  var imageSourceSchema = external_exports.object({
117582
117818
  path: external_exports.string().min(1).optional().describe("Local image path on the MCP client machine (inside OKO_IMAGE_ROOTS)."),
@@ -117584,13 +117820,14 @@ var imageSourceSchema = external_exports.object({
117584
117820
  }).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
117585
117821
  message: "Provide exactly one image source: path or url."
117586
117822
  });
117587
- function buildCardsItems({ inputs, existing, formIds }) {
117823
+ function buildCardsItems({ inputs, existing, formIds, pollIds }) {
117588
117824
  const existingById = new Map(existing.map((item) => [item.id, item]));
117589
117825
  const seen = /* @__PURE__ */ new Set();
117590
117826
  const generatedCardIds = [];
117591
117827
  const preservedImageCardIds = [];
117828
+ const droppedBindings = [];
117592
117829
  const items = inputs.map((input) => {
117593
- const id = input.id ?? randomUUID2();
117830
+ const id = input.id ?? randomUUID3();
117594
117831
  if (seen.has(id))
117595
117832
  throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
117596
117833
  seen.add(id);
@@ -117602,9 +117839,20 @@ function buildCardsItems({ inputs, existing, formIds }) {
117602
117839
  if (formId && !formIds.has(formId)) {
117603
117840
  throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
117604
117841
  }
117842
+ const pollId = normalizeNullableText(input.pollId);
117843
+ if (pollId && !pollIds.has(pollId)) {
117844
+ throw new Error(`Poll ${pollId} not found in event.polls. Create it with oko_configure_blocks polls first, or set pollId:null.`);
117845
+ }
117846
+ if (formId && pollId) {
117847
+ throw new Error(`Card ${id} cannot open a form and a poll at once: a button has one action. Keep formId or pollId and set the other to null.`);
117848
+ }
117605
117849
  const previous = existingById.get(id);
117606
117850
  if (previous && cardHasImage(previous))
117607
117851
  preservedImageCardIds.push(id);
117852
+ if (previous?.formId && !formId)
117853
+ droppedBindings.push({ cardId: id, kind: "form", targetId: previous.formId });
117854
+ if (previous?.pollId && !pollId)
117855
+ droppedBindings.push({ cardId: id, kind: "poll", targetId: previous.pollId });
117608
117856
  return {
117609
117857
  id,
117610
117858
  title: normalizeNullableText(input.title),
@@ -117616,10 +117864,11 @@ function buildCardsItems({ inputs, existing, formIds }) {
117616
117864
  imageVariants: previous?.imageVariants ?? null,
117617
117865
  imageWidth: previous?.imageWidth ?? null,
117618
117866
  imageHeight: previous?.imageHeight ?? null,
117619
- formId
117867
+ formId,
117868
+ pollId
117620
117869
  };
117621
117870
  });
117622
- return { items, generatedCardIds, preservedImageCardIds };
117871
+ return { items, generatedCardIds, preservedImageCardIds, droppedBindings };
117623
117872
  }
117624
117873
  async function deleteStoragePaths(eventId, paths) {
117625
117874
  const uniquePaths = [...new Set(paths.filter(Boolean))];
@@ -117890,6 +118139,8 @@ var connectionStatusOutputSchema = external_exports.object({
117890
118139
  status: external_exports.enum(OKO_CONNECTION_STATUSES),
117891
118140
  errorCode: external_exports.string().nullable(),
117892
118141
  message: external_exports.string(),
118142
+ /** Why the connection could not be verified (`unavailable` only): the cause, next to the stable message. */
118143
+ detail: external_exports.string().optional(),
117893
118144
  product: external_exports.object({
117894
118145
  name: external_exports.string(),
117895
118146
  summary: external_exports.string(),
@@ -118045,8 +118296,18 @@ var notificationsMarkOutputSchema = external_exports.object({
118045
118296
  markedRead: external_exports.number().int().nonnegative(),
118046
118297
  notificationIds: external_exports.array(external_exports.string())
118047
118298
  }).passthrough();
118299
+ var walletPlanSchema = external_exports.object({
118300
+ tier: external_exports.enum(["core", "pro", "max", "legacy"]),
118301
+ status: external_exports.enum(["active", "past_due", "canceled_at_period_end", "expired"]),
118302
+ source: external_exports.enum(["lava", "operator", "legacy-exit", "transition-b", "smoke"]).nullable(),
118303
+ periodEnd: external_exports.string().nullable(),
118304
+ graceUntil: external_exports.string().nullable()
118305
+ });
118048
118306
  var walletOutputSchema = external_exports.object({
118049
118307
  uid: external_exports.string(),
118308
+ billingUrl: external_exports.string().url(),
118309
+ plan: walletPlanSchema.nullable().optional(),
118310
+ planRead: external_exports.enum(["ok", "unreadable"]).optional(),
118050
118311
  hasWallet: external_exports.boolean(),
118051
118312
  balance: external_exports.number().int().nullable(),
118052
118313
  unit: external_exports.literal("lumens"),
@@ -118057,6 +118318,15 @@ var walletOutputSchema = external_exports.object({
118057
118318
  hints: external_exports.array(external_exports.string()),
118058
118319
  ...cursorMetaSchema
118059
118320
  }).passthrough();
118321
+ var workspaceWalletOutputSchema = external_exports.object({
118322
+ workspaceId: external_exports.string(),
118323
+ hasWallet: external_exports.boolean(),
118324
+ available: external_exports.number().int().nullable(),
118325
+ unit: external_exports.literal("lumens"),
118326
+ payerIsCaller: external_exports.boolean(),
118327
+ billingUrl: external_exports.string().url(),
118328
+ hints: external_exports.array(external_exports.string())
118329
+ }).passthrough();
118060
118330
  var FORM_FIELD_TYPES = ["text", "tel", "email", "textarea", "select", "checkbox"];
118061
118331
  var formFieldSchema = external_exports.object({
118062
118332
  id: external_exports.string().min(1).optional(),
@@ -118085,6 +118355,19 @@ var formSchema = external_exports.object({
118085
118355
  consentRequired: external_exports.boolean().default(true),
118086
118356
  fields: external_exports.array(formFieldSchema).max(30)
118087
118357
  }).strict();
118358
+ var pollOptionSchema = external_exports.object({
118359
+ id: external_exports.string().regex(POLL_OPTION_ID_PATTERN, "A letter first, then letters, digits or hyphens, up to 32 characters.").optional().describe("Stable option id. Keep it when editing a poll: the votes of an option are tied to its id, so a renamed option keeps its count. Omit for a NEW option."),
118360
+ text: external_exports.string().min(1).max(POLL_OPTION_TEXT_MAX).describe("The option as viewers read it.")
118361
+ }).strict();
118362
+ var pollSchema = external_exports.object({
118363
+ id: external_exports.string().regex(POLL_ID_PATTERN, "A letter first, then letters, digits or hyphens, up to 64 characters.").optional().describe("Stable poll id; buttons bind to it (pollId). Keep it when editing. Omit for a NEW poll."),
118364
+ question: external_exports.string().min(1).max(POLL_QUESTION_MAX).describe("The question viewers answer."),
118365
+ options: external_exports.array(pollOptionSchema).min(MIN_POLL_OPTIONS).max(MAX_POLL_OPTIONS),
118366
+ multiple: external_exports.boolean().optional().describe("true: viewers may pick several options (checkboxes); false or omitted: exactly one."),
118367
+ title: external_exports.string().max(POLL_TITLE_MAX).nullable().optional().describe("Heading of the popup; null or omitted: the question is the heading."),
118368
+ submitLabel: external_exports.string().max(POLL_LABEL_MAX).nullable().optional().describe("Label of the vote button."),
118369
+ thanksText: external_exports.string().max(POLL_THANKS_MAX).nullable().optional().describe("Shown after the vote while the results are hidden.")
118370
+ }).strict();
118088
118371
  var profileDisplayNameSchema = external_exports.string().max(40).nullable();
118089
118372
  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) => {
118090
118373
  try {
@@ -118158,14 +118441,14 @@ var SCOPE_UNLOCKS = {
118158
118441
  "setup:write": "image uploads and setup-only changes",
118159
118442
  "analytics:read": "analytics, sessions, and reports",
118160
118443
  "analytics:write": "starting, ending, and repairing analytics sessions",
118161
- "live:control": "live block visibility, live badge, viewer refresh",
118444
+ "live:control": "live block visibility, live badge, viewer refresh, poll commands",
118162
118445
  "chat:write": "sending organizer chat messages",
118163
118446
  "chat:moderate": "chat mode, moderation, bans and mutes",
118164
118447
  "telegram:write": "claiming channels and publishing Telegram posts",
118165
118448
  "workspace:read": "listing workspaces, members, invites, notifications",
118166
118449
  "workspace:write": "workspace management, invites, profile edits",
118167
118450
  "leads:read": "reading and exporting leads",
118168
- "wallet:read": "reading the lumen balance, grants and ledger of the organizer's own wallet"
118451
+ "wallet:read": "reading the plan, the lumen balance, grants and ledger of the organizer's own wallet, and the balance of the wallet that pays for a workspace"
118169
118452
  };
118170
118453
  function presetsWithScope(scope) {
118171
118454
  return Object.entries(MCP_PRESETS).filter(([, scopes]) => scopes.includes(scope)).map(([name7]) => name7);
@@ -118409,6 +118692,7 @@ var SCOPE_WORKAROUNDS = {
118409
118692
  "oko_trash_event|events:delete": { text: "This key cannot delete events. Tell the organizer which event to remove; they can do it in the OKO cabinet, or create a key with the full preset." },
118410
118693
  "oko_delete_event_permanently|events:delete": { text: "This key cannot delete events. Tell the organizer which event to remove; they can do it in the OKO cabinet, or create a key with the full preset." },
118411
118694
  "oko_wallet|wallet:read": { text: "A key created before wallet:read existed does not carry it, and the scopes of a key cannot be changed: the organizer creates a new key in the OKO cabinet, puts it into OKO_API_KEY of the MCP client and revokes the old one. Until then the balance and the ledger are visible in the cabinet under Billing." },
118695
+ "oko_workspace_wallet|wallet:read": { text: "A key created before wallet:read existed does not carry it, and the scopes of a key cannot be changed: the organizer creates a new key in the OKO cabinet, puts it into OKO_API_KEY of the MCP client and revokes the old one. Until then the balance is visible in the cabinet under Billing." },
118412
118696
  "oko_publish_telegram_post|telegram:write": { text: "This key cannot publish to Telegram. The organizer can publish the announcement from the OKO cabinet, or create a key with the full preset." },
118413
118697
  // 🔴 The most frequent refusal of this whole release (`ADV-A6-19`): an
118414
118698
  // `operator` key calling oko_configure_blocks with channel ids. The advice
@@ -119155,7 +119439,7 @@ function registerPublicationTools(server2) {
119155
119439
  examples: ["Call without confirm, show the organizer the bill from the preview, and call again with confirm: true after explicit consent."],
119156
119440
  notes: [
119157
119441
  "The wallet belongs to the workspace owner; a member publishing in a shared workspace spends the owner's lumens.",
119158
- "Refusals carry errorCode and whatToDo: ENTITLEMENT_REQUIRED, INSUFFICIENT_TOKENS, EVENT_DATE_REQUIRED, EVENT_WINDOW_PASSED (the event date is more than 48 hours in the past \u2014 move it forward), PAYER_UNRESOLVED, WORKSPACE_INACTIVE (the plan holds fewer shared workspaces than the owner has \u2014 on Pro only the workspace the owner chose as active takes NEW publications; the owner picks it on the OKO cabinet's Workspaces page, or moves to Max), EDIT_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH, EVENT_DATE_LOCKED (from oko_update_event, not this tool).",
119442
+ "Refusals carry errorCode and whatToDo: ENTITLEMENT_REQUIRED, INSUFFICIENT_TOKENS, EVENT_DATE_REQUIRED, EVENT_WINDOW_PASSED (the event date is more than 48 hours in the past \u2014 move it forward), PAYER_UNRESOLVED, WORKSPACE_INACTIVE (only a NEW publication into a shared workspace is closed: Core \u2014 shared workspaces are not included (Pro); Pro \u2014 one active workspace (the owner's choice, or Max); no live subscription \u2014 renew; the owner makes a workspace the active one on the OKO cabinet's Workspaces page), EDIT_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH, EVENT_DATE_LOCKED (from oko_update_event, not this tool).",
119159
119443
  'Repeating confirm on a published page charges nothing and repairs viewer-side switches; on a page whose show is over ("held"/\u041F\u0440\u043E\u0432\u0435\u0434\u0435\u043D\u043E) it charges nothing either and starts nothing new \u2014 duplicate the page for a new show.',
119160
119444
  "Once published, the event date moves only inside the show window bought here (oko_update_event refuses EVENT_DATE_LOCKED outside it): for another date, unpublish with oko_unpublish_event first while the window has not opened, or duplicate the page."
119161
119445
  ]
@@ -119226,6 +119510,8 @@ function paidBlocksTurnedOn(patch) {
119226
119510
  blocks.push("cards");
119227
119511
  if (Array.isArray(patch.forms) && patch.forms.length > 0)
119228
119512
  blocks.push("forms");
119513
+ if (Array.isArray(patch.polls) && patch.polls.length > 0)
119514
+ blocks.push("polls");
119229
119515
  return blocks;
119230
119516
  }
119231
119517
  function pageSoldItsShow(event) {
@@ -119390,6 +119676,59 @@ function controlRoomPreview(event) {
119390
119676
  };
119391
119677
  }
119392
119678
 
119679
+ // build/tools/pollBindings.js
119680
+ var BLOCK_POLL_BINDING_FIELDS = [
119681
+ "blocks.promoBanner.pollId",
119682
+ "blocks.primaryCta.pollId"
119683
+ ];
119684
+ var FORM_FIELD_OF = {
119685
+ "blocks.promoBanner.pollId": "blocks.promoBanner.formId",
119686
+ "blocks.primaryCta.pollId": "blocks.primaryCta.formId"
119687
+ };
119688
+ function pollBindingsAfterPatch(fresh, patch) {
119689
+ const bindings = [];
119690
+ for (const field2 of BLOCK_POLL_BINDING_FIELDS) {
119691
+ const value = valueAfterPatch(fresh, patch, field2);
119692
+ if (typeof value === "string" && value)
119693
+ bindings.push({ field: field2, pollId: value });
119694
+ }
119695
+ const cards = valueAfterPatch(fresh, patch, "blocks.cards.items");
119696
+ if (Array.isArray(cards)) {
119697
+ cards.forEach((card, index) => {
119698
+ const pollId = card && typeof card === "object" && !Array.isArray(card) ? card.pollId : null;
119699
+ if (typeof pollId === "string" && pollId) {
119700
+ bindings.push({ field: `blocks.cards.items[${index}].pollId`, pollId });
119701
+ }
119702
+ });
119703
+ }
119704
+ return bindings;
119705
+ }
119706
+ function assertPollBindingsExist(fresh, patch) {
119707
+ const pollsAfter = pollIdSet(valueAfterPatch(fresh, patch, "polls"));
119708
+ for (const field2 of BLOCK_POLL_BINDING_FIELDS) {
119709
+ if (!Object.prototype.hasOwnProperty.call(patch, field2))
119710
+ continue;
119711
+ const value = patch[field2];
119712
+ if (typeof value !== "string" || !value)
119713
+ continue;
119714
+ if (!pollsAfter.has(value)) {
119715
+ throw new Error(`${field2}: poll "${value}" not found in event.polls. Create the poll in the same call (or beforehand) \u2014 a button bound to a missing poll does nothing for viewers.`);
119716
+ }
119717
+ const formField = FORM_FIELD_OF[field2];
119718
+ const form = valueAfterPatch(fresh, patch, formField);
119719
+ if (typeof form === "string" && form) {
119720
+ throw new Error(`${field2}: this button already opens the form "${form}", and a button has one action. Pass ${field2.split(".")[1]}: {formId: null} in the same call to switch it to the poll.`);
119721
+ }
119722
+ }
119723
+ const pollsBefore = pollIdSet(valueAfterPatch(fresh, {}, "polls"));
119724
+ const orphaned = pollBindingsAfterPatch(fresh, patch).filter((binding) => !pollsAfter.has(binding.pollId) && pollsBefore.has(binding.pollId));
119725
+ if (orphaned.length === 0)
119726
+ return;
119727
+ const listed = orphaned.map((binding) => `${binding.field} -> "${binding.pollId}"`).join(", ");
119728
+ const cardsAffected = orphaned.some((binding) => binding.field.startsWith("blocks.cards."));
119729
+ throw new Error(`This call removes a poll that buttons still point at: ${listed}. A button bound to a missing poll does nothing for viewers, so the removal is refused while something still points at it. Unbind them first: pass promoBanner: {pollId: null} and/or primaryCta: {pollId: null} in this same call` + (cardsAffected ? ", and clear the card bindings with oko_configure_cards (pollId: null on those cards) before removing the poll." : "."));
119730
+ }
119731
+
119393
119732
  // build/tools/eventCreate.js
119394
119733
  async function assertCanCreateInWorkspace(uid, workspaceId) {
119395
119734
  if (workspaceId === uid)
@@ -119467,6 +119806,11 @@ function buildDuplicateEventDocument(input) {
119467
119806
  meta: withoutSessionPlan(source.meta) ?? { eventDate: null, speakerName: null, description: null },
119468
119807
  blocks: source.blocks ?? {},
119469
119808
  ...Array.isArray(source.forms) ? { forms: source.forms } : {},
119809
+ // Polls travel with the copy like forms do: a button of `blocks` can point at a pollId, and a copy without
119810
+ // `polls` would leave that button bound to a poll the new document does not have. Only the CONFIGURATION is
119811
+ // copied — the votes, the rounds and the state of the show stay with the source, so the copy starts at round 1
119812
+ // with no votes (the web's duplicate does the same, `EventsPageClient.tsx`).
119813
+ ...Array.isArray(source.polls) ? { polls: source.polls } : {},
119470
119814
  // 🔴 The copy does NOT inherit server-owned Telegram markers.
119471
119815
  // `reminderSent` is set by CF17 after sending the reminder, and the same
119472
119816
  // function skips events with this flag forever: a copy of an event whose
@@ -119525,7 +119869,7 @@ function scheduleWindowPassed(sessionMode, eventDateMs, rawDurationMinutes, nowM
119525
119869
  }
119526
119870
 
119527
119871
  // build/tools/sessionPlan.js
119528
- function isRecord(value) {
119872
+ function isRecord2(value) {
119529
119873
  return typeof value === "object" && value !== null && !Array.isArray(value);
119530
119874
  }
119531
119875
  function timestampMillis(value) {
@@ -119537,7 +119881,7 @@ function timestampMillis(value) {
119537
119881
  return null;
119538
119882
  }
119539
119883
  function readSessionPlan(event, durationMsOf) {
119540
- const meta2 = isRecord(event.meta) ? event.meta : {};
119884
+ const meta2 = isRecord2(event.meta) ? event.meta : {};
119541
119885
  const startMs = timestampMillis(meta2.sessionStartAt);
119542
119886
  const endMs = timestampMillis(meta2.sessionEndAt);
119543
119887
  if (startMs !== null && endMs !== null && endMs > startMs)
@@ -119555,7 +119899,7 @@ function sessionPlanWarnings(event) {
119555
119899
  const plan = sessionPlanOf(event);
119556
119900
  if (!plan || plan.source !== "meta")
119557
119901
  return [];
119558
- const meta2 = isRecord(event.meta) ? event.meta : {};
119902
+ const meta2 = isRecord2(event.meta) ? event.meta : {};
119559
119903
  const eventDateMs = timestampMillis(meta2.eventDate);
119560
119904
  if (eventDateMs === null)
119561
119905
  return [];
@@ -119759,11 +120103,40 @@ function validateEventSetup(eventId, event, nowMs = Date.now()) {
119759
120103
  if (id && !formIds.has(id))
119760
120104
  add(errors, "form_binding_missing", path, `Referenced formId "${id}" does not exist in event.forms.`);
119761
120105
  };
120106
+ const rawPolls = Array.isArray(event.polls) ? event.polls : [];
120107
+ const servedPollIds = new Set(parseEventPolls(event.polls).map((poll) => poll.id));
120108
+ const seenPollIds = /* @__PURE__ */ new Set();
120109
+ rawPolls.slice(0, MAX_POLLS).forEach((raw, index) => {
120110
+ const id = safeString(asRecord(raw).id).trim();
120111
+ if (id && seenPollIds.has(id)) {
120112
+ add(errors, "poll_id_duplicate", `polls[${index}].id`, `Duplicate poll id: ${id}. OKO serves the first poll with an id and drops the rest.`);
120113
+ } else if (parsePoll(raw) === null) {
120114
+ add(errors, "poll_invalid", `polls[${index}]`, "This poll is not complete, so viewers cannot open it: a poll needs its own id, a question and 2 to 10 options, each with an id and a text. Complete it or remove it with oko_configure_blocks polls.");
120115
+ }
120116
+ if (id)
120117
+ seenPollIds.add(id);
120118
+ });
120119
+ if (rawPolls.length > MAX_POLLS) {
120120
+ add(warnings, "polls_too_many", "polls", `The page lists ${rawPolls.length} polls; OKO serves the first ${MAX_POLLS} and ignores the rest.`);
120121
+ }
120122
+ const checkPollBinding = (path, pollId) => {
120123
+ const id = safeString(pollId).trim();
120124
+ if (id && !servedPollIds.has(id)) {
120125
+ add(errors, "poll_binding_missing", path, `Referenced pollId "${id}" is not a poll OKO serves: it does not exist in event.polls, or it is not complete.`);
120126
+ }
120127
+ };
120128
+ const checkTargetsConflict = (path, formId, pollId) => {
120129
+ if (safeString(formId).trim() && safeString(pollId).trim()) {
120130
+ add(warnings, "poll_binding_shadowed", path, "The button is bound to a form AND a poll; the form wins, so the poll never opens. Keep one: set the other to null.");
120131
+ }
120132
+ };
119762
120133
  const promo = asRecord(blocks.promoBanner);
119763
120134
  if (promo.enabled === true) {
119764
120135
  checkFormBinding("blocks.promoBanner.formId", promo.formId);
119765
- if (!safeString(promo.link).trim() && !safeString(promo.formId).trim()) {
119766
- add(suggestions, "promo_target_missing", "blocks.promoBanner", "Enabled promo banner has no link or form binding.");
120136
+ checkPollBinding("blocks.promoBanner.pollId", promo.pollId);
120137
+ checkTargetsConflict("blocks.promoBanner", promo.formId, promo.pollId);
120138
+ if (!safeString(promo.link).trim() && !safeString(promo.formId).trim() && !safeString(promo.pollId).trim()) {
120139
+ add(suggestions, "promo_target_missing", "blocks.promoBanner", "Enabled promo banner has no link, form or poll binding.");
119767
120140
  }
119768
120141
  if (!safeString(promo.imageUrl).trim()) {
119769
120142
  add(warnings, "promo_image_missing", "blocks.promoBanner.imageUrl", "Enabled promo banner has no image.");
@@ -119776,14 +120149,16 @@ function validateEventSetup(eventId, event, nowMs = Date.now()) {
119776
120149
  const cta = asRecord(blocks.primaryCta);
119777
120150
  if (cta.enabled === true) {
119778
120151
  checkFormBinding("blocks.primaryCta.formId", cta.formId);
120152
+ checkPollBinding("blocks.primaryCta.pollId", cta.pollId);
120153
+ checkTargetsConflict("blocks.primaryCta", cta.formId, cta.pollId);
119779
120154
  if (!safeString(cta.title).trim() && !safeString(cta.text).trim()) {
119780
120155
  add(suggestions, "cta_copy_missing", "blocks.primaryCta", "Enabled CTA has no title or text.");
119781
120156
  }
119782
120157
  if (!safeString(cta.buttonLabel).trim()) {
119783
120158
  add(warnings, "cta_button_missing", "blocks.primaryCta.buttonLabel", "Enabled CTA has no button label.");
119784
120159
  }
119785
- if (safeString(cta.buttonLabel).trim() && !safeString(cta.buttonUrl).trim() && !safeString(cta.formId).trim()) {
119786
- add(warnings, "cta_target_missing", "blocks.primaryCta", "CTA button has no URL or form binding.");
120160
+ if (safeString(cta.buttonLabel).trim() && !safeString(cta.buttonUrl).trim() && !safeString(cta.formId).trim() && !safeString(cta.pollId).trim()) {
120161
+ add(warnings, "cta_target_missing", "blocks.primaryCta", "CTA button has no URL, form or poll binding.");
119787
120162
  }
119788
120163
  }
119789
120164
  const videoPlaceholder = asRecord(asRecord(blocks.video).placeholder);
@@ -119812,10 +120187,13 @@ function validateEventSetup(eventId, event, nowMs = Date.now()) {
119812
120187
  cardIds.add(card.id);
119813
120188
  if (!card.title && !card.text)
119814
120189
  add(suggestions, "card_copy_missing", path, "Card has no title or text.");
119815
- if (card.buttonLabel && !card.buttonUrl && !card.formId)
119816
- add(warnings, "card_target_missing", path, "Card button has no URL or form binding.");
120190
+ if (card.buttonLabel && !card.buttonUrl && !card.formId && !card.pollId)
120191
+ add(warnings, "card_target_missing", path, "Card button has no URL, form or poll binding.");
119817
120192
  if (card.formId && !formIds.has(card.formId))
119818
120193
  add(errors, "card_form_missing", `${path}.formId`, `Referenced formId "${card.formId}" does not exist.`);
120194
+ if (card.pollId && !servedPollIds.has(card.pollId))
120195
+ add(errors, "card_poll_missing", `${path}.pollId`, `Referenced pollId "${card.pollId}" is not a poll OKO serves: it does not exist in event.polls, or it is not complete.`);
120196
+ checkTargetsConflict(path, card.formId, card.pollId);
119819
120197
  if (card.imageMasterUrl && !card.imageVariants)
119820
120198
  add(warnings, "card_variants_pending", `${path}.imageVariants`, "Card image variants are not ready yet.");
119821
120199
  });
@@ -119865,20 +120243,20 @@ function stateWithin(now, window2) {
119865
120243
  return "open";
119866
120244
  return "after";
119867
120245
  }
119868
- function isRecord2(value) {
120246
+ function isRecord3(value) {
119869
120247
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
119870
120248
  }
119871
120249
  function toMillis2(value) {
119872
120250
  if (typeof value === "number" && Number.isFinite(value))
119873
120251
  return value;
119874
- if (isRecord2(value) && typeof value.toMillis === "function") {
120252
+ if (isRecord3(value) && typeof value.toMillis === "function") {
119875
120253
  const ms = value.toMillis();
119876
120254
  return typeof ms === "number" && Number.isFinite(ms) ? ms : null;
119877
120255
  }
119878
120256
  return null;
119879
120257
  }
119880
120258
  function storedWindow(event) {
119881
- const billing = isRecord2(event.billing) ? event.billing : {};
120259
+ const billing = isRecord3(event.billing) ? event.billing : {};
119882
120260
  const start = toMillis2(billing.windowStart);
119883
120261
  const end = toMillis2(billing.windowEnd);
119884
120262
  return start !== null && end !== null && end > start ? { windowStart: new Date(start), windowEnd: new Date(end) } : null;
@@ -119887,12 +120265,12 @@ function instantMs(value) {
119887
120265
  return toMillis2(value);
119888
120266
  }
119889
120267
  function windowFromEvent(event, nowMs) {
119890
- const billing = isRecord2(event.billing) ? event.billing : {};
120268
+ const billing = isRecord3(event.billing) ? event.billing : {};
119891
120269
  const storedStart = toMillis2(billing.windowStart);
119892
120270
  const storedEnd = toMillis2(billing.windowEnd);
119893
120271
  let window2 = storedStart !== null && storedEnd !== null ? { windowStart: new Date(storedStart), windowEnd: new Date(storedEnd) } : null;
119894
120272
  if (!window2) {
119895
- const meta2 = isRecord2(event.meta) ? event.meta : {};
120273
+ const meta2 = isRecord3(event.meta) ? event.meta : {};
119896
120274
  const eventDateMs = toMillis2(meta2.eventDate);
119897
120275
  if (eventDateMs !== null)
119898
120276
  window2 = showWindow(new Date(eventDateMs));
@@ -119901,18 +120279,18 @@ function windowFromEvent(event, nowMs) {
119901
120279
  }
119902
120280
 
119903
120281
  // build/tools/eventDate.js
119904
- function isRecord3(value) {
120282
+ function isRecord4(value) {
119905
120283
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
119906
120284
  }
119907
120285
  function readMeta(event) {
119908
- return isRecord3(event.meta) ? event.meta : {};
120286
+ return isRecord4(event.meta) ? event.meta : {};
119909
120287
  }
119910
120288
  function judgeEventDateChange(fresh, newValue, nowMs) {
119911
120289
  const newMs = newValue === null ? null : instantMs(newValue);
119912
120290
  const currentMs = instantMs(readMeta(fresh).eventDate);
119913
120291
  if (newMs !== null && currentMs !== null && newMs === currentMs)
119914
120292
  return { allowed: true };
119915
- const billing = isRecord3(fresh.billing) ? fresh.billing : null;
120293
+ const billing = isRecord4(fresh.billing) ? fresh.billing : null;
119916
120294
  if (!billing || billing.state !== "published" && billing.state !== "held")
119917
120295
  return { allowed: true };
119918
120296
  const window2 = storedWindow(fresh);
@@ -120090,6 +120468,10 @@ function assertFormBindingsExist(fresh, patch) {
120090
120468
  const cardsAffected = orphaned.some((binding) => binding.field.startsWith("blocks.cards."));
120091
120469
  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." : "."));
120092
120470
  }
120471
+ function assertBindingsExist(fresh, patch) {
120472
+ assertFormBindingsExist(fresh, patch);
120473
+ assertPollBindingsExist(fresh, patch);
120474
+ }
120093
120475
  function configureBlocksScopes(args) {
120094
120476
  return Object.keys(telegramBlocksPatch(args)).length > 0 ? ["setup:write", "telegram:write"] : ["setup:write"];
120095
120477
  }
@@ -120391,26 +120773,30 @@ function registerEventTools(server2) {
120391
120773
  server2.registerTool("oko_configure_blocks", {
120392
120774
  title: "Configure event blocks",
120393
120775
  description: toolDescription({
120394
- summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
120776
+ summary: "Configure supported event setup blocks, forms, polls, and Telegram channel ids.",
120395
120777
  exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "chat": { "enabled": true }, "promoBanner": { "enabled": false }, "primaryCta": { "enabled": true, "title": "Get the slides", "buttonLabel": "Download", "buttonUrl": "https://example.com/slides" }, "videoPlaceholder": { "enabled": true, "mode": "glass", "showTimer": true, "title": "The webinar starts at 14:00", "subtitle": "Moscow time" } },
120396
120778
  args: [
120397
120779
  "eventId: event to edit.",
120398
120780
  "chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
120399
- "forms: replaces configured lead forms when provided; removing a form that a button still points at is refused.",
120781
+ "forms: replaces configured lead forms when provided; removing a form that a button still points at is refused. Omit to leave the forms (and the polls) untouched.",
120782
+ "polls: replaces configured polls when provided (max 10, each with a question and 2-10 options); omit to leave the polls (and the forms) untouched. Pass every existing poll and option back with its id (oko_get_event shows them) \u2014 an option sent without an id is a NEW option, and the votes of an option are tied to its id. Removing a poll that a button still points at is refused.",
120783
+ "promoBanner.pollId / primaryCta.pollId: the button opens this poll in a popup (a card's pollId is set in oko_configure_cards). A button has one action: link, form or poll. null removes the binding.",
120400
120784
  "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.",
120401
- "confirm: only for a call that turns on a paid block (promoBanner, primaryCta, forms) on a PUBLISHED page \u2014 see the note on paid blocks."
120785
+ "confirm: only for a call that turns on a paid block (promoBanner, primaryCta, forms, polls) on a PUBLISHED page \u2014 see the note on paid blocks."
120402
120786
  ],
120403
- returns: "event id and the list of Firestore fields updated; after a paid block, also payment (charged, balanceAfter) and a hint.",
120787
+ returns: "event id and the list of Firestore fields updated; after polls, the polls as stored (their ids, and the ids of their options); after a paid block, also payment (charged, balanceAfter) and a hint.",
120404
120788
  examples: ["Use after oko_create_webinar to enable chat, bind a CTA/form, and configure other audience blocks."],
120405
120789
  notes: [
120406
- "\u{1F534} Paid blocks after publication: the promo banner, the CTA and the forms cost 10 lumens each (the cards block is paid in oko_configure_cards; forms are ONE block however many there are) when they are turned on AFTER the page is published. On a draft they are free \u2014 they enter the bill at publication. On a published page, a call that turns one on without confirm writes NOTHING and answers with the quote: the blocks, the lumens, the wallet that pays and who that is. Show the organizer, and repeat the call with confirm: true after they agree; the wallet is charged once for the whole set and only then is the patch written. Turning a block off and on again in the same show costs nothing more, and a block already paid for is not charged again. Refusals carry errorCode and whatToDo: INSUFFICIENT_TOKENS (the wallet that pays \u2014 the workspace owner's \u2014 is short), ENTITLEMENT_REQUIRED (that owner has no live subscription), PAYER_UNRESOLVED, EDIT_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH; nothing is written or charged then. The key needs the events:write scope besides setup:write for this. Unpublishing the page later does NOT return these top-up charges.",
120790
+ "\u{1F534} Paid blocks after publication: the promo banner, the CTA, the forms and the polls cost 10 lumens each (the cards block is paid in oko_configure_cards; forms are ONE block however many there are, and so are polls \u2014 the polls are their own block, never part of the forms) when they are turned on AFTER the page is published. On a draft they are free \u2014 they enter the bill at publication. On a published page, a call that turns one on without confirm writes NOTHING and answers with the quote: the blocks, the lumens, the wallet that pays and who that is. Show the organizer, and repeat the call with confirm: true after they agree; the wallet is charged once for the whole set and only then is the patch written. Turning a block off and on again in the same show costs nothing more, and a block already paid for is not charged again. Refusals carry errorCode and whatToDo: INSUFFICIENT_TOKENS (the wallet that pays \u2014 the workspace owner's \u2014 is short), ENTITLEMENT_REQUIRED (that owner has no live subscription), PAYER_UNRESOLVED, EDIT_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH; nothing is written or charged then. The key needs the events:write scope besides setup:write for this. Unpublishing the page later does NOT return these top-up charges.",
120407
120791
  '\u{1F534} "Turn off the chat" is ambiguous, and the two meanings are different actions. chat.enabled=false HIDES the chat panel from the page for everyone \u2014 the history is not deleted (messages are kept for 90 days) and comes back when the block is enabled again \u2014 but during or after a webinar that is almost never what is asked for. Stopping new messages is a different tool: oko_set_chat_mode with mode="off" leaves the panel and the history on the page and only closes the input. When the organizer says "turn off the chat", "close the chat", "stop the chat", "mute the chat", "disable chat", assume they mean CLOSING INTAKE (oko_set_chat_mode off), and ask before hiding the panel. The same trap in reverse: "turn the chat back on" after a pause means mode="public", not this field.',
120408
120792
  "A block toggle and its image are independent: enabling a block here works with an image already uploaded via oko_upload_image, and uploading an image never enables the block by itself.",
120409
120793
  "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.",
120410
120794
  '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.',
120411
120795
  "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.",
120412
120796
  "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.",
120413
- "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."
120797
+ "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. Polls follow the same rule with pollId.",
120798
+ "forms and polls are separate lists: a call with forms never touches polls, and a call with polls never touches forms (leave either argument out to keep that list as it is).",
120799
+ "Polls: a poll opens from a button (promoBanner.pollId, primaryCta.pollId, a card's pollId) and, with the control room, can be launched for everyone from oko_control_poll. Viewers see the results only after the organizer shows them (oko_control_poll show_results). Removing a poll takes it off the page; the votes already cast stay in the data and in the session reports. The vote counts are in oko_get_live_state (live) and oko_get_session_report (per session)."
120414
120800
  ]
120415
120801
  }),
120416
120802
  inputSchema: {
@@ -120422,12 +120808,13 @@ function registerEventTools(server2) {
120422
120808
  closeOnSessionEnd: external_exports.boolean().optional().describe("Session trigger: close message intake when the analytics session ends (default false)."),
120423
120809
  visibility: external_exports.enum(["always", "hidden"]).optional()
120424
120810
  }).optional(),
120425
- promoBanner: external_exports.object({ enabled: external_exports.boolean().optional(), link: external_exports.string().max(1e3).optional(), visibility: external_exports.enum(["always", "hidden"]).optional(), formId: external_exports.string().nullable().optional() }).optional(),
120426
- primaryCta: external_exports.object({ enabled: external_exports.boolean().optional(), visibility: external_exports.enum(["always", "hidden"]).optional(), title: external_exports.string().nullable().optional(), text: external_exports.string().nullable().optional(), buttonLabel: external_exports.string().nullable().optional(), buttonUrl: external_exports.string().nullable().optional(), formId: external_exports.string().nullable().optional() }).optional(),
120811
+ promoBanner: external_exports.object({ enabled: external_exports.boolean().optional(), link: external_exports.string().max(1e3).optional(), visibility: external_exports.enum(["always", "hidden"]).optional(), formId: external_exports.string().nullable().optional(), pollId: external_exports.string().regex(POLL_ID_PATTERN).nullable().optional().describe("Opens this poll (an id from polls) when the banner is clicked; null removes the binding.") }).optional(),
120812
+ primaryCta: external_exports.object({ enabled: external_exports.boolean().optional(), visibility: external_exports.enum(["always", "hidden"]).optional(), title: external_exports.string().nullable().optional(), text: external_exports.string().nullable().optional(), buttonLabel: external_exports.string().nullable().optional(), buttonUrl: external_exports.string().nullable().optional(), formId: external_exports.string().nullable().optional(), pollId: external_exports.string().regex(POLL_ID_PATTERN).nullable().optional().describe("Opens this poll (an id from polls) when the CTA button is clicked; null removes the binding.") }).optional(),
120427
120813
  videoPlaceholder: external_exports.object({ enabled: external_exports.boolean().optional(), mode: external_exports.enum(["glass", "image"]).optional(), title: external_exports.string().nullable().optional(), subtitle: external_exports.string().nullable().optional(), showTimer: external_exports.boolean().optional() }).optional(),
120428
120814
  forms: external_exports.array(formSchema).max(20).optional(),
120815
+ polls: external_exports.array(pollSchema).max(MAX_POLLS).optional().describe("Replaces the page's polls. Omit to leave them as they are; [] removes them all."),
120429
120816
  telegramChannelIds: external_exports.array(external_exports.number().int()).max(20).optional(),
120430
- confirm: external_exports.boolean().optional().describe("Needed only when the call turns on a paid block (banner, CTA, forms) on an already PUBLISHED page: without it nothing is written and the answer is the quote (lumens, who pays); true after the organizer confirmed the charge.")
120817
+ confirm: external_exports.boolean().optional().describe("Needed only when the call turns on a paid block (banner, CTA, forms, polls) on an already PUBLISHED page: without it nothing is written and the answer is the quote (lumens, who pays); true after the organizer confirmed the charge.")
120431
120818
  },
120432
120819
  outputSchema: updateOutputSchema,
120433
120820
  annotations: writeTool
@@ -120457,6 +120844,8 @@ function registerEventTools(server2) {
120457
120844
  patch["blocks.promoBanner.visibility"] = args.promoBanner.visibility;
120458
120845
  if (args.promoBanner?.formId !== void 0)
120459
120846
  patch["blocks.promoBanner.formId"] = args.promoBanner.formId;
120847
+ if (args.promoBanner?.pollId !== void 0)
120848
+ patch["blocks.promoBanner.pollId"] = args.promoBanner.pollId;
120460
120849
  if (args.primaryCta?.enabled !== void 0)
120461
120850
  patch["blocks.primaryCta.enabled"] = args.primaryCta.enabled;
120462
120851
  if (args.primaryCta?.visibility !== void 0)
@@ -120471,6 +120860,8 @@ function registerEventTools(server2) {
120471
120860
  patch["blocks.primaryCta.buttonUrl"] = normalizeCardButtonUrl(args.primaryCta.buttonUrl);
120472
120861
  if (args.primaryCta?.formId !== void 0)
120473
120862
  patch["blocks.primaryCta.formId"] = args.primaryCta.formId;
120863
+ if (args.primaryCta?.pollId !== void 0)
120864
+ patch["blocks.primaryCta.pollId"] = args.primaryCta.pollId;
120474
120865
  if (args.videoPlaceholder?.enabled !== void 0)
120475
120866
  patch["blocks.video.placeholder.enabled"] = args.videoPlaceholder.enabled;
120476
120867
  if (args.videoPlaceholder?.mode !== void 0)
@@ -120503,6 +120894,8 @@ function registerEventTools(server2) {
120503
120894
  ...field2.type === "select" || field2.type === "checkbox" ? { options: [...new Set((field2.options ?? []).map((option) => option.trim()).filter(Boolean))] } : {}
120504
120895
  }))
120505
120896
  }));
120897
+ if (args.polls !== void 0)
120898
+ patch.polls = buildPollsForWrite(args.polls);
120506
120899
  Object.assign(patch, telegramPatch);
120507
120900
  const payment = await settleBlockExtras({
120508
120901
  tool: "oko_configure_blocks",
@@ -120511,11 +120904,11 @@ function registerEventTools(server2) {
120511
120904
  event: current,
120512
120905
  patch,
120513
120906
  confirm: args.confirm,
120514
- precheck: () => assertFormBindingsExist(current, patch)
120907
+ precheck: () => assertBindingsExist(current, patch)
120515
120908
  });
120516
120909
  if (payment.kind === "quote")
120517
120910
  requireConfirm(args.confirm, "oko_configure_blocks", payment.preview);
120518
- await writeEventPatchWithCards(args.eventId, patch, null, assertFormBindingsExist);
120911
+ await writeEventPatchWithCards(args.eventId, patch, null, assertBindingsExist);
120519
120912
  const paid = paymentFields(payment);
120520
120913
  const hints = [...paid.hints ?? []];
120521
120914
  if (args.chat?.visibility !== void 0) {
@@ -120544,7 +120937,20 @@ function registerEventTools(server2) {
120544
120937
  if (args.videoPlaceholder?.enabled === false) {
120545
120938
  hints.push('If a live override made the placeholder visible earlier, reset it with oko_set_block_visibility block "placeholder" visible null.');
120546
120939
  }
120547
- return textResult({ id: args.eventId, updated: Object.keys(patch), ...paid.payment ? { payment: paid.payment } : {}, ...hints.length ? { hints } : {} });
120940
+ const writtenPolls = Array.isArray(patch.polls) ? patch.polls : null;
120941
+ if (writtenPolls && writtenPolls.length > 0) {
120942
+ hints.push("A poll reaches viewers through a button that opens it: bind one with promoBanner.pollId, primaryCta.pollId or a card's pollId (oko_configure_cards). Viewers vote in a popup and see the results only after the organizer shows them (oko_control_poll show_results). Launching a poll for everyone and resetting it need the control room.");
120943
+ }
120944
+ if (writtenPolls && writtenPolls.length === 0) {
120945
+ hints.push("The polls were taken off the page. Votes already cast stay in the data and in the session reports.");
120946
+ }
120947
+ return textResult({
120948
+ id: args.eventId,
120949
+ updated: Object.keys(patch),
120950
+ ...writtenPolls ? { polls: writtenPolls } : {},
120951
+ ...paid.payment ? { payment: paid.payment } : {},
120952
+ ...hints.length ? { hints } : {}
120953
+ });
120548
120954
  });
120549
120955
  server2.registerTool("oko_configure_cards", {
120550
120956
  title: "Configure event cards",
@@ -120556,18 +120962,19 @@ function registerEventTools(server2) {
120556
120962
  "enabled: optional Cards block enabled flag; turning it on for a PUBLISHED page is a paid top-up \u2014 see the note on paid blocks.",
120557
120963
  "visibility: optional default visibility, always or hidden.",
120558
120964
  "cardImageRatio: optional image ratio, one of auto, 1:1, 4:3, 3:2, 16:9, 2:3, 3:4.",
120559
- "items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId.",
120965
+ "items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId, pollId.",
120560
120966
  "confirm: only for enabled: true on a published page \u2014 see the note on paid blocks."
120561
120967
  ],
120562
120968
  returns: "event id, updated Firestore fields, normalized cards config, generated card ids, preserved image ids, removed card ids, and warnings; after a paid top-up, also payment (charged, balanceAfter) and a hint.",
120563
120969
  examples: [
120564
120970
  "Use after oko_create_webinar to create cards before uploading per-card images.",
120565
120971
  "Call oko_get_event first and pass the full desired items array when editing existing cards.",
120566
- "Use formId instead of buttonUrl when a card button should open a lead form."
120972
+ "Use formId instead of buttonUrl when a card button should open a lead form, and pollId when it should open a poll (an id from oko_configure_blocks polls)."
120567
120973
  ],
120568
120974
  notes: [
120569
120975
  "\u{1F534} Paid block after publication: the Cards block costs 10 lumens (one block however many cards) when it is turned on AFTER the page is published; on a draft it is free \u2014 it enters the bill at publication. On a published page, enabled: true without confirm writes NOTHING (not the card list either) and answers with the quote: the lumens and the wallet that pays. Show the organizer, and repeat the call with confirm: true after they agree; the wallet is charged once and only then is the patch written. A block already paid for, or turned off and on again in the same show, is not charged twice. Refusals carry errorCode and whatToDo: INSUFFICIENT_TOKENS, ENTITLEMENT_REQUIRED, PAYER_UNRESOLVED, EDIT_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH; nothing is written or charged then. The key needs the events:write scope besides setup:write for this. Unpublishing the page later does NOT return top-up charges.",
120570
120976
  "This tool replaces blocks.cards.items when items is provided; omitted text fields become null.",
120977
+ "A card button has ONE action: buttonUrl, formId or pollId. The bindings travel with the cards you send: pass formId and pollId back as oko_get_event shows them, or the card loses its binding. A binding the replacement drops is named in warnings (the write still goes through); a pollId that is not in the page's polls is refused.",
120571
120978
  "Existing card images are preserved only when the same card id remains in the replacement list.",
120572
120979
  "buttonUrl allows http(s), mailto, or tel URLs; javascript/data URLs are rejected.",
120573
120980
  "If a removed card had an image, Storage cleanup is not automatic; this matches the web Setup behavior."
@@ -120599,6 +121006,7 @@ function registerEventTools(server2) {
120599
121006
  const preservedImageCardIds = [];
120600
121007
  let removedCardIds = [];
120601
121008
  let removedCardsWithImages = [];
121009
+ let droppedBindings = [];
120602
121010
  if (args.enabled !== void 0)
120603
121011
  patch["blocks.cards.enabled"] = args.enabled;
120604
121012
  if (args.visibility !== void 0)
@@ -120610,13 +121018,15 @@ function registerEventTools(server2) {
120610
121018
  const built = buildCardsItems({
120611
121019
  inputs: args.items,
120612
121020
  existing: freshCards.items,
120613
- formIds: eventFormIds(fresh)
121021
+ formIds: eventFormIds(fresh),
121022
+ pollIds: eventPollIds(fresh)
120614
121023
  });
120615
121024
  finalCards.items = built.items;
120616
121025
  generatedCardIds.length = 0;
120617
121026
  preservedImageCardIds.length = 0;
120618
121027
  generatedCardIds.push(...built.generatedCardIds);
120619
121028
  preservedImageCardIds.push(...built.preservedImageCardIds);
121029
+ droppedBindings = built.droppedBindings;
120620
121030
  const nextIds = new Set(built.items.map((item) => item.id));
120621
121031
  const removed = freshCards.items.filter((item) => !nextIds.has(item.id));
120622
121032
  removedCardIds = removed.map((item) => item.id);
@@ -120641,6 +121051,9 @@ function registerEventTools(server2) {
120641
121051
  }
120642
121052
  const paid = paymentFields(payment);
120643
121053
  const warnings = removedCardsWithImages.length > 0 ? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`] : [];
121054
+ for (const dropped of droppedBindings) {
121055
+ warnings.push(`Card ${dropped.cardId} no longer opens the ${dropped.kind} "${dropped.targetId}": the replacement item did not carry ${dropped.kind === "poll" ? "pollId" : "formId"}. If that was not intended, repeat the call with ${dropped.kind === "poll" ? "pollId" : "formId"}: "${dropped.targetId}" on that card.`);
121056
+ }
120644
121057
  if (finalCards.repairedIdPositions.length > 0) {
120645
121058
  warnings.push(`Cards at position(s) ${finalCards.repairedIdPositions.join(", ")} had no usable id in Firestore; a temporary one was generated, so their previous images could not be matched and were dropped. Re-upload images for these cards.`);
120646
121059
  if (args.items === void 0) {
@@ -120956,22 +121369,12 @@ function registerLeadTools(server2) {
120956
121369
  // build/tools/unpublication.js
120957
121370
  var TOOL2 = "oko_unpublish_event";
120958
121371
  var NOTHING_WILL_CHANGE = "The page and its lumens stay exactly as they are now.";
120959
- function isRecord4(value) {
121372
+ function isRecord5(value) {
120960
121373
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
120961
121374
  }
120962
121375
  function refusal4(eventId, body) {
120963
121376
  return { ...textResult({ tool: TOOL2, eventId, ...body }), isError: true };
120964
121377
  }
120965
- function notPublishedRefusal(eventId) {
120966
- return refusal4(eventId, {
120967
- errorCode: "NOT_PUBLISHED",
120968
- message: `This page has no publication to withdraw. ${NOTHING_WILL_CHANGE}`,
120969
- whatToDo: [
120970
- "Explain that only a published page can be unpublished; a draft needs no withdrawal.",
120971
- "Do not call with confirm:true: the answer will not change."
120972
- ]
120973
- });
120974
- }
120975
121378
  function dateRequiredRefusal(eventId) {
120976
121379
  return refusal4(eventId, {
120977
121380
  errorCode: "DATE_REQUIRED",
@@ -121001,20 +121404,48 @@ function topUpsPaid(billing) {
121001
121404
  if (!Array.isArray(additions))
121002
121405
  return 0;
121003
121406
  return additions.reduce((sum, item) => {
121004
- const amount = isRecord4(item) ? item[ADDITION_AMOUNT_FIELD] : null;
121407
+ const amount = isRecord5(item) ? item[ADDITION_AMOUNT_FIELD] : null;
121005
121408
  return sum + (typeof amount === "number" && Number.isFinite(amount) && amount > 0 ? amount : 0);
121006
121409
  }, 0);
121007
121410
  }
121008
121411
  function topUpsNote(topUps) {
121009
121412
  return topUps > 0 ? `The ${topUps} lumens paid on top of the publication (blocks and control room turned on after it) are NOT returned; publishing again bills those blocks in the new show's bill.` : "";
121010
121413
  }
121414
+ var REFUND_UP_TO_NOTE = "Part of the charge may not come back: lumens that would already have burned by now (their plan period is over) are forfeited, not returned. The exact figures are known only after confirmation: the answer carries `refunded` (what came back to the wallet) and `forfeited` (what did not).";
121415
+ function forfeitedNote(forfeited) {
121416
+ return forfeited > 0 ? `${forfeited} lumens of the charge were not returned (forfeited): lumens that would already have burned by now come back nowhere.` : "";
121417
+ }
121418
+ function forfeitedOf(outcome) {
121419
+ const value = outcome.forfeited;
121420
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
121421
+ }
121422
+ function unpublishPreviewNote(topUps) {
121423
+ return [
121424
+ "Unpublishing takes the page back to draft and returns UP TO the publication charge (`refundUpTo`) to the wallet that paid; the live layer (chat, presence, session start) closes immediately.",
121425
+ REFUND_UP_TO_NOTE,
121426
+ "If the page only needs another date inside its show window, oko_update_event moves it with no refund and no new charge \u2014 prefer that to unpublishing.",
121427
+ topUpsNote(topUps)
121428
+ ].filter(Boolean).join(" ");
121429
+ }
121430
+ function unpublishAnswerHints(outcome, topUps) {
121431
+ const refunded = typeof outcome.refunded === "number" ? outcome.refunded : 0;
121432
+ const forfeited = forfeitedOf(outcome);
121433
+ if (refunded > 0) {
121434
+ return [
121435
+ `${refunded} lumens were refunded to payerUid ${safeString(outcome.payerUid)}.`,
121436
+ forfeitedNote(forfeited),
121437
+ ...topUps > 0 ? [topUpsNote(topUps)] : []
121438
+ ].filter(Boolean);
121439
+ }
121440
+ if (outcome.alreadyUnpublished === true)
121441
+ return ["The page was already a draft \u2014 nothing was refunded now."];
121442
+ return [forfeitedNote(forfeited)].filter(Boolean);
121443
+ }
121011
121444
  function previewUnpublish(eventId, event, nowMs) {
121012
- const billing = isRecord4(event.billing) ? event.billing : {};
121445
+ const billing = isRecord5(event.billing) ? event.billing : {};
121013
121446
  const state2 = billing.state === "published" || billing.state === "held" ? billing.state : "unpublished";
121014
121447
  if (state2 === "unpublished")
121015
121448
  return { kind: "already" };
121016
- if (state2 !== "published" && state2 !== "held")
121017
- return { kind: "refuse", result: notPublishedRefusal(eventId) };
121018
121449
  const window2 = windowFromEvent(event, nowMs);
121019
121450
  if (state2 === "held") {
121020
121451
  return { kind: "refuse", result: windowOpenRefusal(eventId, "after", window2?.window.windowStart ?? null) };
@@ -121067,20 +121498,21 @@ function registerUnpublicationTools(server2) {
121067
121498
  server2.registerTool("oko_unpublish_event", {
121068
121499
  title: "Unpublish event page",
121069
121500
  description: toolDescription({
121070
- summary: `Take a published OKO event page back to draft and refund its publication charge. Works only before the page's show window opens; once it has opened (or the page shows "held"/\u041F\u0440\u043E\u0432\u0435\u0434\u0435\u043D\u043E), the page stays published for good. Without confirm nothing is written: the call returns a preview \u2014 what will happen and how many lumens come back to which wallet, or the refusal if it cannot be done now. Built from the event document, not a server round trip: CF61 has no dry run of its own. With confirm: true the page becomes a draft and the charge is refunded in one step.`,
121501
+ summary: `Take a published OKO event page back to draft and refund its publication charge. Works only before the page's show window opens; once it has opened (or the page shows "held"/\u041F\u0440\u043E\u0432\u0435\u0434\u0435\u043D\u043E), the page stays published for good. Without confirm nothing is written: the call returns a preview \u2014 what will happen and UP TO how many lumens come back (refundUpTo) to which wallet, or the refusal if it cannot be done now. Built from the event document, not a server round trip, so it cannot tell how much of the charge was forfeited (lumens that would already have burned): the exact refunded and forfeited figures come only after confirm. With confirm: true the page becomes a draft and the refund (up to the charge) is paid in one step.`,
121071
121502
  args: [
121072
121503
  "eventId: event to unpublish.",
121073
121504
  "confirm: true only after the organizer has seen the preview and agreed."
121074
121505
  ],
121075
- returns: "status unpublished with alreadyUnpublished, refunded, balanceAfter, mirrorsUpdated, plus title, links and hints.",
121076
- examples: ["Call without confirm, show the organizer what will be refunded and to whom, and call again with confirm: true after explicit consent."],
121506
+ returns: "status unpublished with alreadyUnpublished, refunded (what came back), forfeited (what did not, only when above zero), balanceAfter, mirrorsUpdated, plus title, links and hints. The preview (no confirm) carries refundUpTo, not refunded.",
121507
+ examples: ["Call without confirm, show the organizer UP TO how much comes back and to whom (never promise the whole charge), and call again with confirm: true after explicit consent; then tell the exact refunded and forfeited."],
121077
121508
  notes: [
121078
121509
  "Refunds go to the wallet that paid for this publication (billing.payerUid), which may not be the organizer behind this key in a shared workspace.",
121510
+ "The refund is UP TO the charge: the share of it drawn from lumens that would already have burned (their plan period is over) is forfeited. The preview cannot see that, so it says refundUpTo; the answer after confirm says refunded and forfeited.",
121079
121511
  "Only the publication charge comes back. Lumens paid AFTER publication for blocks and the control room turned on later (oko_configure_blocks, oko_configure_cards with confirm) are NOT returned: the preview names them as topUpsNotRefunded, and publishing again bills those blocks in the new show's bill.",
121080
121512
  "Publishing again afterwards (oko_publish_event) is a new show and a new charge, with a fresh show window from the new date.",
121081
- "This is how the event date moves on a published page: unpublish (refund), change the date with oko_update_event, publish again (new charge) \u2014 oko_update_event refuses to move the date directly while the page stays published (EVENT_DATE_LOCKED).",
121513
+ "A date INSIDE the page's show window moves with oko_update_event directly, with no refund and no new charge \u2014 prefer that when the charge may be partly forfeited. A date outside the window: unpublish (refund, up to the charge), change the date with oko_update_event, publish again (new charge); oko_update_event refuses the move otherwise (EVENT_DATE_LOCKED).",
121082
121514
  "One rule for every page: an old page that paid nothing is unpublished the same way before its window opens (the refund is 0 lumens) and refused the same way once it has taken place (WINDOW_OPEN, windowState after).",
121083
- "Refusals carry errorCode and whatToDo: NOT_PUBLISHED, DATE_REQUIRED, WINDOW_OPEN, EDIT_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH."
121515
+ "Refusals carry errorCode and whatToDo: DATE_REQUIRED, WINDOW_OPEN, EDIT_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH. A page that is already a draft is not a refusal: the call answers alreadyUnpublished."
121084
121516
  ]
121085
121517
  }),
121086
121518
  inputSchema: {
@@ -121115,13 +121547,13 @@ function registerUnpublicationTools(server2) {
121115
121547
  requireConfirm(confirm, TOOL2, {
121116
121548
  eventId,
121117
121549
  title,
121118
- refunded: preview.tokensCharged,
121550
+ refundUpTo: preview.tokensCharged,
121119
121551
  topUpsNotRefunded: preview.topUps,
121120
121552
  payerUid: preview.payerUid,
121121
121553
  showId: preview.showId,
121122
121554
  windowStart: preview.windowStart,
121123
121555
  windowEnd: preview.windowEnd,
121124
- note: ["Unpublishing returns this page's publication charge to the wallet that paid and takes the page back to draft; the live layer (chat, presence, session start) closes immediately.", topUpsNote(preview.topUps)].filter(Boolean).join(" ")
121556
+ note: unpublishPreviewNote(preview.topUps)
121125
121557
  });
121126
121558
  }
121127
121559
  }
@@ -121135,9 +121567,7 @@ function registerUnpublicationTools(server2) {
121135
121567
  throw new ToolRefusal(named);
121136
121568
  throw error3;
121137
121569
  }
121138
- const refunded = typeof outcome.refunded === "number" ? outcome.refunded : 0;
121139
- const topUps = topUpsPaid(isRecord4(event.billing) ? event.billing : {});
121140
- const hints = refunded > 0 ? [`${refunded} lumens were refunded to payerUid ${safeString(outcome.payerUid)}.`, ...topUps > 0 ? [topUpsNote(topUps)] : []] : outcome.alreadyUnpublished === true ? ["The page was already a draft \u2014 nothing was refunded now."] : [];
121570
+ const hints = unpublishAnswerHints(outcome, topUpsPaid(isRecord5(event.billing) ? event.billing : {}));
121141
121571
  return textResult({
121142
121572
  ...outcome,
121143
121573
  title,
@@ -121151,7 +121581,8 @@ function registerUnpublicationTools(server2) {
121151
121581
  var TRASH_OUTPUT_SCHEMA = external_exports.object({
121152
121582
  trashed: external_exports.string(),
121153
121583
  purgeAfter: external_exports.number().nullable(),
121154
- refunded: external_exports.number().nonnegative().optional()
121584
+ refunded: external_exports.number().nonnegative().optional(),
121585
+ forfeited: external_exports.number().nonnegative().optional()
121155
121586
  }).passthrough();
121156
121587
  function trashRefundWarning(event, nowMs) {
121157
121588
  const billing = event.billing && typeof event.billing === "object" && !Array.isArray(event.billing) ? event.billing : null;
@@ -121160,9 +121591,17 @@ function trashRefundWarning(event, nowMs) {
121160
121591
  const window2 = windowFromEvent(event, nowMs);
121161
121592
  if (window2 !== null && window2.state !== "before")
121162
121593
  return null;
121163
- const charge = typeof billing.tokensCharged === "number" ? `${billing.tokensCharged} lumens` : "its lumens";
121594
+ const charge = typeof billing.tokensCharged === "number" ? `${billing.tokensCharged} lumens` : "what the publication paid";
121164
121595
  const topUps = topUpsNote(topUpsPaid(billing));
121165
- return `This page is published. Moving it to the trash also unpublishes it and refunds ${charge} to the wallet that paid, because its show window has not opened yet.${topUps ? ` ${topUps}` : ""}`;
121596
+ return `This page is published. Moving it to the trash also unpublishes it and returns up to ${charge} to the wallet that paid, because its show window has not opened yet. ${REFUND_UP_TO_NOTE}${topUps ? ` ${topUps}` : ""}`;
121597
+ }
121598
+ function trashAnswerHints(data) {
121599
+ const refunded = typeof data.refunded === "number" ? data.refunded : 0;
121600
+ const forfeited = forfeitedOf(data);
121601
+ return [
121602
+ ...refunded > 0 ? [`${refunded} lumens were refunded to the wallet that paid for this page's publication: it was still inside its show window when it was trashed.`] : [],
121603
+ ...forfeited > 0 ? [forfeitedNote(forfeited)] : []
121604
+ ];
121166
121605
  }
121167
121606
  function registerLifecycleTools(server2) {
121168
121607
  const moveToTrash = async (toolName, { eventId, confirm }) => {
@@ -121177,7 +121616,7 @@ function registerLifecycleTools(server2) {
121177
121616
  const trash = httpsCallable(functions, "trashEvent");
121178
121617
  const data = (await trash({ eventId })).data;
121179
121618
  const refunded = typeof data.refunded === "number" ? data.refunded : 0;
121180
- const hints = refunded > 0 ? [`${refunded} lumens were refunded to the wallet that paid for this page's publication: it was still inside its show window when it was trashed.`] : [];
121619
+ const hints = trashAnswerHints(data);
121181
121620
  return textResult({ ...data, refunded, ...hints.length ? { hints } : {} });
121182
121621
  };
121183
121622
  server2.registerTool("oko_delete_event", {
@@ -121189,7 +121628,7 @@ function registerLifecycleTools(server2) {
121189
121628
  examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
121190
121629
  notes: [
121191
121630
  "Recoverable for 30 days with oko_restore_event. This does not create an event lifecycle status.",
121192
- "A published, paid page still inside its show window is also unpublished and its charge refunded; the preview (without confirm) names it in `warning`, and the answer's `refunded` field says how much came back."
121631
+ "A published, paid page still inside its show window is also unpublished and UP TO its charge refunded; the preview (without confirm) names it in `warning`, and the answer's `refunded` field says how much came back (`forfeited`, when above zero, how much did not)."
121193
121632
  ]
121194
121633
  }),
121195
121634
  inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
@@ -121205,7 +121644,7 @@ function registerLifecycleTools(server2) {
121205
121644
  examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
121206
121645
  notes: [
121207
121646
  "End any active analytics session first. Restore with oko_restore_event before the purge deadline.",
121208
- "A published, paid page still inside its show window is also unpublished and its charge refunded; the preview (without confirm) names it in `warning`, and the answer's `refunded` field says how much came back."
121647
+ "A published, paid page still inside its show window is also unpublished and UP TO its charge refunded; the preview (without confirm) names it in `warning`, and the answer's `refunded` field says how much came back (`forfeited`, when above zero, how much did not)."
121209
121648
  ]
121210
121649
  }),
121211
121650
  inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
@@ -121269,7 +121708,7 @@ function registerLifecycleTools(server2) {
121269
121708
  returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
121270
121709
  examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
121271
121710
  notes: [
121272
- "Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions, leads, or the publication \u2014 the copy is a new page, born a draft.",
121711
+ "Copies title, video, meta, blocks, forms, polls, and Telegram config; does not copy analytics sessions, leads, poll votes (the copy starts round 1 with no votes), or the publication \u2014 the copy is a new page, born a draft.",
121273
121712
  "Live overrides are NOT copied: the copy starts with chat mode public and the placeholder hidden, whatever the source had. Set them again with oko_set_chat_mode and oko_set_block_visibility on the new event.",
121274
121713
  "Requires only events:write plus createEvents in the target workspace. To move (not copy) an event, use oko_move_event with the same scope.",
121275
121714
  "Telegram config is copied only when the key holds telegram:write AND the organizer may send notifications for the source event; otherwise the copy starts with Telegram off and a hint says so. Deciding which channel carries an event is a send permission, not a setup one."
@@ -121397,6 +121836,275 @@ function killSwitchHint(state2) {
121397
121836
  return null;
121398
121837
  }
121399
121838
 
121839
+ // build/tools/polls.js
121840
+ var TOOL3 = "oko_control_poll";
121841
+ function refusal5(eventId, pollId, body) {
121842
+ return { ...textResult({ tool: TOOL3, eventId, ...pollId ? { pollId } : {}, ...body }), isError: true };
121843
+ }
121844
+ var POLL_ACTION_EFFECT = {
121845
+ close: "Voting closes: viewers who open the poll are told it is closed, and a popup that was launched for everyone is put out.",
121846
+ open: "Voting opens again: viewers can vote in the current round.",
121847
+ show_results: "Viewers see the results of the current round (the bars and the shares, refreshed every couple of seconds). Without this only the organizer sees the count.",
121848
+ hide_results: "Viewers no longer see the results; only the organizer sees the count.",
121849
+ launch: "The poll pops up for every viewer who has not voted in this round (and for those who join while it is launched); a viewer who closes it does not see it again until the next launch. Voting is opened if it was closed.",
121850
+ stop: "The popup is put out for everyone. Viewers can still open the poll from its button.",
121851
+ reset: "A new round starts: the count is zero, every viewer may vote again, and the popup and the results are put out. The votes of the earlier rounds stay in the data and in the session reports."
121852
+ };
121853
+ function needsControlRoom(action) {
121854
+ return CONTROL_ROOM_POLL_ACTIONS.includes(action);
121855
+ }
121856
+ var CONTROL_ROOM_NOTE = `launch and reset use the live control room of this show (${CONTROL_ROOM_LUMENS} lumens, Pro plan and above), which the organizer buys with the "Enable control room" button in the event's analytics \u2014 no MCP tool buys it. When the control room is not open for the show, OKO refuses with CONTROL_ROOM_REQUIRED and changes nothing. close, open, show_results, hide_results and stop need no control room on any plan.`;
121857
+ function planPollCommand(args) {
121858
+ const { eventId, event, pollId, action, state: state2 } = args;
121859
+ const poll = parseEventPolls(event.polls).find((item) => item.id === pollId);
121860
+ if (!poll) {
121861
+ const incomplete = pollIdSet(event.polls).has(pollId);
121862
+ throw new ToolRefusal(refusal5(eventId, pollId, incomplete ? {
121863
+ errorCode: "POLL_NOT_READY",
121864
+ message: "This poll is on the page but is not complete (a question and 2 to 10 options are needed), so OKO does not serve it and cannot command it. Nothing was changed.",
121865
+ whatToDo: [
121866
+ "Complete the poll with oko_configure_blocks polls (pass the poll with its id, a question and at least two options), then repeat.",
121867
+ "Do not retry as is: the answer will not change until the poll is complete."
121868
+ ]
121869
+ } : {
121870
+ errorCode: "POLL_NOT_FOUND",
121871
+ message: "The page has no poll with this id. Nothing was changed.",
121872
+ whatToDo: [
121873
+ "Read the ids with oko_get_event (polls[].id) or create the poll with oko_configure_blocks polls, then repeat.",
121874
+ "Do not retry as is: the answer will not change until the poll exists."
121875
+ ]
121876
+ }));
121877
+ }
121878
+ return {
121879
+ poll,
121880
+ preview: {
121881
+ eventId,
121882
+ pollId,
121883
+ question: poll.question,
121884
+ action,
121885
+ effect: POLL_ACTION_EFFECT[action],
121886
+ needsControlRoom: needsControlRoom(action),
121887
+ // A state that could not be read is `null`, never the defaults: «round 1, open» would be a guess.
121888
+ currentState: state2,
121889
+ ...state2 === null ? { currentStateNote: `The state of the poll could not be read just now${args.stateUnreadable ? ` (${args.stateUnreadable})` : ""}; the command is judged by the server all the same.` } : {},
121890
+ note: needsControlRoom(action) ? `Nothing has been sent yet. ${CONTROL_ROOM_NOTE}` : "Nothing has been sent yet."
121891
+ }
121892
+ };
121893
+ }
121894
+ function controlPollRefusalResult(eventId, pollId, error3) {
121895
+ if (!error3 || typeof error3 !== "object")
121896
+ return null;
121897
+ const { code, message, details } = error3;
121898
+ if (typeof code !== "string" || !code.startsWith("functions/"))
121899
+ return null;
121900
+ const info = asRecord(details);
121901
+ const text = safeString(message);
121902
+ if (info.reason === "control_room_required") {
121903
+ return refusal5(eventId, pollId, {
121904
+ errorCode: "CONTROL_ROOM_REQUIRED",
121905
+ message: "OKO refused this command: launching a poll for everyone and resetting its count use the live control room, and this show has not bought it. Nothing was changed.",
121906
+ whatToDo: [
121907
+ `Tell the organizer: the control room of a show costs ${CONTROL_ROOM_LUMENS} lumens and needs the Pro plan or higher; they buy it with the "Enable control room" button in the event's analytics. No MCP tool buys it.`,
121908
+ "Offer what needs no control room: show_results (viewers see the bars), close, open, hide_results, stop \u2014 and the poll stays available from its button for everyone.",
121909
+ "Do not retry as is: the answer will not change until the control room is bought."
121910
+ ]
121911
+ });
121912
+ }
121913
+ if (info.reason === "not_published") {
121914
+ return refusal5(eventId, pollId, {
121915
+ errorCode: "PAGE_NOT_PUBLISHED",
121916
+ message: "OKO refused this command: launching and resetting need a published page, because a draft has no viewers. Nothing was changed.",
121917
+ whatToDo: [
121918
+ "Publish the page first: oko_publish_event without confirm shows the bill, then again with confirm:true.",
121919
+ "Do not retry as is: the answer will not change until the page is published."
121920
+ ]
121921
+ });
121922
+ }
121923
+ if (code === "functions/permission-denied") {
121924
+ if (/live permission/i.test(text))
121925
+ return livePermissionRejectionResult(TOOL3, { eventId });
121926
+ if (/scope|no longer grants/i.test(text))
121927
+ return scopeRejectionResult(TOOL3, "live:control");
121928
+ return null;
121929
+ }
121930
+ if (code === "functions/not-found") {
121931
+ return /poll/i.test(text) ? refusal5(eventId, pollId, {
121932
+ errorCode: "POLL_NOT_FOUND",
121933
+ message: "The page has no poll with this id (or the poll is not complete, so OKO does not serve it). Nothing was changed.",
121934
+ whatToDo: ["Read the polls with oko_get_event (polls[]) and repeat with a complete poll's id."]
121935
+ }) : refusal5(eventId, null, {
121936
+ errorCode: "EVENT_NOT_FOUND",
121937
+ message: "Event not found.",
121938
+ whatToDo: ["Check the id: oko_list_my_events lists the events this organizer can see."]
121939
+ });
121940
+ }
121941
+ if (code === "functions/failed-precondition" && /trash/i.test(text))
121942
+ return eventInTrashRefusal(eventId, TOOL3);
121943
+ if (code === "functions/aborted") {
121944
+ return refusal5(eventId, pollId, {
121945
+ errorCode: "POLL_CHANGED_AT_THE_SAME_MOMENT",
121946
+ message: "Another command reached the same poll at the same moment, so this one was not applied. Nothing was lost.",
121947
+ whatToDo: [
121948
+ "Read the poll's state with oko_get_live_state (polls[]) and repeat the command only if it is still needed."
121949
+ ]
121950
+ });
121951
+ }
121952
+ return null;
121953
+ }
121954
+ function pollsLiveFrom(args) {
121955
+ const { event, states, tallies } = args;
121956
+ const raw = Array.isArray(event.polls) ? event.polls : [];
121957
+ const served = parseEventPolls(event.polls);
121958
+ const hints = [];
121959
+ if (served.length < Math.min(raw.length, 10)) {
121960
+ hints.push(`${Math.min(raw.length, 10) - served.length} poll(s) on the page are not served (incomplete, or a repeated id): they are not listed here. oko_validate_event_setup names them.`);
121961
+ }
121962
+ if (served.length === 0)
121963
+ return { polls: [], read: "none", hints };
121964
+ const nodes = states.kind === "read" && states.node && typeof states.node === "object" && !Array.isArray(states.node) ? states.node : {};
121965
+ let unreadable = 0;
121966
+ const polls = served.map((poll) => {
121967
+ const state2 = states.kind === "read" ? Object.hasOwn(nodes, poll.id) ? readPollState(nodes[poll.id]) : { ...DEFAULT_POLL_CTRL } : null;
121968
+ const tallyRead = tallies.get(poll.id);
121969
+ if (state2 === null || !tallyRead || tallyRead.kind === "unreadable") {
121970
+ unreadable += 1;
121971
+ return { pollId: poll.id, question: poll.question, multiple: poll.multiple, state: state2, tally: null };
121972
+ }
121973
+ const tally = readPollTally(tallyRead.node);
121974
+ const known = new Set(poll.options.map((option) => option.id));
121975
+ const removed = Object.entries(tally.options).filter(([id]) => !known.has(id)).map(([id, count]) => ({ id, count }));
121976
+ return {
121977
+ pollId: poll.id,
121978
+ question: poll.question,
121979
+ multiple: poll.multiple,
121980
+ state: state2,
121981
+ tally: {
121982
+ round: state2.round,
121983
+ total: tally.total,
121984
+ options: poll.options.map((option) => ({ id: option.id, text: option.text, count: Object.hasOwn(tally.options, option.id) ? tally.options[option.id] ?? 0 : 0 })),
121985
+ ...removed.length > 0 ? { removedOptions: removed } : {}
121986
+ }
121987
+ };
121988
+ });
121989
+ const read = unreadable === 0 ? "ok" : unreadable === polls.length ? "unreadable" : "partial";
121990
+ if (read !== "ok") {
121991
+ const reasons = [
121992
+ ...states.kind === "unreadable" ? [states.reason] : [],
121993
+ ...[...tallies.values()].flatMap((item) => item.kind === "unreadable" ? [item.reason] : [])
121994
+ ];
121995
+ hints.push(`${unreadable} of ${polls.length} poll(s) could not be read from the show (${[...new Set(reasons)].join("; ") || "no answer"}): their state or count is null, which is NOT zero. Repeat the call; do not report a count from a null.`);
121996
+ }
121997
+ hints.push("polls[].tally is the live count of the CURRENT round of each poll, shared by the whole page across sessions. The server raises it at every vote and recounts it from the votes at the end of a session, so it can be a vote short in between; the figures of one session are in oko_get_session_report.");
121998
+ return { polls, read, hints };
121999
+ }
122000
+ async function readPollsLive(eventId, event) {
122001
+ const served = parseEventPolls(event.polls);
122002
+ if (served.length === 0)
122003
+ return pollsLiveFrom({ event, states: { kind: "read", node: null }, tallies: /* @__PURE__ */ new Map() });
122004
+ let states;
122005
+ try {
122006
+ states = { kind: "read", node: (await get(ref(rtdb, `events/${eventId}/pollState`))).val() };
122007
+ } catch (error3) {
122008
+ states = { kind: "unreadable", reason: error3 instanceof Error ? error3.message : String(error3) };
122009
+ }
122010
+ const tallies = /* @__PURE__ */ new Map();
122011
+ if (states.kind === "read") {
122012
+ const nodes = states.node && typeof states.node === "object" && !Array.isArray(states.node) ? states.node : {};
122013
+ await Promise.all(served.map(async (poll) => {
122014
+ const round = Object.hasOwn(nodes, poll.id) ? readPollState(nodes[poll.id]).round : DEFAULT_POLL_CTRL.round;
122015
+ try {
122016
+ tallies.set(poll.id, { kind: "read", node: (await get(ref(rtdb, `events/${eventId}/pollTally/${poll.id}/${round}`))).val() });
122017
+ } catch (error3) {
122018
+ tallies.set(poll.id, { kind: "unreadable", reason: error3 instanceof Error ? error3.message : String(error3) });
122019
+ }
122020
+ }));
122021
+ }
122022
+ return pollsLiveFrom({ event, states, tallies });
122023
+ }
122024
+ async function readPollStateForPreview(eventId, pollId) {
122025
+ try {
122026
+ const node = (await get(ref(rtdb, `events/${eventId}/pollState/${pollId}`))).val();
122027
+ return { state: readPollState(node) };
122028
+ } catch (error3) {
122029
+ return { state: null, stateUnreadable: error3 instanceof Error ? error3.message : String(error3) };
122030
+ }
122031
+ }
122032
+ function pollCommandHints(action, state2) {
122033
+ const hints = [POLL_ACTION_EFFECT[action]];
122034
+ if (action === "reset")
122035
+ hints.push(`The poll is now in round ${state2.round}.`);
122036
+ if (action === "launch" || action === "open")
122037
+ hints.push("Voting is open.");
122038
+ return hints;
122039
+ }
122040
+ var callControlPoll = async (request) => {
122041
+ const call2 = httpsCallable(functions, "controlPoll");
122042
+ return (await call2(request)).data;
122043
+ };
122044
+ async function sendPollCommand(request, call2 = callControlPoll) {
122045
+ let answer;
122046
+ try {
122047
+ answer = await call2(request);
122048
+ } catch (error3) {
122049
+ const named = controlPollRefusalResult(request.eventId, request.pollId, error3);
122050
+ if (named)
122051
+ throw new ToolRefusal(named);
122052
+ throw error3;
122053
+ }
122054
+ return readPollState(answer.state);
122055
+ }
122056
+ function registerPollTools(server2) {
122057
+ server2.registerTool("oko_control_poll", {
122058
+ title: "Control a poll",
122059
+ description: toolDescription({
122060
+ summary: "Command one poll of an event during the show: close or reopen voting, show or hide the results to viewers, launch it as a popup for everyone, stop the popup, or reset the count (a new round). Without confirm nothing is sent: the call asks for confirmation and its preview says what viewers will get.",
122061
+ exampleCall: { eventId: "Kx9pQ2mR7tL4vB8nW3cY", pollId: "p-1b2c3d4e", action: "show_results", confirm: true },
122062
+ args: [
122063
+ "eventId: event whose poll to command.",
122064
+ "pollId: the poll's id (oko_get_event \u2192 polls[].id).",
122065
+ `action: ${POLL_ACTIONS.join(" | ")}.`,
122066
+ "confirm: must be true \u2014 every command changes what viewers see or can do right now."
122067
+ ],
122068
+ returns: "eventId, pollId, action and the poll's state after the command (closed, live, liveAt, resultsShown, round), plus hints.",
122069
+ examples: [
122070
+ "Use show_results when the organizer says to show the results; hide_results to take them down.",
122071
+ "Use launch to pop the poll up for everyone (control room); reset to start a new round with the count at zero (control room).",
122072
+ "Read the current count with oko_get_live_state (polls[])."
122073
+ ],
122074
+ notes: [
122075
+ `Needs the live:control scope and the right to control the show (control live) on the event. ${CONTROL_ROOM_NOTE}`,
122076
+ "close puts out a launched popup and stops votes (open lets them in again); show_results and hide_results decide whether VIEWERS see the bars \u2014 the organizer always sees the count; stop only puts the popup out.",
122077
+ "reset starts a new round: viewers may vote again and the count restarts at zero. Nothing is deleted \u2014 the votes of the earlier rounds stay in the data and in the session reports.",
122078
+ "Refusals carry errorCode and whatToDo: CONTROL_ROOM_REQUIRED, PAGE_NOT_PUBLISHED (launch and reset on a draft), POLL_NOT_FOUND, POLL_NOT_READY, POLL_CHANGED_AT_THE_SAME_MOMENT, LIVE_PERMISSION_REQUIRED, EVENT_NOT_FOUND, EVENT_IN_TRASH. Nothing was changed after any of them; relay them and do not retry as is."
122079
+ ]
122080
+ }),
122081
+ inputSchema: {
122082
+ eventId: eventIdField,
122083
+ pollId: external_exports.string().regex(POLL_ID_PATTERN).describe("Id of a poll of the event (oko_get_event \u2192 polls[].id)."),
122084
+ action: external_exports.enum(POLL_ACTIONS).describe("The command."),
122085
+ confirm: external_exports.boolean().optional().describe("Required true to send the command.")
122086
+ },
122087
+ outputSchema: basicEventOutputSchema,
122088
+ // Not idempotent: a second `reset` is another round and a second `launch` is another popup (`liveAt` moves).
122089
+ annotations: writeTool
122090
+ }, async ({ eventId, pollId, action, confirm }) => {
122091
+ const { uid } = await authed(["live:control"]);
122092
+ const event = await readEvent(eventId, uid, "live");
122093
+ const plan = planPollCommand({ eventId, event, pollId, action, ...await readPollStateForPreview(eventId, pollId) });
122094
+ requireConfirm(confirm, "oko_control_poll", plan.preview);
122095
+ const state2 = await sendPollCommand({ eventId, pollId, action });
122096
+ return textResult({
122097
+ eventId,
122098
+ pollId,
122099
+ action,
122100
+ question: plan.poll.question,
122101
+ state: state2,
122102
+ hints: pollCommandHints(action, state2),
122103
+ ...eventLinks(eventId)
122104
+ });
122105
+ });
122106
+ }
122107
+
121400
122108
  // build/tools/live.js
121401
122109
  var CHAT_LIVE_SETTINGS = ["mode", "slowModeMs", "automodEnabled", "allowViewerLinks"];
121402
122110
  async function writeChatControl(eventId, mode) {
@@ -121408,7 +122116,7 @@ function registerLiveTools(server2) {
121408
122116
  description: toolDescription({
121409
122117
  summary: "Read main RTDB live controls, aggregate analytics, and chat settings for an event.",
121410
122118
  args: ["eventId: event to inspect."],
121411
- returns: "eventId, agg metrics, ctrl live overrides, chat settings (mode, slowModeMs, automodEnabled, allowViewerLinks, plus accepting \u2014 whether message intake is open right now, as the server enforces it), and analyticsPaused \u2014 true/false, or null when the platform's kill switch could not be read. ctrl.chatMode is the live control for message intake: public, off, or absent (then the event's starting state blocks.chat.intake applies and accepting shows the result). ctrl.<block>Visible is an override; when it is absent the block follows its setup default \u2014 oko_get_event \u2192 live gives the resolved visibleNow per block.",
122119
+ returns: "eventId, agg metrics, ctrl live overrides, chat settings (mode, slowModeMs, automodEnabled, allowViewerLinks, plus accepting \u2014 whether message intake is open right now, as the server enforces it), polls (one entry per poll the page serves: its state \u2014 closed, live, liveAt, resultsShown, round \u2014 and the live count of the current round per option; a figure that could not be read is null, never zero) with pollsRead ok|partial|unreadable|none, and analyticsPaused \u2014 true/false, or null when the platform's kill switch could not be read. ctrl.chatMode is the live control for message intake: public, off, or absent (then the event's starting state blocks.chat.intake applies and accepting shows the result). ctrl.<block>Visible is an override; when it is absent the block follows its setup default \u2014 oko_get_event \u2192 live gives the resolved visibleNow per block.",
121412
122120
  examples: ["Use during a webinar before changing live block visibility or starting/ending sessions."],
121413
122121
  notes: [
121414
122122
  "Live state does not carry the event team: to see who has access to an event and with which permissions, use oko_list_workspace_access on the event's workspace."
@@ -121419,11 +122127,12 @@ function registerLiveTools(server2) {
121419
122127
  annotations: readOnlyTool
121420
122128
  }, async ({ eventId }) => {
121421
122129
  const { uid } = await authed(["analytics:read"]);
121422
- await readEvent(eventId, uid, "analytics");
121423
- const [aggSnap, ctrlSnap, killSwitch, ...chatSnaps] = await Promise.all([
122130
+ const event = await readEvent(eventId, uid, "analytics");
122131
+ const [aggSnap, ctrlSnap, killSwitch, pollsLive, ...chatSnaps] = await Promise.all([
121424
122132
  get(ref(rtdb, `events/${eventId}/agg`)),
121425
122133
  get(ref(rtdb, `events/${eventId}/ctrl`)),
121426
122134
  readKillSwitch(eventId),
122135
+ readPollsLive(eventId, event),
121427
122136
  ...CHAT_LIVE_SETTINGS.map((field2) => get(ref(chatRtdb, `settings/${eventId}/${field2}`)))
121428
122137
  ]);
121429
122138
  const chat = {};
@@ -121439,12 +122148,15 @@ function registerLiveTools(server2) {
121439
122148
  const killSwitchNote = killSwitchHint(killSwitch);
121440
122149
  if (killSwitchNote)
121441
122150
  liveStateHints.push(killSwitchNote);
122151
+ liveStateHints.push(...pollsLive.hints);
121442
122152
  return textResult({
121443
122153
  eventId,
121444
122154
  agg: aggSnap.val() ?? {},
121445
122155
  analyticsPaused: killSwitchFlag(killSwitch),
121446
122156
  ctrl: ctrlSnap.val() ?? {},
121447
122157
  chat,
122158
+ polls: pollsLive.polls,
122159
+ pollsRead: pollsLive.read,
121448
122160
  hints: liveStateHints
121449
122161
  });
121450
122162
  });
@@ -121783,9 +122495,9 @@ async function assertSessionHasNothingToEnd(eventId, sessionId) {
121783
122495
  read = { ok: false };
121784
122496
  readFailure = error3 instanceof Error ? error3.message : String(error3);
121785
122497
  }
121786
- const refusal6 = sessionRepairRefusal(sessionRepairAction({ liveSessionBaseline: sessionBaselineFromRead(read) }));
121787
- if (refusal6)
121788
- throw new Error(readFailure ? `${refusal6} (read failed: ${readFailure})` : refusal6);
122498
+ const refusal7 = sessionRepairRefusal(sessionRepairAction({ liveSessionBaseline: sessionBaselineFromRead(read) }));
122499
+ if (refusal7)
122500
+ throw new Error(readFailure ? `${refusal7} (read failed: ${readFailure})` : refusal7);
121789
122501
  }
121790
122502
  async function attemptCleanup(path, run) {
121791
122503
  try {
@@ -121923,9 +122635,9 @@ function extendSessionRefusalResult(eventId, error3) {
121923
122635
  return null;
121924
122636
  const info = details && typeof details === "object" ? details : {};
121925
122637
  const text = safeString(message);
121926
- const refusal6 = (body) => ({ ...textResult({ tool: "oko_extend_session", eventId, ...body }), isError: true });
122638
+ const refusal7 = (body) => ({ ...textResult({ tool: "oko_extend_session", eventId, ...body }), isError: true });
121927
122639
  if (info.reason === "session_not_running") {
121928
- return refusal6({
122640
+ return refusal7({
121929
122641
  errorCode: "SESSION_NOT_RUNNING",
121930
122642
  message: text || "No analytics session is running on this page, so there is nothing to extend.",
121931
122643
  whatToDo: [
@@ -121934,14 +122646,14 @@ function extendSessionRefusalResult(eventId, error3) {
121934
122646
  });
121935
122647
  }
121936
122648
  if (info.reason === "planned_end_invalid") {
121937
- return refusal6({
122649
+ return refusal7({
121938
122650
  errorCode: "PLANNED_END_INVALID",
121939
122651
  message: text || "The new end must be a moment in the future.",
121940
122652
  whatToDo: ["Pass endsAt later than now, or addMinutes to move the current end forward."]
121941
122653
  });
121942
122654
  }
121943
122655
  if (info.reason === "outside_show_window") {
121944
- return refusal6({
122656
+ return refusal7({
121945
122657
  errorCode: "OUTSIDE_SHOW_WINDOW",
121946
122658
  message: text || "The show window of this page is over: a session can no longer be extended.",
121947
122659
  whatToDo: ["End the session with oko_end_session; to run the show on another date, duplicate the page with oko_duplicate_event."]
@@ -122217,7 +122929,7 @@ function registerSessionTools(server2) {
122217
122929
  "timelineLimit: max timeline buckets per session when includeTimeline=true.",
122218
122930
  "includeSnapshots: include baseline/final snapshots; default false."
122219
122931
  ],
122220
- returns: "sessions plus count, limit, hasMore/has_more, nextCursor/next_cursor, and analyticsPaused \u2014 true/false, or null when the platform's kill switch could not be read. Heavy timeline/snapshot fields are omitted unless requested.",
122932
+ returns: "sessions plus count, limit, hasMore/has_more, nextCursor/next_cursor, and analyticsPaused \u2014 true/false, or null when the platform's kill switch could not be read. Heavy timeline/snapshot fields are omitted unless requested. A session carries polls (the poll rounds voted in it) when viewers voted.",
122221
122933
  examples: ["Use default options for dashboard-like summaries; request includeTimeline only for chart/debug tasks."]
122222
122934
  }),
122223
122935
  inputSchema: {
@@ -122280,7 +122992,10 @@ function registerSessionTools(server2) {
122280
122992
  "includeSnapshots: include baseline/final snapshots; default false."
122281
122993
  ],
122282
122994
  returns: "event/session ids, report URL, markdown summary, event summary, and compact session object. For a session that has not ended yet, also analyticsPaused \u2014 true/false, or null when the platform's kill switch could not be read.",
122283
- examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."]
122995
+ examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
122996
+ notes: [
122997
+ "Polls: when viewers voted during the session, the markdown has a Polls section and session.polls lists each poll round with its options and counts (votes cast IN THIS SESSION; a round that ran through several sessions is split between their reports; shares are of the voters, so with several answers allowed they can pass 100%). dataQualityWarnings says when the vote count of a session failed (pollsUnmeasured): then an empty polls section means not measured, not nobody voted."
122998
+ ]
122284
122999
  }),
122285
123000
  inputSchema: {
122286
123001
  eventId: eventIdField,
@@ -122333,7 +123048,7 @@ function registerSessionTools(server2) {
122333
123048
 
122334
123049
  // build/tools/telegramBilling.js
122335
123050
  var TELEGRAM_POST_PRICE_LUMENS = 5;
122336
- var TOOL3 = "oko_publish_telegram_post";
123051
+ var TOOL4 = "oko_publish_telegram_post";
122337
123052
  function telegramPostCost(scheduled) {
122338
123053
  return {
122339
123054
  lumens: TELEGRAM_POST_PRICE_LUMENS,
@@ -122343,26 +123058,26 @@ function telegramPostCost(scheduled) {
122343
123058
  when: scheduled ? "Scheduling is free. At the scheduled time the platform checks that the payer's plan includes Telegram posts and the wallet holds enough lumens; otherwise the post is refused then (deliveryError plan_required or no_lumens) and nothing is charged." : "The plan and the balance are checked before the send. A refused post is not sent and nothing is charged."
122344
123059
  };
122345
123060
  }
122346
- function refusal5(body) {
122347
- return { ...textResult({ tool: TOOL3, ...body }), isError: true };
123061
+ function refusal6(body) {
123062
+ return { ...textResult({ tool: TOOL4, ...body }), isError: true };
122348
123063
  }
122349
- function isRecord5(value) {
123064
+ function isRecord6(value) {
122350
123065
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
122351
123066
  }
122352
123067
  function telegramPostRefusalResult(eventId, postId, error3, callerUid) {
122353
- if (!isRecord5(error3))
123068
+ if (!isRecord6(error3))
122354
123069
  return null;
122355
123070
  const { code, message, details } = error3;
122356
123071
  if (typeof code !== "string" || !code.startsWith("functions/"))
122357
123072
  return null;
122358
- const info = isRecord5(details) ? details : {};
123073
+ const info = isRecord6(details) ? details : {};
122359
123074
  const text = safeString(message);
122360
123075
  const afterwards = "After the cause is fixed, create a new post with oko_publish_telegram_post and delete this failed one with oko_cancel_telegram_post (confirm:true) so the history shows one post.";
122361
123076
  if (info.errorCode === "ENTITLEMENT_REQUIRED") {
122362
123077
  const reason = typeof info.reason === "string" ? info.reason : null;
122363
123078
  const inactive = reason === "expired" || reason === "grace_over" || reason === "period_ended";
122364
123079
  const callerIsPayer = typeof info.payerUid === "string" && info.payerUid === callerUid;
122365
- return refusal5({
123080
+ return refusal6({
122366
123081
  errorCode: "ENTITLEMENT_REQUIRED",
122367
123082
  eventId,
122368
123083
  postId,
@@ -122381,7 +123096,7 @@ function telegramPostRefusalResult(eventId, postId, error3, callerUid) {
122381
123096
  if (info.errorCode === "INSUFFICIENT_TOKENS") {
122382
123097
  const callerIsPayer = info.actorIsPayer === true || info.callerIsPayer === true;
122383
123098
  const needed = typeof info.needed === "number" ? info.needed : TELEGRAM_POST_PRICE_LUMENS;
122384
- return refusal5({
123099
+ return refusal6({
122385
123100
  errorCode: "INSUFFICIENT_TOKENS",
122386
123101
  eventId,
122387
123102
  postId,
@@ -122399,7 +123114,7 @@ function telegramPostRefusalResult(eventId, postId, error3, callerUid) {
122399
123114
  });
122400
123115
  }
122401
123116
  if (typeof info.errorCode !== "string" && info.reason === "payer_unresolved") {
122402
- return refusal5({
123117
+ return refusal6({
122403
123118
  errorCode: "PAYER_UNRESOLVED",
122404
123119
  eventId,
122405
123120
  postId,
@@ -122702,13 +123417,13 @@ function registerTelegramTools(server2) {
122702
123417
  }
122703
123418
 
122704
123419
  // build/tools/wallet.js
122705
- var TOOL4 = "oko_wallet";
123420
+ var TOOL5 = "oko_wallet";
122706
123421
  var GRANTS_LIMIT = 50;
122707
123422
  var LEDGER_MAX = 50;
122708
123423
  var LEDGER_DEFAULT = 20;
122709
123424
  var GRANT_BUCKETS = ["plan", "rollover", "topup"];
122710
123425
  var LEDGER_KINDS = ["grant", "charge", "refund", "expire", "settlement"];
122711
- function isRecord6(value) {
123426
+ function isRecord7(value) {
122712
123427
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
122713
123428
  }
122714
123429
  function isSafeInt(value) {
@@ -122723,10 +123438,10 @@ function isoOrNull(value) {
122723
123438
  return ms === null ? null : new Date(ms).toISOString();
122724
123439
  }
122725
123440
  function readWalletBalance(raw) {
122726
- return isRecord6(raw) && isSafeInt(raw.balance) ? raw.balance : null;
123441
+ return isRecord7(raw) && isSafeInt(raw.balance) ? raw.balance : null;
122727
123442
  }
122728
123443
  function grantForOutput(id, raw) {
122729
- if (!isRecord6(raw))
123444
+ if (!isRecord7(raw))
122730
123445
  return null;
122731
123446
  if (!GRANT_BUCKETS.includes(raw.bucket))
122732
123447
  return null;
@@ -122745,13 +123460,13 @@ function grantForOutput(id, raw) {
122745
123460
  };
122746
123461
  }
122747
123462
  function ledgerRowForOutput(id, raw) {
122748
- if (!isRecord6(raw))
123463
+ if (!isRecord7(raw))
122749
123464
  return null;
122750
123465
  if (!LEDGER_KINDS.includes(raw.kind))
122751
123466
  return null;
122752
123467
  if (!isSafeInt(raw.amount) || !isSafeInt(raw.delta) || !isSafeInt(raw.balanceAfter))
122753
123468
  return null;
122754
- const ref2 = isRecord6(raw.ref) ? raw.ref : {};
123469
+ const ref2 = isRecord7(raw.ref) ? raw.ref : {};
122755
123470
  const text = (value) => typeof value === "string" && value ? value : null;
122756
123471
  const sessionId = text(ref2.sessionId) ?? text(raw.sessionId);
122757
123472
  const isSessionRow = raw.kind === "charge" && sessionId !== null;
@@ -122772,11 +123487,93 @@ function ledgerRowForOutput(id, raw) {
122772
123487
  forgiven: isSessionRow ? readCount(raw.forgiven ?? ref2.forgiven) : null
122773
123488
  };
122774
123489
  }
123490
+ var PLAN_TIERS = ["core", "pro", "max", "legacy"];
123491
+ var PLAN_STATUSES = ["active", "past_due", "canceled_at_period_end", "expired"];
123492
+ var PLAN_SOURCES = ["lava", "operator", "legacy-exit", "transition-b", "smoke"];
123493
+ function momentMs(value) {
123494
+ if (value instanceof Date)
123495
+ return Number.isNaN(value.getTime()) ? null : value.getTime();
123496
+ if (isRecord7(value) && typeof value.toMillis === "function") {
123497
+ const ms = value.toMillis();
123498
+ return typeof ms === "number" && Number.isFinite(ms) ? ms : null;
123499
+ }
123500
+ return null;
123501
+ }
123502
+ function readOrganizerPlan(accessData) {
123503
+ if (!isRecord7(accessData))
123504
+ return { state: "unreadable" };
123505
+ const raw = accessData.plan;
123506
+ if (raw === void 0 || raw === null)
123507
+ return { state: "none" };
123508
+ if (!isRecord7(raw))
123509
+ return { state: "unreadable" };
123510
+ const tier = PLAN_TIERS.find((candidate) => candidate === raw.tier);
123511
+ const status = PLAN_STATUSES.find((candidate) => candidate === raw.status);
123512
+ if (!tier || !status)
123513
+ return { state: "unreadable" };
123514
+ const dates = { periodStart: null, periodEnd: null, graceUntil: null };
123515
+ for (const key of ["periodStart", "periodEnd", "graceUntil"]) {
123516
+ const value = raw[key];
123517
+ if (value === void 0 || value === null)
123518
+ continue;
123519
+ const ms = momentMs(value);
123520
+ if (ms === null)
123521
+ return { state: "unreadable" };
123522
+ dates[key] = ms;
123523
+ }
123524
+ const seats = raw.seatsIncluded;
123525
+ if (seats !== void 0 && seats !== null && !(Number.isInteger(seats) && seats > 0))
123526
+ return { state: "unreadable" };
123527
+ const iso2 = (ms) => ms === null ? null : new Date(ms).toISOString();
123528
+ return {
123529
+ state: "plan",
123530
+ plan: {
123531
+ tier,
123532
+ status,
123533
+ source: PLAN_SOURCES.find((candidate) => candidate === raw.source) ?? null,
123534
+ periodEnd: iso2(dates.periodEnd),
123535
+ graceUntil: iso2(dates.graceUntil)
123536
+ }
123537
+ };
123538
+ }
123539
+ function planAnswer(read) {
123540
+ const url = billingUrl();
123541
+ const reading = read.kind === "access" && read.exists ? readOrganizerPlan(read.data) : { state: "unreadable" };
123542
+ const standing = `This tool only reads. A plan or lumens are bought, changed or cancelled only in the OKO web cabinet (${url}): send the organizer there, and never ask for card details or a password.`;
123543
+ if (reading.state === "unreadable") {
123544
+ return {
123545
+ plan: null,
123546
+ planRead: "unreadable",
123547
+ hints: [
123548
+ `The plan could not be read (the platform refused the read, or the stored plan is malformed). Do not guess it: the organizer sees the real plan in the OKO web cabinet (${url}).`,
123549
+ standing
123550
+ ]
123551
+ };
123552
+ }
123553
+ if (reading.state === "none") {
123554
+ return {
123555
+ plan: null,
123556
+ planRead: "ok",
123557
+ hints: [`This organizer has no plan: the paid features and spending lumens stay unavailable until a plan is bought in the OKO web cabinet (${url}). This tool cannot buy one.`, standing]
123558
+ };
123559
+ }
123560
+ const { plan } = reading;
123561
+ const hints = [];
123562
+ if (plan.status === "past_due") {
123563
+ hints.push(`The last renewal payment failed. The plan keeps working until graceUntil${plan.graceUntil ? ` (${plan.graceUntil})` : ""}; the organizer renews it in the OKO web cabinet (${url}) before then.`);
123564
+ } else if (plan.status === "canceled_at_period_end") {
123565
+ hints.push(`The subscription is cancelled: the plan works until periodEnd${plan.periodEnd ? ` (${plan.periodEnd})` : ""} and does not renew. Any change to it is made only in the OKO web cabinet (${url}).`);
123566
+ } else if (plan.status === "expired") {
123567
+ hints.push(`The plan has ended. A new plan is bought only in the OKO web cabinet (${url}).`);
123568
+ }
123569
+ hints.push(standing);
123570
+ return { plan, planRead: "ok", hints };
123571
+ }
122775
123572
  function walletReadRefusal() {
122776
123573
  return {
122777
123574
  ...textResult({
122778
123575
  errorCode: "PLATFORM_REJECTED_READ",
122779
- tool: TOOL4,
123576
+ tool: TOOL5,
122780
123577
  message: "OKO's security rules refused to read this organizer's wallet although the key carries wallet:read. The organizer behind the key may be blocked or inactive, or the platform has not opened wallets to MCP keys yet.",
122781
123578
  whatToDo: [
122782
123579
  "Do not retry: rules do not change between attempts.",
@@ -122788,7 +123585,7 @@ function walletReadRefusal() {
122788
123585
  };
122789
123586
  }
122790
123587
  function walletAnswer(input) {
122791
- const { uid, walletExists, walletData, grantDocs, ledgerDocs, max } = input;
123588
+ const { uid, walletExists, walletData, grantDocs, ledgerDocs, max, planRead } = input;
122792
123589
  let balance = null;
122793
123590
  if (walletExists) {
122794
123591
  balance = readWalletBalance(walletData);
@@ -122816,8 +123613,9 @@ function walletAnswer(input) {
122816
123613
  unreadableLedgerRows += 1;
122817
123614
  }
122818
123615
  const last = page.at(-1);
122819
- const lastAt = isRecord6(last?.data) ? last.data.at : void 0;
123616
+ const lastAt = isRecord7(last?.data) ? last.data.at : void 0;
122820
123617
  const nextCursor = hasMore ? timestampCursorFrom(lastAt, last?.id) : null;
123618
+ const planBlock = planRead ? planAnswer(planRead) : null;
122821
123619
  const hints = [];
122822
123620
  if (grantDocs !== null && !walletExists)
122823
123621
  hints.push("This organizer has no wallet yet: nothing was ever granted. Plans and lumens are bought only in the OKO web cabinet.");
@@ -122825,8 +123623,12 @@ function walletAnswer(input) {
122825
123623
  hints.push("Some rows did not read (unreadableGrants / unreadableLedgerRows): tell the organizer, the balance is still the wallet's own figure.");
122826
123624
  if (hasMore && nextCursor === null)
122827
123625
  hints.push("There are more ledger rows but the page boundary could not be named: the listing stops here.");
123626
+ if (planBlock)
123627
+ hints.push(...planBlock.hints);
122828
123628
  return {
122829
123629
  uid,
123630
+ billingUrl: billingUrl(),
123631
+ ...planBlock ? { plan: planBlock.plan, planRead: planBlock.planRead } : {},
122830
123632
  hasWallet: walletExists,
122831
123633
  balance,
122832
123634
  unit: "lumens",
@@ -122838,24 +123640,96 @@ function walletAnswer(input) {
122838
123640
  hints
122839
123641
  };
122840
123642
  }
123643
+ async function readPlanDocument(uid) {
123644
+ try {
123645
+ const snap = await getDoc(doc(firestore, "organizerAccess", uid));
123646
+ return { kind: "access", exists: snap.exists(), data: snap.data() };
123647
+ } catch (error3) {
123648
+ if (isFirestorePermissionDenied(error3))
123649
+ return { kind: "refused" };
123650
+ throw error3;
123651
+ }
123652
+ }
123653
+ var WORKSPACE_TOOL = "oko_workspace_wallet";
123654
+ function workspaceWalletRefusal(workspaceId, body) {
123655
+ return { ...textResult({ tool: WORKSPACE_TOOL, workspaceId, ...body }), isError: true };
123656
+ }
123657
+ function workspaceWalletRefusalResult(workspaceId, error3) {
123658
+ if (!error3 || typeof error3 !== "object")
123659
+ return null;
123660
+ const { code, details } = error3;
123661
+ if (typeof code !== "string" || !code.startsWith("functions/"))
123662
+ return null;
123663
+ const reason = isRecord7(details) ? details.reason : null;
123664
+ if (reason === "wallet_view_forbidden") {
123665
+ return workspaceWalletRefusal(workspaceId, {
123666
+ errorCode: "WALLET_VIEW_FORBIDDEN",
123667
+ message: "OKO does not show this wallet to this organizer. The balance of the wallet that pays for a workspace is told only to the payer and to the members who may spend from it (create events, edit setup, control the live show, start sessions, send notifications).",
123668
+ whatToDo: [
123669
+ "Check the workspace id with oko_list_workspaces: it must be a workspace this organizer belongs to.",
123670
+ "If it is, ask the workspace owner for the right to spend from the workspace, or ask them for the balance. Do not guess a figure.",
123671
+ "Do not retry as is: rights do not change between attempts."
123672
+ ]
123673
+ });
123674
+ }
123675
+ if (reason === "wallet_unreadable") {
123676
+ return workspaceWalletRefusal(workspaceId, {
123677
+ errorCode: "WALLET_UNREADABLE",
123678
+ message: "The balance could not be read just now. It is not zero: it is unknown.",
123679
+ whatToDo: [
123680
+ "Repeat the call in a moment. If it keeps failing, tell the organizer the balance is visible in the OKO cabinet under Billing.",
123681
+ "Do not report a balance and do not say the wallet is empty."
123682
+ ]
123683
+ });
123684
+ }
123685
+ return null;
123686
+ }
123687
+ function workspaceWalletAnswer(workspaceId, answer) {
123688
+ const data = isRecord7(answer) ? answer : {};
123689
+ const hasWallet = data.hasWallet;
123690
+ const available = data.available;
123691
+ if (typeof hasWallet !== "boolean") {
123692
+ throw new Error("The server's answer about the workspace wallet is malformed (hasWallet is not a yes/no). This is a platform data fault: report it, do not present a balance.");
123693
+ }
123694
+ if (hasWallet && !isSafeInt(available)) {
123695
+ throw new Error("The workspace wallet exists but its balance is not a whole number. This is a platform data fault: report it, do not present the balance as zero.");
123696
+ }
123697
+ const payerIsCaller = data.payerIsCaller === true;
123698
+ const hints = [];
123699
+ if (!hasWallet)
123700
+ hints.push("The payer of this workspace has no wallet yet (nothing was ever granted): available is null, which is not zero. Plans and lumens are bought only in the OKO web cabinet.");
123701
+ if (!payerIsCaller)
123702
+ hints.push("This is the wallet of the workspace OWNER, who pays for the pages of this workspace. Only the number is told; the owner's plan, grants and ledger are theirs. Only the owner tops it up, in the OKO web cabinet.");
123703
+ hints.push(`This tool only reads. Plans and lumens are bought, changed and cancelled only in the OKO web cabinet (${billingUrl()}): send the organizer there, and never ask for card details or a password.`);
123704
+ return {
123705
+ workspaceId: safeString(data.workspaceId, workspaceId),
123706
+ hasWallet,
123707
+ available: hasWallet ? available : null,
123708
+ unit: "lumens",
123709
+ payerIsCaller,
123710
+ billingUrl: billingUrl(),
123711
+ hints
123712
+ };
123713
+ }
122841
123714
  function registerWalletTools(server2) {
122842
123715
  server2.registerTool("oko_wallet", {
122843
123716
  title: "Read lumen wallet",
122844
123717
  description: toolDescription({
122845
- summary: "Read the lumen balance, the grants (buckets) and the ledger of the organizer's OWN wallet, the one behind this key. Read-only.",
123718
+ summary: "Read the plan, the lumen balance, the grants (buckets) and the ledger of the organizer's OWN account, the one behind this key, with the link to the OKO cabinet's Billing page. Read-only: it cannot buy anything.",
122846
123719
  args: [
122847
123720
  "limit: ledger rows per page, default 20, max 50, newest first.",
122848
- "cursor: nextCursor returned by the previous page. The grants come with the first page only; the balance with every page."
123721
+ "cursor: nextCursor returned by the previous page. The plan and the grants come with the first page only; the balance with every page."
122849
123722
  ],
122850
- returns: 'hasWallet, balance (whole lumens, null when the organizer has no wallet yet), unit "lumens", grants on the first page (id, bucket plan|rollover|topup, amount, remaining, expiresAt, createdAt), ledger rows (id, kind grant|charge|refund|expire|settlement, amount, delta, balanceAfter, by, at, eventId, showId, workspaceId, postId, sessionId, minutes, forgiven), unreadableGrants/unreadableLedgerRows (rows that did not read: say so, never hide them) and cursor pagination metadata.',
123723
+ returns: `billingUrl (the cabinet's Billing page), plan on the first page (tier core|pro|max|legacy, status active|past_due|canceled_at_period_end|expired, source lava|operator|legacy-exit|transition-b|smoke or null, periodEnd and graceUntil as ISO times or null; plan is null when the organizer has no plan) with planRead ok|unreadable (unreadable: the plan could not be read, so null means unknown, not no plan), hasWallet, balance (whole lumens, null when the organizer has no wallet yet), unit "lumens", grants on the first page (id, bucket plan|rollover|topup, amount, remaining, expiresAt, createdAt), ledger rows (id, kind grant|charge|refund|expire|settlement, amount, delta, balanceAfter, by, at, eventId, showId, workspaceId, postId, sessionId, minutes, forgiven), unreadableGrants/unreadableLedgerRows (rows that did not read: say so, never hide them) and cursor pagination metadata.`,
122851
123724
  examples: [
122852
123725
  "Use before publishing a page or starting a session when the organizer asks how many lumens are left or what was charged.",
122853
123726
  "Use to explain a charge or a refund: every ledger row names what paid (eventId, sessionId, postId) and the balance after it."
122854
123727
  ],
122855
123728
  notes: [
122856
123729
  "Needs the wallet:read scope. A key created before that scope existed gets SCOPE_REQUIRED and its scopes cannot be changed: the organizer creates a new key in the OKO cabinet.",
122857
- "Shows only the wallet of the organizer behind the key. A page in a shared workspace is paid from the WORKSPACE OWNER's wallet: the publish, session and Telegram previews and refusals name that payer and tell its balance to whoever may spend from it (publish, start a session, send posts); the owner's grants and ledger are theirs to read, not this key's.",
122858
- "This tool cannot buy, grant, top up or spend lumens. Plans, lumens and payment details belong to the OKO web cabinet only; never ask the organizer for card data or a password.",
123730
+ "Shows only the wallet of the organizer behind the key. A page in a shared workspace is paid from the WORKSPACE OWNER's wallet: the publish, session and Telegram previews and refusals name that payer and tell its balance to whoever may spend from it (publish, start a session, send posts); the owner's grants and ledger are theirs to read, not this key's. The balance of the wallet that pays for a workspace is one call away: oko_workspace_wallet.",
123731
+ "This tool cannot buy, change, cancel, grant, top up or spend anything. A plan and lumens are bought, changed and cancelled in the OKO web cabinet only (billingUrl): send the organizer there, and never ask for card data or a password.",
123732
+ "The plan shown is the organizer's own, the one behind this key. A paid action on a page of a shared workspace is judged by the PAYER's plan (the workspace owner's), which this key cannot read: the refusal of the paid tool names what is missing.",
122859
123733
  "Refusals of the paid tools use the same codes as the web: ENTITLEMENT_REQUIRED (the plan does not include the feature) and INSUFFICIENT_TOKENS (not enough lumens); PLATFORM_REJECTED_READ here means the platform refused the read."
122860
123734
  ]
122861
123735
  }),
@@ -122872,10 +123746,11 @@ function registerWalletTools(server2) {
122872
123746
  const constraints = [orderBy("at", "desc"), limit(max + 1)];
122873
123747
  if (cursor)
122874
123748
  constraints.splice(1, 0, startAfter(...parseTimestampCursor(cursor)));
122875
- const [walletSnap, grantsSnap, ledgerSnap] = await Promise.all([
123749
+ const [walletSnap, grantsSnap, ledgerSnap, planRead] = await Promise.all([
122876
123750
  getDoc(doc(firestore, "wallets", uid)),
122877
123751
  first ? getDocs(query2(collection(firestore, "wallets", uid, "grants"), orderBy("createdAt", "desc"), limit(GRANTS_LIMIT))) : Promise.resolve(null),
122878
- getDocs(query2(collection(firestore, "wallets", uid, "ledger"), ...constraints))
123752
+ getDocs(query2(collection(firestore, "wallets", uid, "ledger"), ...constraints)),
123753
+ first ? readPlanDocument(uid) : Promise.resolve(null)
122879
123754
  ]);
122880
123755
  return textResult(walletAnswer({
122881
123756
  uid,
@@ -122883,7 +123758,8 @@ function registerWalletTools(server2) {
122883
123758
  walletData: walletSnap.data(),
122884
123759
  grantDocs: grantsSnap ? grantsSnap.docs.map((item) => ({ id: item.id, data: item.data() })) : null,
122885
123760
  ledgerDocs: ledgerSnap.docs.map((item) => ({ id: item.id, data: item.data() })),
122886
- max
123761
+ max,
123762
+ planRead
122887
123763
  }));
122888
123764
  } catch (error3) {
122889
123765
  if (isFirestorePermissionDenied(error3))
@@ -122891,6 +123767,43 @@ function registerWalletTools(server2) {
122891
123767
  throw error3;
122892
123768
  }
122893
123769
  });
123770
+ server2.registerTool("oko_workspace_wallet", {
123771
+ title: "Read a workspace's lumen balance",
123772
+ description: toolDescription({
123773
+ summary: "Read how many lumens the wallet that pays for a workspace holds \u2014 one number. For a shared workspace that is the WORKSPACE OWNER's wallet; for the organizer's personal workspace (its id is the organizer's own uid) it is their own. Read-only: it cannot buy anything.",
123774
+ exampleCall: { workspaceId: "yH4dN8sQ2wE6rT1uI9oP" },
123775
+ args: ["workspaceId: the workspace whose payer's balance to read (oko_list_workspaces)."],
123776
+ returns: `workspaceId, hasWallet, available (whole lumens; null when the payer has no wallet yet \u2014 not zero), unit "lumens", payerIsCaller (true: it is the organizer's own wallet), billingUrl and hints.`,
123777
+ examples: [
123778
+ "Use before publishing a page or starting a session in a shared workspace, to tell whether the wallet that pays can cover it.",
123779
+ "oko_wallet shows the organizer's OWN plan, grants and ledger; this tool shows the balance that pays for a workspace."
123780
+ ],
123781
+ notes: [
123782
+ "Needs the wallet:read scope (presets operator and full). Allowed for the payer and for members who may spend from the payer's wallet (create events, edit setup, control the live show, start sessions, send notifications); anyone else, and a workspace that does not exist, get the same refusal WALLET_VIEW_FORBIDDEN.",
123783
+ "Only the number is told to a member. The owner's plan, grants and ledger stay the owner's: use oko_wallet for your own.",
123784
+ "An unreadable balance is the refusal WALLET_UNREADABLE, never zero.",
123785
+ "This tool cannot buy, change, cancel, grant, top up or spend anything. Plans and lumens are bought only in the OKO web cabinet (billingUrl): send the organizer there, and never ask for card data or a password."
123786
+ ]
123787
+ }),
123788
+ inputSchema: {
123789
+ workspaceId: external_exports.string().min(1).max(128).regex(/^[^/]+$/, "A workspace id has no slash.").describe("Workspace id, as oko_list_workspaces lists it (a personal workspace's id is the organizer's uid).")
123790
+ },
123791
+ outputSchema: workspaceWalletOutputSchema,
123792
+ annotations: readOnlyTool
123793
+ }, async ({ workspaceId }) => {
123794
+ await authed(["wallet:read"]);
123795
+ const read = httpsCallable(functions, "readWorkspaceWallet");
123796
+ let answer;
123797
+ try {
123798
+ answer = (await read({ workspaceId })).data;
123799
+ } catch (error3) {
123800
+ const named = workspaceWalletRefusalResult(workspaceId, error3);
123801
+ if (named)
123802
+ throw new ToolRefusal(named);
123803
+ throw error3;
123804
+ }
123805
+ return textResult(workspaceWalletAnswer(workspaceId, answer));
123806
+ });
122894
123807
  }
122895
123808
 
122896
123809
  // build/tools/workspaceRefusal.js
@@ -122997,9 +123910,9 @@ function registerWorkspaceTools(server2) {
122997
123910
  const created = await createWorkspace({ name: name7.trim() });
122998
123911
  return textResult({ workspaceId: created.data.workspaceId, name: created.data.name ?? name7.trim(), created: true });
122999
123912
  } catch (error3) {
123000
- const refusal6 = workspaceRefusalResult("oko_create_workspace", error3);
123001
- if (refusal6)
123002
- throw new ToolRefusal(refusal6);
123913
+ const refusal7 = workspaceRefusalResult("oko_create_workspace", error3);
123914
+ if (refusal7)
123915
+ throw new ToolRefusal(refusal7);
123003
123916
  throw error3;
123004
123917
  }
123005
123918
  });
@@ -123134,12 +124047,12 @@ function registerWorkspaceTools(server2) {
123134
124047
  ...args.comment !== void 0 ? { comment: args.comment.trim() } : {}
123135
124048
  });
123136
124049
  } catch (error3) {
123137
- const refusal6 = workspaceRefusalResult("oko_update_workspace_member", error3, {
124050
+ const refusal7 = workspaceRefusalResult("oko_update_workspace_member", error3, {
123138
124051
  workspaceId: args.workspaceId,
123139
124052
  uid: args.uid
123140
124053
  });
123141
- if (refusal6)
123142
- throw new ToolRefusal(refusal6);
124054
+ if (refusal7)
124055
+ throw new ToolRefusal(refusal7);
123143
124056
  throw error3;
123144
124057
  }
123145
124058
  const createdFields = ["permissions"];
@@ -123263,9 +124176,9 @@ function registerWorkspaceTools(server2) {
123263
124176
  const result = await claimInvite({ code: code.trim().toUpperCase() });
123264
124177
  return textResult({ ...serialize(result.data) });
123265
124178
  } catch (error3) {
123266
- const refusal6 = workspaceRefusalResult("oko_claim_workspace_invite", error3);
123267
- if (refusal6)
123268
- throw new ToolRefusal(refusal6);
124179
+ const refusal7 = workspaceRefusalResult("oko_claim_workspace_invite", error3);
124180
+ if (refusal7)
124181
+ throw new ToolRefusal(refusal7);
123269
124182
  throw error3;
123270
124183
  }
123271
124184
  });
@@ -123347,6 +124260,7 @@ function registerTools(server2) {
123347
124260
  registerPublicationTools(server2);
123348
124261
  registerUnpublicationTools(server2);
123349
124262
  registerLiveTools(server2);
124263
+ registerPollTools(server2);
123350
124264
  registerSessionTools(server2);
123351
124265
  registerChatTools(server2);
123352
124266
  registerLeadTools(server2);