@okostream/mcp 0.12.7 → 0.12.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +481 -149
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -70639,7 +70639,7 @@ var GUIDE_RESOURCES = [
|
|
|
70639
70639
|
"1. Call `oko_whoami` and inspect scopes.",
|
|
70640
70640
|
"2. If the workspace is unclear, call `oko_list_workspaces`.",
|
|
70641
70641
|
"3. Call `oko_create_webinar` with title/date/workspace and video URL when known.",
|
|
70642
|
-
"4. Call `oko_configure_blocks` for video, forms, CTA, banner, and Telegram settings.",
|
|
70642
|
+
"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).",
|
|
70643
70643
|
"5. Call `oko_configure_cards` with stable card ids before uploading card images.",
|
|
70644
70644
|
"6. Upload images with `oko_upload_card_images_batch` or `oko_upload_image`, then wait for variants.",
|
|
70645
70645
|
"7. Call `oko_validate_event_setup` and fix blocking findings.",
|
|
@@ -70731,8 +70731,9 @@ var GUIDE_RESOURCES = [
|
|
|
70731
70731
|
"1. Call `oko_create_telegram_link` and complete the one-time bot link in the organizer's account.",
|
|
70732
70732
|
"2. Add `@OKOStreamBot` to the target channel as an administrator with posting permission.",
|
|
70733
70733
|
"3. Use the claim code returned by the bot with `oko_claim_telegram_channel` and `confirm:true`.",
|
|
70734
|
-
"4. Call `oko_list_telegram_channels`, then connect the selected id through `oko_configure_blocks
|
|
70734
|
+
"4. Call `oko_list_telegram_channels`, then connect the selected id through `oko_configure_blocks` \u2014 its `telegramChannelIds` needs the telegram:write scope and the notification permission on the event, and is refused before the write without them.",
|
|
70735
70735
|
"5. Publish or schedule with `oko_publish_telegram_post` only after showing the exact channel and copy.",
|
|
70736
|
+
" The caption is Telegram HTML: `<b>`, `<i>` and `<a href>` are markup. Escape `&`, `<` and `>` in any text copied from event fields, or Telegram rejects the message and the post lands in `failed`.",
|
|
70736
70737
|
"6. Inspect delivery through `oko_list_telegram_posts`; avoid retrying `delivery_unknown` automatically.",
|
|
70737
70738
|
"",
|
|
70738
70739
|
"The MCP does not toggle the platform auto-reminder. Schedule an explicit reminder post or use web setup."
|
|
@@ -70855,6 +70856,18 @@ var DEFAULT_CHARACTER_LIMIT = 3e4;
|
|
|
70855
70856
|
var MAX_COMPACT_ARRAY_ITEMS = 25;
|
|
70856
70857
|
var MAX_COMPACT_OBJECT_KEYS = 60;
|
|
70857
70858
|
var MAX_COMPACT_STRING_LENGTH = 2e3;
|
|
70859
|
+
function isFirestorePermissionDenied(error3) {
|
|
70860
|
+
if (!error3 || typeof error3 !== "object")
|
|
70861
|
+
return false;
|
|
70862
|
+
const code = error3.code;
|
|
70863
|
+
return code === "permission-denied" || code === "firestore/permission-denied" || code === "PERMISSION_DENIED";
|
|
70864
|
+
}
|
|
70865
|
+
function isFirestoreNotFound(error3) {
|
|
70866
|
+
if (!error3 || typeof error3 !== "object")
|
|
70867
|
+
return false;
|
|
70868
|
+
const code = error3.code;
|
|
70869
|
+
return code === "not-found" || code === "firestore/not-found" || code === "NOT_FOUND";
|
|
70870
|
+
}
|
|
70858
70871
|
function serialize(value) {
|
|
70859
70872
|
if (value == null)
|
|
70860
70873
|
return value;
|
|
@@ -116130,6 +116143,41 @@ function readImageRoots() {
|
|
|
116130
116143
|
}
|
|
116131
116144
|
return roots;
|
|
116132
116145
|
}
|
|
116146
|
+
var FIREBASE_FIELDS = [
|
|
116147
|
+
["apiKey", ["OKO_FIREBASE_API_KEY", "NEXT_PUBLIC_FIREBASE_API_KEY"]],
|
|
116148
|
+
["authDomain", ["OKO_FIREBASE_AUTH_DOMAIN", "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN"]],
|
|
116149
|
+
["databaseURL", ["OKO_FIREBASE_DATABASE_URL", "NEXT_PUBLIC_FIREBASE_DATABASE_URL"]],
|
|
116150
|
+
["projectId", ["OKO_FIREBASE_PROJECT_ID", "NEXT_PUBLIC_FIREBASE_PROJECT_ID"]],
|
|
116151
|
+
["storageBucket", ["OKO_FIREBASE_STORAGE_BUCKET", "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET"]],
|
|
116152
|
+
["messagingSenderId", ["OKO_FIREBASE_MESSAGING_SENDER_ID", "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID"]],
|
|
116153
|
+
["appId", ["OKO_FIREBASE_APP_ID", "NEXT_PUBLIC_FIREBASE_APP_ID"]],
|
|
116154
|
+
["chatDatabaseURL", ["OKO_FIREBASE_CHAT_DB_URL", "NEXT_PUBLIC_FIREBASE_CHAT_DB_URL"]]
|
|
116155
|
+
];
|
|
116156
|
+
function firebaseConfigError(source) {
|
|
116157
|
+
const overridden = [];
|
|
116158
|
+
const missing = [];
|
|
116159
|
+
for (const [field2, names] of FIREBASE_FIELDS) {
|
|
116160
|
+
const label = names[0] ?? field2;
|
|
116161
|
+
let value = null;
|
|
116162
|
+
for (const name7 of names) {
|
|
116163
|
+
const candidate = normalizeOptionalEnv(source[name7]);
|
|
116164
|
+
if (candidate) {
|
|
116165
|
+
value = candidate;
|
|
116166
|
+
break;
|
|
116167
|
+
}
|
|
116168
|
+
}
|
|
116169
|
+
if (value === null)
|
|
116170
|
+
missing.push(label);
|
|
116171
|
+
else if (value !== DEFAULT_FIREBASE_CONFIG[field2])
|
|
116172
|
+
overridden.push(label);
|
|
116173
|
+
}
|
|
116174
|
+
if (overridden.length === 0 || missing.length === 0)
|
|
116175
|
+
return null;
|
|
116176
|
+
return `OKO MCP: the Firebase config is only partially set. ${overridden.join(", ")} point at another project, while ${missing.join(", ")} are not set and would silently fall back to the built-in okostreamcore values. Set the whole Firebase config or none of it.`;
|
|
116177
|
+
}
|
|
116178
|
+
var firebaseConfigProblem = firebaseConfigError(process.env);
|
|
116179
|
+
if (firebaseConfigProblem)
|
|
116180
|
+
throw new Error(firebaseConfigProblem);
|
|
116133
116181
|
var env = {
|
|
116134
116182
|
apiKey: readOptionalEnv("OKO_API_KEY"),
|
|
116135
116183
|
imageRoots: readImageRoots(),
|
|
@@ -116224,7 +116272,7 @@ function sanitizeScopes(scopes) {
|
|
|
116224
116272
|
}
|
|
116225
116273
|
|
|
116226
116274
|
// build/version.js
|
|
116227
|
-
var OKO_MCP_VERSION = "0.12.
|
|
116275
|
+
var OKO_MCP_VERSION = "0.12.9";
|
|
116228
116276
|
|
|
116229
116277
|
// build/auth.js
|
|
116230
116278
|
var state = null;
|
|
@@ -116348,29 +116396,40 @@ async function exchangeApiKey() {
|
|
|
116348
116396
|
latestVersion: typeof data.latestVersion === "string" && parseVersion(data.latestVersion) ? data.latestVersion.trim() : null
|
|
116349
116397
|
};
|
|
116350
116398
|
}
|
|
116351
|
-
|
|
116399
|
+
function singleFlight(run) {
|
|
116400
|
+
let inFlight = null;
|
|
116401
|
+
return () => {
|
|
116402
|
+
inFlight ??= run().finally(() => {
|
|
116403
|
+
inFlight = null;
|
|
116404
|
+
});
|
|
116405
|
+
return inFlight;
|
|
116406
|
+
};
|
|
116407
|
+
}
|
|
116408
|
+
var refreshSession = singleFlight(async () => {
|
|
116352
116409
|
const now = Date.now();
|
|
116353
|
-
|
|
116354
|
-
|
|
116355
|
-
|
|
116356
|
-
|
|
116357
|
-
|
|
116358
|
-
throw new OkoAuthError("unavailable", "OKO MCP received an organizer session but could not initialize it. Reconnect and try again.");
|
|
116359
|
-
}
|
|
116360
|
-
state = {
|
|
116361
|
-
uid: exchanged.organizerUid,
|
|
116362
|
-
scopes: exchanged.scopes,
|
|
116363
|
-
expiresAt: now + exchanged.expiresIn * 1e3
|
|
116364
|
-
};
|
|
116365
|
-
if (exchanged.latestVersion)
|
|
116366
|
-
recordLatestPublishedVersion(exchanged.latestVersion);
|
|
116410
|
+
const exchanged = await exchangeApiKey();
|
|
116411
|
+
try {
|
|
116412
|
+
await signInWithCustomToken(auth, exchanged.customToken);
|
|
116413
|
+
} catch {
|
|
116414
|
+
throw new OkoAuthError("unavailable", "OKO MCP received an organizer session but could not initialize it. Reconnect and try again.");
|
|
116367
116415
|
}
|
|
116416
|
+
state = {
|
|
116417
|
+
uid: exchanged.organizerUid,
|
|
116418
|
+
scopes: exchanged.scopes,
|
|
116419
|
+
expiresAt: now + exchanged.expiresIn * 1e3
|
|
116420
|
+
};
|
|
116421
|
+
if (exchanged.latestVersion)
|
|
116422
|
+
recordLatestPublishedVersion(exchanged.latestVersion);
|
|
116423
|
+
return state;
|
|
116424
|
+
});
|
|
116425
|
+
async function ensureAuth(requiredScopes = []) {
|
|
116426
|
+
const current = state && Date.now() <= state.expiresAt - 5 * 60 * 1e3 && auth.currentUser ? state : await refreshSession();
|
|
116368
116427
|
for (const scope of requiredScopes) {
|
|
116369
|
-
if (!
|
|
116428
|
+
if (!current.scopes.includes(scope)) {
|
|
116370
116429
|
throw new OkoScopeError(scope);
|
|
116371
116430
|
}
|
|
116372
116431
|
}
|
|
116373
|
-
return
|
|
116432
|
+
return current;
|
|
116374
116433
|
}
|
|
116375
116434
|
function currentScopes() {
|
|
116376
116435
|
return state?.scopes ?? [];
|
|
@@ -116389,7 +116448,9 @@ async function getConnectionStatus() {
|
|
|
116389
116448
|
if (error3 instanceof OkoAuthError) {
|
|
116390
116449
|
return buildConnectionStatus(error3.status, authErrorOptions(error3));
|
|
116391
116450
|
}
|
|
116392
|
-
|
|
116451
|
+
const detail = error3 instanceof Error ? `${error3.name}: ${error3.message}` : String(error3);
|
|
116452
|
+
console.error(`[oko-mcp] connection status: unexpected error \u2014 ${detail}`);
|
|
116453
|
+
return buildConnectionStatus("unavailable", { serverMessage: `Unexpected client-side error: ${detail}` });
|
|
116393
116454
|
}
|
|
116394
116455
|
}
|
|
116395
116456
|
function authErrorOptions(error3) {
|
|
@@ -116444,14 +116505,14 @@ var telegramChannelSchema = external_exports.object({
|
|
|
116444
116505
|
chatId: external_exports.number().int(),
|
|
116445
116506
|
title: external_exports.string().max(200).optional(),
|
|
116446
116507
|
username: external_exports.string().max(120).nullable().optional()
|
|
116447
|
-
}).
|
|
116508
|
+
}).passthrough();
|
|
116448
116509
|
var canonicalTelegramChannelSchema = telegramChannelSchema.extend({
|
|
116449
116510
|
workspaceIds: external_exports.array(external_exports.string()).max(50),
|
|
116450
116511
|
owned: external_exports.boolean(),
|
|
116451
116512
|
subscriberCount: external_exports.number().int().nonnegative().nullable().optional(),
|
|
116452
116513
|
health: external_exports.enum(["connected", "needs_attention", "unknown"]).optional(),
|
|
116453
116514
|
healthCheckedAt: external_exports.number().int().nonnegative().nullable().optional()
|
|
116454
|
-
}).
|
|
116515
|
+
}).passthrough();
|
|
116455
116516
|
|
|
116456
116517
|
// build/tools/imagePipeline.js
|
|
116457
116518
|
var import_undici = __toESM(require_undici(), 1);
|
|
@@ -116491,6 +116552,24 @@ function isPublicUnicastAddress(address) {
|
|
|
116491
116552
|
return false;
|
|
116492
116553
|
return import_ipaddr.default.parse(normalized).range() === "unicast";
|
|
116493
116554
|
}
|
|
116555
|
+
function orderAddressCandidates(addresses, maxCandidates = 4) {
|
|
116556
|
+
const known = addresses.filter((item) => item.family === 4 || item.family === 6);
|
|
116557
|
+
const preferred = known[0]?.family;
|
|
116558
|
+
if (preferred === void 0)
|
|
116559
|
+
return [];
|
|
116560
|
+
const first = known.filter((item) => item.family === preferred);
|
|
116561
|
+
const second = known.filter((item) => item.family !== preferred);
|
|
116562
|
+
const ordered = [];
|
|
116563
|
+
for (let index = 0; index < Math.max(first.length, second.length); index += 1) {
|
|
116564
|
+
const fromFirst = first[index];
|
|
116565
|
+
const fromSecond = second[index];
|
|
116566
|
+
if (fromFirst)
|
|
116567
|
+
ordered.push(fromFirst);
|
|
116568
|
+
if (fromSecond)
|
|
116569
|
+
ordered.push(fromSecond);
|
|
116570
|
+
}
|
|
116571
|
+
return ordered.slice(0, maxCandidates);
|
|
116572
|
+
}
|
|
116494
116573
|
function pinnedLookup(address, family) {
|
|
116495
116574
|
return (_hostname, options2, callback) => {
|
|
116496
116575
|
if (options2 && typeof options2 === "object" && options2.all) {
|
|
@@ -116523,13 +116602,7 @@ function legacyEventAllowsMode(data, uid, mode) {
|
|
|
116523
116602
|
const fields = mode === "read" ? ["accessibleBy", "editors"] : [EVENT_ACCESS_FIELD_BY_MODE[mode]];
|
|
116524
116603
|
return fields.some((field2) => Array.isArray(data[field2]) && data[field2].includes(uid));
|
|
116525
116604
|
}
|
|
116526
|
-
function
|
|
116527
|
-
if (!error3 || typeof error3 !== "object")
|
|
116528
|
-
return false;
|
|
116529
|
-
const code = error3.code;
|
|
116530
|
-
return code === "permission-denied" || code === "firestore/permission-denied" || code === "PERMISSION_DENIED";
|
|
116531
|
-
}
|
|
116532
|
-
async function readEvent(eventId, uid, mode = "read") {
|
|
116605
|
+
async function readEventAccess(eventId, uid, mode) {
|
|
116533
116606
|
const [snap, grant] = await Promise.all([
|
|
116534
116607
|
getDoc(doc(firestore, "events", eventId)),
|
|
116535
116608
|
getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error3) => {
|
|
@@ -116542,6 +116615,13 @@ async function readEvent(eventId, uid, mode = "read") {
|
|
|
116542
116615
|
throw new Error("Event not found.");
|
|
116543
116616
|
const data = snap.data();
|
|
116544
116617
|
const allowed = grant === null ? legacyEventAllowsMode(data, uid, mode) : data.ownerId === uid || grant.exists() && grantAllowsMode(grant.data(), mode);
|
|
116618
|
+
return { data, allowed };
|
|
116619
|
+
}
|
|
116620
|
+
async function eventGrantsMode(eventId, uid, mode) {
|
|
116621
|
+
return (await readEventAccess(eventId, uid, mode)).allowed;
|
|
116622
|
+
}
|
|
116623
|
+
async function readEvent(eventId, uid, mode = "read") {
|
|
116624
|
+
const { data, allowed } = await readEventAccess(eventId, uid, mode);
|
|
116545
116625
|
if (!allowed) {
|
|
116546
116626
|
throw new Error(`Event does not grant ${mode} access to this organizer.`);
|
|
116547
116627
|
}
|
|
@@ -116604,10 +116684,6 @@ function nonPublicResolutionMessage(hostname2, addresses) {
|
|
|
116604
116684
|
const environmentHint = fakeIp ? "Addresses in 198.18.0.0/15 mean a VPN or proxy with DNS interception (fake-IP mode) is active on this machine: every public host resolves there, so no remote URL can be verified." : "If other public hosts resolve to private addresses too, a VPN or proxy with DNS interception is active on this machine.";
|
|
116605
116685
|
return `Image URL resolves to a non-public IP address (${hostname2} \u2192 ${list}), which is not allowed. ${environmentHint} Workarounds: upload the file from disk via source.path (allowed directories come from OKO_IMAGE_ROOTS; the organizer can download the image first), or disable the VPN/proxy for this upload.`;
|
|
116606
116686
|
}
|
|
116607
|
-
var remoteFetchNotes = [];
|
|
116608
|
-
function takeRemoteFetchNotes() {
|
|
116609
|
-
return remoteFetchNotes.splice(0, remoteFetchNotes.length);
|
|
116610
|
-
}
|
|
116611
116687
|
async function resolveRemoteImageAddress(url) {
|
|
116612
116688
|
if (url.protocol !== "https:") {
|
|
116613
116689
|
throw new Error("Image URL must use https://.");
|
|
@@ -116624,18 +116700,18 @@ async function resolveRemoteImageAddress(url) {
|
|
|
116624
116700
|
if (!isPublicUnicastAddress(literalAddress)) {
|
|
116625
116701
|
throw new Error(`Image URL points to a private/local IP address (${literalAddress}), which is not allowed. Use a public HTTPS image URL, or upload the file from disk via source.path (requires OKO_IMAGE_ROOTS).`);
|
|
116626
116702
|
}
|
|
116627
|
-
return { address: literalAddress, family: literalFamily, transparentProxy: false };
|
|
116703
|
+
return { addresses: [{ address: literalAddress, family: literalFamily }], transparentProxy: false };
|
|
116628
116704
|
}
|
|
116629
116705
|
const addresses = await lookup(url.hostname, { all: true });
|
|
116630
116706
|
const transparentProxy = addresses.length > 0 && addresses.every((item) => isTransparentProxyAddress(item.address));
|
|
116631
116707
|
if (!transparentProxy && (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address)))) {
|
|
116632
116708
|
throw new Error(nonPublicResolutionMessage(url.hostname, addresses.map((item) => item.address)));
|
|
116633
116709
|
}
|
|
116634
|
-
const
|
|
116635
|
-
if (
|
|
116710
|
+
const candidates = orderAddressCandidates(addresses);
|
|
116711
|
+
if (candidates.length === 0) {
|
|
116636
116712
|
throw new Error("Image URL host could not be resolved safely.");
|
|
116637
116713
|
}
|
|
116638
|
-
return {
|
|
116714
|
+
return { addresses: candidates, transparentProxy };
|
|
116639
116715
|
}
|
|
116640
116716
|
async function responseBufferWithLimit(response, maxBytes) {
|
|
116641
116717
|
const body = response.body;
|
|
@@ -116665,39 +116741,80 @@ async function responseBufferWithLimit(response, maxBytes) {
|
|
|
116665
116741
|
}
|
|
116666
116742
|
return Buffer.concat(chunks, total);
|
|
116667
116743
|
}
|
|
116668
|
-
|
|
116744
|
+
function shareLinkHint(url) {
|
|
116745
|
+
let host = "";
|
|
116746
|
+
try {
|
|
116747
|
+
host = new URL(url).hostname.toLowerCase();
|
|
116748
|
+
} catch {
|
|
116749
|
+
return "";
|
|
116750
|
+
}
|
|
116751
|
+
if (host === "drive.google.com" || host === "docs.google.com") {
|
|
116752
|
+
return " This is a Google Drive page, not the file: share it as 'anyone with the link' and use https://drive.google.com/uc?export=download&id=<fileId>, or download the file and pass source.path.";
|
|
116753
|
+
}
|
|
116754
|
+
if (host.endsWith("disk.yandex.ru") || host.endsWith("disk.yandex.com") || host === "yadi.sk" || host.endsWith("disk.360.yandex.ru")) {
|
|
116755
|
+
return " This is a Yandex Disk page, not the file: use the direct download link from the page (\u0421\u043A\u0430\u0447\u0430\u0442\u044C \u2192 copy link), or download the file and pass source.path.";
|
|
116756
|
+
}
|
|
116757
|
+
if (host === "dropbox.com" || host.endsWith(".dropbox.com")) {
|
|
116758
|
+
return " This is a Dropbox page: replace ?dl=0 with ?dl=1 in the link, or download the file and pass source.path.";
|
|
116759
|
+
}
|
|
116760
|
+
if (host.endsWith("onedrive.live.com") || host === "1drv.ms" || host.endsWith("sharepoint.com")) {
|
|
116761
|
+
return " This is a OneDrive page: use its direct download link, or download the file and pass source.path.";
|
|
116762
|
+
}
|
|
116763
|
+
return " Use a direct link to the image file (right-click the image \u2192 copy image address), or download the file and pass source.path.";
|
|
116764
|
+
}
|
|
116765
|
+
async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
|
|
116669
116766
|
if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
|
|
116670
116767
|
throw new Error("Image URL has too many redirects.");
|
|
116671
116768
|
}
|
|
116672
116769
|
const url = new URL(rawUrl);
|
|
116673
116770
|
const pinned = await resolveRemoteImageAddress(url);
|
|
116674
116771
|
if (pinned.transparentProxy && redirects === 0) {
|
|
116675
|
-
|
|
116772
|
+
notes.push(`Image fetched through a VPN/proxy with DNS interception: ${url.hostname} resolved to ${pinned.addresses[0]?.address}, so OKO could not verify the destination address itself; the connection went through the proxy. This is expected on such machines and fine for public image links.`);
|
|
116676
116773
|
}
|
|
116774
|
+
let lastConnectError = null;
|
|
116775
|
+
for (const candidate of pinned.addresses) {
|
|
116776
|
+
const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects);
|
|
116777
|
+
if ("buffer" in attempt)
|
|
116778
|
+
return attempt.buffer;
|
|
116779
|
+
if ("redirectTo" in attempt)
|
|
116780
|
+
return readRemoteImage(attempt.redirectTo, redirects + 1, notes);
|
|
116781
|
+
lastConnectError = attempt.connectError;
|
|
116782
|
+
}
|
|
116783
|
+
throw new Error(`Image URL host ${url.hostname} could not be reached at any of its addresses (${pinned.addresses.map((item) => item.address).join(", ")}): ${lastConnectError?.message ?? "connection failed"}.`);
|
|
116784
|
+
}
|
|
116785
|
+
async function readRemoteImageFrom(url, rawUrl, candidate, redirects) {
|
|
116677
116786
|
const dispatcher = new import_undici.Agent({
|
|
116678
|
-
connect: { lookup: pinnedLookup(
|
|
116787
|
+
connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
|
|
116679
116788
|
});
|
|
116680
116789
|
const controller = new AbortController();
|
|
116681
116790
|
const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
|
|
116682
116791
|
try {
|
|
116683
|
-
|
|
116684
|
-
|
|
116685
|
-
|
|
116686
|
-
|
|
116687
|
-
|
|
116688
|
-
|
|
116792
|
+
let response;
|
|
116793
|
+
try {
|
|
116794
|
+
response = await (0, import_undici.fetch)(url, {
|
|
116795
|
+
redirect: "manual",
|
|
116796
|
+
signal: controller.signal,
|
|
116797
|
+
headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
|
|
116798
|
+
dispatcher
|
|
116799
|
+
});
|
|
116800
|
+
} catch (err) {
|
|
116801
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
116802
|
+
throw new Error("Image URL fetch timed out.");
|
|
116803
|
+
}
|
|
116804
|
+
return { connectError: err instanceof Error ? err : new Error(String(err)) };
|
|
116805
|
+
}
|
|
116689
116806
|
if (response.status >= 300 && response.status < 400) {
|
|
116690
116807
|
const location2 = response.headers.get("location");
|
|
116691
116808
|
if (!location2)
|
|
116692
116809
|
throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
|
|
116693
|
-
return
|
|
116810
|
+
return { redirectTo: new URL(location2, url).toString() };
|
|
116694
116811
|
}
|
|
116695
116812
|
if (!response.ok) {
|
|
116696
116813
|
throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
|
|
116697
116814
|
}
|
|
116698
116815
|
const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
|
|
116699
116816
|
if (!contentType || !contentType.startsWith("image/")) {
|
|
116700
|
-
throw new Error(
|
|
116817
|
+
throw new Error(`Image URL must return Content-Type image/* (got ${contentType || "none"}).${shareLinkHint(rawUrl)}`);
|
|
116701
116818
|
}
|
|
116702
116819
|
if (!ALLOWED_REMOTE_IMAGE_TYPES.has(contentType)) {
|
|
116703
116820
|
throw new Error(`Image URL Content-Type ${contentType} is not supported. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
|
|
@@ -116706,7 +116823,7 @@ async function readRemoteImage(rawUrl, redirects = 0) {
|
|
|
116706
116823
|
if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
|
|
116707
116824
|
throw new Error("Image URL is larger than 15MB.");
|
|
116708
116825
|
}
|
|
116709
|
-
return await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES);
|
|
116826
|
+
return { buffer: await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES) };
|
|
116710
116827
|
} catch (err) {
|
|
116711
116828
|
if (err instanceof Error && err.name === "AbortError") {
|
|
116712
116829
|
throw new Error("Image URL fetch timed out.");
|
|
@@ -116760,6 +116877,12 @@ function serverFetchRefusal(error3) {
|
|
|
116760
116877
|
if (code === "functions/invalid-argument" && message.includes("Image payload is empty or too large")) {
|
|
116761
116878
|
return { reason: "server_without_url_support", message: "The OKO server release in use does not fetch URLs yet." };
|
|
116762
116879
|
}
|
|
116880
|
+
if (code === "functions/deadline-exceeded") {
|
|
116881
|
+
return { reason: "server_timeout", message: "The OKO server did not finish fetching the URL within its time limit." };
|
|
116882
|
+
}
|
|
116883
|
+
if (code === "functions/internal") {
|
|
116884
|
+
return { reason: "server_error", message: message && message !== "internal" ? message : "The OKO server failed while fetching the URL." };
|
|
116885
|
+
}
|
|
116763
116886
|
return null;
|
|
116764
116887
|
}
|
|
116765
116888
|
var SERVER_FETCH_NOTE = "Image fetched by the OKO server directly from the URL; this machine's network was not involved.";
|
|
@@ -116789,10 +116912,10 @@ async function uploadImageFromSource(eventId, kind, cardId, source) {
|
|
|
116789
116912
|
serverFailure = refusal;
|
|
116790
116913
|
}
|
|
116791
116914
|
let buffer;
|
|
116915
|
+
const fetchNotes = [];
|
|
116792
116916
|
try {
|
|
116793
|
-
buffer = await readRemoteImage(source.url);
|
|
116917
|
+
buffer = await readRemoteImage(source.url, 0, fetchNotes);
|
|
116794
116918
|
} catch (localError) {
|
|
116795
|
-
takeRemoteFetchNotes();
|
|
116796
116919
|
throw new Error(remoteImageUnavailableMessage(source.url, serverFailure, localError));
|
|
116797
116920
|
}
|
|
116798
116921
|
const uploaded = await uploadMasterImage(eventId, kind, cardId, { imageBase64: buffer.toString("base64") });
|
|
@@ -116800,7 +116923,7 @@ async function uploadImageFromSource(eventId, kind, cardId, source) {
|
|
|
116800
116923
|
uploaded,
|
|
116801
116924
|
sourceBytes: buffer.byteLength,
|
|
116802
116925
|
sourceVia: "client",
|
|
116803
|
-
notes: [localFallbackNote(serverFailure), ...
|
|
116926
|
+
notes: [localFallbackNote(serverFailure), ...fetchNotes]
|
|
116804
116927
|
};
|
|
116805
116928
|
}
|
|
116806
116929
|
function readImageRef(eventData, kind, cardId) {
|
|
@@ -116907,20 +117030,23 @@ function normalizeNullableText(value) {
|
|
|
116907
117030
|
return null;
|
|
116908
117031
|
return value.trim() || null;
|
|
116909
117032
|
}
|
|
117033
|
+
var CARD_LINK_SCHEME = /^(https?:\/\/|mailto:|tel:|\/(?!\/))/i;
|
|
117034
|
+
var CARD_BARE_DOMAIN = /^[\p{L}\d-]+(\.[\p{L}\d-]+)+(:\d+)?(\/\S*)?$/u;
|
|
116910
117035
|
function normalizeCardButtonUrl(value) {
|
|
116911
117036
|
const trimmed = normalizeNullableText(value);
|
|
116912
117037
|
if (!trimmed)
|
|
116913
117038
|
return null;
|
|
117039
|
+
if (CARD_LINK_SCHEME.test(trimmed))
|
|
117040
|
+
return trimmed;
|
|
117041
|
+
if (CARD_BARE_DOMAIN.test(trimmed))
|
|
117042
|
+
return `https://${trimmed}`;
|
|
116914
117043
|
let url;
|
|
116915
117044
|
try {
|
|
116916
117045
|
url = new URL(trimmed);
|
|
116917
117046
|
} catch {
|
|
116918
|
-
throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
|
|
116919
|
-
}
|
|
116920
|
-
if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
|
|
116921
|
-
throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
|
|
117047
|
+
throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
|
|
116922
117048
|
}
|
|
116923
|
-
|
|
117049
|
+
throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
|
|
116924
117050
|
}
|
|
116925
117051
|
function eventFormIds(eventData) {
|
|
116926
117052
|
const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
|
|
@@ -117692,6 +117818,7 @@ function detectAndNormalize(url) {
|
|
|
117692
117818
|
}
|
|
117693
117819
|
|
|
117694
117820
|
// build/tools/formatting.js
|
|
117821
|
+
var RETIRED_SESSION_FIELDS = ["nmoEvaluation"];
|
|
117695
117822
|
function cursorMeta(count, max, hasMore, nextCursor) {
|
|
117696
117823
|
return {
|
|
117697
117824
|
count,
|
|
@@ -117715,8 +117842,8 @@ function assertEmbeddableUrl(value) {
|
|
|
117715
117842
|
} catch {
|
|
117716
117843
|
throw new Error(`Invalid videoUrl "${value}". Provide an https:// link or a full <iframe> embed code.`);
|
|
117717
117844
|
}
|
|
117718
|
-
if (url.protocol !== "https:"
|
|
117719
|
-
throw new Error(`Unsupported videoUrl protocol "${url.protocol}".
|
|
117845
|
+
if (url.protocol !== "https:") {
|
|
117846
|
+
throw new Error(`Unsupported videoUrl protocol "${url.protocol}". The player must be served over https:// \u2014 http:// links are refused by the platform.`);
|
|
117720
117847
|
}
|
|
117721
117848
|
return value;
|
|
117722
117849
|
}
|
|
@@ -117760,15 +117887,31 @@ function timestampCursorFrom(value) {
|
|
|
117760
117887
|
const millis = toMillis(value);
|
|
117761
117888
|
return millis == null ? null : String(millis);
|
|
117762
117889
|
}
|
|
117890
|
+
var ISO_WITH_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
117763
117891
|
function parseIsoBound(value, label) {
|
|
117764
117892
|
if (value === void 0)
|
|
117765
117893
|
return null;
|
|
117766
117894
|
const parsed = Date.parse(value);
|
|
117767
|
-
if (!Number.isFinite(parsed)) {
|
|
117768
|
-
throw new Error(`Invalid ${label}: expected an ISO timestamp such as 2026-08-28T00:00:00+03:00.`);
|
|
117895
|
+
if (!Number.isFinite(parsed) || !ISO_WITH_OFFSET.test(value.trim())) {
|
|
117896
|
+
throw new Error(`Invalid ${label}: expected an ISO timestamp with an explicit offset, such as 2026-08-28T00:00:00+03:00 or 2026-08-27T21:00:00Z.`);
|
|
117769
117897
|
}
|
|
117770
117898
|
return Timestamp.fromMillis(parsed);
|
|
117771
117899
|
}
|
|
117900
|
+
function absoluteHttpUrl(value, label) {
|
|
117901
|
+
const trimmed = (value ?? "").trim();
|
|
117902
|
+
if (!trimmed)
|
|
117903
|
+
return null;
|
|
117904
|
+
let url;
|
|
117905
|
+
try {
|
|
117906
|
+
url = new URL(trimmed);
|
|
117907
|
+
} catch {
|
|
117908
|
+
throw new Error(`Invalid ${label} "${trimmed}": Telegram needs an absolute http(s) URL.`);
|
|
117909
|
+
}
|
|
117910
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
117911
|
+
throw new Error(`Unsupported ${label} protocol "${url.protocol}": Telegram needs an absolute http(s) URL.`);
|
|
117912
|
+
}
|
|
117913
|
+
return trimmed;
|
|
117914
|
+
}
|
|
117772
117915
|
function compactAnalyticsSession(raw, options2) {
|
|
117773
117916
|
const session = serialize(raw);
|
|
117774
117917
|
const timeline = Array.isArray(session.timeline) ? session.timeline : null;
|
|
@@ -117794,7 +117937,8 @@ function compactAnalyticsSession(raw, options2) {
|
|
|
117794
117937
|
session.finalSnapshotOmitted = true;
|
|
117795
117938
|
}
|
|
117796
117939
|
}
|
|
117797
|
-
|
|
117940
|
+
for (const field2 of RETIRED_SESSION_FIELDS)
|
|
117941
|
+
delete session[field2];
|
|
117798
117942
|
return session;
|
|
117799
117943
|
}
|
|
117800
117944
|
function numberMetric(value) {
|
|
@@ -117865,15 +118009,78 @@ function copyableTelegram(source) {
|
|
|
117865
118009
|
|
|
117866
118010
|
// build/tools/workspaceAccess.js
|
|
117867
118011
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
117868
|
-
|
|
117869
|
-
|
|
117870
|
-
|
|
118012
|
+
|
|
118013
|
+
// build/personalWorkspace.js
|
|
118014
|
+
var PERSONAL_WORKSPACE_NAME = "Personal";
|
|
118015
|
+
function personalWorkspaceRecord(uid) {
|
|
118016
|
+
return { id: uid, name: PERSONAL_WORKSPACE_NAME, ownerId: uid, personal: true };
|
|
118017
|
+
}
|
|
118018
|
+
function resolveWorkspaceForOwner(workspaceId, uid, snapshot) {
|
|
118019
|
+
if (!snapshot.exists) {
|
|
118020
|
+
if (workspaceId === uid)
|
|
118021
|
+
return personalWorkspaceRecord(uid);
|
|
117871
118022
|
throw new Error("Workspace not found.");
|
|
117872
|
-
|
|
118023
|
+
}
|
|
118024
|
+
const data = snapshot.data ?? {};
|
|
117873
118025
|
if (data.ownerId !== uid)
|
|
117874
118026
|
throw new Error("Only the workspace owner can manage this workspace.");
|
|
117875
118027
|
return data;
|
|
117876
118028
|
}
|
|
118029
|
+
function refusalMayMeanMissing(error3, workspaceId, uid) {
|
|
118030
|
+
if (workspaceId !== uid)
|
|
118031
|
+
return false;
|
|
118032
|
+
return isFirestorePermissionDenied(error3) || isFirestoreNotFound(error3);
|
|
118033
|
+
}
|
|
118034
|
+
function withSyntheticPersonalWorkspace(uid, rows) {
|
|
118035
|
+
if (rows.some((row) => row.id === uid))
|
|
118036
|
+
return rows;
|
|
118037
|
+
return [{ ...personalWorkspaceRecord(uid), isOwner: true }, ...rows];
|
|
118038
|
+
}
|
|
118039
|
+
function isSharedWorkspaceId(workspaceId) {
|
|
118040
|
+
return /^[A-Za-z0-9]{20}$/.test(workspaceId);
|
|
118041
|
+
}
|
|
118042
|
+
function workspaceMemberRemoval(existed, workspaceId, targetUid) {
|
|
118043
|
+
if (existed)
|
|
118044
|
+
return { removed: true };
|
|
118045
|
+
return {
|
|
118046
|
+
removed: false,
|
|
118047
|
+
missing: true,
|
|
118048
|
+
hints: [`No share for ${targetUid} exists in workspace ${workspaceId}: nothing was removed. List the members with oko_list_workspace_access before removing one.`]
|
|
118049
|
+
};
|
|
118050
|
+
}
|
|
118051
|
+
function personalWorkspaceLimitMessage(action) {
|
|
118052
|
+
return `A personal workspace has no members or invites: it exists for its owner alone, and the platform refuses ${action} there. Create a shared workspace with oko_create_workspace, move the event into it with oko_move_event, and repeat this call with the shared workspace id.`;
|
|
118053
|
+
}
|
|
118054
|
+
|
|
118055
|
+
// build/tools/workspaceAccess.js
|
|
118056
|
+
async function readWorkspaceForOwnerDetailed(workspaceId, uid) {
|
|
118057
|
+
let snap;
|
|
118058
|
+
try {
|
|
118059
|
+
snap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
118060
|
+
} catch (error3) {
|
|
118061
|
+
if (!refusalMayMeanMissing(error3, workspaceId, uid))
|
|
118062
|
+
throw error3;
|
|
118063
|
+
const owned = await getDocs(query2(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
118064
|
+
const existing = owned.docs.find((item) => item.id === workspaceId);
|
|
118065
|
+
if (existing) {
|
|
118066
|
+
return {
|
|
118067
|
+
workspace: resolveWorkspaceForOwner(workspaceId, uid, { exists: true, data: existing.data() }),
|
|
118068
|
+
synthesized: false
|
|
118069
|
+
};
|
|
118070
|
+
}
|
|
118071
|
+
return { workspace: personalWorkspaceRecord(uid), synthesized: true };
|
|
118072
|
+
}
|
|
118073
|
+
return {
|
|
118074
|
+
workspace: resolveWorkspaceForOwner(workspaceId, uid, {
|
|
118075
|
+
exists: snap.exists(),
|
|
118076
|
+
data: snap.exists() ? snap.data() : void 0
|
|
118077
|
+
}),
|
|
118078
|
+
synthesized: !snap.exists()
|
|
118079
|
+
};
|
|
118080
|
+
}
|
|
118081
|
+
async function readWorkspaceForOwner(workspaceId, uid) {
|
|
118082
|
+
return (await readWorkspaceForOwnerDetailed(workspaceId, uid)).workspace;
|
|
118083
|
+
}
|
|
117877
118084
|
function normalizeWorkspacePermissions(value) {
|
|
117878
118085
|
const out = {};
|
|
117879
118086
|
for (const key of WORKSPACE_PERMISSION_KEYS) {
|
|
@@ -117900,7 +118107,7 @@ async function listVisibleWorkspaces(uid, includeShared) {
|
|
|
117900
118107
|
out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
|
|
117901
118108
|
}
|
|
117902
118109
|
}
|
|
117903
|
-
return out;
|
|
118110
|
+
return withSyntheticPersonalWorkspace(uid, out);
|
|
117904
118111
|
}
|
|
117905
118112
|
async function getChatRole(eventId, uid) {
|
|
117906
118113
|
const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
|
|
@@ -117986,7 +118193,7 @@ function registerChatTools(server2) {
|
|
|
117986
118193
|
const { uid } = await authed(["chat:moderate"]);
|
|
117987
118194
|
await readEvent(eventId, uid, "moderate");
|
|
117988
118195
|
requireConfirm(confirm, "oko_delete_chat_message", { eventId, messageId });
|
|
117989
|
-
await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: Date.now(), by: uid });
|
|
118196
|
+
await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: Date.now(), by: uid, reason: "manual" });
|
|
117990
118197
|
return textResult({ eventId, messageId, deleted: true });
|
|
117991
118198
|
});
|
|
117992
118199
|
server2.registerTool("oko_mute_user", {
|
|
@@ -118074,7 +118281,7 @@ function registerChatTools(server2) {
|
|
|
118074
118281
|
title: "Set chat settings",
|
|
118075
118282
|
description: toolDescription({
|
|
118076
118283
|
summary: "Set slow mode and automod settings for an event chat.",
|
|
118077
|
-
args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle.", "confirm: must be true."],
|
|
118284
|
+
args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle.", "allowViewerLinks: optional; true lets viewers post links (the chat rule and automod both honour it; default false).", "confirm: must be true."],
|
|
118078
118285
|
returns: "eventId and updated RTDB setting paths.",
|
|
118079
118286
|
examples: ["Use slowModeMs=5000 to slow busy chats; use automodEnabled=false as an emergency kill switch."]
|
|
118080
118287
|
}),
|
|
@@ -118082,19 +118289,22 @@ function registerChatTools(server2) {
|
|
|
118082
118289
|
eventId: eventIdField,
|
|
118083
118290
|
slowModeMs: external_exports.number().int().min(0).max(6e4).optional().describe("Slow mode delay in milliseconds."),
|
|
118084
118291
|
automodEnabled: external_exports.boolean().optional().describe("Enable or disable chat automoderation."),
|
|
118292
|
+
allowViewerLinks: external_exports.boolean().optional().describe("Allow viewers to post links in chat (default false: the rule blocks links from viewers)."),
|
|
118085
118293
|
confirm: external_exports.boolean().optional().describe("Required true to change live chat settings.")
|
|
118086
118294
|
},
|
|
118087
118295
|
outputSchema: basicEventOutputSchema,
|
|
118088
118296
|
annotations: idempotentWriteTool
|
|
118089
|
-
}, async ({ eventId, slowModeMs, automodEnabled, confirm }) => {
|
|
118297
|
+
}, async ({ eventId, slowModeMs, automodEnabled, allowViewerLinks, confirm }) => {
|
|
118090
118298
|
const { uid } = await authed(["chat:moderate"]);
|
|
118091
118299
|
await readEvent(eventId, uid, "moderate");
|
|
118092
|
-
requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null });
|
|
118300
|
+
requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null, allowViewerLinks: allowViewerLinks ?? null });
|
|
118093
118301
|
const patch = {};
|
|
118094
118302
|
if (slowModeMs !== void 0)
|
|
118095
118303
|
patch[`settings/${eventId}/slowModeMs`] = slowModeMs;
|
|
118096
118304
|
if (automodEnabled !== void 0)
|
|
118097
118305
|
patch[`settings/${eventId}/automodEnabled`] = automodEnabled;
|
|
118306
|
+
if (allowViewerLinks !== void 0)
|
|
118307
|
+
patch[`settings/${eventId}/allowViewerLinks`] = allowViewerLinks;
|
|
118098
118308
|
if (Object.keys(patch).length === 0) {
|
|
118099
118309
|
throw new Error("Provide slowModeMs and/or automodEnabled to change chat settings.");
|
|
118100
118310
|
}
|
|
@@ -118281,6 +118491,17 @@ function validateEventSetup(eventId, event) {
|
|
|
118281
118491
|
}
|
|
118282
118492
|
|
|
118283
118493
|
// build/tools/events.js
|
|
118494
|
+
function telegramBlocksPatch(args) {
|
|
118495
|
+
if (args.telegramChannelIds === void 0)
|
|
118496
|
+
return {};
|
|
118497
|
+
return {
|
|
118498
|
+
"telegram.enabled": args.telegramChannelIds.length > 0,
|
|
118499
|
+
"telegram.channelIds": args.telegramChannelIds
|
|
118500
|
+
};
|
|
118501
|
+
}
|
|
118502
|
+
function configureBlocksScopes(args) {
|
|
118503
|
+
return Object.keys(telegramBlocksPatch(args)).length > 0 ? ["setup:write", "telegram:write"] : ["setup:write"];
|
|
118504
|
+
}
|
|
118284
118505
|
function registerEventTools(server2) {
|
|
118285
118506
|
server2.registerTool("oko_list_my_events", {
|
|
118286
118507
|
title: "List my events",
|
|
@@ -118348,7 +118569,8 @@ function registerEventTools(server2) {
|
|
|
118348
118569
|
return { id: item.id, ...data };
|
|
118349
118570
|
});
|
|
118350
118571
|
}
|
|
118351
|
-
|
|
118572
|
+
const projectionHint = args.detail === "compact" ? { hints: ['Compact rows are a projection rebuilt after each change and can lag the event by seconds (title, date, permissions). For fields that must be current, call oko_get_event or use detail="full".'] } : {};
|
|
118573
|
+
return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor), ...projectionHint }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
|
|
118352
118574
|
}
|
|
118353
118575
|
throw new Error("Event list not received: the eventAccess index returned an empty result.");
|
|
118354
118576
|
});
|
|
@@ -118358,7 +118580,10 @@ function registerEventTools(server2) {
|
|
|
118358
118580
|
summary: "Read one OKO event visible to the organizer.",
|
|
118359
118581
|
args: ["eventId: OKO event document id."],
|
|
118360
118582
|
returns: "full event document fields plus id and public event URL.",
|
|
118361
|
-
examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."]
|
|
118583
|
+
examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
|
|
118584
|
+
notes: [
|
|
118585
|
+
"blocks.chat.mode and blocks.chat.visibility are both legacy: the event page reads neither \u2014 the chat panel exists exactly when blocks.chat.enabled is true, and sending is opened or closed live with oko_set_chat_mode. Ignore both fields."
|
|
118586
|
+
]
|
|
118362
118587
|
}),
|
|
118363
118588
|
inputSchema: { eventId: eventIdField },
|
|
118364
118589
|
outputSchema: eventOutputSchema,
|
|
@@ -118517,20 +118742,21 @@ function registerEventTools(server2) {
|
|
|
118517
118742
|
title: "Configure event blocks",
|
|
118518
118743
|
description: toolDescription({
|
|
118519
118744
|
summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
|
|
118520
|
-
exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "chat": { "enabled": true
|
|
118745
|
+
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" } },
|
|
118521
118746
|
args: [
|
|
118522
118747
|
"eventId: event to edit.",
|
|
118523
118748
|
"chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
|
|
118524
118749
|
"forms: replaces configured lead forms when provided.",
|
|
118525
|
-
"telegramChannelIds: replaces configured Telegram channels when provided."
|
|
118750
|
+
"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."
|
|
118526
118751
|
],
|
|
118527
118752
|
returns: "event id and the list of Firestore fields updated.",
|
|
118528
|
-
examples: ["Use after oko_create_webinar to
|
|
118753
|
+
examples: ["Use after oko_create_webinar to enable chat, bind a CTA/form, and configure other audience blocks."],
|
|
118529
118754
|
notes: [
|
|
118530
118755
|
"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.",
|
|
118531
118756
|
"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.",
|
|
118532
118757
|
'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.',
|
|
118533
|
-
"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."
|
|
118758
|
+
"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.",
|
|
118759
|
+
"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."
|
|
118534
118760
|
]
|
|
118535
118761
|
}),
|
|
118536
118762
|
inputSchema: {
|
|
@@ -118545,8 +118771,12 @@ function registerEventTools(server2) {
|
|
|
118545
118771
|
outputSchema: updateOutputSchema,
|
|
118546
118772
|
annotations: writeTool
|
|
118547
118773
|
}, async (args) => {
|
|
118548
|
-
const { uid } = await authed(
|
|
118774
|
+
const { uid } = await authed(configureBlocksScopes(args));
|
|
118549
118775
|
const current = await readEvent(args.eventId, uid, "edit");
|
|
118776
|
+
const telegramPatch = telegramBlocksPatch(args);
|
|
118777
|
+
if (Object.keys(telegramPatch).length > 0 && !await eventGrantsMode(args.eventId, uid, "notify")) {
|
|
118778
|
+
throw new Error("Configuring Telegram channels needs the notification permission on this event, not just setup rights: the channels decide where this event's messages go, and the reminder is sent under the event owner's name. Ask the event owner (or the workspace owner) to grant sendNotifications, or call this tool without telegramChannelIds \u2014 every other block is still yours to configure.");
|
|
118779
|
+
}
|
|
118550
118780
|
const patch = {};
|
|
118551
118781
|
if (args.chat?.enabled !== void 0)
|
|
118552
118782
|
patch["blocks.chat.enabled"] = args.chat.enabled;
|
|
@@ -118602,10 +118832,7 @@ function registerEventTools(server2) {
|
|
|
118602
118832
|
placeholder: field2.placeholder?.trim() || null
|
|
118603
118833
|
}))
|
|
118604
118834
|
}));
|
|
118605
|
-
|
|
118606
|
-
patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
|
|
118607
|
-
patch["telegram.channelIds"] = args.telegramChannelIds;
|
|
118608
|
-
}
|
|
118835
|
+
Object.assign(patch, telegramPatch);
|
|
118609
118836
|
const formsAfterPatch = patch.forms ?? current.forms;
|
|
118610
118837
|
const knownFormIds = new Set((Array.isArray(formsAfterPatch) ? formsAfterPatch : []).map((form) => typeof form?.id === "string" ? form.id : null).filter((id) => Boolean(id)));
|
|
118611
118838
|
for (const [field2, value] of [
|
|
@@ -118618,6 +118845,9 @@ function registerEventTools(server2) {
|
|
|
118618
118845
|
}
|
|
118619
118846
|
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
118620
118847
|
const hints = [];
|
|
118848
|
+
if (args.chat?.visibility !== void 0) {
|
|
118849
|
+
hints.push("blocks.chat.visibility was stored but the event page does not read it: the chat panel exists exactly when blocks.chat.enabled is true, and sending is opened or closed live with oko_set_chat_mode. Drop the field.");
|
|
118850
|
+
}
|
|
118621
118851
|
const currentBlocks = current.blocks && typeof current.blocks === "object" ? current.blocks : {};
|
|
118622
118852
|
const bannerHasImage = Boolean(safeString(currentBlocks.promoBanner?.imageUrl) || safeString(currentBlocks.promoBanner?.imageMasterUrl));
|
|
118623
118853
|
if (args.promoBanner?.enabled === false && bannerHasImage) {
|
|
@@ -118717,6 +118947,9 @@ function registerEventTools(server2) {
|
|
|
118717
118947
|
const warnings = removedCardsWithImages.length > 0 ? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`] : [];
|
|
118718
118948
|
if (finalCards.repairedIdPositions.length > 0) {
|
|
118719
118949
|
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.`);
|
|
118950
|
+
if (args.items === void 0) {
|
|
118951
|
+
warnings.push("The ids shown for those positions are provisional (generated on read) and were NOT stored: oko_upload_image and oko_delete_image will not accept them. Save the cards first by passing items to oko_configure_cards \u2014 the saved ids are the ones to use.");
|
|
118952
|
+
}
|
|
118720
118953
|
}
|
|
118721
118954
|
return textResult({
|
|
118722
118955
|
eventId: args.eventId,
|
|
@@ -118826,8 +119059,16 @@ function registerImageTools(server2) {
|
|
|
118826
119059
|
}
|
|
118827
119060
|
}
|
|
118828
119061
|
const uploadedCardIds = new Set(images.map((image) => image.cardId).filter((cardId) => !failures.some((failure) => failure.cardId === cardId)));
|
|
118829
|
-
|
|
118830
|
-
const warnings =
|
|
119062
|
+
let variants = [];
|
|
119063
|
+
const warnings = [];
|
|
119064
|
+
if (uploadedCardIds.size > 0) {
|
|
119065
|
+
try {
|
|
119066
|
+
variants = await waitForImageVariantTargets(uid, eventId, [...uploadedCardIds].map((cardId) => ({ kind: "card", cardId })), timeoutSec);
|
|
119067
|
+
} catch (error3) {
|
|
119068
|
+
warnings.push(`Uploads succeeded, but waiting for their variants failed: ${error3 instanceof Error ? error3.message : String(error3)}. Check readiness later with oko_wait_for_image_variants.`);
|
|
119069
|
+
}
|
|
119070
|
+
}
|
|
119071
|
+
warnings.push(...variants.filter((item) => !item.ready).map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`));
|
|
118831
119072
|
warnings.push(...failures.map((failure) => `Card ${failure.cardId} upload failed: ${failure.error}`));
|
|
118832
119073
|
return textResult({
|
|
118833
119074
|
eventId,
|
|
@@ -118989,14 +119230,37 @@ async function recordToolAudit(toolName, outcome, args, startedAt, error3) {
|
|
|
118989
119230
|
// build/tools/instrumentation.js
|
|
118990
119231
|
var REPORT_URL = `${APP_ORIGIN}/contact`;
|
|
118991
119232
|
var KEYS_URL = `${APP_ORIGIN}/app/mcp`;
|
|
118992
|
-
var
|
|
118993
|
-
|
|
118994
|
-
oko_delete_event_permanently: "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.",
|
|
118995
|
-
oko_publish_telegram_post: "This key cannot publish to Telegram. The organizer can publish the announcement from the OKO cabinet, or create a key with the full preset."
|
|
119233
|
+
var PERMISSION_GATED_ARGS = {
|
|
119234
|
+
telegramChannelIds: "telegramChannelIds \u2014 needs the telegram:write scope and the notification permission on the event"
|
|
118996
119235
|
};
|
|
119236
|
+
var SCOPE_WORKAROUNDS = {
|
|
119237
|
+
"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." },
|
|
119238
|
+
"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." },
|
|
119239
|
+
"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." },
|
|
119240
|
+
// 🔴 The most frequent refusal of this whole release (`ADV-A6-19`): an
|
|
119241
|
+
// `operator` key calling oko_configure_blocks with channel ids. The advice
|
|
119242
|
+
// promises that the rest of the call would work — true only when the key can
|
|
119243
|
+
// configure blocks at all.
|
|
119244
|
+
"oko_configure_blocks|telegram:write": {
|
|
119245
|
+
text: "Repeat the call WITHOUT telegramChannelIds: every other block, form and CTA in it will be configured normally. Choosing which channel carries an event is a send permission, so the channels themselves need a key with the full preset (or telegram:write) and an organizer allowed to send notifications for that event.",
|
|
119246
|
+
requires: ["setup:write"]
|
|
119247
|
+
}
|
|
119248
|
+
};
|
|
119249
|
+
function scopeWorkaroundFor(toolName, missing, granted) {
|
|
119250
|
+
const entry = SCOPE_WORKAROUNDS[`${toolName}|${missing}`];
|
|
119251
|
+
if (!entry)
|
|
119252
|
+
return void 0;
|
|
119253
|
+
if (entry.requires?.some((scope) => !granted.includes(scope)))
|
|
119254
|
+
return void 0;
|
|
119255
|
+
return entry.text;
|
|
119256
|
+
}
|
|
118997
119257
|
function presetsWithScope2(scope) {
|
|
118998
119258
|
return Object.entries(MCP_PRESETS).filter(([, scopes]) => scopes.includes(scope)).map(([name7]) => name7);
|
|
118999
119259
|
}
|
|
119260
|
+
function permissionGatedArgsIn(args) {
|
|
119261
|
+
const source = args && typeof args === "object" ? args : {};
|
|
119262
|
+
return Object.entries(PERMISSION_GATED_ARGS).filter(([key]) => source[key] !== void 0).map(([, explanation]) => explanation);
|
|
119263
|
+
}
|
|
119000
119264
|
function idsFromArgs(args) {
|
|
119001
119265
|
if (!args || typeof args !== "object")
|
|
119002
119266
|
return {};
|
|
@@ -119011,7 +119275,7 @@ function idsFromArgs(args) {
|
|
|
119011
119275
|
function scopeRejectionResult(toolName, scope) {
|
|
119012
119276
|
const granted = currentScopes();
|
|
119013
119277
|
const presets = presetsWithScope2(scope);
|
|
119014
|
-
const workaround =
|
|
119278
|
+
const workaround = scopeWorkaroundFor(toolName, scope, granted);
|
|
119015
119279
|
return {
|
|
119016
119280
|
...textResult({
|
|
119017
119281
|
errorCode: "SCOPE_REQUIRED",
|
|
@@ -119050,22 +119314,27 @@ function platformRejectionResult(toolName, args, readOnly) {
|
|
|
119050
119314
|
isError: true
|
|
119051
119315
|
};
|
|
119052
119316
|
}
|
|
119317
|
+
const gated = permissionGatedArgsIn(args);
|
|
119053
119318
|
return {
|
|
119054
119319
|
...textResult({
|
|
119055
119320
|
errorCode: "PLATFORM_REJECTED_WRITE",
|
|
119056
119321
|
tool: toolName,
|
|
119057
119322
|
...ids,
|
|
119058
|
-
message: "OKO's security rules refused this write. The request reached the platform and was rejected server-side; nothing was changed.",
|
|
119323
|
+
message: "OKO's security rules refused this write. The request reached the platform and was rejected server-side; nothing was changed. A rule can refuse the whole write because of a single field in it.",
|
|
119324
|
+
mayBeCausedBy: [
|
|
119325
|
+
`A field that needs a permission of its own: ${gated.length ? gated.join("; ") : "none of the fields this client knows to be permission-gated is present in this call, but the platform may gate others"}.`,
|
|
119326
|
+
`A permission on the target itself: this key holds ${currentScopes().join(", ") || "no scopes"} and acts for the organizer behind it, who may not hold the right this particular field requires.`
|
|
119327
|
+
],
|
|
119059
119328
|
notCausedBy: [
|
|
119060
|
-
`API key scopes: this key holds ${currentScopes().join(", ") || "no scopes"}, and the tool's scope check passed before the write.`,
|
|
119061
119329
|
`Client version: ${OKO_MCP_VERSION}${update2.updateAvailable ? ` (an update to ${update2.latest} is available, but rules apply to every version equally)` : " (current)"}.`,
|
|
119062
119330
|
"Wording or parameter choice: the same rule rejects the same data regardless of phrasing."
|
|
119063
119331
|
],
|
|
119064
119332
|
whatToDo: [
|
|
119065
119333
|
"Do not retry with different parameters and do not create the event again: retries hit the same rule and only produce duplicates.",
|
|
119066
|
-
|
|
119334
|
+
...gated.length ? [`Repeat the call WITHOUT ${gated.length > 1 ? "those fields" : "that field"} first: if it succeeds, the refusal was about ${gated.length > 1 ? "them" : "it"} \u2014 the organizer then needs a key with the missing scope, or the missing permission on the event, and the rest of the change is already saved.`] : [],
|
|
119335
|
+
"If no such field is involved, tell the organizer plainly that the OKO platform refused this change.",
|
|
119067
119336
|
`Ask them to report it to OKO at ${REPORT_URL} with the "report" block below, so it can be fixed on the platform side.`,
|
|
119068
|
-
...toolName === "oko_create_webinar" ? ["If the organizer needs the page today: oko_duplicate_event of an existing event
|
|
119337
|
+
...toolName === "oko_create_webinar" ? ["If the organizer needs the page today: oko_duplicate_event of an existing event often works when creation is refused \u2014 but pick a source event WITHOUT Telegram channels, or hold telegram:write: duplicating a Telegram-configured event needs the same permission that creating one does."] : []
|
|
119069
119338
|
],
|
|
119070
119339
|
report: {
|
|
119071
119340
|
tool: toolName,
|
|
@@ -119134,6 +119403,17 @@ function installToolInstrumentation(server2) {
|
|
|
119134
119403
|
}
|
|
119135
119404
|
|
|
119136
119405
|
// build/tools/leads.js
|
|
119406
|
+
function leadSubmittedMs(lead) {
|
|
119407
|
+
const value = lead.submittedAt;
|
|
119408
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
119409
|
+
return value;
|
|
119410
|
+
if (typeof value === "string") {
|
|
119411
|
+
const parsed = Date.parse(value);
|
|
119412
|
+
if (Number.isFinite(parsed))
|
|
119413
|
+
return parsed;
|
|
119414
|
+
}
|
|
119415
|
+
return 0;
|
|
119416
|
+
}
|
|
119137
119417
|
function registerLeadTools(server2) {
|
|
119138
119418
|
server2.registerTool("oko_list_leads", {
|
|
119139
119419
|
title: "List form leads",
|
|
@@ -119146,7 +119426,7 @@ function registerLeadTools(server2) {
|
|
|
119146
119426
|
"limit: max rows, default 50, max 200.",
|
|
119147
119427
|
"cursor: lead document id returned as nextCursor by the previous page."
|
|
119148
119428
|
],
|
|
119149
|
-
returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
119429
|
+
returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor. Without formId the rows are newest-first across pages; with formId they are newest-first WITHIN a page only, because paging there follows document ids \u2014 to rank one form's submissions by time, read every page and sort by submittedAt.",
|
|
119150
119430
|
examples: ["Use formId to export one form; if Firestore requests an index, create it from the error link and rerun."]
|
|
119151
119431
|
}),
|
|
119152
119432
|
inputSchema: {
|
|
@@ -119162,10 +119442,21 @@ function registerLeadTools(server2) {
|
|
|
119162
119442
|
const { uid } = await authed(["leads:read"]);
|
|
119163
119443
|
await readEvent(eventId, uid, "analytics");
|
|
119164
119444
|
const base = collection(firestore, "events", eventId, "leads");
|
|
119165
|
-
|
|
119445
|
+
let constraints;
|
|
119446
|
+
if (formId) {
|
|
119447
|
+
constraints = [where("formId", "==", formId), orderBy(documentId$1()), ...cursor ? [startAfter(cursor)] : [], limit(max + 1)];
|
|
119448
|
+
} else {
|
|
119449
|
+
const cursorSnap = cursor ? await getDoc(doc(base, cursor)) : null;
|
|
119450
|
+
if (cursor && !cursorSnap?.exists()) {
|
|
119451
|
+
throw new Error(`Lead cursor ${cursor} no longer exists (the submission was deleted). Restart the listing without a cursor.`);
|
|
119452
|
+
}
|
|
119453
|
+
constraints = [orderBy("submittedAt", "desc"), ...cursorSnap ? [startAfter(cursorSnap)] : [], limit(max + 1)];
|
|
119454
|
+
}
|
|
119166
119455
|
const snap = await getDocs(query2(base, ...constraints));
|
|
119167
119456
|
const page = snap.docs.slice(0, max);
|
|
119168
119457
|
const leads = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
119458
|
+
if (formId)
|
|
119459
|
+
leads.sort((a, b) => leadSubmittedMs(b) - leadSubmittedMs(a));
|
|
119169
119460
|
const hasMore = snap.docs.length > max;
|
|
119170
119461
|
const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
|
|
119171
119462
|
const meta2 = cursorMeta(leads.length, max, hasMore, nextCursor);
|
|
@@ -119207,6 +119498,13 @@ function registerLeadTools(server2) {
|
|
|
119207
119498
|
|
|
119208
119499
|
// build/tools/lifecycle.js
|
|
119209
119500
|
function registerLifecycleTools(server2) {
|
|
119501
|
+
const moveToTrash = async (toolName, { eventId, confirm }) => {
|
|
119502
|
+
const { uid } = await authed(["events:delete"]);
|
|
119503
|
+
const event = await readEvent(eventId, uid, "delete");
|
|
119504
|
+
requireConfirm(confirm, toolName, { eventId, title: event.title });
|
|
119505
|
+
const trash = httpsCallable(functions, "trashEvent");
|
|
119506
|
+
return textResult((await trash({ eventId })).data);
|
|
119507
|
+
};
|
|
119210
119508
|
server2.registerTool("oko_delete_event", {
|
|
119211
119509
|
title: "Move event to trash",
|
|
119212
119510
|
description: toolDescription({
|
|
@@ -119219,14 +119517,7 @@ function registerLifecycleTools(server2) {
|
|
|
119219
119517
|
inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
|
|
119220
119518
|
outputSchema: external_exports.object({ trashed: external_exports.string(), purgeAfter: external_exports.number().nullable() }).passthrough(),
|
|
119221
119519
|
annotations: destructiveTool
|
|
119222
|
-
}, async ({ eventId, confirm }) => {
|
|
119223
|
-
const { uid } = await authed(["events:delete"]);
|
|
119224
|
-
const data = await readEvent(eventId, uid, "delete");
|
|
119225
|
-
requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
|
|
119226
|
-
const trash = httpsCallable(functions, "trashEvent");
|
|
119227
|
-
const result = await trash({ eventId });
|
|
119228
|
-
return textResult(result.data);
|
|
119229
|
-
});
|
|
119520
|
+
}, async ({ eventId, confirm }) => moveToTrash("oko_delete_event", { eventId, confirm }));
|
|
119230
119521
|
server2.registerTool("oko_trash_event", {
|
|
119231
119522
|
title: "Move event to trash",
|
|
119232
119523
|
description: toolDescription({
|
|
@@ -119239,13 +119530,7 @@ function registerLifecycleTools(server2) {
|
|
|
119239
119530
|
inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
|
|
119240
119531
|
outputSchema: external_exports.object({ trashed: external_exports.string(), purgeAfter: external_exports.number().nullable() }).passthrough(),
|
|
119241
119532
|
annotations: destructiveTool
|
|
119242
|
-
}, async ({ eventId, confirm }) => {
|
|
119243
|
-
const { uid } = await authed(["events:delete"]);
|
|
119244
|
-
const event = await readEvent(eventId, uid, "delete");
|
|
119245
|
-
requireConfirm(confirm, "oko_trash_event", { eventId, title: event.title });
|
|
119246
|
-
const trash = httpsCallable(functions, "trashEvent");
|
|
119247
|
-
return textResult((await trash({ eventId })).data);
|
|
119248
|
-
});
|
|
119533
|
+
}, async ({ eventId, confirm }) => moveToTrash("oko_trash_event", { eventId, confirm }));
|
|
119249
119534
|
server2.registerTool("oko_restore_event", {
|
|
119250
119535
|
title: "Restore event",
|
|
119251
119536
|
description: toolDescription({
|
|
@@ -119305,7 +119590,8 @@ function registerLifecycleTools(server2) {
|
|
|
119305
119590
|
notes: [
|
|
119306
119591
|
"Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions or leads.",
|
|
119307
119592
|
"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.",
|
|
119308
|
-
"Requires only events:write plus createEvents in the target workspace. To move (not copy) an event, use oko_move_event with the same scope."
|
|
119593
|
+
"Requires only events:write plus createEvents in the target workspace. To move (not copy) an event, use oko_move_event with the same scope.",
|
|
119594
|
+
"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."
|
|
119309
119595
|
]
|
|
119310
119596
|
}),
|
|
119311
119597
|
inputSchema: {
|
|
@@ -119319,6 +119605,8 @@ function registerLifecycleTools(server2) {
|
|
|
119319
119605
|
const source = await readEvent(eventId, uid, "read");
|
|
119320
119606
|
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
119321
119607
|
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
119608
|
+
const sourceHasTelegram = !!source.telegram && typeof source.telegram === "object";
|
|
119609
|
+
const mayCopyTelegram = sourceHasTelegram && currentScopes().includes("telegram:write") && await eventGrantsMode(eventId, uid, "notify");
|
|
119322
119610
|
const newRef = await addEventWithRtdbMirror({
|
|
119323
119611
|
ownerId: uid,
|
|
119324
119612
|
workspaceId,
|
|
@@ -119334,7 +119622,7 @@ function registerLifecycleTools(server2) {
|
|
|
119334
119622
|
// function skips events with this flag forever: a copy of an event whose
|
|
119335
119623
|
// reminder has already gone out would never get its own — silently
|
|
119336
119624
|
// (review 02.09, R3b-5).
|
|
119337
|
-
...
|
|
119625
|
+
...mayCopyTelegram ? { telegram: copyableTelegram(source.telegram) } : {}
|
|
119338
119626
|
});
|
|
119339
119627
|
return textResult({
|
|
119340
119628
|
id: newRef.id,
|
|
@@ -119342,7 +119630,9 @@ function registerLifecycleTools(server2) {
|
|
|
119342
119630
|
workspaceId,
|
|
119343
119631
|
...eventLinks(newRef.id),
|
|
119344
119632
|
liveStateNotCopied: ["chatMode", "placeholderVisible", "liveBadge", "block visibility overrides"],
|
|
119633
|
+
telegramCopied: mayCopyTelegram,
|
|
119345
119634
|
hints: [
|
|
119635
|
+
...sourceHasTelegram && !mayCopyTelegram ? ["Telegram config was NOT copied: this key needs telegram:write and the notification permission on the source event. The copy starts with Telegram off; ask the event owner to grant the permission, then configure channels on the copy."] : [],
|
|
119346
119636
|
"Live state was not copied: chat mode is public and the placeholder is hidden on the copy. If the source had chat off or the placeholder shown, set them again with oko_set_chat_mode and oko_set_block_visibility on the new event.",
|
|
119347
119637
|
"The copy is live at publicUrl immediately; edit title, eventDate, and video with oko_update_event, then run oko_validate_event_setup."
|
|
119348
119638
|
]
|
|
@@ -119373,6 +119663,14 @@ function registerLifecycleTools(server2) {
|
|
|
119373
119663
|
requireConfirm(confirm, "oko_move_event", { eventId, targetWorkspaceId });
|
|
119374
119664
|
const moveEvent = httpsCallable(functions, "moveEventToWorkspace");
|
|
119375
119665
|
const result = await moveEvent({ eventId, targetWorkspaceId });
|
|
119666
|
+
if (result.data.rtdbMirrorUpdated === false) {
|
|
119667
|
+
return textResult({
|
|
119668
|
+
...result.data,
|
|
119669
|
+
hints: [
|
|
119670
|
+
"The event document moved, but the live-control mirror in the main RTDB did not confirm: members of the ORIGINAL workspace keep live control (start/end sessions, live badge, analytics) and members of the target workspace do not have it yet. Retry oko_move_event with the same target \u2014 the call is idempotent \u2014 and check oko_get_live_state afterwards."
|
|
119671
|
+
]
|
|
119672
|
+
});
|
|
119673
|
+
}
|
|
119376
119674
|
return textResult(result.data);
|
|
119377
119675
|
});
|
|
119378
119676
|
}
|
|
@@ -119443,18 +119741,15 @@ function registerLiveTools(server2) {
|
|
|
119443
119741
|
};
|
|
119444
119742
|
if (block === "chat") {
|
|
119445
119743
|
const modeRef = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
|
|
119446
|
-
const
|
|
119447
|
-
if (visible === null) {
|
|
119448
|
-
|
|
119449
|
-
} else if (visible === false) {
|
|
119450
|
-
await set(modeRef, "off");
|
|
119451
|
-
await remove(legacyRef);
|
|
119744
|
+
const patch = { chatVisible: null };
|
|
119745
|
+
if (visible === null || visible === false) {
|
|
119746
|
+
patch.chatMode = visible === false ? "off" : null;
|
|
119452
119747
|
} else {
|
|
119453
119748
|
const current = await get(modeRef);
|
|
119454
119749
|
if (current.val() === "off")
|
|
119455
|
-
|
|
119456
|
-
await remove(legacyRef);
|
|
119750
|
+
patch.chatMode = null;
|
|
119457
119751
|
}
|
|
119752
|
+
await update(ref(rtdb, `events/${eventId}/ctrl`), patch);
|
|
119458
119753
|
return textResult({ eventId, block, visible, hints });
|
|
119459
119754
|
}
|
|
119460
119755
|
const target = ref(rtdb, `events/${eventId}/ctrl/${paths[block]}`);
|
|
@@ -119479,7 +119774,7 @@ function registerLiveTools(server2) {
|
|
|
119479
119774
|
const { uid } = await authed(["live:control"]);
|
|
119480
119775
|
await readEvent(eventId, uid, "live");
|
|
119481
119776
|
requireConfirm(confirm, "oko_refresh_viewers", { eventId });
|
|
119482
|
-
await
|
|
119777
|
+
await runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1);
|
|
119483
119778
|
return textResult({ eventId, refreshed: true });
|
|
119484
119779
|
});
|
|
119485
119780
|
server2.registerTool("oko_set_live_badge", {
|
|
@@ -119523,23 +119818,26 @@ function registerLiveTools(server2) {
|
|
|
119523
119818
|
server2.registerTool("oko_set_chat_mode", {
|
|
119524
119819
|
title: "Set chat mode",
|
|
119525
119820
|
description: toolDescription({
|
|
119526
|
-
summary: "
|
|
119821
|
+
summary: "Open or close chat sending on the public event page.",
|
|
119527
119822
|
exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "mode": "off", "confirm": true },
|
|
119528
119823
|
args: [
|
|
119529
119824
|
"eventId: event to control.",
|
|
119530
|
-
"mode: public (anyone
|
|
119825
|
+
"mode: public (anyone can send), off (input closed, history stays visible), or null to reset to the product default (public).",
|
|
119531
119826
|
"confirm: must be true."
|
|
119532
119827
|
],
|
|
119533
119828
|
returns: "eventId and the effective chat mode.",
|
|
119534
119829
|
examples: ['Use mode="off" to pause a chat storm; use mode=null or "public" to reopen.'],
|
|
119535
119830
|
notes: [
|
|
119536
119831
|
'This is the full replacement for the legacy pause via oko_set_block_visibility(block="chat"): prefer oko_set_chat_mode.',
|
|
119537
|
-
"History always
|
|
119832
|
+
"There are no other levels: the server has exactly two states, sending open or closed. History is always visible; the mode never restricts who can read or hides anyone's messages."
|
|
119538
119833
|
]
|
|
119539
119834
|
}),
|
|
119540
119835
|
inputSchema: {
|
|
119541
119836
|
eventId: eventIdField,
|
|
119542
|
-
|
|
119837
|
+
// LD5a-2 / A8 (owner decision 03.09): "auth" and "owner" were promised
|
|
119838
|
+
// here and enforced nowhere — the rule and every client only check
|
|
119839
|
+
// `mode !== "off"`. The contract now says what the server does.
|
|
119840
|
+
mode: external_exports.enum(["public", "off"]).nullable().describe("Chat mode: public or off; null resets the override to the default."),
|
|
119543
119841
|
confirm: external_exports.boolean().optional().describe("Required true to change the live chat mode.")
|
|
119544
119842
|
},
|
|
119545
119843
|
outputSchema: basicEventOutputSchema,
|
|
@@ -119548,11 +119846,7 @@ function registerLiveTools(server2) {
|
|
|
119548
119846
|
const { uid } = await authed(["live:control"]);
|
|
119549
119847
|
await readEvent(eventId, uid, "live");
|
|
119550
119848
|
requireConfirm(confirm, "oko_set_chat_mode", { eventId, mode });
|
|
119551
|
-
|
|
119552
|
-
if (mode === null)
|
|
119553
|
-
await remove(target);
|
|
119554
|
-
else
|
|
119555
|
-
await set(target, mode);
|
|
119849
|
+
await update(ref(rtdb, `events/${eventId}/ctrl`), { chatMode: mode, chatVisible: null });
|
|
119556
119850
|
return textResult({ eventId, mode });
|
|
119557
119851
|
});
|
|
119558
119852
|
server2.registerTool("oko_force_viewer_reload", {
|
|
@@ -119645,7 +119939,7 @@ function registerNotificationTools(server2) {
|
|
|
119645
119939
|
],
|
|
119646
119940
|
returns: "markedRead count and notification ids updated.",
|
|
119647
119941
|
examples: ["Use after oko_list_notifications when the user asks to clear the bell/unread state."],
|
|
119648
|
-
notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications."]
|
|
119942
|
+
notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications.", "Requires workspace:read (marking your own notifications read is not a workspace write)."]
|
|
119649
119943
|
}),
|
|
119650
119944
|
inputSchema: {
|
|
119651
119945
|
notificationIds: external_exports.array(external_exports.string().min(1).max(160)).max(100).optional().describe("Specific notification document ids."),
|
|
@@ -119655,7 +119949,7 @@ function registerNotificationTools(server2) {
|
|
|
119655
119949
|
outputSchema: notificationsMarkOutputSchema,
|
|
119656
119950
|
annotations: idempotentWriteTool
|
|
119657
119951
|
}, async ({ notificationIds, allUnread, limit: max }) => {
|
|
119658
|
-
const { uid } = await authed(["workspace:
|
|
119952
|
+
const { uid } = await authed(["workspace:read"]);
|
|
119659
119953
|
if (Boolean(notificationIds?.length) === allUnread) {
|
|
119660
119954
|
throw new Error("Provide either notificationIds or allUnread=true.");
|
|
119661
119955
|
}
|
|
@@ -120111,7 +120405,7 @@ function registerTelegramTools(server2) {
|
|
|
120111
120405
|
"limit: max posts, default 30, max 100.",
|
|
120112
120406
|
"cursor: createdAt cursor returned by previous page."
|
|
120113
120407
|
],
|
|
120114
|
-
returns: "posts plus cursor pagination metadata.",
|
|
120408
|
+
returns: "posts plus cursor pagination metadata. A failed post may carry deliveryError: sender_right_revoked (the author lost the notification permission and the queued post was cancelled on purpose) or orphaned_pending (delivery never started).",
|
|
120115
120409
|
examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."]
|
|
120116
120410
|
}),
|
|
120117
120411
|
inputSchema: {
|
|
@@ -120177,18 +120471,21 @@ function registerTelegramTools(server2) {
|
|
|
120177
120471
|
args: [
|
|
120178
120472
|
"eventId: event used for access checks and default button URL.",
|
|
120179
120473
|
"channelId: Telegram channel id configured for the organizer.",
|
|
120180
|
-
"caption: post caption, max 4096 chars.",
|
|
120474
|
+
"caption: post caption, max 4096 chars, parsed as Telegram HTML.",
|
|
120181
120475
|
"scheduleAt: optional ISO datetime; omitted means immediate send.",
|
|
120182
120476
|
"confirm: must be true for both immediate and scheduled publication."
|
|
120183
120477
|
],
|
|
120184
120478
|
returns: "postId, scheduled flag, and sent flag.",
|
|
120185
120479
|
examples: ["Use scheduleAt for scheduled posts; for immediate send, show the exact channel/caption and ask for confirmation."],
|
|
120186
|
-
notes: [
|
|
120480
|
+
notes: [
|
|
120481
|
+
"External side effect: immediate sends can publish to real Telegram channels.",
|
|
120482
|
+
"The caption is parsed as Telegram HTML, so <b>, <i> and <a href> are markup. Text copied from event fields (title, speaker, description) must have &, < and > escaped as &, < and > first \u2014 an unescaped < makes Telegram reject the whole message and the post ends up failed."
|
|
120483
|
+
]
|
|
120187
120484
|
}),
|
|
120188
120485
|
inputSchema: {
|
|
120189
120486
|
eventId: eventIdField,
|
|
120190
120487
|
channelId: external_exports.number().int().describe("Telegram channel id."),
|
|
120191
|
-
caption: external_exports.string().min(1).max(4096).describe("Telegram post caption."),
|
|
120488
|
+
caption: external_exports.string().min(1).max(4096).describe("Telegram post caption, parsed as Telegram HTML; escape &, < and > in text taken from event fields."),
|
|
120192
120489
|
imageUrl: external_exports.string().nullable().optional().describe("Optional image URL."),
|
|
120193
120490
|
buttonLabel: external_exports.string().max(64).nullable().optional().describe("Optional inline button label."),
|
|
120194
120491
|
buttonUrl: external_exports.string().max(1e3).nullable().optional().describe("Optional inline button URL; defaults to event URL when buttonLabel is set."),
|
|
@@ -120226,8 +120523,10 @@ function registerTelegramTools(server2) {
|
|
|
120226
120523
|
channelId: args.channelId,
|
|
120227
120524
|
channelKey: String(args.channelId),
|
|
120228
120525
|
caption: args.caption.trim(),
|
|
120229
|
-
|
|
120230
|
-
|
|
120526
|
+
// R3c-10 (04.09): a wrong protocol used to surface only from Telegram at
|
|
120527
|
+
// delivery, leaving the post `failed`; the check now precedes the write.
|
|
120528
|
+
imageUrl: absoluteHttpUrl(args.imageUrl, "imageUrl"),
|
|
120529
|
+
buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: absoluteHttpUrl(args.buttonUrl, "buttonUrl") ?? eventUrl(args.eventId) }] : [],
|
|
120231
120530
|
// 🔴 Immediate sending is `pending`, as in the web, not `draft`. A break
|
|
120232
120531
|
// between creating the document and sending left the post a "draft",
|
|
120233
120532
|
// indistinguishable from one never sent: CF15 picks up only `scheduled`,
|
|
@@ -120301,8 +120600,17 @@ function registerWorkspaceTools(server2) {
|
|
|
120301
120600
|
annotations: idempotentWriteTool
|
|
120302
120601
|
}, async ({ workspaceId, name: name7 }) => {
|
|
120303
120602
|
const { uid } = await authed(["workspace:write"]);
|
|
120304
|
-
await
|
|
120305
|
-
|
|
120603
|
+
const { synthesized } = await readWorkspaceForOwnerDetailed(workspaceId, uid);
|
|
120604
|
+
if (synthesized) {
|
|
120605
|
+
await setDoc(doc(firestore, "workspaces", workspaceId), {
|
|
120606
|
+
name: name7.trim(),
|
|
120607
|
+
ownerId: uid,
|
|
120608
|
+
personal: true,
|
|
120609
|
+
createdAt: serverTimestamp2()
|
|
120610
|
+
});
|
|
120611
|
+
} else {
|
|
120612
|
+
await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name7.trim() });
|
|
120613
|
+
}
|
|
120306
120614
|
return textResult({ workspaceId, updated: ["name"] });
|
|
120307
120615
|
});
|
|
120308
120616
|
server2.registerTool("oko_delete_workspace", {
|
|
@@ -120320,12 +120628,12 @@ function registerWorkspaceTools(server2) {
|
|
|
120320
120628
|
}, async ({ workspaceId, confirm }) => {
|
|
120321
120629
|
const { uid } = await authed(["workspace:write"]);
|
|
120322
120630
|
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
120323
|
-
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
120324
120631
|
if (workspace.personal === true)
|
|
120325
120632
|
throw new Error("Personal workspace cannot be deleted.");
|
|
120633
|
+
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
120326
120634
|
const removeWorkspace = httpsCallable(functions, "deleteWorkspaceCompletely");
|
|
120327
120635
|
const removed = await removeWorkspace({ workspaceId });
|
|
120328
|
-
return textResult({ ...removed.data,
|
|
120636
|
+
return textResult({ ...removed.data, deleted: removed.data.deleted === true });
|
|
120329
120637
|
});
|
|
120330
120638
|
server2.registerTool("oko_list_workspace_access", {
|
|
120331
120639
|
title: "List workspace access",
|
|
@@ -120346,16 +120654,26 @@ function registerWorkspaceTools(server2) {
|
|
|
120346
120654
|
getDocs(query2(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)))
|
|
120347
120655
|
]);
|
|
120348
120656
|
const members = [];
|
|
120657
|
+
let anyProfileRestricted = false;
|
|
120349
120658
|
for (const share of sharesSnap.docs) {
|
|
120350
|
-
const profile = await getDoc(doc(firestore, "userProfiles", share.id)).then((snap) => snap.exists() ? serialize(snap.data()) : null).catch(() =>
|
|
120659
|
+
const profile = await getDoc(doc(firestore, "userProfiles", share.id)).then((snap) => ({ value: snap.exists() ? serialize(snap.data()) : null, restricted: false })).catch((error3) => {
|
|
120660
|
+
if (isFirestorePermissionDenied(error3))
|
|
120661
|
+
return { value: null, restricted: true };
|
|
120662
|
+
console.error(`[oko-mcp] userProfiles/${share.id} read failed: ${error3 instanceof Error ? error3.message : String(error3)}`);
|
|
120663
|
+
return { value: null, restricted: false, failed: true };
|
|
120664
|
+
});
|
|
120665
|
+
if (profile.restricted)
|
|
120666
|
+
anyProfileRestricted = true;
|
|
120351
120667
|
members.push({
|
|
120352
120668
|
uid: share.id,
|
|
120353
120669
|
...serialize(share.data()),
|
|
120354
|
-
profile
|
|
120670
|
+
profile: profile.value,
|
|
120671
|
+
profileAccess: profile.restricted ? "restricted" : "failed" in profile ? "unavailable" : "readable"
|
|
120355
120672
|
});
|
|
120356
120673
|
}
|
|
120357
120674
|
const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
|
|
120358
|
-
|
|
120675
|
+
const hints = anyProfileRestricted ? ["Some members report profileAccess=restricted: this client may not read other people's profiles, so their display name and avatar are unavailable here even though the OKO cabinet shows them. Refer to those members by uid or by the email stored on the share."] : [];
|
|
120676
|
+
return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites, hints });
|
|
120359
120677
|
});
|
|
120360
120678
|
server2.registerTool("oko_update_workspace_member", {
|
|
120361
120679
|
title: "Update workspace member",
|
|
@@ -120379,6 +120697,11 @@ function registerWorkspaceTools(server2) {
|
|
|
120379
120697
|
}, async (args) => {
|
|
120380
120698
|
const { uid } = await authed(["workspace:write"]);
|
|
120381
120699
|
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
120700
|
+
if (!isSharedWorkspaceId(args.workspaceId))
|
|
120701
|
+
throw new Error(personalWorkspaceLimitMessage("adding or changing members"));
|
|
120702
|
+
if (args.uid === workspace.ownerId) {
|
|
120703
|
+
throw new Error("The workspace owner already holds every permission; a share for the owner is not needed and would be undone by the platform. Pick another member uid.");
|
|
120704
|
+
}
|
|
120382
120705
|
requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
|
|
120383
120706
|
const shareRef = doc(firestore, "workspaces", args.workspaceId, "shares", args.uid);
|
|
120384
120707
|
const existing = await getDoc(shareRef);
|
|
@@ -120423,9 +120746,15 @@ function registerWorkspaceTools(server2) {
|
|
|
120423
120746
|
}, async ({ workspaceId, uid: targetUid, confirm }) => {
|
|
120424
120747
|
const { uid } = await authed(["workspace:write"]);
|
|
120425
120748
|
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
120749
|
+
if (!isSharedWorkspaceId(workspaceId))
|
|
120750
|
+
throw new Error(personalWorkspaceLimitMessage("removing members"));
|
|
120426
120751
|
requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
|
|
120427
|
-
|
|
120428
|
-
|
|
120752
|
+
const shareRef = doc(firestore, "workspaces", workspaceId, "shares", targetUid);
|
|
120753
|
+
const outcome = workspaceMemberRemoval((await getDoc(shareRef)).exists(), workspaceId, targetUid);
|
|
120754
|
+
if (outcome.removed === false)
|
|
120755
|
+
return textResult({ workspaceId, uid: targetUid, ...outcome });
|
|
120756
|
+
await deleteDoc(shareRef);
|
|
120757
|
+
return textResult({ workspaceId, uid: targetUid, ...outcome });
|
|
120429
120758
|
});
|
|
120430
120759
|
server2.registerTool("oko_list_workspace_invites", {
|
|
120431
120760
|
title: "List workspace invites",
|
|
@@ -120474,7 +120803,7 @@ function registerWorkspaceTools(server2) {
|
|
|
120474
120803
|
description: toolDescription({
|
|
120475
120804
|
summary: "Accept a workspace invite code for the authenticated organizer.",
|
|
120476
120805
|
args: ["code: invite code from /invite/{code}.", "confirm: must be true."],
|
|
120477
|
-
returns: "success
|
|
120806
|
+
returns: "success, workspace id/name, and keptExistingPermissions: true when the organizer was already a member \u2014 an invite never changes existing permissions; the owner changes them with oko_update_workspace_member.",
|
|
120478
120807
|
examples: ["Use when the organizer wants to join a workspace from an invite code."]
|
|
120479
120808
|
}),
|
|
120480
120809
|
inputSchema: {
|
|
@@ -120483,6 +120812,7 @@ function registerWorkspaceTools(server2) {
|
|
|
120483
120812
|
},
|
|
120484
120813
|
outputSchema: external_exports.object({
|
|
120485
120814
|
success: external_exports.boolean(),
|
|
120815
|
+
keptExistingPermissions: external_exports.boolean().optional(),
|
|
120486
120816
|
workspace: external_exports.object({ id: external_exports.string(), name: external_exports.string() }).passthrough()
|
|
120487
120817
|
}).passthrough(),
|
|
120488
120818
|
annotations: writeTool
|
|
@@ -120527,6 +120857,8 @@ function registerWorkspaceTools(server2) {
|
|
|
120527
120857
|
}, async (args) => {
|
|
120528
120858
|
const { uid } = await authed(["workspace:write"]);
|
|
120529
120859
|
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
120860
|
+
if (!isSharedWorkspaceId(args.workspaceId))
|
|
120861
|
+
throw new Error(personalWorkspaceLimitMessage("creating invites"));
|
|
120530
120862
|
requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
|
|
120531
120863
|
const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
|
|
120532
120864
|
const url = inviteUrl(code);
|